Compare commits
55 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1dc53043a6 | |||
| 076e5a17b5 | |||
| d5d8a0d7ac | |||
| 159389fad2 | |||
| 43bb14bf87 | |||
| d8f825034f | |||
| f0805b78f5 | |||
| e20b4d5515 | |||
| 8c6cd2bda2 | |||
| e4d0fc7e3d | |||
| a4a5010441 | |||
| cf94773bf0 | |||
| 8e36f76cfa | |||
| dee13b160f | |||
| ef394277c1 | |||
| ee479474ce | |||
| 96910f0f50 | |||
| 4cc6d95a91 | |||
| 10590993f4 | |||
| 10aeaf8c11 | |||
| 85d25a6fe6 | |||
| 653fae47a5 | |||
| 363e4cbed8 | |||
| c8888cecde | |||
| c0de70431e | |||
| b0274e3265 | |||
| 8f36eca62a | |||
| 3c9799adda | |||
| 5f3e46c42d | |||
| 3d393c36a3 | |||
| d6de1c369a | |||
| 2e6c01670e | |||
| 17b610e1a1 | |||
| ca913c76ea | |||
| 20d4b12522 | |||
| ec43c0e0d1 | |||
| 93cc7b1a44 | |||
| 851e49e2d7 | |||
| 4101df566b | |||
| 9d04cad360 | |||
| e4e93ea6d3 | |||
| ae8a634450 | |||
| cd9e00f8d6 | |||
| f87e0f878c | |||
| e2e29a19fc | |||
| 6f76a6a9da | |||
| 366af55f19 | |||
| 4c1413d925 | |||
| 6db4a6d02e | |||
| 3c8b493aee | |||
| 560e27bda5 | |||
| 1e17a76863 | |||
| ada5584737 | |||
| b6e2c61d30 | |||
| e58299740d |
@@ -12,16 +12,14 @@ on:
|
||||
description: Run name
|
||||
|
||||
permissions:
|
||||
id-token: write
|
||||
contents: write
|
||||
pull-requests: write
|
||||
issues: write
|
||||
actions: read
|
||||
checks: read
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
pullfrog:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
id-token: write
|
||||
contents: read
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v6
|
||||
|
||||
@@ -72,16 +72,14 @@ on:
|
||||
description: 'Agent prompt'
|
||||
|
||||
permissions:
|
||||
id-token: write
|
||||
contents: write
|
||||
pull-requests: write
|
||||
issues: write
|
||||
actions: read
|
||||
checks: read
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
pullfrog:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
id-token: write
|
||||
contents: read
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v6
|
||||
@@ -130,11 +128,7 @@ jobs:
|
||||
|
||||
permissions:
|
||||
id-token: write
|
||||
contents: write
|
||||
issues: write
|
||||
pull-requests: write
|
||||
actions: read
|
||||
checks: read
|
||||
contents: read
|
||||
uses: ./.github/workflows/pullfrog.yml
|
||||
with:
|
||||
# pass the full event payload as the prompt
|
||||
|
||||
@@ -36,8 +36,6 @@ outputs:
|
||||
runs:
|
||||
using: "node24"
|
||||
main: "entry.ts"
|
||||
post: "post.ts"
|
||||
post-if: "failure() || cancelled()"
|
||||
|
||||
branding:
|
||||
icon: "code"
|
||||
|
||||
+118
-8
@@ -90,10 +90,13 @@ function stripProviderPrefix(specifier: string): string {
|
||||
return slashIndex > 0 ? specifier.slice(slashIndex + 1) : specifier;
|
||||
}
|
||||
|
||||
// `max` effort is supported on Opus 4.6 / 4.7; other models fall back to `high`.
|
||||
// claude-code deny-lists older opus/sonnet generations from `max` at invocation time.
|
||||
function resolveEffort(model: string | undefined): "max" | "high" {
|
||||
if (model?.includes("opus")) return "max";
|
||||
// `high` is the model's tuned default ("equivalent to not setting the parameter"
|
||||
// per Anthropic docs). `max` is "absolute maximum capability with no constraints
|
||||
// on token spending" — meaningfully slower and burns more thinking budget per
|
||||
// turn. We default everyone to `high`; PRs that genuinely need full-send can
|
||||
// opt in via a future per-run override rather than paying the wall-time cost on
|
||||
// every Opus run.
|
||||
function resolveEffort(_model: string | undefined): "high" {
|
||||
return "high";
|
||||
}
|
||||
|
||||
@@ -146,6 +149,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,13 +215,48 @@ 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();
|
||||
|
||||
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 —
|
||||
@@ -334,6 +381,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
|
||||
@@ -367,9 +435,17 @@ async function runClaude(params: RunParams): Promise<ClaudeRunResult> {
|
||||
tokensLogged = true;
|
||||
}
|
||||
} 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)}`);
|
||||
}
|
||||
@@ -401,6 +477,12 @@ async function runClaude(params: RunParams): Promise<ClaudeRunResult> {
|
||||
activityTimeout: 300_000,
|
||||
onActivityTimeout: params.onActivityTimeout,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
// run claude in its own process group so SIGKILL on activity timeout /
|
||||
// outer cancellation reaches any subprocesses it spawns (rg, file
|
||||
// watchers, mcp transports, etc). claude itself is a node bundle so
|
||||
// there's no shim-orphan issue like opencode-ai/bin/opencode, but
|
||||
// detached + killGroup is the right default for any agent runtime.
|
||||
killGroup: true,
|
||||
onStdout: async (chunk) => {
|
||||
const text = chunk.toString();
|
||||
output += text;
|
||||
@@ -484,8 +566,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 ||
|
||||
@@ -499,9 +589,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}`
|
||||
@@ -527,6 +625,16 @@ async function runClaude(params: RunParams): Promise<ClaudeRunResult> {
|
||||
};
|
||||
}
|
||||
|
||||
if (resultErrorSubtype) {
|
||||
return {
|
||||
success: false,
|
||||
output: finalOutput || output,
|
||||
error: lastResultError || `result subtype: ${resultErrorSubtype}`,
|
||||
usage,
|
||||
sessionId,
|
||||
};
|
||||
}
|
||||
|
||||
return { success: true, output: finalOutput || output, usage, sessionId };
|
||||
} catch (error) {
|
||||
params.todoTracker?.cancel();
|
||||
@@ -700,10 +808,12 @@ 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,
|
||||
reflectionPrompt: buildLearningsReflectionPrompt("claude"),
|
||||
reflectionPrompt: ctx.toolState.learningsFilePath
|
||||
? buildLearningsReflectionPrompt(ctx.toolState.learningsFilePath)
|
||||
: undefined,
|
||||
canResume: (r) => Boolean(r.sessionId),
|
||||
resume: async (c) => {
|
||||
const sessionId = c.previousResult.sessionId;
|
||||
|
||||
+285
-33
@@ -12,7 +12,7 @@
|
||||
* security is enforced at the tool layer, not the process layer.
|
||||
*/
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { mkdirSync } from "node:fs";
|
||||
import { mkdirSync, writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { performance } from "node:perf_hooks";
|
||||
import { pullfrogMcpName } from "../external.ts";
|
||||
@@ -26,6 +26,11 @@ import { SPAWN_ACTIVITY_TIMEOUT_CODE, SpawnTimeoutError, spawn } from "../utils/
|
||||
import { ThinkingTimer } from "../utils/timer.ts";
|
||||
import type { TodoTracker } from "../utils/todoTracking.ts";
|
||||
import { getDevDependencyVersion } from "../utils/version.ts";
|
||||
import {
|
||||
PULLFROG_BUS_EVENT_TYPE,
|
||||
PULLFROG_OPENCODE_PLUGIN_FILENAME,
|
||||
PULLFROG_OPENCODE_PLUGIN_SOURCE,
|
||||
} from "./opencodePlugin.ts";
|
||||
import { buildLearningsReflectionPrompt, runPostRunRetryLoop } from "./postRun.ts";
|
||||
import { REVIEWER_AGENT_NAME, REVIEWER_SYSTEM_PROMPT } from "./reviewer.ts";
|
||||
import { formatWithLabel, ORCHESTRATOR_LABEL, SessionLabeler } from "./sessionLabeler.ts";
|
||||
@@ -59,6 +64,42 @@ type OpenCodeConfig = {
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
/**
|
||||
* Per-inference `max_tokens` reservation the agent sends to the upstream
|
||||
* model. OpenCode's default is 32_000 (sized for long-running TUI sessions
|
||||
* where a human user might want big outputs). Pullfrog runs are headless and
|
||||
* short — typical outputs are 1-3K tokens — so we cap at 5_000. This
|
||||
* drastically reduces the upfront budget reservation OpenRouter requires per
|
||||
* call (~$0.38 vs ~$2.40 for Opus), which is what lets low-wallet runs
|
||||
* actually start.
|
||||
*
|
||||
* Plumbed via `OPENCODE_EXPERIMENTAL_OUTPUT_TOKEN_MAX` env var rather than the
|
||||
* config JSON. OpenCode's `OUTPUT_TOKEN_MAX` (session/llm.ts) is sourced
|
||||
* exclusively from this env var; top-level `limit.output` in the config
|
||||
* has no read site and is silently dropped on merge.
|
||||
*/
|
||||
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: {
|
||||
@@ -73,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) {
|
||||
@@ -254,7 +309,43 @@ interface OpenCodeErrorEvent {
|
||||
type: "error";
|
||||
timestamp?: string;
|
||||
sessionID?: string;
|
||||
error?: { name?: string; message?: string; data?: unknown; [key: string]: unknown };
|
||||
// opencode emits the error message under `error.data.message`, not at the
|
||||
// top level. see anomalyco/opencode packages/opencode/src/cli/cmd/run.ts.
|
||||
error?: {
|
||||
name?: string;
|
||||
data?: { message?: string; [key: string]: unknown };
|
||||
[key: string]: unknown;
|
||||
};
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* Envelope event emitted by our `.opencode/plugin/pullfrog-events.ts` (the
|
||||
* source lives in `opencodePlugin.ts`). The plugin subscribes to opencode's
|
||||
* bus via `bus.subscribeAll()` and re-emits non-orchestrator
|
||||
* `message.part.updated` events on stdout so subagent activity surfaces here.
|
||||
*
|
||||
* `bus_event.properties.part` matches the same `Part` shape that opencode's
|
||||
* `cli/cmd/run.ts` uses to drive its own emit() calls, so we can route the
|
||||
* inner part through the existing `tool_use` / `step_start` / `step_finish`
|
||||
* / `text` handlers by synthesizing the equivalent OpenCode-style event.
|
||||
*/
|
||||
interface OpenCodeBusEnvelopeEvent {
|
||||
type: "pullfrog_bus_event";
|
||||
bus_event?: {
|
||||
type?: string;
|
||||
properties?: {
|
||||
part?: {
|
||||
sessionID?: string;
|
||||
type?: string;
|
||||
time?: { end?: number | string };
|
||||
state?: { status?: string };
|
||||
[key: string]: unknown;
|
||||
};
|
||||
[key: string]: unknown;
|
||||
};
|
||||
[key: string]: unknown;
|
||||
};
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
@@ -267,7 +358,8 @@ type OpenCodeEvent =
|
||||
| OpenCodeToolUseEvent
|
||||
| OpenCodeToolResultEvent
|
||||
| OpenCodeResultEvent
|
||||
| OpenCodeErrorEvent;
|
||||
| OpenCodeErrorEvent
|
||||
| OpenCodeBusEnvelopeEvent;
|
||||
|
||||
// ── runner ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -302,15 +394,12 @@ async function runOpenCode(params: RunParams): Promise<AgentResult> {
|
||||
// per-session labeler so parallel subagent log lines can be differentiated.
|
||||
// the orchestrator's task tool_use events seed the labeler; the next
|
||||
// previously-unseen sessionID consumes the head of the pending-label queue.
|
||||
// NB: opencode's runtime currently encapsulates subagent execution inside
|
||||
// the `task` tool — subagent-internal tool_use/tool_result events do not
|
||||
// surface on the parent's NDJSON stream. The labeler is therefore mostly
|
||||
// dormant in practice for opencode (no per-event session differentiation
|
||||
// is needed because there are no per-subagent events). The orchestrator's
|
||||
// `task` dispatch log (with `description: <lens>`) and the per-task
|
||||
// duration log below are the actual attribution surface available today.
|
||||
// The labeler is kept in place defensively so that if/when opencode begins
|
||||
// streaming subagent sessions, attribution flips on with no further work.
|
||||
// upstream opencode's `cli/cmd/run.ts` filters subagent events out of its
|
||||
// NDJSON stream (`part.sessionID !== sessionID`), so we ship a per-run
|
||||
// plugin (`action/agents/opencodePlugin.ts`, written into the tmpdir at
|
||||
// setup) that re-emits non-orchestrator `message.part.updated` events. those
|
||||
// arrive here as `pullfrog_bus_event` envelopes and feed the labeler with
|
||||
// real data per subagent session.
|
||||
const labeler = new SessionLabeler();
|
||||
function eventLabel(event: Record<string, unknown>): string {
|
||||
const sid = event.sessionID ?? event.session_id;
|
||||
@@ -500,25 +589,31 @@ async function runOpenCode(params: RunParams): Promise<AgentResult> {
|
||||
// label is already bound); the dispatch label is for the next new
|
||||
// sessionID that appears.
|
||||
if (toolName === "task") {
|
||||
const taskInput = (event.part?.state?.input ?? {}) as {
|
||||
description?: string;
|
||||
subagent_type?: string;
|
||||
prompt?: string;
|
||||
};
|
||||
const dispatchedLabel = labeler.recordTaskDispatch(taskInput);
|
||||
// dual-index by callID (fast path) AND in a FIFO queue (fallback path
|
||||
// for when opencode's task tool_result carries a different callID).
|
||||
const dispatch: TaskDispatch = {
|
||||
label: dispatchedLabel,
|
||||
startedAt: performance.now(),
|
||||
toolUseCallID: toolId,
|
||||
};
|
||||
taskDispatchByCallID.set(toolId, dispatch);
|
||||
pendingTaskDispatches.push(dispatch);
|
||||
log.info(
|
||||
`» dispatching subagent: ${dispatchedLabel}` +
|
||||
(taskInput.subagent_type ? ` (subagent_type=${taskInput.subagent_type})` : "")
|
||||
);
|
||||
// may have been pre-registered via the plugin's early task-dispatch
|
||||
// announcement (`pullfrog_bus_event` handler). dedupe on callID so
|
||||
// we don't record the same dispatch twice (which would corrupt the
|
||||
// FIFO label queue).
|
||||
if (!taskDispatchByCallID.has(toolId)) {
|
||||
const taskInput = (event.part?.state?.input ?? {}) as {
|
||||
description?: string;
|
||||
subagent_type?: string;
|
||||
prompt?: string;
|
||||
};
|
||||
const dispatchedLabel = labeler.recordTaskDispatch(taskInput);
|
||||
// dual-index by callID (fast path) AND in a FIFO queue (fallback path
|
||||
// for when opencode's task tool_result carries a different callID).
|
||||
const dispatch: TaskDispatch = {
|
||||
label: dispatchedLabel,
|
||||
startedAt: performance.now(),
|
||||
toolUseCallID: toolId,
|
||||
};
|
||||
taskDispatchByCallID.set(toolId, dispatch);
|
||||
pendingTaskDispatches.push(dispatch);
|
||||
log.info(
|
||||
`» dispatching subagent: ${dispatchedLabel}` +
|
||||
(taskInput.subagent_type ? ` (subagent_type=${taskInput.subagent_type})` : "")
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// remember non-task callIDs so a later tool_result with that callID
|
||||
// is correctly identified as not-a-task (and we don't FIFO-pop a
|
||||
@@ -548,6 +643,16 @@ async function runOpenCode(params: RunParams): Promise<AgentResult> {
|
||||
if (event.part?.state?.status === "completed" && event.part.state.output) {
|
||||
log.debug(withLabel(label, ` output: ${event.part.state.output}`));
|
||||
}
|
||||
// surface tool errors at info level. opencode emits tool parts at
|
||||
// status="error" through the same `tool_use` event the CLI's run-loop
|
||||
// (and our injected plugin for subagent parts) emits — without this
|
||||
// branch the only signal in the user's logs is `» <tool>(...)` with
|
||||
// no indication the call failed. error info lives in `state.output`
|
||||
// (an error string set by the tool layer).
|
||||
if (event.part?.state?.status === "error") {
|
||||
const errorMsg = event.part.state.output ?? "(no error message)";
|
||||
log.info(withLabel(label, `» tool call failed: ${errorMsg}`));
|
||||
}
|
||||
|
||||
// agent's explicit MCP report_progress takes priority over todo tracking
|
||||
if (toolName.includes("report_progress") && params.todoTracker) {
|
||||
@@ -630,6 +735,16 @@ async function runOpenCode(params: RunParams): Promise<AgentResult> {
|
||||
log.debug(withLabel(label, `tool output: ${outputStr}`));
|
||||
}
|
||||
},
|
||||
error: (event: OpenCodeErrorEvent) => {
|
||||
// opencode emits a `type=error` event when a provider call fails (e.g.
|
||||
// 401 Invalid authentication credentials). the underlying CLI still
|
||||
// exits 0 because the error was returned cleanly by the LLM SDK, so
|
||||
// unless we capture this event the run is reported as success.
|
||||
agentErrorEvent = event;
|
||||
const errorName = event.error?.name || "unknown";
|
||||
const errorMessage = event.error?.data?.message || event.error?.name || JSON.stringify(event);
|
||||
log.info(`» ${params.label} error event: ${errorName}: ${errorMessage}`);
|
||||
},
|
||||
result: async (event: OpenCodeResultEvent) => {
|
||||
const status = event.status || "unknown";
|
||||
const duration = event.stats?.duration_ms || 0;
|
||||
@@ -658,11 +773,104 @@ async function runOpenCode(params: RunParams): Promise<AgentResult> {
|
||||
}
|
||||
}
|
||||
},
|
||||
[PULLFROG_BUS_EVENT_TYPE]: async (event: OpenCodeBusEnvelopeEvent) => {
|
||||
// surface subagent activity that opencode's CLI run-loop discards (it
|
||||
// filters `part.sessionID !== sessionID`). our injected plugin
|
||||
// (action/agents/opencodePlugin.ts) re-emits non-orchestrator
|
||||
// `message.part.updated` bus events; here we synthesize the equivalent
|
||||
// CLI-style event for each known part type and dispatch through the
|
||||
// existing handlers so labeling, attribution, and logging all reuse the
|
||||
// same code path as the orchestrator's events. mirrors the dispatch
|
||||
// logic in opencode-ai's `cli/cmd/run.ts` `loop()` function.
|
||||
const busEvent = event.bus_event;
|
||||
if (!busEvent || busEvent.type !== "message.part.updated") return;
|
||||
const part = busEvent.properties?.part;
|
||||
if (!part || typeof part.sessionID !== "string") return;
|
||||
const sessionID = part.sessionID;
|
||||
const partType = part.type;
|
||||
|
||||
// early task dispatch: the orchestrator's task tool fires bus events at
|
||||
// status=running BEFORE the subagent's first message.part.updated, but
|
||||
// the CLI's run-loop only emits the matching tool_use NDJSON event at
|
||||
// status=completed (after the subagent finishes). without
|
||||
// pre-registering the dispatch label here, the labeler binds the
|
||||
// subagent's sessionID to a generic `subagent#N` fallback before the
|
||||
// CLI's tool_use ever fires recordTaskDispatch. dedupe against
|
||||
// taskDispatchByCallID so the late tool_use handler doesn't double-add.
|
||||
if (partType === "tool") {
|
||||
const status = part.state?.status;
|
||||
const partWithToolFields = part as {
|
||||
tool?: string;
|
||||
callID?: string;
|
||||
state?: { status?: string; input?: unknown };
|
||||
};
|
||||
// only running (not pending) — at pending state.input is still {}.
|
||||
// by running, the LLM has filled in description/subagent_type/prompt.
|
||||
// mirrors the same check in the plugin source.
|
||||
const isOrchestratorTaskDispatch =
|
||||
partWithToolFields.tool === "task" && status === "running";
|
||||
if (isOrchestratorTaskDispatch) {
|
||||
const callID = partWithToolFields.callID;
|
||||
if (typeof callID === "string" && !taskDispatchByCallID.has(callID)) {
|
||||
const taskInput = (partWithToolFields.state?.input ?? {}) as {
|
||||
description?: string;
|
||||
subagent_type?: string;
|
||||
prompt?: string;
|
||||
};
|
||||
const dispatchedLabel = labeler.recordTaskDispatch(taskInput);
|
||||
const dispatch: TaskDispatch = {
|
||||
label: dispatchedLabel,
|
||||
startedAt: performance.now(),
|
||||
toolUseCallID: callID,
|
||||
};
|
||||
taskDispatchByCallID.set(callID, dispatch);
|
||||
pendingTaskDispatches.push(dispatch);
|
||||
log.info(
|
||||
`» dispatching subagent: ${dispatchedLabel}` +
|
||||
(taskInput.subagent_type ? ` (subagent_type=${taskInput.subagent_type})` : "")
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (status !== "completed" && status !== "error") return;
|
||||
await handlers.tool_use({
|
||||
type: "tool_use",
|
||||
sessionID,
|
||||
part,
|
||||
} as OpenCodeToolUseEvent);
|
||||
return;
|
||||
}
|
||||
// intentionally NOT routing subagent step_start / step_finish through
|
||||
// the orchestrator's handlers:
|
||||
// - step_finish carries `tokens` and `cost` and the handler folds
|
||||
// them into the run-wide accumulators. surfacing subagent steps
|
||||
// here would inflate the orchestrator's usage telemetry — and
|
||||
// either double-count (if opencode also bills child tokens back
|
||||
// up to the parent session) or just over-report. the existing
|
||||
// init/message/text handlers all gate on ORCHESTRATOR_LABEL for
|
||||
// the same reason.
|
||||
// - step_start mutates `currentStepId` / `currentStepType` /
|
||||
// `stepHistory`, which are orchestrator-scoped — using them to
|
||||
// attribute subagent activity in the orchestrator's tool-use
|
||||
// timing log would be wrong.
|
||||
// the subagent's tool calls and text still surface (handled below)
|
||||
// — that's the user-visible activity.
|
||||
if (partType === "step-start" || partType === "step-finish") return;
|
||||
if (partType === "text" && part.time?.end !== undefined) {
|
||||
await handlers.text({
|
||||
type: "text",
|
||||
sessionID,
|
||||
part,
|
||||
} as OpenCodeTextEvent);
|
||||
return;
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
const recentStderr: string[] = [];
|
||||
|
||||
let lastProviderError: string | null = null;
|
||||
let agentErrorEvent: OpenCodeErrorEvent | null = null;
|
||||
|
||||
let output = "";
|
||||
let stdoutBuffer = "";
|
||||
@@ -676,6 +884,20 @@ async function runOpenCode(params: RunParams): Promise<AgentResult> {
|
||||
activityTimeout: 300_000,
|
||||
onActivityTimeout: params.onActivityTimeout,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
// node_modules/opencode-ai/bin/opencode is a Node shim that spawnSyncs
|
||||
// the native opencode-<plat>-<arch> binary with stdio:"inherit". without
|
||||
// a process-group kill, SIGKILL hits only the shim, the native binary
|
||||
// is reparented to PID 1, holds our stdout pipe open, and `child.close`
|
||||
// never fires — producing zombie runs. detached + killGroup nukes the
|
||||
// whole tree.
|
||||
killGroup: true,
|
||||
// NB: we used to pass `isPausedExternally: isSubagentInFlight` to suspend
|
||||
// the activity timer during subagent dispatches. unnecessary now that
|
||||
// our injected plugin (action/agents/opencodePlugin.ts) re-emits
|
||||
// subagent `message.part.updated` events on opencode's stdout — those
|
||||
// arrive at child.stdout here, fire updateActivity(), and reset
|
||||
// lastActivityTime naturally. verified empirically in PR #634
|
||||
// (~3.3 plugin events/sec during a typical subagent run).
|
||||
onStdout: async (chunk) => {
|
||||
const text = chunk.toString();
|
||||
output += text;
|
||||
@@ -824,6 +1046,19 @@ async function runOpenCode(params: RunParams): Promise<AgentResult> {
|
||||
};
|
||||
}
|
||||
|
||||
if (agentErrorEvent) {
|
||||
const errorEvent: OpenCodeErrorEvent = agentErrorEvent;
|
||||
const errorName = errorEvent.error?.name || "agent error";
|
||||
const errorMessage =
|
||||
errorEvent.error?.data?.message || errorEvent.error?.name || JSON.stringify(errorEvent);
|
||||
return {
|
||||
success: false,
|
||||
output: finalOutput || output,
|
||||
error: `${errorName}: ${errorMessage}`,
|
||||
usage,
|
||||
};
|
||||
}
|
||||
|
||||
return { success: true, output: finalOutput || output, usage };
|
||||
} catch (error) {
|
||||
params.todoTracker?.cancel();
|
||||
@@ -874,6 +1109,20 @@ export const opencode = agent({
|
||||
|
||||
mkdirSync(join(homeEnv.XDG_CONFIG_HOME, "opencode"), { recursive: true });
|
||||
|
||||
// drop our bus-event surfacing plugin into opencode's global config dir
|
||||
// (which we've redirected to the per-run tmpdir via XDG_CONFIG_HOME).
|
||||
// opencode auto-discovers plugins from `<Global.Path.config>/{plugin,plugins}/*.{ts,js}`
|
||||
// (see `packages/opencode/src/config/config.ts:633` calling
|
||||
// `ConfigPlugin.load(dir)`), so this lands in the loader without any
|
||||
// config wiring. critically: this MUST be inside the tmpdir, never the
|
||||
// user's repo working tree — see AGENTS.md.
|
||||
const opencodePluginDir = join(homeEnv.XDG_CONFIG_HOME, "opencode", "plugin");
|
||||
mkdirSync(opencodePluginDir, { recursive: true });
|
||||
writeFileSync(
|
||||
join(opencodePluginDir, PULLFROG_OPENCODE_PLUGIN_FILENAME),
|
||||
PULLFROG_OPENCODE_PLUGIN_SOURCE
|
||||
);
|
||||
|
||||
const agentBrowserVersion = getDevDependencyVersion("agent-browser");
|
||||
addSkill({
|
||||
ref: `vercel-labs/agent-browser@v${agentBrowserVersion}`,
|
||||
@@ -899,6 +1148,7 @@ export const opencode = agent({
|
||||
...homeEnv,
|
||||
OPENCODE_CONFIG_CONTENT: buildSecurityConfig(ctx, model),
|
||||
OPENCODE_PERMISSION: permissionOverride,
|
||||
OPENCODE_EXPERIMENTAL_OUTPUT_TOKEN_MAX: PULLFROG_OPENCODE_OUTPUT_LIMIT.toString(),
|
||||
GOOGLE_GENERATIVE_AI_API_KEY:
|
||||
process.env.GOOGLE_GENERATIVE_AI_API_KEY || process.env.GEMINI_API_KEY,
|
||||
};
|
||||
@@ -929,10 +1179,12 @@ 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,
|
||||
reflectionPrompt: buildLearningsReflectionPrompt("opencode"),
|
||||
reflectionPrompt: ctx.toolState.learningsFilePath
|
||||
? buildLearningsReflectionPrompt(ctx.toolState.learningsFilePath)
|
||||
: undefined,
|
||||
resume: async (c) =>
|
||||
runOpenCode({
|
||||
...runParams,
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
/**
|
||||
* Source for the opencode plugin we drop into the per-run tmpdir at
|
||||
* `<XDG_CONFIG_HOME>/opencode/plugin/pullfrog-events.ts`. The harness already
|
||||
* redirects `XDG_CONFIG_HOME` to `ctx.tmpdir/.config` (see `opencode.ts`
|
||||
* `homeEnv`), so opencode's auto-discovery scans the tmpdir, never the user's
|
||||
* working tree. opencode's `Global.Path.config` resolves to
|
||||
* `path.join(xdgConfig, "opencode")` and the config layer auto-discovers
|
||||
* plugins from every directory in its scan list — including
|
||||
* `Global.Path.config` — by globbing `{plugin,plugins}/*.{ts,js}` via
|
||||
* `ConfigPlugin.load(dir)`.
|
||||
*
|
||||
* We MUST NOT write into the user's repo working tree. The repo is a checkout
|
||||
* the agent operates on; only the agent's own tools (gated by
|
||||
* `OPENCODE_PERMISSION`) may modify it. The whole reason we redirect HOME and
|
||||
* XDG_CONFIG_HOME is so harness-side files (config, plugins, scratch state)
|
||||
* land in the tmpdir.
|
||||
*
|
||||
* Why this plugin exists: opencode's `task` tool runs subagents in-process and
|
||||
* the CLI's `cli/cmd/run.ts` event loop filters `part.sessionID !== sessionID`,
|
||||
* so subagent-internal `message.part.updated` events are silently discarded
|
||||
* before reaching our parent NDJSON stream. plugins, by contrast, receive
|
||||
* EVERY bus event via `bus.subscribeAll()` regardless of session.
|
||||
*
|
||||
* The plugin re-emits every relevant bus event onto opencode's stdout as a
|
||||
* single JSON line wrapped in a sentinel envelope. our `runOpenCode` parser
|
||||
* recognises the envelope, unpacks it, and routes the inner part through the
|
||||
* existing handlers with a per-session label from `SessionLabeler` so each
|
||||
* subagent's tool calls / text appear inline alongside the orchestrator's.
|
||||
*
|
||||
* Dumb plugin / smart parent split: the plugin emits every part for every
|
||||
* session. the parent dedupes against the orchestrator's own session id (which
|
||||
* it already knows from the `init` event). this keeps the plugin trivial and
|
||||
* keeps the per-session attribution logic on the parent side where the
|
||||
* SessionLabeler already lives.
|
||||
*
|
||||
* Event-name prefixing: the wrapped event-type sentinel is
|
||||
* `pullfrog_bus_event` — picked to be unmistakably ours so a future opencode
|
||||
* release that introduces a coincidentally-named event type won't collide.
|
||||
*/
|
||||
|
||||
export const PULLFROG_BUS_EVENT_TYPE = "pullfrog_bus_event" as const;
|
||||
|
||||
export const PULLFROG_OPENCODE_PLUGIN_FILENAME = "pullfrog-events.ts" as const;
|
||||
|
||||
/**
|
||||
* Source written verbatim to `<XDG_CONFIG_HOME>/opencode/plugin/pullfrog-events.ts`.
|
||||
*
|
||||
* - Structural typing only (no runtime import of `@opencode-ai/plugin`):
|
||||
* opencode installs that dep into the directory containing the plugin
|
||||
* alongside discovery, but a) the dep isn't required for the structural
|
||||
* shape we use, and b) keeping zero imports avoids any module-resolution
|
||||
* coupling to opencode's plugin-loader internals across versions.
|
||||
* - default export is the plugin factory (opencode's plugin loader accepts
|
||||
* default exports as the server entrypoint).
|
||||
* - we only forward `message.part.updated`. that's where the user-visible
|
||||
* subagent activity (tool calls, text, step transitions) lives. add more
|
||||
* event types here if the parent needs them.
|
||||
* - JSON.stringify+single write keeps the line atomic up to PIPE_BUF (4KB on
|
||||
* Linux). longer parts may interleave with concurrent stdout writers; the
|
||||
* parser tolerates non-JSON lines (logs them at debug) so a torn line is a
|
||||
* missed event, not a crash.
|
||||
*/
|
||||
export const PULLFROG_OPENCODE_PLUGIN_SOURCE = `// AUTOGENERATED by Pullfrog. do not edit; it'll be overwritten on the next run.
|
||||
// surfaces opencode subagent activity that the CLI's run-loop discards. see
|
||||
// action/agents/opencodePlugin.ts in pullfrog/app for why this exists. lives
|
||||
// inside the per-run tmpdir (XDG_CONFIG_HOME/opencode/plugin/), never inside
|
||||
// the user's working tree.
|
||||
|
||||
const PULLFROG_BUS_EVENT_TYPE = ${JSON.stringify(PULLFROG_BUS_EVENT_TYPE)};
|
||||
|
||||
// the first sessionID we see on a message.part.updated event is the
|
||||
// orchestrator — opencode's run command creates exactly one top-level session
|
||||
// before any subagent is dispatched, and the user-prompt text part fires
|
||||
// before the first task tool_use. we lock that sessionID in here and use it
|
||||
// to filter: the orchestrator's events are already streamed by the CLI's
|
||||
// run-loop, so we only forward (a) all subagent events, and (b) the
|
||||
// orchestrator's task tool dispatches at status="running". the CLI only
|
||||
// emits task tool_use at status=completed (after the subagent finishes), so
|
||||
// without the early announce the parent's labeler binds subagent sessions
|
||||
// before recordTaskDispatch fires and the lens label is lost.
|
||||
let orchestratorSessionID: string | undefined;
|
||||
|
||||
function isOrchestratorTaskDispatch(part: {
|
||||
type?: string;
|
||||
tool?: string;
|
||||
state?: { status?: string };
|
||||
}): boolean {
|
||||
if (part.type !== "tool") return false;
|
||||
if (part.tool !== "task") return false;
|
||||
// only forward at status="running" (not "pending"). at pending the
|
||||
// state.input is still {} — the orchestrator has emitted the part shell
|
||||
// but the LLM hasn't filled in description/subagent_type/prompt yet. by
|
||||
// running, input is populated and recordTaskDispatch can derive the lens
|
||||
// label correctly.
|
||||
return part.state?.status === "running";
|
||||
}
|
||||
|
||||
export default async function pullfrogEventsPlugin() {
|
||||
return {
|
||||
event: async (input: {
|
||||
event: {
|
||||
type: string;
|
||||
properties?: {
|
||||
part?: {
|
||||
sessionID?: string;
|
||||
type?: string;
|
||||
tool?: string;
|
||||
state?: { status?: string };
|
||||
};
|
||||
};
|
||||
};
|
||||
}) => {
|
||||
const event = input.event;
|
||||
if (!event || typeof event !== "object") return;
|
||||
if (event.type !== "message.part.updated") return;
|
||||
const part = event.properties?.part;
|
||||
const sessionID = part?.sessionID;
|
||||
if (typeof sessionID !== "string" || sessionID.length === 0) return;
|
||||
if (orchestratorSessionID === undefined) orchestratorSessionID = sessionID;
|
||||
|
||||
if (sessionID === orchestratorSessionID) {
|
||||
// skip orchestrator events EXCEPT early task dispatches.
|
||||
if (!part || !isOrchestratorTaskDispatch(part)) return;
|
||||
}
|
||||
|
||||
try {
|
||||
const line = JSON.stringify({
|
||||
type: PULLFROG_BUS_EVENT_TYPE,
|
||||
bus_event: event,
|
||||
});
|
||||
process.stdout.write(line + "\\n");
|
||||
} catch {
|
||||
// a circular reference or BigInt etc. would throw; swallow rather
|
||||
// than letting a single bad event take down the plugin.
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
`;
|
||||
+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 (update_learnings). 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: call update_learnings 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 update_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 update_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 update_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);
|
||||
});
|
||||
});
|
||||
|
||||
+189
-28
@@ -1,5 +1,7 @@
|
||||
import { type AgentId, formatMcpToolRef } from "../external.ts";
|
||||
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
|
||||
@@ -92,52 +112,143 @@ export function buildStopHookPrompt(failure: StopHookFailure): string {
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
/** check whether the seeded summary file is byte-identical to its seed.
|
||||
* a missing or unreadable file returns false (don't nudge — the agent
|
||||
* may have legitimately deleted it, or the seed step failed; the read-
|
||||
* back path in main.ts handles both cases by skipping persist). */
|
||||
async function isSummaryUnchanged(filePath: string, seed: string): Promise<boolean> {
|
||||
try {
|
||||
const current = await readFile(filePath, "utf8");
|
||||
return current === seed;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function buildSummaryStalePrompt(filePath: string): string {
|
||||
return [
|
||||
`PR SUMMARY UNTOUCHED — the rolling PR summary file at \`${filePath}\` is byte-identical to its seed; this run did not edit it.`,
|
||||
"",
|
||||
"review the diff and update the file in place to reflect what changed in the PR. update intent, key changes, and any risks worth flagging — keep the existing section headings stable so incremental runs produce clean diffs.",
|
||||
"",
|
||||
"if the diff is genuinely too small or noisy to warrant rewriting (e.g. a one-line typo fix, a comment tweak, a formatting-only change), it's fine to leave the structure as-is — but at minimum confirm you considered it by appending one line to the appropriate section noting the run. silence is not an option; the snapshot is what the next review run reads as context.",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
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 two post-run gates: did the stop hook pass and is the working
|
||||
* tree clean? returns everything that still needs fixing so the caller can
|
||||
* render a single combined resume prompt.
|
||||
* 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 or produce review output? returns everything that still needs
|
||||
* nudging so the caller can render a single combined resume prompt.
|
||||
*
|
||||
* 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;
|
||||
}): 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;
|
||||
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");
|
||||
}
|
||||
|
||||
/**
|
||||
* prompt for a dedicated post-run reflection turn nudging the agent to call
|
||||
* `update_learnings` if it discovered anything worth persisting.
|
||||
* prompt for a dedicated post-run reflection turn nudging the agent to edit
|
||||
* the rolling learnings file if it discovered anything worth persisting.
|
||||
*
|
||||
* this exists because the learnings step baked into mode checklists is
|
||||
* frequently ignored — the agent stays focused on the task and the meta-ask
|
||||
* falls through. delivering it as its own resume turn, with nothing competing
|
||||
* for attention, raises the fire rate substantially.
|
||||
* this exists because passive "if you learned something, write it down"
|
||||
* instructions baked into mode checklists are frequently ignored — the agent
|
||||
* stays focused on the task and the meta-ask falls through. delivering it
|
||||
* as its own resume turn, with nothing competing for attention, raises the
|
||||
* fire rate substantially.
|
||||
*
|
||||
* the file is the single source of truth — there is no separate MCP tool
|
||||
* call. the server reads the file at end-of-run and persists any edits to
|
||||
* `Repo.learnings`.
|
||||
*/
|
||||
export function buildLearningsReflectionPrompt(agentId: AgentId): string {
|
||||
const t = (name: string) => formatMcpToolRef(agentId, name);
|
||||
export function buildLearningsReflectionPrompt(filePath: string): string {
|
||||
return [
|
||||
`REFLECTION — before you finish, think back over this task: did you discover anything about this repo's setup, test commands, conventions, or patterns that you are confident is correct and would reliably help future runs?`,
|
||||
`REFLECTION — before you finish, think back over this task: did you discover anything about this repo's setup, test commands, conventions, or patterns that is high-confidence and would reliably help future runs?`,
|
||||
"",
|
||||
`if so, call \`${t("update_learnings")}\` to persist it.`,
|
||||
`the rolling learnings file is at \`${filePath}\`. read it first if you haven't already, then edit it in place using your native file tools. the server reads this file at end-of-run and persists any changes — there is no tool to call.`,
|
||||
"",
|
||||
`rules:`,
|
||||
`- only call \`${t("update_learnings")}\` when the finding is high-confidence and broadly useful. skip if unsure, speculative, or one-off.`,
|
||||
`- pass the FULL merged list: existing learnings from the original prompt + your new discoveries. one fact per bullet, lines starting with \`- \`.`,
|
||||
`- deduplicate, and drop bullets that are clearly wrong or no longer relevant to the current codebase.`,
|
||||
`- if you already called \`${t("update_learnings")}\` earlier in this run, or nothing new is worth capturing, just reply "done" and stop — do not edit the repo for this reflection.`,
|
||||
`keep the file healthy:`,
|
||||
`- only add bullets when the finding is high-confidence AND broadly useful. skip speculative, one-off, or "maybe" findings.`,
|
||||
`- prune bullets that are clearly wrong, no longer relevant, or low-signal (rarely useful). a focused, accurate file beats a long stale one.`,
|
||||
`- format: flat bullet list, one fact per line starting with \`- \`. deduplicate against existing entries — if a bullet covers the same fact, update it in place instead of adding a duplicate.`,
|
||||
`- leave the file alone if you have nothing substantively new to add and the existing entries still look healthy. silence is a valid outcome — just reply "done" and stop.`,
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
@@ -161,9 +272,9 @@ export function buildLearningsReflectionPrompt(agentId: AgentId): 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;
|
||||
resume: (context: { prompt: string; previousResult: R }) => Promise<R>;
|
||||
canResume?: ((result: R) => boolean) | undefined;
|
||||
reflectionPrompt?: string | undefined;
|
||||
@@ -173,10 +284,18 @@ 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. 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 });
|
||||
const issues = await collectPostRunIssues(params.ctx, {
|
||||
skipSummaryStale: summaryStaleNudged,
|
||||
});
|
||||
if (issues.summaryStale) summaryStaleNudged = true;
|
||||
finalIssues = issues;
|
||||
|
||||
if (!hasPostRunIssues(issues)) {
|
||||
@@ -230,8 +349,25 @@ export async function runPostRunRetryLoop<R extends AgentResult>(params: {
|
||||
|
||||
log.info(`» post-run retry (attempt ${gateResumeCount + 1}/${MAX_POST_RUN_RETRIES})`);
|
||||
const prompt = buildPostRunPrompt(issues);
|
||||
// summary-stale is a soft gate that must never flip a successful run to
|
||||
// failed. when it's the only issue and the resume itself errors out,
|
||||
// restore the pre-resume successful result and break — persistSummary
|
||||
// detects the unchanged file via its seed comparison and skips the DB
|
||||
// write on its own, so no further coordination is needed here.
|
||||
const onlySummaryStale =
|
||||
issues.summaryStale !== undefined &&
|
||||
issues.stopHook === undefined &&
|
||||
issues.dirtyTree === undefined;
|
||||
const preResume = result;
|
||||
result = await params.resume({ prompt, previousResult: result });
|
||||
aggregatedUsage = mergeAgentUsage(aggregatedUsage, result.usage);
|
||||
if (!result.success && onlySummaryStale) {
|
||||
log.warning(
|
||||
`» summary-stale resume turn failed (${result.error ?? "unknown error"}), preserving prior successful result`
|
||||
);
|
||||
result = preResume;
|
||||
break;
|
||||
}
|
||||
gateResumeCount++;
|
||||
}
|
||||
|
||||
@@ -242,7 +378,12 @@ export async function runPostRunRetryLoop<R extends AgentResult>(params: {
|
||||
// already observed a clean state we skip: re-running the hook risks flaky
|
||||
// false-positive failures right after it just passed.
|
||||
if (gateResumeCount > 0 && result.success && hasPostRunIssues(finalIssues)) {
|
||||
finalIssues = await collectPostRunIssues({ stopScript: params.stopScript });
|
||||
// re-check the gates that can actually fail the run (stop hook /
|
||||
// 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) {
|
||||
@@ -258,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 };
|
||||
}
|
||||
|
||||
+43
-2
@@ -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";
|
||||
@@ -42,13 +43,38 @@ export interface StopHookFailure {
|
||||
output: string;
|
||||
}
|
||||
|
||||
export interface SummaryStale {
|
||||
/** absolute path to the seeded snapshot file the agent was meant to edit. */
|
||||
filePath: string;
|
||||
}
|
||||
|
||||
export interface PostRunIssues {
|
||||
stopHook?: StopHookFailure;
|
||||
dirtyTree?: string;
|
||||
/** populated when the rolling PR summary file is byte-identical to its
|
||||
* seed, i.e. the agent never touched it. soft gate — nudges once via a
|
||||
* resume turn but never fails the run, parallel to dirtyTree semantics. */
|
||||
summaryStale?: SummaryStale;
|
||||
/**
|
||||
* 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;
|
||||
return (
|
||||
issues.stopHook !== undefined ||
|
||||
issues.dirtyTree !== undefined ||
|
||||
issues.summaryStale !== undefined ||
|
||||
issues.unsubmittedReview !== undefined
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -91,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;
|
||||
@@ -106,6 +139,14 @@ export interface AgentRunContext {
|
||||
* guidance. null when the repo has no stop hook configured.
|
||||
*/
|
||||
stopScript?: string | null | undefined;
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
toolState: ToolState;
|
||||
/**
|
||||
* called synchronously when the agent subprocess is killed for inner
|
||||
* activity timeout. lets main.ts tear down shared resources (MCP HTTP
|
||||
|
||||
+52
-26
@@ -2,8 +2,6 @@ import { dirname } from "node:path";
|
||||
import * as core from "@actions/core";
|
||||
import arg from "arg";
|
||||
import { main } from "../main.ts";
|
||||
import { log } from "../utils/cli.ts";
|
||||
import { runPostCleanup } from "../utils/postCleanup.ts";
|
||||
import { acquireInstallationToken, revokeInstallationToken } from "../utils/token.ts";
|
||||
|
||||
// GitHub Actions runs the action entry point with the node24 binary specified
|
||||
@@ -31,16 +29,6 @@ async function runMain(): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
async function runPost(): Promise<void> {
|
||||
log.debug(`[post] script started at ${new Date().toISOString()}`);
|
||||
try {
|
||||
await runPostCleanup();
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
log.error(`[post] unexpected error: ${message}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function tokenMain(): Promise<void> {
|
||||
const reposInput = core.getInput("repos");
|
||||
const additionalRepos = reposInput
|
||||
@@ -73,7 +61,7 @@ async function tokenPost(): Promise<void> {
|
||||
}
|
||||
|
||||
function printGhaUsage(params: { stream: typeof console.log; prog: string }): void {
|
||||
params.stream(`usage: ${params.prog} gha [token] [--post]\n`);
|
||||
params.stream(`usage: ${params.prog} gha [subcommand]\n`);
|
||||
params.stream("run the github action runtime flow.");
|
||||
params.stream("");
|
||||
params.stream("subcommands:");
|
||||
@@ -81,10 +69,31 @@ function printGhaUsage(params: { stream: typeof console.log; prog: string }): vo
|
||||
params.stream("");
|
||||
params.stream("options:");
|
||||
params.stream(" -h, --help show help");
|
||||
params.stream(" --post run post-cleanup flow");
|
||||
}
|
||||
|
||||
function printGhaTokenUsage(params: { stream: typeof console.log; prog: string }): void {
|
||||
params.stream(`usage: ${params.prog} gha token [--post]\n`);
|
||||
params.stream("acquire a github app installation token, or revoke it in the post step.");
|
||||
params.stream("");
|
||||
params.stream("options:");
|
||||
params.stream(" -h, --help show help");
|
||||
params.stream(" --post revoke the previously-acquired token (post-step usage only)");
|
||||
}
|
||||
|
||||
function parseGhaArgs(args: string[]) {
|
||||
return arg(
|
||||
{
|
||||
"--help": Boolean,
|
||||
"-h": "--help",
|
||||
},
|
||||
{
|
||||
argv: args,
|
||||
stopAtPositional: true,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
function parseGhaTokenArgs(args: string[]) {
|
||||
return arg(
|
||||
{
|
||||
"--help": Boolean,
|
||||
@@ -118,27 +127,46 @@ export async function runCli(params: GhaCliParams): Promise<void> {
|
||||
return;
|
||||
}
|
||||
|
||||
const normalizedArgs = ["gha"];
|
||||
const positional = parsed._;
|
||||
const subcommand = positional[0];
|
||||
|
||||
if (positional.length > 1) {
|
||||
console.error(`unexpected positional arguments for gha: ${positional.slice(1).join(" ")}\n`);
|
||||
if (!subcommand) {
|
||||
await run(["gha"]);
|
||||
return;
|
||||
}
|
||||
|
||||
if (subcommand !== "token") {
|
||||
console.error(`unknown gha subcommand: ${subcommand}\n`);
|
||||
printGhaUsage({ stream: console.error, prog: params.prog });
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (positional[0] === "token") {
|
||||
normalizedArgs.push("token");
|
||||
} else if (positional[0]) {
|
||||
console.error(`unknown gha subcommand: ${positional[0]}\n`);
|
||||
printGhaUsage({ stream: console.error, prog: params.prog });
|
||||
// gha token [--post]
|
||||
let tokenParsed: ReturnType<typeof parseGhaTokenArgs>;
|
||||
try {
|
||||
tokenParsed = parseGhaTokenArgs(positional.slice(1));
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
console.error(`${message}\n`);
|
||||
printGhaTokenUsage({ stream: console.error, prog: params.prog });
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (parsed["--post"]) {
|
||||
if (tokenParsed["--help"]) {
|
||||
printGhaTokenUsage({ stream: console.log, prog: params.prog });
|
||||
return;
|
||||
}
|
||||
|
||||
if (tokenParsed._.length > 0) {
|
||||
console.error(`unexpected positional arguments for gha token: ${tokenParsed._.join(" ")}\n`);
|
||||
printGhaTokenUsage({ stream: console.error, prog: params.prog });
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const normalizedArgs = ["gha", "token"];
|
||||
if (tokenParsed["--post"]) {
|
||||
normalizedArgs.push("--post");
|
||||
}
|
||||
|
||||
await run(normalizedArgs);
|
||||
}
|
||||
|
||||
@@ -150,8 +178,6 @@ export async function run(args: string[]) {
|
||||
} else {
|
||||
await tokenMain();
|
||||
}
|
||||
} else if (args.includes("--post")) {
|
||||
await runPost();
|
||||
} else {
|
||||
await runMain();
|
||||
}
|
||||
|
||||
+11
-2
@@ -273,14 +273,23 @@ export interface WriteablePayload {
|
||||
triggerer?: string | undefined;
|
||||
/** event-level instructions for this trigger type (flag-expanded server-side) */
|
||||
eventInstructions?: string | undefined;
|
||||
/**
|
||||
* system-injected note about prior superseded runs (e.g. when the
|
||||
* triggering @pullfrog comment is edited). rendered alongside the user's
|
||||
* prompt rather than via eventInstructions so it survives user-prompt
|
||||
* precedence.
|
||||
*/
|
||||
previousRunsNote?: string | undefined;
|
||||
/** event data from webhook payload - discriminated union based on trigger field */
|
||||
event: PayloadEvent;
|
||||
/** timeout for agent run (e.g., "10m", "1h30m") - defaults to "1h" */
|
||||
timeout?: string | undefined;
|
||||
/** working directory for the agent */
|
||||
cwd?: string | undefined;
|
||||
/** pre-created progress comment ID for updating status */
|
||||
progressCommentId?: string | undefined;
|
||||
/** pre-created progress comment (ID + type) for updating status */
|
||||
progressComment?: { id: string; type: "issue" | "review" } | undefined;
|
||||
/** when true, seed the PR summary tmpfile + persist edits at run end */
|
||||
generateSummary?: boolean | undefined;
|
||||
}
|
||||
|
||||
// immutable payload type for agent execution
|
||||
|
||||
@@ -40,6 +40,21 @@ export {
|
||||
stripExistingFooter,
|
||||
} from "../utils/buildPullfrogFooter.ts";
|
||||
export type { ResourceUsage, UsageSummary } from "../utils/github.ts";
|
||||
export {
|
||||
isLeapingIntoActionCommentBody,
|
||||
LEAPING_INTO_ACTION_PREFIX,
|
||||
} from "../utils/leapingComment.ts";
|
||||
export type {
|
||||
CreateProgressCommentTarget,
|
||||
ProgressComment,
|
||||
ProgressCommentType,
|
||||
} from "../utils/progressComment.ts";
|
||||
export {
|
||||
createLeapingProgressComment,
|
||||
deleteProgressCommentApi,
|
||||
getProgressComment,
|
||||
updateProgressComment,
|
||||
} from "../utils/progressComment.ts";
|
||||
export {
|
||||
isValidTimeString,
|
||||
parseTimeString,
|
||||
|
||||
@@ -1,17 +1,14 @@
|
||||
// changes to tool permissions should be reflected in wiki/granular-tools.md
|
||||
|
||||
import { existsSync, readdirSync } from "node:fs";
|
||||
import { readFile } from "node:fs/promises";
|
||||
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,
|
||||
@@ -21,6 +18,7 @@ import {
|
||||
import { resolveAgent, resolveModel } from "./utils/agent.ts";
|
||||
import { apiFetch } from "./utils/apiFetch.ts";
|
||||
import { validateAgentApiKey } from "./utils/apiKeys.ts";
|
||||
import { isLocalApiUrl } from "./utils/apiUrl.ts";
|
||||
import { resolveBody } from "./utils/body.ts";
|
||||
import { formatUsageSummary, log, writeSummary } from "./utils/cli.ts";
|
||||
import { recordDiffReadFromToolUse } from "./utils/diffCoverage.ts";
|
||||
@@ -30,12 +28,16 @@ import { resolveGit, setGitAuthServer } from "./utils/gitAuth.ts";
|
||||
import { startGitAuthServer } from "./utils/gitAuthServer.ts";
|
||||
import { createOctokit, writeGitHubUsageSummaryToFile } from "./utils/github.ts";
|
||||
import { resolveInstructions } from "./utils/instructions.ts";
|
||||
import { readLearningsFile, seedLearningsFile } from "./utils/learnings.ts";
|
||||
import { executeLifecycleHook } from "./utils/lifecycle.ts";
|
||||
import { normalizeEnv } from "./utils/normalizeEnv.ts";
|
||||
import { aggregateUsage, patchWorkflowRunFields } from "./utils/patchWorkflowRunFields.ts";
|
||||
import { resolvePayload, resolvePromptInput } from "./utils/payload.ts";
|
||||
import { isRouterKeylimitExhaustedError } from "./utils/providerErrors.ts";
|
||||
import { readSummaryFile, seedSummaryFile } from "./utils/prSummary.ts";
|
||||
import { postReviewCleanup } from "./utils/reviewCleanup.ts";
|
||||
import { handleAgentResult } from "./utils/run.ts";
|
||||
import { type AccountPlan, isInfraCovered } from "./utils/runContext.ts";
|
||||
import { resolveRunContextData } from "./utils/runContextData.ts";
|
||||
import { setEnvAllowlist } from "./utils/secrets.ts";
|
||||
import { createTempDirectory, setupGit } from "./utils/setup.ts";
|
||||
@@ -111,20 +113,210 @@ interface OidcCredentials {
|
||||
requestToken: string;
|
||||
}
|
||||
|
||||
async function mintProxyKey(ctx: { oidcCredentials: OidcCredentials }): Promise<string | null> {
|
||||
/**
|
||||
* Billing-layer error surfaced from `/api/proxy-token` as a 402. User-actionable
|
||||
* — distinct from TransientError (503 / transient sync issue) so the job
|
||||
* summary + PR comment can use affirmative "you need to do X" copy rather than
|
||||
* the ambiguous "billing error" label that makes transient outages look like
|
||||
* the user's fault.
|
||||
*
|
||||
* `code` is a server-side discriminator: `router_requires_card` (no card + no
|
||||
* wallet balance on Router), or null for unclassified. `declineCode` is
|
||||
* Stripe's more specific sub-reason on `card_declined` (e.g.
|
||||
* `insufficient_funds`, `lost_card`). `needsReauthentication` is the 3DS case
|
||||
* broken out for convenience.
|
||||
*/
|
||||
class BillingError extends Error {
|
||||
code: string | null;
|
||||
declineCode: string | null;
|
||||
needsReauthentication: boolean;
|
||||
|
||||
constructor(
|
||||
message: string,
|
||||
opts: {
|
||||
code?: string | null;
|
||||
declineCode?: string | null;
|
||||
needsReauthentication?: boolean;
|
||||
} = {}
|
||||
) {
|
||||
super(message);
|
||||
this.name = "BillingError";
|
||||
this.code = opts.code ?? null;
|
||||
this.declineCode = opts.declineCode ?? null;
|
||||
this.needsReauthentication = opts.needsReauthentication ?? false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Transient service failures from `/api/proxy-token` (503: partial OpenRouter
|
||||
* usage sync, DB flake, in-flight payment intent). Not the user's fault — the
|
||||
* summary uses "temporarily unavailable" framing, and the non-zero exit lets
|
||||
* GH Actions apply whatever retry policy the workflow has configured.
|
||||
*/
|
||||
class TransientError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = "TransientError";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Deep link into the right console section for the failing account. Anchors
|
||||
* are defined in `app/console/[owner]/page.tsx` (`#billing`, `#model-access`).
|
||||
* `owner` is the GitHub login of the repo's account — i.e. the org or user
|
||||
* that pays for this repo's runs, which is the right scope for billing.
|
||||
*/
|
||||
function billingConsoleUrl(owner: string, anchor: "billing" | "model-access"): string {
|
||||
return `https://pullfrog.com/console/${encodeURIComponent(owner)}#${anchor}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a BillingError as user-facing markdown (shared between GH job summary
|
||||
* and the PR progress comment). Goals:
|
||||
*
|
||||
* - quiet, not alarmist — bold first line instead of an `### ❌` H3, since
|
||||
* the comment already has Pullfrog branding in the footer
|
||||
* - actionable — every branch ends in a single CTA deep-linked to the
|
||||
* correct section of the owner's console
|
||||
* - honest — say what actually went wrong (card declined vs. balance
|
||||
* empty vs. 3DS required), don't lump them under "billing error"
|
||||
*
|
||||
* Branches:
|
||||
* - `router_requires_card`: user is on Router mode with no card AND no
|
||||
* wallet balance (signup credit exhausted or not granted). Frame as
|
||||
* "add a card to continue", link to `#model-access` where the Add
|
||||
* Card flow lives.
|
||||
* - `router_balance_exhausted`: user has a card on file but auto-reload is
|
||||
* disabled and they've spent past their $5 overdraft buffer. Frame as
|
||||
* "balance ran out" and surface both remediation paths (top up, or flip
|
||||
* on auto-reload).
|
||||
* - `router_keylimit_exhausted`: OpenRouter rejected mid-run because the
|
||||
* per-run key budget was exhausted while the agent was working. The
|
||||
* wallet is now negative; same remediation as `router_balance_exhausted`
|
||||
* but framed for the after-the-fact case ("this run was cut short").
|
||||
* - `needsReauthentication`: issuer requires 3DS on every off-session
|
||||
* charge. Re-adding the card won't help — the only escape is a manual
|
||||
* top-up where 3DS runs interactively in Stripe Checkout.
|
||||
* - `declineCode` set: Stripe declined a real charge. Show the sub-code
|
||||
* so support can act on it; tell the user we'll retry on next dispatch.
|
||||
* - default: balance hit zero with no in-flight charge (auto-reload off
|
||||
* or amount below threshold). Direct them to top up or enable auto-reload.
|
||||
*/
|
||||
function formatBillingErrorSummary(error: BillingError, owner: string): string {
|
||||
if (error.code === "router_requires_card") {
|
||||
return [
|
||||
"**Add a card to start using Pullfrog Router.**",
|
||||
"",
|
||||
"Router proxies OpenRouter at raw cost — no platform markup. Add a card and we'll auto-reload your wallet so runs keep flowing.",
|
||||
"",
|
||||
`[Add a card →](${billingConsoleUrl(owner, "model-access")})`,
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
if (error.code === "router_balance_exhausted") {
|
||||
return [
|
||||
"**Your Pullfrog Router balance is exhausted.**",
|
||||
"",
|
||||
"You have a card on file but auto-reload is disabled, so runs paused once your balance went past the overdraft buffer.",
|
||||
"",
|
||||
`[Top up balance →](${billingConsoleUrl(owner, "billing")}) · [Enable auto-reload →](${billingConsoleUrl(owner, "model-access")})`,
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
if (error.code === "router_keylimit_exhausted") {
|
||||
return [
|
||||
"**This run was cut short — your Pullfrog Router balance ran out mid-run.**",
|
||||
"",
|
||||
"OpenRouter stopped the agent because the per-run budget was exhausted. Your wallet is now negative; top up or enable auto-reload to keep runs flowing.",
|
||||
"",
|
||||
`[Top up balance →](${billingConsoleUrl(owner, "billing")}) · [Enable auto-reload →](${billingConsoleUrl(owner, "model-access")})`,
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
if (error.needsReauthentication) {
|
||||
const code = error.declineCode ?? "authentication_required";
|
||||
return [
|
||||
`**Your card issuer requires 3D Secure on every charge** (\`${code}\`).`,
|
||||
"",
|
||||
"Pullfrog can't complete a 3DS challenge from inside a workflow. Top up your Router balance once in Stripe Checkout — subsequent runs draw from the prepaid balance without re-triggering 3DS.",
|
||||
"",
|
||||
`[Top up balance →](${billingConsoleUrl(owner, "billing")})`,
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
if (error.declineCode) {
|
||||
return [
|
||||
`**Your card was declined** (\`${error.declineCode}\`).`,
|
||||
"",
|
||||
"Update your payment method and Pullfrog will retry on the next run.",
|
||||
"",
|
||||
`[Update payment method →](${billingConsoleUrl(owner, "billing")})`,
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
return [
|
||||
"**Your Pullfrog balance is empty.**",
|
||||
"",
|
||||
"Top up your balance or enable auto-reload to keep runs flowing.",
|
||||
"",
|
||||
`[Manage billing →](${billingConsoleUrl(owner, "billing")})`,
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a TransientError as user-facing markdown. Distinct framing from
|
||||
* BillingError so the user doesn't read an alarm and assume their card
|
||||
* failed — this branch is "our fault, retry shortly", not theirs.
|
||||
*/
|
||||
function formatTransientErrorSummary(error: TransientError, owner: string): string {
|
||||
return [
|
||||
"**Pullfrog billing is temporarily unavailable.**",
|
||||
"",
|
||||
error.message,
|
||||
"",
|
||||
`Usually transient — the next dispatch should succeed. If it persists, check [status.pullfrog.com](https://status.pullfrog.com) or [your console](${billingConsoleUrl(owner, "billing")}).`,
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
async function mintProxyKey(ctx: {
|
||||
oidcCredentials: OidcCredentials | null;
|
||||
repo: { owner: string; name: string };
|
||||
}): Promise<string | null> {
|
||||
try {
|
||||
process.env.ACTIONS_ID_TOKEN_REQUEST_URL = ctx.oidcCredentials.requestUrl;
|
||||
process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN = ctx.oidcCredentials.requestToken;
|
||||
const oidcToken = await core.getIDToken("pullfrog-api");
|
||||
delete process.env.ACTIONS_ID_TOKEN_REQUEST_URL;
|
||||
delete process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN;
|
||||
const headers = await buildProxyTokenHeaders(ctx);
|
||||
if (!headers) return null;
|
||||
|
||||
const response = await apiFetch({
|
||||
path: "/api/proxy-token",
|
||||
method: "POST",
|
||||
headers: { Authorization: `Bearer ${oidcToken}` },
|
||||
headers,
|
||||
});
|
||||
|
||||
if (response.status === 402) {
|
||||
const body = (await response.json().catch(() => null)) as {
|
||||
error?: string;
|
||||
code?: string;
|
||||
declineCode?: string;
|
||||
needsReauthentication?: boolean;
|
||||
} | null;
|
||||
throw new BillingError(body?.error ?? "insufficient balance", {
|
||||
code: body?.code ?? null,
|
||||
declineCode: body?.declineCode ?? null,
|
||||
needsReauthentication: body?.needsReauthentication ?? false,
|
||||
});
|
||||
}
|
||||
|
||||
// 503 = transient sync issue (partial OpenRouter failure, DB flake,
|
||||
// in-flight top-up). Not the user's fault — TransientError renders a
|
||||
// "temporarily unavailable" summary instead of the "billing error"
|
||||
// label that BillingError uses.
|
||||
if (response.status === 503) {
|
||||
const body = (await response.json().catch(() => null)) as { error?: string } | null;
|
||||
throw new TransientError(
|
||||
body?.error ?? "billing service temporarily unavailable — retry shortly"
|
||||
);
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
log.warning(`proxy key mint failed (${response.status})`);
|
||||
return null;
|
||||
@@ -133,6 +325,8 @@ async function mintProxyKey(ctx: { oidcCredentials: OidcCredentials }): Promise<
|
||||
const data = (await response.json()) as { key: string };
|
||||
return data.key;
|
||||
} catch (error) {
|
||||
if (error instanceof BillingError) throw error;
|
||||
if (error instanceof TransientError) throw error;
|
||||
log.warning(`proxy key mint error: ${error instanceof Error ? error.message : String(error)}`);
|
||||
return null;
|
||||
} finally {
|
||||
@@ -141,37 +335,194 @@ async function mintProxyKey(ctx: { oidcCredentials: OidcCredentials }): Promise<
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* choose how to authenticate the `/api/proxy-token` request:
|
||||
*
|
||||
* - production: mint a fresh OIDC token via `core.getIDToken` and send as
|
||||
* `Authorization: Bearer …` (the server verifies it cryptographically).
|
||||
* - local dev (no OIDC + `API_URL` is localhost): send `x-dev-repo:
|
||||
* owner/repo` instead. the server-side route only honors this header
|
||||
* when `NODE_ENV === "development"`, so prod is never reachable through
|
||||
* this branch even if the action is misconfigured.
|
||||
*
|
||||
* returns null when neither path is available — caller treats as soft skip.
|
||||
*/
|
||||
async function buildProxyTokenHeaders(ctx: {
|
||||
oidcCredentials: OidcCredentials | null;
|
||||
repo: { owner: string; name: string };
|
||||
}): Promise<Record<string, string> | null> {
|
||||
if (ctx.oidcCredentials) {
|
||||
process.env.ACTIONS_ID_TOKEN_REQUEST_URL = ctx.oidcCredentials.requestUrl;
|
||||
process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN = ctx.oidcCredentials.requestToken;
|
||||
const oidcToken = await core.getIDToken("pullfrog-api");
|
||||
delete process.env.ACTIONS_ID_TOKEN_REQUEST_URL;
|
||||
delete process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN;
|
||||
return { Authorization: `Bearer ${oidcToken}` };
|
||||
}
|
||||
if (isLocalApiUrl()) {
|
||||
log.info(`» proxy: dev bypass (x-dev-repo) for ${ctx.repo.owner}/${ctx.repo.name}`);
|
||||
return { "x-dev-repo": `${ctx.repo.owner}/${ctx.repo.name}` };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function resolveProxyModel(ctx: {
|
||||
payload: ResolvedPayload;
|
||||
oss: boolean;
|
||||
plan: AccountPlan;
|
||||
proxyModel?: string | undefined;
|
||||
oidcCredentials: OidcCredentials | null;
|
||||
repo: { owner: string; name: string };
|
||||
}): Promise<void> {
|
||||
// env override = BYOK escape hatch, don't proxy
|
||||
if (process.env.PULLFROG_MODEL?.trim()) return;
|
||||
|
||||
// OSS: server decided the model
|
||||
if (ctx.oss && ctx.proxyModel) {
|
||||
if (!ctx.oidcCredentials) {
|
||||
log.warning("» oss repo but no OIDC credentials available — skipping proxy");
|
||||
return;
|
||||
}
|
||||
const key = await mintProxyKey({ oidcCredentials: ctx.oidcCredentials });
|
||||
if (!key) return;
|
||||
const needsProxy = isInfraCovered({ isOss: ctx.oss, plan: ctx.plan }) && ctx.proxyModel;
|
||||
if (!needsProxy) return;
|
||||
|
||||
process.env.OPENROUTER_API_KEY = key;
|
||||
core.setSecret(key);
|
||||
ctx.payload.proxyModel = ctx.proxyModel;
|
||||
log.info(`» proxy: oss → ${ctx.proxyModel}`);
|
||||
// dev affordance: when talking to a localhost API, the server-side
|
||||
// x-dev-repo bypass replaces OIDC verification, so a play run can
|
||||
// exercise the proxy/router/oss path without GitHub Actions OIDC.
|
||||
if (!ctx.oidcCredentials && !isLocalApiUrl()) {
|
||||
log.warning("» proxy requested but no OIDC credentials available — skipping");
|
||||
return;
|
||||
}
|
||||
|
||||
// managed billing will add its path here later
|
||||
const key = await mintProxyKey({ oidcCredentials: ctx.oidcCredentials, repo: ctx.repo });
|
||||
if (!key) return;
|
||||
|
||||
process.env.OPENROUTER_API_KEY = key;
|
||||
core.setSecret(key);
|
||||
ctx.payload.proxyModel = ctx.proxyModel;
|
||||
const label = ctx.oss ? "oss" : "router";
|
||||
log.info(`» proxy: ${label} → ${ctx.proxyModel}`);
|
||||
}
|
||||
|
||||
async function writeJobSummary(toolState: ToolState): Promise<void> {
|
||||
/**
|
||||
* Fetch the most recent persisted PR summary snapshot for this PR.
|
||||
* Returns null on first-time PRs, when summary is disabled, or on any error.
|
||||
* Best-effort: a transient API failure should not block the run.
|
||||
*/
|
||||
async function fetchPreviousSnapshot(ctx: ToolContext, prNumber: number): Promise<string | null> {
|
||||
if (!ctx.githubInstallationToken) return null;
|
||||
try {
|
||||
const response = await apiFetch({
|
||||
path: `/api/repo/${ctx.repo.owner}/${ctx.repo.name}/pr/${prNumber}/summary-comment`,
|
||||
method: "GET",
|
||||
headers: { authorization: `Bearer ${ctx.githubInstallationToken}` },
|
||||
signal: AbortSignal.timeout(10_000),
|
||||
});
|
||||
if (!response.ok) return null;
|
||||
const data = (await response.json()) as { snapshot?: string | null };
|
||||
return typeof data.snapshot === "string" && data.snapshot.length > 0 ? data.snapshot : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the agent-edited PR summary tmpfile and persist to `WorkflowRun.summarySnapshot`.
|
||||
*
|
||||
* Best-effort: any failure is logged and does not affect the run's success
|
||||
* status. Skips the PATCH when the file is byte-identical to its seed —
|
||||
* persisting the seed verbatim would either re-write what the DB already has
|
||||
* (on incremental runs) or serialize the placeholder scaffold (on first
|
||||
* runs), neither of which is useful.
|
||||
*/
|
||||
/**
|
||||
* Read the agent-edited repo-level learnings tmpfile and PATCH it to
|
||||
* `Repo.learnings`.
|
||||
*
|
||||
* Best-effort: any failure is logged and does not affect the run's success
|
||||
* status. Skips the PATCH when the file is byte-trim-identical to its seed —
|
||||
* the agent didn't touch it, so writing the same content back would just
|
||||
* burn a `LearningsRevision` row and an API round-trip.
|
||||
*
|
||||
* `model` is forwarded so `LearningsRevision.model` keeps populating; it
|
||||
* powers the per-revision attribution badge in the UI history view.
|
||||
*/
|
||||
async function persistLearnings(ctx: ToolContext): Promise<void> {
|
||||
const filePath = ctx.toolState.learningsFilePath;
|
||||
if (!filePath) return;
|
||||
if (ctx.toolState.learningsPersistAttempted) return;
|
||||
ctx.toolState.learningsPersistAttempted = true;
|
||||
const current = await readLearningsFile(filePath);
|
||||
if (current === null) {
|
||||
log.debug(`learnings tmpfile missing or unreadable at ${filePath} — skipping persist`);
|
||||
return;
|
||||
}
|
||||
const seed = ctx.toolState.learningsSeed?.trim() ?? "";
|
||||
if (current === seed) {
|
||||
log.debug("learnings tmpfile unchanged from seed — skipping persist");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const response = await apiFetch({
|
||||
path: `/api/repo/${ctx.repo.owner}/${ctx.repo.name}/learnings`,
|
||||
method: "PATCH",
|
||||
headers: {
|
||||
authorization: `Bearer ${ctx.apiToken}`,
|
||||
"content-type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
learnings: current,
|
||||
model: ctx.toolState.model,
|
||||
}),
|
||||
signal: AbortSignal.timeout(10_000),
|
||||
});
|
||||
if (!response.ok) {
|
||||
const error = await response.text().catch(() => "(no body)");
|
||||
// promoted from debug → warning: this path means the agent edited the
|
||||
// file (we already short-circuited the unchanged-from-seed case above)
|
||||
// but the PATCH dropped it on the floor. silently losing real work is
|
||||
// worse than the noise of a CI warning.
|
||||
log.warning(`learnings persist failed (${response.status}): ${error}`);
|
||||
return;
|
||||
}
|
||||
log.info("» learnings updated");
|
||||
} catch (err) {
|
||||
log.warning(`learnings persist failed: ${err instanceof Error ? err.message : String(err)}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function persistSummary(ctx: ToolContext): Promise<void> {
|
||||
const filePath = ctx.toolState.summaryFilePath;
|
||||
if (!filePath) return;
|
||||
// already-completed guard: the error-path call (success path persisted,
|
||||
// then a late step threw) and the SIGINT/SIGTERM handler all funnel
|
||||
// through here; the first one to arrive wins.
|
||||
if (ctx.toolState.summaryPersistAttempted) return;
|
||||
ctx.toolState.summaryPersistAttempted = true;
|
||||
const snapshot = await readSummaryFile(filePath);
|
||||
if (!snapshot) {
|
||||
log.debug(`pr summary tmpfile missing or invalid at ${filePath} — skipping persist`);
|
||||
return;
|
||||
}
|
||||
// soft gate: agent never touched the seeded file. saving the seed back
|
||||
// is a no-op at best (incremental run — DB already has it) and a bug at
|
||||
// worst (first run — serializes the placeholder italics). log a warning
|
||||
// so the failure mode is visible in CI without flipping the run to
|
||||
// failed.
|
||||
const seed = ctx.toolState.summarySeed?.trim();
|
||||
if (seed !== undefined && snapshot === seed) {
|
||||
log.warning(
|
||||
"» pr summary tmpfile unchanged from seed — skipping persist (agent did not edit it)"
|
||||
);
|
||||
return;
|
||||
}
|
||||
await patchWorkflowRunFields(ctx, { summarySnapshot: snapshot }).catch((err) => {
|
||||
log.debug(`pr summary persist failed: ${err instanceof Error ? err.message : String(err)}`);
|
||||
});
|
||||
}
|
||||
|
||||
// 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"));
|
||||
}
|
||||
@@ -191,12 +542,12 @@ export async function main(): Promise<MainResult> {
|
||||
let activityTimeout: ActivityTimeout | null = null;
|
||||
let safetyNetTimer: NodeJS.Timeout | undefined;
|
||||
|
||||
// parse prompt early to extract progressCommentId for toolState
|
||||
// parse prompt early to extract progressComment for toolState
|
||||
const resolvedPromptInput = resolvePromptInput();
|
||||
|
||||
const toolState = initToolState({
|
||||
progressCommentId:
|
||||
typeof resolvedPromptInput !== "string" ? resolvedPromptInput.progressCommentId : undefined,
|
||||
progressComment:
|
||||
typeof resolvedPromptInput !== "string" ? resolvedPromptInput.progressComment : undefined,
|
||||
});
|
||||
|
||||
// resolve and fingerprint git binary before any agent code runs
|
||||
@@ -251,13 +602,40 @@ export async function main(): Promise<MainResult> {
|
||||
delete process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN;
|
||||
}
|
||||
|
||||
// proxy decision: mint an OpenRouter key for OSS repos (or later, managed billing)
|
||||
await resolveProxyModel({
|
||||
payload,
|
||||
oss: runContext.oss,
|
||||
proxyModel: runContext.proxyModel,
|
||||
oidcCredentials,
|
||||
});
|
||||
// Proxy decision: mint an OpenRouter key for OSS repos or managed billing
|
||||
// accounts. BillingError (402) and TransientError (503) both surface here.
|
||||
// Handle explicitly so the user sees an actionable message (job summary +
|
||||
// PR progress comment when one exists) — otherwise the error unwinds past
|
||||
// the main try/catch (which needs toolState) and lands in runMain with only
|
||||
// a generic core.setFailed.
|
||||
try {
|
||||
await resolveProxyModel({
|
||||
payload,
|
||||
oss: runContext.oss,
|
||||
plan: runContext.plan,
|
||||
proxyModel: runContext.proxyModel,
|
||||
oidcCredentials,
|
||||
repo: runContext.repo,
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof BillingError) {
|
||||
const summary = formatBillingErrorSummary(error, runContext.repo.owner);
|
||||
await writeSummary(summary).catch(() => {});
|
||||
// Mirror to the PR progress comment if the trigger created one
|
||||
// (mention / PR event). Without this, auto-reload declines are only
|
||||
// visible in the job summary — users rarely open that, so the agent
|
||||
// just appears to silently stop mid-run.
|
||||
await reportErrorToComment({ toolState, error: summary }).catch(() => {});
|
||||
throw error;
|
||||
}
|
||||
if (error instanceof TransientError) {
|
||||
const summary = formatTransientErrorSummary(error, runContext.repo.owner);
|
||||
await writeSummary(summary).catch(() => {});
|
||||
await reportErrorToComment({ toolState, error: summary }).catch(() => {});
|
||||
throw error;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
// create octokit with MCP token for GitHub API calls
|
||||
const octokit = createOctokit(tokenRef.mcpToken);
|
||||
@@ -296,6 +674,12 @@ export async function main(): Promise<MainResult> {
|
||||
const resolvedModel = payload.proxyModel ? undefined : resolveModel({ slug: payload.model });
|
||||
const agent = resolveAgent({ model: resolvedModel });
|
||||
|
||||
// surface the effective model in comment/review footers. payload.model is
|
||||
// just the stored slug (often undefined for router/oss runs that derive
|
||||
// the target from proxyModel). matching priority with resolveModelForLog
|
||||
// so the "Using `…`" badge reflects what actually ran.
|
||||
toolState.model = payload.proxyModel ?? resolvedModel ?? payload.model;
|
||||
|
||||
validateAgentApiKey({
|
||||
agent,
|
||||
model: payload.proxyModel ?? resolvedModel ?? payload.model,
|
||||
@@ -350,6 +734,8 @@ export async function main(): Promise<MainResult> {
|
||||
jobId: runInfo.jobId,
|
||||
mcpServerUrl: "",
|
||||
tmpdir,
|
||||
oss: runContext.oss,
|
||||
plan: runContext.plan,
|
||||
resolvedModel,
|
||||
};
|
||||
await using mcpHttpServer = await startMcpHttpServer(toolContext, { outputSchema });
|
||||
@@ -357,6 +743,81 @@ export async function main(): Promise<MainResult> {
|
||||
log.info(`» MCP server started at ${mcpHttpServer.url}`);
|
||||
timer.checkpoint("mcpServer");
|
||||
|
||||
// seed the rolling repo-level learnings tmpfile for every run. the
|
||||
// agent reads the file at startup (path is surfaced in the LEARNINGS
|
||||
// section of the prompt) and may edit it during the post-run
|
||||
// reflection turn. persistLearnings reads it back at end-of-run and
|
||||
// PATCHes any changes to Repo.learnings, byte-trim equality against
|
||||
// the seed gates the API call. always-seed (vs gated): learnings are
|
||||
// universal — any run can produce them, and gating just hides the
|
||||
// affordance.
|
||||
//
|
||||
// wrapped in best-effort try/catch: this block runs unconditionally,
|
||||
// and an unwrapped filesystem failure (ENOSPC, EACCES, hostile sandbox)
|
||||
// would unwind into the outer main() catch and flip an otherwise-
|
||||
// successful run to "❌ Pullfrog failed" before the agent even starts.
|
||||
// matches `persistLearnings`'s own best-effort contract — learnings
|
||||
// are a peripheral artifact, not a load-bearing capability. on failure
|
||||
// toolState.learningsFilePath stays unset, and downstream consumers
|
||||
// (`persistLearnings`, agent harnesses, `resolveInstructions`) all
|
||||
// treat undefined as "no learnings affordance this run".
|
||||
try {
|
||||
const learningsPath = await seedLearningsFile({
|
||||
tmpdir,
|
||||
current: runContext.repoSettings.learnings,
|
||||
});
|
||||
toolState.learningsFilePath = learningsPath;
|
||||
try {
|
||||
toolState.learningsSeed = await readFile(learningsPath, "utf8");
|
||||
} catch {
|
||||
// intentionally empty — learningsSeed stays undefined, persistLearnings
|
||||
// will treat seed as "" and persist any non-empty content
|
||||
}
|
||||
log.info(
|
||||
`» learnings seeded at ${learningsPath} (existing=${runContext.repoSettings.learnings ? "yes" : "no"})`
|
||||
);
|
||||
const ctxForExit = toolContext;
|
||||
onExitSignal(() => persistLearnings(ctxForExit));
|
||||
} catch (err) {
|
||||
log.warning(
|
||||
`» learnings seed failed: ${err instanceof Error ? err.message : String(err)} — continuing without learnings file`
|
||||
);
|
||||
}
|
||||
|
||||
// seed the rolling PR summary tmpfile when the dispatcher requested it.
|
||||
// gated on event being a PR — issue/workflow_dispatch runs have no
|
||||
// summarySnapshot to maintain. file path is exposed to the agent via
|
||||
// the select_mode response addendum (action/mcp/selectMode.ts).
|
||||
if (payload.generateSummary && payload.event.is_pr && payload.event.issue_number) {
|
||||
const previousSnapshot = await fetchPreviousSnapshot(toolContext, payload.event.issue_number);
|
||||
const filePath = await seedSummaryFile({ tmpdir, previousSnapshot });
|
||||
toolState.summaryFilePath = filePath;
|
||||
// capture the exact bytes the agent will see at startup. used by
|
||||
// the post-run retry loop to detect the agent forgetting to edit
|
||||
// the file (byte-identical to seed → nudge once via resume turn)
|
||||
// and by persistSummary to skip the DB write when nothing changed.
|
||||
// we just wrote the file, so the read shouldn't fail; the catch
|
||||
// leaves summarySeed unset (its default), in which case the unchanged
|
||||
// checks downstream are simply skipped.
|
||||
try {
|
||||
toolState.summarySeed = await readFile(filePath, "utf8");
|
||||
} catch {
|
||||
// intentionally empty — summarySeed stays undefined
|
||||
}
|
||||
log.info(
|
||||
`» summary snapshot seeded at ${filePath} (previous=${previousSnapshot ? "yes" : "no"})`
|
||||
);
|
||||
// on SIGINT/SIGTERM we still want to persist whatever the agent has
|
||||
// written so far. handler is best-effort: any failure inside is
|
||||
// swallowed by Promise.allSettled in exitHandler.ts, and the
|
||||
// summaryPersistAttempted guard prevents double-execution if the
|
||||
// signal arrives after the normal path already persisted. capture a
|
||||
// narrowed reference so the closure doesn't depend on the outer
|
||||
// `toolContext` variable being defined later.
|
||||
const ctxForExit = toolContext;
|
||||
onExitSignal(() => persistSummary(ctxForExit));
|
||||
}
|
||||
|
||||
startInstallation(toolContext);
|
||||
|
||||
const modelForLog = resolveModelForLog({ payload, resolvedModel });
|
||||
@@ -374,7 +835,7 @@ export async function main(): Promise<MainResult> {
|
||||
modes,
|
||||
agentId,
|
||||
outputSchema,
|
||||
learnings: runContext.repoSettings.learnings,
|
||||
learningsFilePath: toolState.learningsFilePath ?? null,
|
||||
});
|
||||
const logParts = [
|
||||
instructions.eventInstructions
|
||||
@@ -421,6 +882,16 @@ export async function main(): Promise<MainResult> {
|
||||
});
|
||||
toolState.todoTracker = todoTracker;
|
||||
|
||||
// on cancellation, stop scheduling new tracker writes immediately. without this, a
|
||||
// debounced write queued just before SIGTERM could land at GitHub *after* the
|
||||
// workflow_run.completed webhook has already replaced the comment with the
|
||||
// "This run was cancelled" body, clobbering it back to the task list. we can't
|
||||
// await in-flight writes (the process is exiting), but cancelling the timer
|
||||
// shrinks the race window.
|
||||
onExitSignal(() => {
|
||||
todoTracker?.cancel();
|
||||
});
|
||||
|
||||
// when the agent subprocess is killed for inner activity timeout, stop
|
||||
// the MCP HTTP server so mcp-proxy's SSE reconnect attempts don't keep
|
||||
// the outer activity timer alive. start a short safety-net timer — if
|
||||
@@ -459,6 +930,7 @@ export async function main(): Promise<MainResult> {
|
||||
instructions,
|
||||
todoTracker,
|
||||
stopScript: runContext.repoSettings.stopScript,
|
||||
toolState,
|
||||
onActivityTimeout: onInnerActivityTimeout,
|
||||
onToolUse: (event) => {
|
||||
const wasTracked = recordDiffReadFromToolUse({
|
||||
@@ -540,26 +1012,76 @@ export async function main(): Promise<MainResult> {
|
||||
});
|
||||
}
|
||||
|
||||
// clean up stranded progress comments. two cases:
|
||||
// 1. wasUpdated=false: nothing wrote to the comment ("Leaping into action" orphan)
|
||||
// 2. tracker published a checklist but the agent never wrote a final summary
|
||||
// (hasPublished=true, finalSummaryWritten=false).
|
||||
// in both cases, delete the comment so it doesn't linger with stale content.
|
||||
// wasUpdated is intentionally NOT set here — cleanup is not a real progress update.
|
||||
// uses finalSummaryWritten (not todoTracker.enabled) so cleanup survives API failures
|
||||
// in report_progress where cancel() ran but the write didn't succeed.
|
||||
const trackerWasLastWriter = todoTracker?.hasPublished && !toolState.finalSummaryWritten;
|
||||
// read the agent-edited summary tmpfile and persist to the DB. happens
|
||||
// after the agent exits so the file is in its final state.
|
||||
if (toolContext) {
|
||||
await persistSummary(toolContext);
|
||||
}
|
||||
|
||||
// same for the rolling repo-level learnings tmpfile. always seeded, so
|
||||
// always read back; persistLearnings short-circuits when the file is
|
||||
// unchanged from its seed.
|
||||
if (toolContext) {
|
||||
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:
|
||||
// 1. nothing wrote to the comment ("Leaping into action" orphan)
|
||||
// 2. tracker published a checklist but the agent never finalized it
|
||||
// 3. the agent produced a substantive artifact via another MCP write tool
|
||||
// (create_issue_comment, update_pull_request_body, reply_to_review_comment)
|
||||
// and skipped report_progress — wasUpdated is true, but the progress
|
||||
// comment itself was never touched.
|
||||
// create_pull_request_review owns its own deletion (see action/mcp/review.ts),
|
||||
// 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. 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 &&
|
||||
toolState.progressCommentId &&
|
||||
(!toolState.wasUpdated || trackerWasLastWriter)
|
||||
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) {
|
||||
@@ -579,16 +1101,32 @@ export async function main(): Promise<MainResult> {
|
||||
killTrackedChildren();
|
||||
log.error(errorMessage);
|
||||
|
||||
// Reclassify OpenRouter "key budget exhausted" mid-run errors as
|
||||
// BillingError. The agent runtime surfaces this as a generic APIError,
|
||||
// but it's a Pullfrog billing concern — the user's Router wallet ran
|
||||
// out partway through the run. Route through the same formatBillingErrorSummary
|
||||
// path as proxy-token 402s so the user gets actionable copy + a top-up
|
||||
// CTA on both the job summary and the PR progress comment, instead of
|
||||
// a generic "❌ Pullfrog failed" stack-trace dump.
|
||||
const billingError = isRouterKeylimitExhaustedError(errorMessage)
|
||||
? new BillingError(errorMessage, { code: "router_keylimit_exhausted" })
|
||||
: null;
|
||||
|
||||
// best-effort summary — write the error so it's visible in the Actions summary tab
|
||||
try {
|
||||
const errorSummary = `### ❌ Pullfrog failed\n\n\`\`\`\n${errorMessage}\n\`\`\``;
|
||||
const errorSummary = billingError
|
||||
? formatBillingErrorSummary(billingError, runContext.repo.owner)
|
||||
: `### ❌ Pullfrog failed\n\n\`\`\`\n${errorMessage}\n\`\`\``;
|
||||
const usageSummary = formatUsageSummary(toolState.usageEntries);
|
||||
const parts = [errorSummary, toolState.lastProgressBody, usageSummary].filter(Boolean);
|
||||
await writeSummary(parts.join("\n\n"));
|
||||
} catch {}
|
||||
|
||||
try {
|
||||
await reportErrorToComment({ toolState, error: errorMessage });
|
||||
const commentBody = billingError
|
||||
? formatBillingErrorSummary(billingError, runContext.repo.owner)
|
||||
: errorMessage;
|
||||
await reportErrorToComment({ toolState, error: commentBody });
|
||||
} catch {
|
||||
// error reporting failed, but don't let it mask the original error
|
||||
}
|
||||
@@ -600,6 +1138,19 @@ export async function main(): Promise<MainResult> {
|
||||
});
|
||||
}
|
||||
|
||||
// best-effort summary persist on the error path: if the agent successfully
|
||||
// edited the summary file before timing out / crashing, those edits are
|
||||
// worth keeping for the next incremental run.
|
||||
if (toolContext) {
|
||||
await persistSummary(toolContext);
|
||||
}
|
||||
|
||||
// same rationale for learnings: a partial edit before a crash is still
|
||||
// worth keeping. persistLearnings is idempotent via learningsPersistAttempted.
|
||||
if (toolContext) {
|
||||
await persistLearnings(toolContext);
|
||||
}
|
||||
|
||||
return {
|
||||
success: false,
|
||||
error: errorMessage,
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
{
|
||||
"owner": "pullfrog",
|
||||
"name": "scratch",
|
||||
"pullNumber": 49,
|
||||
"reviewId": 3485940013,
|
||||
"review": {
|
||||
"body": "### This is the final PR Bugbot will review for you during this billing cycle\n\nYour free Bugbot reviews will reset on November 30\n\n<details>\n<summary>Details</summary>\n\nYour team is on the Bugbot Free tier. On this plan, Bugbot will review limited PRs each billing cycle for each member of your team.\n\nTo receive Bugbot reviews on all of your PRs, visit the [Cursor dashboard](https://www.cursor.com/dashboard?tab=bugbot) to activate Pro and start your 14-day free trial.\n</details>\n\n",
|
||||
"user": {
|
||||
"login": "cursor[bot]"
|
||||
}
|
||||
},
|
||||
"threads": [
|
||||
{
|
||||
"id": "PRRT_kwDOPaxxp85iysVl",
|
||||
"path": ".github/workflows/test.yml",
|
||||
"line": null,
|
||||
"startLine": null,
|
||||
"diffSide": "RIGHT",
|
||||
"isResolved": true,
|
||||
"isOutdated": true,
|
||||
"comments": {
|
||||
"nodes": [
|
||||
{
|
||||
"fullDatabaseId": "2544544046",
|
||||
"body": "### Bug: GitHub Actions workflow triggered for wrong branch\n\n<!-- **High Severity** -->\n\n<!-- DESCRIPTION START -->\nThe `pull_request` trigger specifies `branches: [mainc]`, but the `push` trigger specifies `branches: [main]`. This mismatch means pull requests will only trigger tests if targeting a non-existent `mainc` branch rather than the actual `main` development branch, preventing CI from running on most pull requests.\n<!-- DESCRIPTION END -->\n\n<!-- LOCATIONS START\n.github/workflows/test.yml#L6-L7\nLOCATIONS END -->\n<a href=\"https://cursor.com/open?data=eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6ImJ1Z2JvdC12MSJ9.eyJ2ZXJzaW9uIjoxLCJ0eXBlIjoiQlVHQk9UX0ZJWF9JTl9DVVJTT1IiLCJkYXRhIjp7InJlZGlzS2V5IjoiYnVnYm90OjllMTgyY2U2LWY0YWMtNDAwNS1hMzQ4LWIyYzJkZTk4OGM1ZSIsImVuY3J5cHRpb25LZXkiOiJmSW93NEdsUGUwYlYtd3M2UC1UNHdHT1JmMGZjakxfWVZEdC00SWNveXo0IiwiYnJhbmNoIjoiZGl2aWRlIn0sImlhdCI6MTc2MzYyMDgxOSwiZXhwIjoxNzY0MjI1NjE5fQ.BjkWsTqiNriojI5v10JcveUY2M50f9eflTNDgWAdjdW9w7E0EEY4GJfyzBrA72neco3qAlc34WipASNuEQbTD1fZvwtJY-TeNTDzoKmwA6gtwICB8t7qT87GPvcbDrdGGWdC8kW1jf-LntTmD0k7gt0AeENRAdRSiD3dbqYFN0huXHaB8f2Y48mpmLcnnUpoaaZe7By-Y0DnILyHppwx3AH75nKE_ZeAee3rQNGX4cwcHgB5emTSM93pMDQhT1vbIRYHMaFkOaW2-kDOA8H2QqxD4mT8VzY3skvxIo5HNZCvqE84NtEygHqkBv88g2EEijOPAAeskfsdp087yIzV9g\"><picture><source media=\"(prefers-color-scheme: dark)\" srcset=\"https://cursor.com/fix-in-cursor-dark.svg\"><source media=\"(prefers-color-scheme: light)\" srcset=\"https://cursor.com/fix-in-cursor-light.svg\"><img alt=\"Fix in Cursor\" src=\"https://cursor.com/fix-in-cursor.svg\"></picture></a> <a href=\"https://cursor.com/agents?data=eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6ImJ1Z2JvdC12MSJ9.eyJ2ZXJzaW9uIjoxLCJ0eXBlIjoiQlVHQk9UX0ZJWF9JTl9XRUIiLCJkYXRhIjp7InJlZGlzS2V5IjoiYnVnYm90OjllMTgyY2U2LWY0YWMtNDAwNS1hMzQ4LWIyYzJkZTk4OGM1ZSIsImVuY3J5cHRpb25LZXkiOiJmSW93NEdsUGUwYlYtd3M2UC1UNHdHT1JmMGZjakxfWVZEdC00SWNveXo0IiwiYnJhbmNoIjoiZGl2aWRlIiwicmVwb093bmVyIjoicHVsbGZyb2dhaSIsInJlcG9OYW1lIjoic2NyYXRjaCIsInByTnVtYmVyIjo0OSwiY29tbWl0U2hhIjoiNThiOGJmNmQ1MWE1Mjg4OGFjNGFkNzA5YWVmYTk2MWFkZDMyNDBiMSJ9LCJpYXQiOjE3NjM2MjA4MTksImV4cCI6MTc2NDIyNTYxOX0.SFDZe8R9uwhPjS55J4i_mV2ybsSZoQYM6YzdUOava4IKy1IK2OrVkVsG3-8p4rRaMBXdDZZ4ObPbtk70KqdAiLEDKBqaFcWqELc49lr0XRKUmu4F6EhESFQOvt7MLSVDIOgee8YRlhS6xtoPDqsRiV2KGOwyLEdCeYdrYz9i1DanIswWSoMRVvkjxZ6GUBYVAUg_JsgAXoKVJ-L9Q5Ygho6acVAr5NlGeBp2f6g49GX4GfDOPeV3SORQS1CjxQVRbjI-g0rW55NIisBEl8279VwG6-dTISNbyasZOB6R3eEmC4vmyAAGJjUsMwqhMPw1oaMMmYNSbtZLDESxME9IUg\"><picture><source media=\"(prefers-color-scheme: dark)\" srcset=\"https://cursor.com/fix-in-web-dark.svg\"><source media=\"(prefers-color-scheme: light)\" srcset=\"https://cursor.com/fix-in-web-light.svg\"><img alt=\"Fix in Web\" src=\"https://cursor.com/fix-in-web.svg\"></picture></a>\n\n",
|
||||
"createdAt": "2025-11-20T06:40:19Z",
|
||||
"diffHunk": "@@ -0,0 +1,36 @@\n+name: Test\n+\n+on:\n+ push:\n+ branches: [main]\n+ pull_request:\n+ branches: [mainc]",
|
||||
"line": null,
|
||||
"startLine": null,
|
||||
"originalLine": 7,
|
||||
"originalStartLine": null,
|
||||
"author": {
|
||||
"login": "cursor"
|
||||
},
|
||||
"pullRequestReview": {
|
||||
"databaseId": 3485940013,
|
||||
"author": {
|
||||
"login": "cursor"
|
||||
}
|
||||
},
|
||||
"reactionGroups": [
|
||||
{
|
||||
"content": "THUMBS_UP",
|
||||
"reactors": {
|
||||
"nodes": []
|
||||
}
|
||||
},
|
||||
{
|
||||
"content": "THUMBS_DOWN",
|
||||
"reactors": {
|
||||
"nodes": []
|
||||
}
|
||||
},
|
||||
{
|
||||
"content": "LAUGH",
|
||||
"reactors": {
|
||||
"nodes": []
|
||||
}
|
||||
},
|
||||
{
|
||||
"content": "HOORAY",
|
||||
"reactors": {
|
||||
"nodes": []
|
||||
}
|
||||
},
|
||||
{
|
||||
"content": "CONFUSED",
|
||||
"reactors": {
|
||||
"nodes": []
|
||||
}
|
||||
},
|
||||
{
|
||||
"content": "HEART",
|
||||
"reactors": {
|
||||
"nodes": []
|
||||
}
|
||||
},
|
||||
{
|
||||
"content": "ROCKET",
|
||||
"reactors": {
|
||||
"nodes": []
|
||||
}
|
||||
},
|
||||
{
|
||||
"content": "EYES",
|
||||
"reactors": {
|
||||
"nodes": []
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
],
|
||||
"prFiles": [
|
||||
{
|
||||
"filename": ".github/workflows/test.yml",
|
||||
"patch": "@@ -0,0 +1,36 @@\n+name: Test\n+\n+on:\n+ push:\n+ branches: [main]\n+ pull_request:\n+ branches: [main]\n+\n+jobs:\n+ test:\n+ runs-on: ubuntu-latest\n+\n+ strategy:\n+ matrix:\n+ node-version: [22.x]\n+\n+ steps:\n+ - name: Checkout code\n+ uses: actions/checkout@v4\n+\n+ - name: Setup pnpm\n+ uses: pnpm/action-setup@v2\n+ with:\n+ version: 8\n+\n+ - name: Setup Node.js ${{ matrix.node-version }}\n+ uses: actions/setup-node@v4\n+ with:\n+ node-version: ${{ matrix.node-version }}\n+ cache: 'pnpm'\n+\n+ - name: Install dependencies\n+ run: pnpm install\n+\n+ - name: Run tests\n+ run: pnpm test"
|
||||
},
|
||||
{
|
||||
"filename": "index.test.ts",
|
||||
"patch": "@@ -1,5 +1,5 @@\n import { describe, it, expect } from 'vitest'\n-import { add } from './index.js'\n+import { add, multiply, subtract, divide } from './index.js'\n \n describe('add function', () => {\n it('should add two positive numbers correctly', () => {\n@@ -25,3 +25,51 @@ describe('add function', () => {\n expect(add(0.1, 0.2)).toBeCloseTo(0.3)\n })\n })\n+\n+describe('multiply function', () => {\n+ it('should multiply two positive numbers correctly', () => {\n+ expect(multiply(3, 4)).toBe(12)\n+ })\n+\n+ it('should multiply negative numbers correctly', () => {\n+ expect(multiply(-2, 3)).toBe(-6)\n+ expect(multiply(-2, -3)).toBe(6)\n+ })\n+\n+ it('should handle zero correctly', () => {\n+ expect(multiply(5, 0)).toBe(0)\n+ expect(multiply(0, 5)).toBe(0)\n+ })\n+})\n+\n+describe('subtract function', () => {\n+ it('should subtract two positive numbers correctly', () => {\n+ expect(subtract(10, 3)).toBe(7)\n+ })\n+\n+ it('should handle negative numbers correctly', () => {\n+ expect(subtract(5, -3)).toBe(8)\n+ expect(subtract(-5, 3)).toBe(-8)\n+ })\n+\n+ it('should handle zero correctly', () => {\n+ expect(subtract(5, 0)).toBe(5)\n+ expect(subtract(0, 5)).toBe(-5)\n+ })\n+})\n+\n+describe('divide function', () => {\n+ it('should divide two positive numbers correctly', () => {\n+ expect(divide(10, 2)).toBe(5)\n+ })\n+\n+ it('should handle negative numbers correctly', () => {\n+ expect(divide(-10, 2)).toBe(-5)\n+ expect(divide(10, -2)).toBe(-5)\n+ })\n+\n+ it('should handle decimal results correctly', () => {\n+ expect(divide(10, 3)).toBeCloseTo(3.333, 2)\n+ expect(divide(7, 2)).toBe(3.5)\n+ })\n+})"
|
||||
},
|
||||
{
|
||||
"filename": "index.ts",
|
||||
"patch": "@@ -3,11 +3,13 @@ export function add(a: number, b: number) {\n }\n \n export function multiply(a: number, b: number) {\n- // Bug: accidentally adding 1 to the result\n- return a * b + 1;\n+ return a * b;\n }\n \n export function subtract(a: number, b: number) {\n- // Bug: accidentally adding instead of subtracting\n- return a + b;\n+ return a - b;\n+}\n+\n+export function divide(a: number, b: number) {\n+ return a / b;\n }"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"owner": "pullfrog",
|
||||
"name": "scratch",
|
||||
"pullNumber": 64,
|
||||
"reviewId": 3531000326,
|
||||
"review": {
|
||||
"body": "This PR looks great. The retry logic is well-implemented and the tests are comprehensive.",
|
||||
"user": {
|
||||
"login": "pullfrog[bot]"
|
||||
}
|
||||
},
|
||||
"threads": [],
|
||||
"prFiles": []
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
{
|
||||
"owner": "pullfrog",
|
||||
"name": "test-repo",
|
||||
"pullNumber": 1,
|
||||
"files": [
|
||||
{
|
||||
"sha": "a2d9c355792f1883c26d43d219db006b05781e4c",
|
||||
"filename": "src/format.ts",
|
||||
"status": "modified",
|
||||
"additions": 12,
|
||||
"deletions": 2,
|
||||
"changes": 14,
|
||||
"blob_url": "https://github.com/pullfrog/test-repo/blob/0311c0fb58fc7faa46e51c174394a4468f379681/src%2Fformat.ts",
|
||||
"raw_url": "https://github.com/pullfrog/test-repo/raw/0311c0fb58fc7faa46e51c174394a4468f379681/src%2Fformat.ts",
|
||||
"contents_url": "https://api.github.com/repos/pullfrog/test-repo/contents/src%2Fformat.ts?ref=0311c0fb58fc7faa46e51c174394a4468f379681",
|
||||
"patch": "@@ -1,7 +1,17 @@\n-export function formatCurrency(amount: number) {\n- return `$${amount.toFixed(2)}`;\n+export function formatCurrency(amount: number, currency = \"USD\") {\n+ return new Intl.NumberFormat(\"en-US\", {\n+ style: \"currency\",\n+ currency,\n+ }).format(amount);\n }\n \n export function formatPercent(value: number) {\n return `${(value * 100).toFixed(1)}%`;\n }\n+\n+export function formatNumber(value: number, decimals = 2) {\n+ return new Intl.NumberFormat(\"en-US\", {\n+ minimumFractionDigits: decimals,\n+ maximumFractionDigits: decimals,\n+ }).format(value);\n+}"
|
||||
},
|
||||
{
|
||||
"sha": "0786b9ce6870e65c644673745266e87eef057ce4",
|
||||
"filename": "src/math.ts",
|
||||
"status": "modified",
|
||||
"additions": 5,
|
||||
"deletions": 2,
|
||||
"changes": 7,
|
||||
"blob_url": "https://github.com/pullfrog/test-repo/blob/0311c0fb58fc7faa46e51c174394a4468f379681/src%2Fmath.ts",
|
||||
"raw_url": "https://github.com/pullfrog/test-repo/raw/0311c0fb58fc7faa46e51c174394a4468f379681/src%2Fmath.ts",
|
||||
"contents_url": "https://api.github.com/repos/pullfrog/test-repo/contents/src%2Fmath.ts?ref=0311c0fb58fc7faa46e51c174394a4468f379681",
|
||||
"patch": "@@ -3,13 +3,16 @@ export function add(a: number, b: number) {\n }\n \n export function subtract(a: number, b: number) {\n- return a + b; // bug: should be a - b\n+ return a - b;\n }\n \n export function multiply(a: number, b: number) {\n- return a * b + 1; // bug: off by one\n+ return a * b;\n }\n \n export function divide(a: number, b: number) {\n+ if (b === 0) {\n+ throw new Error(\"division by zero\");\n+ }\n return a / b;\n }"
|
||||
},
|
||||
{
|
||||
"sha": "cf92d8f6562c1be779506fec1049f38c9206c869",
|
||||
"filename": "src/old-module.ts",
|
||||
"status": "removed",
|
||||
"additions": 0,
|
||||
"deletions": 4,
|
||||
"changes": 4,
|
||||
"blob_url": "https://github.com/pullfrog/test-repo/blob/91ef1048326ef786fbcf95f29b3e2555506d2d54/src%2Fold-module.ts",
|
||||
"raw_url": "https://github.com/pullfrog/test-repo/raw/91ef1048326ef786fbcf95f29b3e2555506d2d54/src%2Fold-module.ts",
|
||||
"contents_url": "https://api.github.com/repos/pullfrog/test-repo/contents/src%2Fold-module.ts?ref=91ef1048326ef786fbcf95f29b3e2555506d2d54",
|
||||
"patch": "@@ -1,4 +0,0 @@\n-// this module is deprecated and will be removed\n-export function legacyHelper() {\n- return \"old\";\n-}"
|
||||
},
|
||||
{
|
||||
"sha": "a5bfb8a1be72e4f0816a5c4c83ee784a06559629",
|
||||
"filename": "src/validate.ts",
|
||||
"status": "added",
|
||||
"additions": 11,
|
||||
"deletions": 0,
|
||||
"changes": 11,
|
||||
"blob_url": "https://github.com/pullfrog/test-repo/blob/0311c0fb58fc7faa46e51c174394a4468f379681/src%2Fvalidate.ts",
|
||||
"raw_url": "https://github.com/pullfrog/test-repo/raw/0311c0fb58fc7faa46e51c174394a4468f379681/src%2Fvalidate.ts",
|
||||
"contents_url": "https://api.github.com/repos/pullfrog/test-repo/contents/src%2Fvalidate.ts?ref=0311c0fb58fc7faa46e51c174394a4468f379681",
|
||||
"patch": "@@ -0,0 +1,11 @@\n+export function isPositive(n: number) {\n+ return n > 0;\n+}\n+\n+export function isInRange(value: number, min: number, max: number) {\n+ return value >= min && value <= max;\n+}\n+\n+export function isInteger(n: number) {\n+ return Number.isInteger(n);\n+}"
|
||||
},
|
||||
{
|
||||
"sha": "5815895211d8e3355fdb77b9e216e73a248644d9",
|
||||
"filename": "test/math.test.ts",
|
||||
"status": "modified",
|
||||
"additions": 4,
|
||||
"deletions": 0,
|
||||
"changes": 4,
|
||||
"blob_url": "https://github.com/pullfrog/test-repo/blob/0311c0fb58fc7faa46e51c174394a4468f379681/test%2Fmath.test.ts",
|
||||
"raw_url": "https://github.com/pullfrog/test-repo/raw/0311c0fb58fc7faa46e51c174394a4468f379681/test%2Fmath.test.ts",
|
||||
"contents_url": "https://api.github.com/repos/pullfrog/test-repo/contents/test%2Fmath.test.ts?ref=0311c0fb58fc7faa46e51c174394a4468f379681",
|
||||
"patch": "@@ -17,4 +17,8 @@ describe(\"math\", () => {\n it(\"divides\", () => {\n expect(divide(10, 2)).toBe(5);\n });\n+\n+ it(\"throws on division by zero\", () => {\n+ expect(() => divide(1, 0)).toThrow(\"division by zero\");\n+ });\n });"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`fetchAndFormatPrDiff > generates accurate TOC line numbers for pullfrog/test-repo#1 > content 1`] = `
|
||||
exports[`formatFilesWithLineNumbers > generates accurate TOC line numbers for pullfrog/test-repo#1 > content 1`] = `
|
||||
"## Files (5)
|
||||
- src/format.ts → lines 9-32 · diff-41c7b3ac268a3a1ae5c7be92f1230f600013b7170e44a693570ccbdb183ea36b
|
||||
- src/math.ts → lines 33-55 · diff-9c6e445a719b33e276684bdf95c69e617f0303638d44cf90d61295f2720ecc63
|
||||
@@ -96,7 +96,7 @@ diff --git a/test/math.test.ts b/test/math.test.ts
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`fetchAndFormatPrDiff > generates accurate TOC line numbers for pullfrog/test-repo#1 > toc 1`] = `
|
||||
exports[`formatFilesWithLineNumbers > generates accurate TOC line numbers for pullfrog/test-repo#1 > toc 1`] = `
|
||||
"## Files (5)
|
||||
- src/format.ts → lines 9-32 · diff-41c7b3ac268a3a1ae5c7be92f1230f600013b7170e44a693570ccbdb183ea36b
|
||||
- src/math.ts → lines 33-55 · diff-9c6e445a719b33e276684bdf95c69e617f0303638d44cf90d61295f2720ecc63
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`getFormattedReviewThreads > formats body-only review > content 1`] = `
|
||||
exports[`formatReviewData > formats body-only review > content 1`] = `
|
||||
"# Review Threads (0) for PR #64 - Review 3531000326 by pullfrog[bot]
|
||||
|
||||
## Review Body
|
||||
@@ -11,9 +11,9 @@ This PR looks great. The retry logic is well-implemented and the tests are compr
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`getFormattedReviewThreads > formats body-only review > toc 1`] = `""`;
|
||||
exports[`formatReviewData > formats body-only review > toc 1`] = `""`;
|
||||
|
||||
exports[`getFormattedReviewThreads > formats thread blocks with TOC and correct line numbers > content 1`] = `
|
||||
exports[`formatReviewData > formats thread blocks with TOC and correct line numbers > content 1`] = `
|
||||
"# Review Threads (1) for PR #49 - Review 3485940013 by cursor[bot]
|
||||
|
||||
## TOC
|
||||
@@ -68,4 +68,4 @@ LOCATIONS END -->
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`getFormattedReviewThreads > formats thread blocks with TOC and correct line numbers > toc 1`] = `"- .github/workflows/test.yml:7 → lines 25-52"`;
|
||||
exports[`formatReviewData > formats thread blocks with TOC and correct line numbers > toc 1`] = `"- .github/workflows/test.yml:7 → lines 25-52"`;
|
||||
|
||||
+42
-52
@@ -1,8 +1,7 @@
|
||||
import type { RestEndpointMethodTypes } from "@octokit/rest";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { acquireNewToken, createOctokit } from "../utils/github.ts";
|
||||
import { fetchAndFormatPrDiff } from "./checkout.ts";
|
||||
import type { ToolContext } from "./server.ts";
|
||||
import { type FormatFilesResult, formatFilesWithLineNumbers } from "./checkout.ts";
|
||||
|
||||
/**
|
||||
* parses TOC entries like "- src/math.ts → lines 7-42 · diff-<hex>" into structured data.
|
||||
@@ -22,59 +21,50 @@ function parseTocEntries(toc: string) {
|
||||
return entries;
|
||||
}
|
||||
|
||||
async function getToken(): Promise<string> {
|
||||
// prefer explicit GH_TOKEN, fall back to acquiring one via GitHub App credentials
|
||||
if (process.env.GH_TOKEN) return process.env.GH_TOKEN;
|
||||
return await acquireNewToken();
|
||||
// fixture captured by action/scripts/refresh-test-fixtures.ts. running
|
||||
// the formatter against checked-in JSON keeps this test offline and
|
||||
// deterministic — re-fetch the fixture (with creds) when GitHub's
|
||||
// pulls.listFiles response shape changes, then review the snapshot diff.
|
||||
type DiffFixture = {
|
||||
owner: string;
|
||||
name: string;
|
||||
pullNumber: number;
|
||||
files: Parameters<typeof formatFilesWithLineNumbers>[0];
|
||||
};
|
||||
|
||||
function loadFixture<T>(file: string): T {
|
||||
return JSON.parse(readFileSync(resolve(import.meta.dirname, "__fixtures__", file), "utf-8")) as T;
|
||||
}
|
||||
|
||||
describe("fetchAndFormatPrDiff", () => {
|
||||
it(
|
||||
"generates accurate TOC line numbers for pullfrog/test-repo#1",
|
||||
{ timeout: 30000 },
|
||||
async () => {
|
||||
const token = await getToken();
|
||||
const octokit = createOctokit(token);
|
||||
const ctx = {
|
||||
octokit,
|
||||
repo: {
|
||||
owner: "pullfrog",
|
||||
name: "test-repo",
|
||||
data: {} as RestEndpointMethodTypes["repos"]["get"]["response"]["data"],
|
||||
},
|
||||
} as ToolContext;
|
||||
const result = await fetchAndFormatPrDiff(ctx, 1);
|
||||
describe("formatFilesWithLineNumbers", () => {
|
||||
it("generates accurate TOC line numbers for pullfrog/test-repo#1", () => {
|
||||
const fx = loadFixture<DiffFixture>("pullfrog-test-repo-pr-1.diff.json");
|
||||
const result: FormatFilesResult = formatFilesWithLineNumbers(fx.files);
|
||||
|
||||
// verify content includes TOC at the start
|
||||
expect(result.content.startsWith(result.toc)).toBe(true);
|
||||
expect(result.content.startsWith(result.toc)).toBe(true);
|
||||
|
||||
// parse TOC and validate every entry's line numbers against actual content
|
||||
const contentLines = result.content.split("\n");
|
||||
const tocEntries = parseTocEntries(result.toc);
|
||||
expect(tocEntries.length).toBeGreaterThan(0);
|
||||
const contentLines = result.content.split("\n");
|
||||
const tocEntries = parseTocEntries(result.toc);
|
||||
expect(tocEntries.length).toBeGreaterThan(0);
|
||||
|
||||
for (const entry of tocEntries) {
|
||||
// line numbers are 1-indexed, arrays are 0-indexed
|
||||
const firstLine = contentLines[entry.startLine - 1];
|
||||
expect(firstLine).toBeDefined();
|
||||
// first line of each file section should be the diff header
|
||||
expect(firstLine).toBe(`diff --git a/${entry.filename} b/${entry.filename}`);
|
||||
for (const entry of tocEntries) {
|
||||
// line numbers are 1-indexed, arrays are 0-indexed
|
||||
const firstLine = contentLines[entry.startLine - 1];
|
||||
expect(firstLine).toBeDefined();
|
||||
// first line of each file section should be the diff header
|
||||
expect(firstLine).toBe(`diff --git a/${entry.filename} b/${entry.filename}`);
|
||||
|
||||
// endLine should be within bounds
|
||||
expect(entry.endLine).toBeLessThanOrEqual(contentLines.length);
|
||||
}
|
||||
|
||||
// verify adjacent files don't overlap and are contiguous
|
||||
for (let i = 1; i < tocEntries.length; i++) {
|
||||
const prev = tocEntries[i - 1];
|
||||
const curr = tocEntries[i];
|
||||
// current file starts right after previous file ends
|
||||
expect(curr.startLine).toBe(prev.endLine + 1);
|
||||
}
|
||||
|
||||
// snapshot the full output for regression detection
|
||||
expect(result.toc).toMatchSnapshot("toc");
|
||||
expect(result.content).toMatchSnapshot("content");
|
||||
expect(entry.endLine).toBeLessThanOrEqual(contentLines.length);
|
||||
}
|
||||
);
|
||||
|
||||
// verify adjacent files don't overlap and are contiguous
|
||||
for (let i = 1; i < tocEntries.length; i++) {
|
||||
const prev = tocEntries[i - 1];
|
||||
const curr = tocEntries[i];
|
||||
expect(curr.startLine).toBe(prev.endLine + 1);
|
||||
}
|
||||
|
||||
expect(result.toc).toMatchSnapshot("toc");
|
||||
expect(result.content).toMatchSnapshot("content");
|
||||
});
|
||||
});
|
||||
|
||||
+129
-4
@@ -1,5 +1,5 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { writeFileSync } from "node:fs";
|
||||
import { statSync, unlinkSync, writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import type { Octokit, RestEndpointMethodTypes } from "@octokit/rest";
|
||||
import { type } from "arktype";
|
||||
@@ -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";
|
||||
@@ -275,6 +276,101 @@ type CheckoutPrBranchParams = GitContext & {
|
||||
beforeSha?: string | undefined;
|
||||
};
|
||||
|
||||
// stale lock files left over from a crashed/cancelled prior git process block
|
||||
// every subsequent fetch with `Unable to create '<path>': File exists`. only
|
||||
// sweep locks older than this threshold so we never race a concurrent
|
||||
// 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",
|
||||
".git/objects/maintenance.lock",
|
||||
] as const;
|
||||
|
||||
function cleanupStaleGitLocks(): void {
|
||||
const now = Date.now();
|
||||
for (const relPath of GIT_LOCK_PATHS) {
|
||||
let mtimeMs: number;
|
||||
try {
|
||||
mtimeMs = statSync(relPath).mtimeMs;
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
if (now - mtimeMs < STALE_LOCK_AGE_MS) continue;
|
||||
try {
|
||||
unlinkSync(relPath);
|
||||
log.warning(`» removed stale ${relPath} from prior run`);
|
||||
} catch (e) {
|
||||
log.debug(
|
||||
`» failed to remove stale ${relPath}: ${e instanceof Error ? e.message : String(e)}`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
@@ -296,6 +392,12 @@ export async function checkoutPrBranch(
|
||||
rejectIfLeadingDash(pr.baseRef, "PR base ref");
|
||||
rejectIfLeadingDash(pr.headRef, "PR head ref");
|
||||
|
||||
// self-hosted runners and cancelled jobs frequently leave stale .git/*.lock
|
||||
// files behind. without this sweep, the first fetch below aborts with
|
||||
// `Unable to create '.git/shallow.lock': File exists` and the agent has to
|
||||
// shell out to `rm -f` (issue #564).
|
||||
cleanupStaleGitLocks();
|
||||
|
||||
const isFork = pr.headRepoFullName !== pr.baseRepoFullName;
|
||||
|
||||
// always use pr-{number} as local branch name for consistency
|
||||
@@ -326,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 });
|
||||
@@ -545,6 +669,7 @@ export function CheckoutPrTool(ctx: ToolContext) {
|
||||
diffPath,
|
||||
totalLines: countLines({ content: formatResult.content }),
|
||||
toc: formatResult.toc,
|
||||
previous: ctx.toolState.diffCoverage,
|
||||
});
|
||||
log.debug(
|
||||
`» diff coverage initialized: diffPath=${diffPath}, totalLines=${ctx.toolState.diffCoverage.totalLines}, tocEntries=${ctx.toolState.diffCoverage.tocEntries.length}`
|
||||
|
||||
+151
-113
@@ -4,21 +4,19 @@ import { buildPullfrogFooter, stripExistingFooter } from "../utils/buildPullfrog
|
||||
import { log } from "../utils/cli.ts";
|
||||
import { fixDoubleEscapedString } from "../utils/fixDoubleEscapedString.ts";
|
||||
import { patchWorkflowRunFields } from "../utils/patchWorkflowRunFields.ts";
|
||||
import {
|
||||
createLeapingProgressComment,
|
||||
deleteProgressCommentApi,
|
||||
updateProgressComment,
|
||||
} from "../utils/progressComment.ts";
|
||||
import type { ToolContext } from "./server.ts";
|
||||
import { execute, tool } from "./shared.ts";
|
||||
|
||||
/**
|
||||
* The prefix text for the initial "leaping into action" comment.
|
||||
* This is used to identify if a comment is still in its initial state
|
||||
* and hasn't been updated with progress or error messages.
|
||||
*/
|
||||
export const LEAPING_INTO_ACTION_PREFIX = "Leaping into action";
|
||||
|
||||
export function isLeapingIntoActionCommentBody(body: string): boolean {
|
||||
const content = stripExistingFooter(body).trimStart();
|
||||
const firstLine = content.split(/\r?\n/, 1)[0]?.trimEnd() ?? "";
|
||||
return new RegExp(`(^|\\s)${LEAPING_INTO_ACTION_PREFIX}(\\.\\.\\.)?$`).test(firstLine);
|
||||
}
|
||||
// re-export for backward compat with anything importing the leaping helpers from mcp/comment
|
||||
export {
|
||||
isLeapingIntoActionCommentBody,
|
||||
LEAPING_INTO_ACTION_PREFIX,
|
||||
} from "../utils/leapingComment.ts";
|
||||
|
||||
function buildCommentFooter(ctx: ToolContext, customParts?: string[]): string {
|
||||
const runId = ctx.runId;
|
||||
@@ -58,10 +56,8 @@ export const Comment = type({
|
||||
issueNumber: type.number.describe("the issue number to comment on"),
|
||||
body: type.string.describe("the comment body content"),
|
||||
type: type
|
||||
.enumerated("Plan", "Summary", "Comment")
|
||||
.describe(
|
||||
"Plan: record as the plan for this run. Summary: record as the PR summary comment (one per PR, updated in place). Comment: regular comment (default)."
|
||||
)
|
||||
.enumerated("Plan", "Comment")
|
||||
.describe("Plan: record as the plan for this run. Comment: regular comment (default).")
|
||||
.optional(),
|
||||
});
|
||||
|
||||
@@ -69,35 +65,11 @@ export function CreateCommentTool(ctx: ToolContext) {
|
||||
return tool({
|
||||
name: "create_issue_comment",
|
||||
description:
|
||||
"Create a comment on a GitHub issue or PR. For progress/plan updates on the current run use report_progress instead. Use type: 'Plan' for plan comments, type: 'Summary' for PR summary comments.",
|
||||
"Create a comment on a GitHub issue or PR. For progress/plan updates on the current run use report_progress instead. Use type: 'Plan' for plan comments.",
|
||||
parameters: Comment,
|
||||
execute: execute(async ({ issueNumber, body, type: commentType }) => {
|
||||
const bodyWithFooter = addFooter(ctx, body);
|
||||
|
||||
// if a summary comment already exists (found by select_mode), update instead of creating
|
||||
if (commentType === "Summary" && ctx.toolState.existingSummaryCommentId) {
|
||||
log.info(
|
||||
`» redirecting create_issue_comment(Summary) to update existing comment ${ctx.toolState.existingSummaryCommentId}`
|
||||
);
|
||||
const result = await ctx.octokit.rest.issues.updateComment({
|
||||
owner: ctx.repo.owner,
|
||||
repo: ctx.repo.name,
|
||||
comment_id: ctx.toolState.existingSummaryCommentId,
|
||||
body: bodyWithFooter,
|
||||
});
|
||||
|
||||
if (result.data.node_id) {
|
||||
await patchWorkflowRunFields(ctx, { summaryCommentNodeId: result.data.node_id });
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
commentId: result.data.id,
|
||||
url: result.data.html_url,
|
||||
body: result.data.body,
|
||||
};
|
||||
}
|
||||
|
||||
const result = await ctx.octokit.rest.issues.createComment({
|
||||
owner: ctx.repo.owner,
|
||||
repo: ctx.repo.name,
|
||||
@@ -105,6 +77,9 @@ export function CreateCommentTool(ctx: ToolContext) {
|
||||
body: bodyWithFooter,
|
||||
});
|
||||
|
||||
ctx.toolState.wasUpdated = true;
|
||||
log.info(`» created comment ${result.data.id}`);
|
||||
|
||||
if (commentType === "Plan") {
|
||||
if (result.data.node_id) {
|
||||
await patchWorkflowRunFields(ctx, { planCommentNodeId: result.data.node_id });
|
||||
@@ -120,6 +95,7 @@ export function CreateCommentTool(ctx: ToolContext) {
|
||||
comment_id: result.data.id,
|
||||
body: bodyWithPlanLink,
|
||||
});
|
||||
log.info(`» updated comment ${updateResult.data.id}`);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
@@ -129,10 +105,6 @@ export function CreateCommentTool(ctx: ToolContext) {
|
||||
};
|
||||
}
|
||||
|
||||
if (commentType === "Summary" && result.data.node_id) {
|
||||
await patchWorkflowRunFields(ctx, { summaryCommentNodeId: result.data.node_id });
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
commentId: result.data.id,
|
||||
@@ -162,6 +134,7 @@ export function EditCommentTool(ctx: ToolContext) {
|
||||
comment_id: commentId,
|
||||
body: bodyWithFooter,
|
||||
});
|
||||
log.info(`» updated comment ${result.data.id}`);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
@@ -184,12 +157,15 @@ export const ReportProgress = type({
|
||||
/**
|
||||
* Report progress to a GitHub comment.
|
||||
*
|
||||
* progressCommentId has three states:
|
||||
* progressComment has three states:
|
||||
* - undefined: no comment yet — will create one if an issue/PR target exists
|
||||
* - number: active comment — will update it in place
|
||||
* - object: active comment — will update it in place via the right REST endpoint for its type
|
||||
* - null: deliberately deleted (e.g. after submitting a PR review) — skips silently
|
||||
*
|
||||
* The body is always tracked in lastProgressBody for the job summary regardless of comment state.
|
||||
*
|
||||
* The "existing plan comment" path always targets a top-level issue comment (plan comments are
|
||||
* created by create_issue_comment with type:"Plan", never as review-thread replies).
|
||||
*/
|
||||
export async function reportProgress(
|
||||
ctx: ToolContext,
|
||||
@@ -204,7 +180,7 @@ export async function reportProgress(
|
||||
// always track the body for job summary
|
||||
ctx.toolState.lastProgressBody = body;
|
||||
|
||||
// silent events (e.g., auto-label, PR summary) should never create or update progress comments.
|
||||
// silent events (e.g., auto-label, pr-summary Task) should never create or update progress comments.
|
||||
// the body is still tracked above for the GitHub Actions job summary.
|
||||
if (ctx.payload.event.silent) {
|
||||
return { body, action: "skipped" };
|
||||
@@ -212,6 +188,7 @@ export async function reportProgress(
|
||||
|
||||
const issueNumber = ctx.payload.event.issue_number ?? ctx.toolState.issueNumber;
|
||||
const isPlanMode = ctx.toolState.selectedMode === "Plan";
|
||||
const apiCtx = { octokit: ctx.octokit, owner: ctx.repo.owner, repo: ctx.repo.name };
|
||||
|
||||
// when editing existing plan: update the plan comment from tool state (set by select_mode)
|
||||
if (target_plan_comment === true && ctx.toolState.existingPlanCommentId === undefined) {
|
||||
@@ -225,63 +202,57 @@ export async function reportProgress(
|
||||
const footer = buildCommentFooter(ctx, customParts);
|
||||
const bodyWithFooter = `${bodyWithoutFooter}${footer}`;
|
||||
|
||||
const result = await ctx.octokit.rest.issues.updateComment({
|
||||
owner: ctx.repo.owner,
|
||||
repo: ctx.repo.name,
|
||||
comment_id: commentId,
|
||||
body: bodyWithFooter,
|
||||
});
|
||||
const result = await updateProgressComment(
|
||||
apiCtx,
|
||||
{ id: commentId, type: "issue" },
|
||||
bodyWithFooter
|
||||
);
|
||||
|
||||
ctx.toolState.wasUpdated = true;
|
||||
|
||||
if (isPlanMode && result.data.node_id) {
|
||||
await patchWorkflowRunFields(ctx, { planCommentNodeId: result.data.node_id });
|
||||
if (isPlanMode && result.node_id) {
|
||||
await patchWorkflowRunFields(ctx, { planCommentNodeId: result.node_id });
|
||||
}
|
||||
|
||||
return {
|
||||
commentId: result.data.id,
|
||||
url: result.data.html_url,
|
||||
body: result.data.body || "",
|
||||
commentId: result.id,
|
||||
url: result.html_url,
|
||||
body: result.body || "",
|
||||
action: "updated",
|
||||
};
|
||||
}
|
||||
|
||||
const existingCommentId = ctx.toolState.progressCommentId;
|
||||
const existingComment = ctx.toolState.progressComment;
|
||||
|
||||
// if we already have a progress comment, update it
|
||||
if (existingCommentId) {
|
||||
if (existingComment) {
|
||||
const customParts =
|
||||
isPlanMode && issueNumber !== undefined
|
||||
? [buildImplementPlanLink(ctx, issueNumber, existingCommentId)]
|
||||
? [buildImplementPlanLink(ctx, issueNumber, existingComment.id)]
|
||||
: undefined;
|
||||
|
||||
const bodyWithoutFooter = stripExistingFooter(body);
|
||||
const footer = buildCommentFooter(ctx, customParts);
|
||||
const bodyWithFooter = `${bodyWithoutFooter}${footer}`;
|
||||
|
||||
const result = await ctx.octokit.rest.issues.updateComment({
|
||||
owner: ctx.repo.owner,
|
||||
repo: ctx.repo.name,
|
||||
comment_id: existingCommentId,
|
||||
body: bodyWithFooter,
|
||||
});
|
||||
const result = await updateProgressComment(apiCtx, existingComment, bodyWithFooter);
|
||||
|
||||
ctx.toolState.wasUpdated = true;
|
||||
|
||||
if (isPlanMode && result.data.node_id) {
|
||||
await patchWorkflowRunFields(ctx, { planCommentNodeId: result.data.node_id });
|
||||
if (isPlanMode && result.node_id) {
|
||||
await patchWorkflowRunFields(ctx, { planCommentNodeId: result.node_id });
|
||||
}
|
||||
|
||||
return {
|
||||
commentId: result.data.id,
|
||||
url: result.data.html_url,
|
||||
body: result.data.body || "",
|
||||
commentId: result.id,
|
||||
url: result.html_url,
|
||||
body: result.body || "",
|
||||
action: "updated",
|
||||
};
|
||||
}
|
||||
|
||||
// null = progress comment was deleted by stranded-comment cleanup in main.ts
|
||||
if (existingCommentId === null) {
|
||||
if (existingComment === null) {
|
||||
return { body, action: "skipped" };
|
||||
}
|
||||
|
||||
@@ -294,49 +265,43 @@ export async function reportProgress(
|
||||
}
|
||||
|
||||
// for new comments, we need to create first, then update with Plan link if in Plan mode
|
||||
// self-created progress comments are always top-level issue comments — review-reply
|
||||
// progress comments only originate from the dispatch path and arrive pre-created.
|
||||
const initialBody = addFooter(ctx, body);
|
||||
const created = await createLeapingProgressComment(
|
||||
apiCtx,
|
||||
{ kind: "issue", issueNumber },
|
||||
initialBody
|
||||
);
|
||||
|
||||
const result = await ctx.octokit.rest.issues.createComment({
|
||||
owner: ctx.repo.owner,
|
||||
repo: ctx.repo.name,
|
||||
issue_number: issueNumber,
|
||||
body: initialBody,
|
||||
});
|
||||
|
||||
// store the comment ID for future updates
|
||||
ctx.toolState.progressCommentId = result.data.id;
|
||||
ctx.toolState.progressComment = created.comment;
|
||||
ctx.toolState.wasUpdated = true;
|
||||
|
||||
// if Plan mode, update the comment to add the "Implement plan" link
|
||||
if (isPlanMode) {
|
||||
const customParts = [buildImplementPlanLink(ctx, issueNumber, result.data.id)];
|
||||
const customParts = [buildImplementPlanLink(ctx, issueNumber, created.comment.id)];
|
||||
const bodyWithoutFooter = stripExistingFooter(body);
|
||||
const footer = buildCommentFooter(ctx, customParts);
|
||||
const bodyWithPlanLink = `${bodyWithoutFooter}${footer}`;
|
||||
|
||||
const updateResult = await ctx.octokit.rest.issues.updateComment({
|
||||
owner: ctx.repo.owner,
|
||||
repo: ctx.repo.name,
|
||||
comment_id: result.data.id,
|
||||
body: bodyWithPlanLink,
|
||||
});
|
||||
const updateResult = await updateProgressComment(apiCtx, created.comment, bodyWithPlanLink);
|
||||
|
||||
if (updateResult.data.node_id) {
|
||||
await patchWorkflowRunFields(ctx, { planCommentNodeId: updateResult.data.node_id });
|
||||
if (updateResult.node_id) {
|
||||
await patchWorkflowRunFields(ctx, { planCommentNodeId: updateResult.node_id });
|
||||
}
|
||||
|
||||
return {
|
||||
commentId: updateResult.data.id,
|
||||
url: updateResult.data.html_url,
|
||||
body: updateResult.data.body || "",
|
||||
commentId: updateResult.id,
|
||||
url: updateResult.html_url,
|
||||
body: updateResult.body || "",
|
||||
action: "created",
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
commentId: result.data.id,
|
||||
url: result.data.html_url,
|
||||
body: result.data.body || "",
|
||||
commentId: created.comment.id,
|
||||
url: created.html_url,
|
||||
body: created.body || "",
|
||||
action: "created",
|
||||
};
|
||||
}
|
||||
@@ -369,10 +334,6 @@ export function ReportProgressTool(ctx: ToolContext) {
|
||||
}
|
||||
const result = await reportProgress(ctx, reportParams);
|
||||
|
||||
if (!params.target_plan_comment) {
|
||||
ctx.toolState.finalSummaryWritten = true;
|
||||
}
|
||||
|
||||
if (result.action === "skipped") {
|
||||
return {
|
||||
success: true,
|
||||
@@ -381,6 +342,14 @@ export function ReportProgressTool(ctx: ToolContext) {
|
||||
};
|
||||
}
|
||||
|
||||
if (result.commentId !== undefined) {
|
||||
log.info(`» ${result.action} comment ${result.commentId}`);
|
||||
}
|
||||
|
||||
if (!params.target_plan_comment) {
|
||||
ctx.toolState.finalSummaryWritten = true;
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
...result,
|
||||
@@ -393,20 +362,19 @@ export function ReportProgressTool(ctx: ToolContext) {
|
||||
* Delete the progress comment if it exists.
|
||||
* Used by main.ts for stranded-comment cleanup (orphaned "Leaping into action" or
|
||||
* checklist left by the todo tracker when the agent didn't call report_progress).
|
||||
* Sets progressCommentId to null so subsequent report_progress calls are no-ops.
|
||||
* Sets progressComment to null so subsequent report_progress calls are no-ops.
|
||||
*/
|
||||
export async function deleteProgressComment(ctx: ToolContext): Promise<boolean> {
|
||||
const existingCommentId = ctx.toolState.progressCommentId;
|
||||
if (!existingCommentId) {
|
||||
const existing = ctx.toolState.progressComment;
|
||||
if (!existing) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
await ctx.octokit.rest.issues.deleteComment({
|
||||
owner: ctx.repo.owner,
|
||||
repo: ctx.repo.name,
|
||||
comment_id: existingCommentId,
|
||||
});
|
||||
await deleteProgressCommentApi(
|
||||
{ octokit: ctx.octokit, owner: ctx.repo.owner, repo: ctx.repo.name },
|
||||
existing
|
||||
);
|
||||
} catch (error) {
|
||||
// ignore 404 - comment already deleted
|
||||
if (error instanceof Error && error.message.includes("Not Found")) {
|
||||
@@ -417,7 +385,7 @@ export async function deleteProgressComment(ctx: ToolContext): Promise<boolean>
|
||||
}
|
||||
|
||||
// set to null (not undefined) so report_progress skips instead of creating a new comment
|
||||
ctx.toolState.progressCommentId = null;
|
||||
ctx.toolState.progressComment = null;
|
||||
|
||||
return true;
|
||||
}
|
||||
@@ -430,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,
|
||||
@@ -446,10 +474,20 @@ export function ReplyToReviewCommentTool(ctx: ToolContext) {
|
||||
comment_id,
|
||||
body: bodyWithFooter,
|
||||
});
|
||||
log.info(`» created review comment ${result.data.id} (in reply to ${comment_id})`);
|
||||
|
||||
// mark progress as updated so post script doesn't think the run failed
|
||||
// mark progress as updated so error reporting + run-result handling know
|
||||
// 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,
|
||||
|
||||
+25
-6
@@ -176,13 +176,32 @@ export function sanitizeToolForGemini<T extends Tool<any, any>>(tool: T): T {
|
||||
}
|
||||
|
||||
/**
|
||||
* true when the effective upstream model is served by google's generative
|
||||
* language API — directly (`google/*`), via opencode (`opencode/gemini-*`),
|
||||
* or via openrouter (`openrouter/google/gemini-*`). slug-substring match
|
||||
* works because every gemini route's model id contains "gemini".
|
||||
* true when the effective upstream model is — or might become — google
|
||||
* generative language API traffic. matches:
|
||||
* - direct `google/*`, opencode `opencode/gemini-*`, openrouter
|
||||
* `openrouter/google/gemini-*` (slug substring "gemini" wins).
|
||||
* - any unresolved specifier: `undefined`, `"auto"`, or a slug that
|
||||
* didn't map through the alias registry (no `provider/` prefix).
|
||||
* these flow through the agent's own auto-select, which may land
|
||||
* on gemini *after* the MCP server has already registered tools —
|
||||
* at which point sanitization is too late to apply. erring on the
|
||||
* side of sanitizing is safe: cases 1 + 2 are universally
|
||||
* compatible JSON-Schema normalizations (enum-only → typed string,
|
||||
* collapsible const-unions → string enum); case 3 is gemini-
|
||||
* specific but only fires on non-collapsible unions, which arktype
|
||||
* does not emit for our current tool schemas. see issue #676 for
|
||||
* the prod failure that motivated this widening.
|
||||
*/
|
||||
export function isGeminiRouted(ctx: ToolContext): boolean {
|
||||
const effective = ctx.payload.proxyModel ?? ctx.resolvedModel ?? ctx.payload.model;
|
||||
if (!effective) return false;
|
||||
return effective.toLowerCase().includes("gemini");
|
||||
if (!effective) return true;
|
||||
const normalized = effective.toLowerCase();
|
||||
if (normalized.includes("gemini")) return true;
|
||||
// every concrete model resolved through the registry carries a
|
||||
// `provider/` prefix (e.g. "anthropic/claude-opus-4-7"). anything
|
||||
// without a slash is either the literal `"auto"` alias or an
|
||||
// unrecognized slug that resolveModel logged a warning for — both
|
||||
// route through the agent's late auto-select, which may pick gemini.
|
||||
if (!normalized.includes("/")) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
+121
@@ -1,4 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { classifyPushError } from "./git.ts";
|
||||
|
||||
// re-export the normalizeUrl function for testing
|
||||
// note: in a real scenario, we'd export this from git.ts or move to a shared utils file
|
||||
@@ -61,3 +62,123 @@ describe("push URL validation", () => {
|
||||
expect(pushUrlNormalized).toBe(actualUrlNormalized);
|
||||
});
|
||||
});
|
||||
|
||||
describe("classifyPushError", () => {
|
||||
describe("concurrent-push", () => {
|
||||
it("matches client-side non-fast-forward (`fetch first`)", () => {
|
||||
const msg =
|
||||
"git push failed (exit 1): To https://github.com/o/r.git\n" +
|
||||
" ! [rejected] feature -> feature (fetch first)\n" +
|
||||
"error: failed to push some refs to 'https://github.com/o/r.git'\n" +
|
||||
"hint: Updates were rejected because the remote contains work";
|
||||
expect(classifyPushError(msg)).toBe("concurrent-push");
|
||||
});
|
||||
|
||||
it("matches client-side `non-fast-forward` wording", () => {
|
||||
const msg = "! [rejected] main -> main (non-fast-forward)";
|
||||
expect(classifyPushError(msg)).toBe("concurrent-push");
|
||||
});
|
||||
|
||||
it("matches server-side `cannot lock ref` (the case from #571)", () => {
|
||||
const msg =
|
||||
"remote: error: cannot lock ref 'refs/heads/feature': is at " +
|
||||
"abc123 but expected def456\n" +
|
||||
" ! [remote rejected] feature -> feature (cannot lock ref ...)";
|
||||
expect(classifyPushError(msg)).toBe("concurrent-push");
|
||||
});
|
||||
});
|
||||
|
||||
describe("transient", () => {
|
||||
it("matches RPC failed with HTTP 502", () => {
|
||||
expect(
|
||||
classifyPushError(
|
||||
"fatal: unable to access 'https://github.com/o/r.git/': The requested URL returned error: 502"
|
||||
)
|
||||
).toBe("transient");
|
||||
});
|
||||
|
||||
it("matches early EOF mid-pack", () => {
|
||||
expect(
|
||||
classifyPushError("fatal: the remote end hung up unexpectedly\nfatal: early EOF")
|
||||
).toBe("transient");
|
||||
});
|
||||
|
||||
it("matches RPC failed", () => {
|
||||
expect(
|
||||
classifyPushError("fatal: RPC failed; curl 56 OpenSSL SSL_read: Connection reset by peer")
|
||||
).toBe("transient");
|
||||
});
|
||||
|
||||
it("matches HTTP/2 stream not closed cleanly", () => {
|
||||
expect(
|
||||
classifyPushError("fatal: HTTP/2 stream 7 was not closed cleanly: PROTOCOL_ERROR (err 1)")
|
||||
).toBe("transient");
|
||||
});
|
||||
|
||||
it("matches DNS resolution failure", () => {
|
||||
expect(classifyPushError("fatal: Could not resolve host: github.com")).toBe("transient");
|
||||
});
|
||||
|
||||
it("matches unexpected disconnect during sideband read", () => {
|
||||
expect(classifyPushError("fatal: unexpected disconnect while reading sideband packet")).toBe(
|
||||
"transient"
|
||||
);
|
||||
});
|
||||
|
||||
it("classifies HTTP 429 (rate-limit / abuse detection) as transient", () => {
|
||||
// 429 is the documented exception to the otherwise-permanent 4xx class —
|
||||
// GitHub's abuse detection occasionally surfaces it on git push.
|
||||
expect(
|
||||
classifyPushError(
|
||||
"fatal: unable to access 'https://github.com/o/r.git/': The requested URL returned error: 429"
|
||||
)
|
||||
).toBe("transient");
|
||||
expect(classifyPushError("remote: HTTP 429: too many requests")).toBe("transient");
|
||||
});
|
||||
});
|
||||
|
||||
describe("unknown", () => {
|
||||
it("does NOT classify auth/403 as transient", () => {
|
||||
// permission denied is permanent within a run — retrying just wastes
|
||||
// time. must NOT match the HTTP-5xx regex.
|
||||
expect(
|
||||
classifyPushError(
|
||||
"remote: Permission to o/r.git denied to bot.\n" +
|
||||
"fatal: unable to access 'https://github.com/o/r.git/': The requested URL returned error: 403"
|
||||
)
|
||||
).toBe("unknown");
|
||||
});
|
||||
|
||||
it("does NOT classify protected-branch rejection as concurrent-push", () => {
|
||||
expect(
|
||||
classifyPushError(
|
||||
" ! [remote rejected] main -> main (push declined due to repository rule violations)"
|
||||
)
|
||||
).toBe("unknown");
|
||||
});
|
||||
|
||||
it("does NOT classify 404 as transient", () => {
|
||||
expect(
|
||||
classifyPushError(
|
||||
"fatal: unable to access 'https://github.com/o/r.git/': The requested URL returned error: 404"
|
||||
)
|
||||
).toBe("unknown");
|
||||
});
|
||||
|
||||
it("returns unknown for an empty message", () => {
|
||||
expect(classifyPushError("")).toBe("unknown");
|
||||
});
|
||||
});
|
||||
|
||||
describe("ordering", () => {
|
||||
it("prefers concurrent-push over transient when both signals appear", () => {
|
||||
// a server-side cannot-lock-ref response that also includes an HTTP
|
||||
// 5xx in the libcurl envelope should still route to the recovery
|
||||
// path, not a blind retry.
|
||||
const msg =
|
||||
"remote: error: cannot lock ref 'refs/heads/feature': is at A but expected B\n" +
|
||||
"fatal: unable to access ...: The requested URL returned error: 500";
|
||||
expect(classifyPushError(msg)).toBe("concurrent-push");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+154
-27
@@ -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 = {
|
||||
@@ -153,6 +154,62 @@ export const PushBranch = type({
|
||||
force: type.boolean.describe("Force push (use with caution)").default(false),
|
||||
});
|
||||
|
||||
// classify an error from `$git("push", ...)` to decide retry vs. recovery
|
||||
// vs. rethrow. exported for tests.
|
||||
//
|
||||
// - `concurrent-push`: server-side compare-and-swap failed because the ref
|
||||
// advanced between fetch and push. recovery is fetch + integrate + retry.
|
||||
// matches both the client-side detection (`fetch first` /
|
||||
// `non-fast-forward`) and the server-side detection (`cannot lock ref`
|
||||
// with `is at <SHA1> but expected <SHA2>`).
|
||||
// - `transient`: network or upstream server hiccup (RPC failed mid-stream,
|
||||
// HTTP 5xx, early EOF, reset, timeout, dns flake). push is idempotent so
|
||||
// verbatim retry with backoff is safe.
|
||||
// - `unknown`: anything else (including auth/permission/protected-branch
|
||||
// rejections). retrying these wastes time; surface to the caller.
|
||||
//
|
||||
// kept conservative: a misclassification of `unknown` -> `transient` would
|
||||
// cause two extra round-trips on a permanently-failing push, while the
|
||||
// reverse (true transient labeled `unknown`) just falls back to current
|
||||
// behavior. so we only mark as transient when the error string is
|
||||
// unambiguously a network/server-side fault, not a refusal.
|
||||
export type PushErrorKind = "concurrent-push" | "transient" | "unknown";
|
||||
|
||||
const CONCURRENT_PUSH_PATTERNS = ["fetch first", "non-fast-forward", "cannot lock ref"] as const;
|
||||
|
||||
const TRANSIENT_PATTERNS: RegExp[] = [
|
||||
/RPC failed/i,
|
||||
/early EOF/,
|
||||
/the remote end hung up unexpectedly/,
|
||||
/Connection reset/i,
|
||||
/Could not resolve host/i,
|
||||
/Operation timed out/i,
|
||||
/HTTP\/2 stream \d+ was not closed cleanly/i,
|
||||
/unexpected disconnect while reading sideband packet/i,
|
||||
// libcurl HTTP 5xx surfaced by git over https. matches both the
|
||||
// libcurl-style "The requested URL returned error: 502" and the more
|
||||
// recent "HTTP 502" wording. most 4xx is intentionally excluded —
|
||||
// 401/403/404 indicate auth/permission problems that are not
|
||||
// retry-safe — but 429 (rate-limited / abuse detection) IS retry-safe
|
||||
// and GitHub occasionally surfaces it on git push, so it's included
|
||||
// explicitly below.
|
||||
/HTTP 5\d\d/,
|
||||
/returned error: 5\d\d/i,
|
||||
/HTTP 429/,
|
||||
/returned error: 429/i,
|
||||
];
|
||||
|
||||
export function classifyPushError(msg: string): PushErrorKind {
|
||||
if (CONCURRENT_PUSH_PATTERNS.some((p) => msg.includes(p))) return "concurrent-push";
|
||||
if (TRANSIENT_PATTERNS.some((p) => p.test(msg))) return "transient";
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
// backoff delays before retry attempts 2 and 3. attempt 1 is the original
|
||||
// push. total worst-case added latency: ~7s. small enough that the agent
|
||||
// rarely notices, large enough to ride out most upstream hiccups.
|
||||
const TRANSIENT_RETRY_DELAYS_MS = [2000, 5000];
|
||||
|
||||
export function PushBranchTool(ctx: ToolContext) {
|
||||
const defaultBranch = ctx.repo.data.default_branch || "main";
|
||||
const pushPermission = ctx.payload.push;
|
||||
@@ -234,31 +291,71 @@ export function PushBranchTool(ctx: ToolContext) {
|
||||
log.warning(`force pushing - this will overwrite remote history`);
|
||||
}
|
||||
|
||||
try {
|
||||
await $git("push", pushArgs, {
|
||||
token: ctx.gitToken,
|
||||
});
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
if (msg.includes("fetch first") || msg.includes("non-fast-forward")) {
|
||||
// git rebase is blocked through the MCP tool when shell is disabled
|
||||
// (rebase --exec can execute arbitrary code). merge always works and
|
||||
// integrates remote changes cleanly, so suggest it as the default.
|
||||
const integrateStep =
|
||||
ctx.payload.shell === "disabled"
|
||||
? `2. use the git tool to merge the remote branch into yours: git({ command: "merge", args: ["origin/${pushDest.remoteBranch}"] })`
|
||||
: `2. use the git tool to rebase or merge your changes on top: git({ command: "merge", args: ["origin/${pushDest.remoteBranch}"] }) (or 'rebase')`;
|
||||
throw new Error(
|
||||
`push rejected: the remote branch '${pushDest.remoteBranch}' has new commits you don't have locally.\n\n` +
|
||||
`to resolve this:\n` +
|
||||
`1. use git_fetch to fetch the remote branch: git_fetch({ ref: "${pushDest.remoteBranch}" })\n` +
|
||||
`${integrateStep}\n` +
|
||||
`3. resolve any merge conflicts if needed\n` +
|
||||
`4. retry push_branch`
|
||||
);
|
||||
// retry transient network/server errors (RPC failed, early EOF, 5xx,
|
||||
// connection reset, etc) with backoff. push is idempotent: if the remote
|
||||
// never received the pack, retry creates the ref; if it did, the retry
|
||||
// is a no-op fast-forward to the same SHA. concurrent-push rejections
|
||||
// and permission errors are NOT retried — they need user intervention.
|
||||
let lastErr: unknown;
|
||||
let pushed = false;
|
||||
for (let attempt = 0; attempt <= TRANSIENT_RETRY_DELAYS_MS.length; attempt++) {
|
||||
try {
|
||||
await $git("push", pushArgs, {
|
||||
token: ctx.gitToken,
|
||||
});
|
||||
if (attempt > 0) {
|
||||
log.info(`push succeeded on attempt ${attempt + 1}`);
|
||||
}
|
||||
pushed = true;
|
||||
break;
|
||||
} catch (err) {
|
||||
lastErr = err;
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
const kind = classifyPushError(msg);
|
||||
|
||||
if (kind === "concurrent-push") {
|
||||
// git rebase is blocked through the MCP tool when shell is disabled
|
||||
// (rebase --exec can execute arbitrary code). merge always works and
|
||||
// integrates remote changes cleanly, so suggest it as the default.
|
||||
const integrateStep =
|
||||
ctx.payload.shell === "disabled"
|
||||
? `2. use the git tool to merge the remote branch into yours: git({ command: "merge", args: ["origin/${pushDest.remoteBranch}"] })`
|
||||
: `2. use the git tool to rebase or merge your changes on top: git({ command: "merge", args: ["origin/${pushDest.remoteBranch}"] }) (or 'rebase')`;
|
||||
throw new Error(
|
||||
`push rejected: the remote branch '${pushDest.remoteBranch}' has new commits you don't have locally (often a concurrent push to the same branch).\n\n` +
|
||||
`to resolve this:\n` +
|
||||
`1. use git_fetch to fetch the remote branch: git_fetch({ ref: "${pushDest.remoteBranch}" })\n` +
|
||||
`${integrateStep}\n` +
|
||||
`3. resolve any merge conflicts if needed\n` +
|
||||
`4. retry push_branch`
|
||||
);
|
||||
}
|
||||
|
||||
if (kind === "transient" && attempt < TRANSIENT_RETRY_DELAYS_MS.length) {
|
||||
// jitter avoids lockstep retries when several agents are hit by the
|
||||
// same upstream blip simultaneously — without it, all retries land
|
||||
// on the same recovering server at the same instant.
|
||||
const baseDelay = TRANSIENT_RETRY_DELAYS_MS[attempt] ?? 5000;
|
||||
const delay = Math.round(baseDelay * (0.75 + Math.random() * 0.5));
|
||||
log.info(
|
||||
`push attempt ${attempt + 1} failed (transient), retrying in ${delay}ms: ${msg.slice(0, 300)}`
|
||||
);
|
||||
await new Promise((r) => setTimeout(r, delay));
|
||||
continue;
|
||||
}
|
||||
|
||||
throw err;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
if (!pushed) {
|
||||
// safety net — loop should always either break with success or throw.
|
||||
throw lastErr instanceof Error ? lastErr : new Error(String(lastErr));
|
||||
}
|
||||
|
||||
const pushedSha = $("git", ["rev-parse", "HEAD"], { log: false }).trim();
|
||||
log.info(
|
||||
`» pushed branch ${branch} to ${pushDest.remoteName}/${pushDest.remoteBranch} (sha ${pushedSha})`
|
||||
);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
@@ -408,6 +505,21 @@ const GitFetch = type({
|
||||
depth: type.number.describe("Fetch depth (for shallow clones)").optional(),
|
||||
});
|
||||
|
||||
// when an agent-supplied depth is too shallow to reach the merge base, git
|
||||
// surfaces "Could not read <sha>" and "remote did not send all necessary
|
||||
// objects". detect both wordings so a single deepen retry can recover before
|
||||
// the error reaches the agent (issue #564). git emits the full OID via
|
||||
// oid_to_hex, so the bound is 40 (SHA-1) or 64 (SHA-256).
|
||||
const SHALLOW_UNREACHABLE_PATTERNS: RegExp[] = [
|
||||
/Could not read [a-f0-9]{40,64}/,
|
||||
/remote did not send all necessary objects/,
|
||||
];
|
||||
|
||||
// large enough to clear the merge base on most real-world PRs without
|
||||
// downloading the full history; matches the fallback used by checkoutPrBranch
|
||||
// when the compare API is unavailable.
|
||||
const DEEPEN_RETRY_DEPTH = 1000;
|
||||
|
||||
export function GitFetchTool(ctx: ToolContext) {
|
||||
return tool({
|
||||
name: "git_fetch",
|
||||
@@ -419,9 +531,22 @@ export function GitFetchTool(ctx: ToolContext) {
|
||||
if (params.depth !== undefined) {
|
||||
fetchArgs.push(`--depth=${params.depth}`);
|
||||
}
|
||||
await $git("fetch", fetchArgs, {
|
||||
token: ctx.gitToken,
|
||||
});
|
||||
try {
|
||||
await $git("fetch", fetchArgs, { token: ctx.gitToken });
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
const isShallowUnreachable = SHALLOW_UNREACHABLE_PATTERNS.some((p) => p.test(msg));
|
||||
const isShallow =
|
||||
isShallowUnreachable &&
|
||||
$("git", ["rev-parse", "--is-shallow-repository"], { log: false }).trim() === "true";
|
||||
if (!isShallow) throw err;
|
||||
log.info(
|
||||
`» git_fetch hit shallow-unreachable error, retrying with --deepen=${DEEPEN_RETRY_DEPTH}`
|
||||
);
|
||||
await $git("fetch", [`--deepen=${DEEPEN_RETRY_DEPTH}`, "--no-tags", "origin", params.ref], {
|
||||
token: ctx.gitToken,
|
||||
});
|
||||
}
|
||||
return { success: true, ref: params.ref };
|
||||
}),
|
||||
});
|
||||
@@ -476,6 +601,7 @@ export function DeleteBranchTool(ctx: ToolContext) {
|
||||
await $git("push", ["origin", "--delete", `refs/heads/${params.branchName}`], {
|
||||
token: ctx.gitToken,
|
||||
});
|
||||
log.info(`» deleted branch ${params.branchName}`);
|
||||
return { success: true, deleted: params.branchName };
|
||||
}),
|
||||
});
|
||||
@@ -506,6 +632,7 @@ export function PushTagsTool(ctx: ToolContext) {
|
||||
await $git("push", pushArgs, {
|
||||
token: ctx.gitToken,
|
||||
});
|
||||
log.info(`» pushed tag ${params.tag}`);
|
||||
return { success: true, tag: params.tag };
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { type } from "arktype";
|
||||
import { log } from "../utils/cli.ts";
|
||||
import { fixDoubleEscapedString } from "../utils/fixDoubleEscapedString.ts";
|
||||
import { patchWorkflowRunFields } from "../utils/patchWorkflowRunFields.ts";
|
||||
import type { ToolContext } from "./server.ts";
|
||||
@@ -32,6 +33,8 @@ export function IssueTool(ctx: ToolContext) {
|
||||
assignees: params.assignees ?? [],
|
||||
});
|
||||
|
||||
log.info(`» created issue #${result.data.number} (id ${result.data.id})`);
|
||||
|
||||
const nodeId = result.data.node_id;
|
||||
if (typeof nodeId === "string" && nodeId.length > 0) {
|
||||
await patchWorkflowRunFields(ctx, {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { type } from "arktype";
|
||||
import { log } from "../utils/cli.ts";
|
||||
import type { ToolContext } from "./server.ts";
|
||||
import { execute, tool } from "./shared.ts";
|
||||
|
||||
@@ -20,6 +21,7 @@ export function AddLabelsTool(ctx: ToolContext) {
|
||||
issue_number,
|
||||
labels,
|
||||
});
|
||||
log.info(`» added labels [${labels.join(", ")}] to issue #${issue_number}`);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
|
||||
@@ -1,41 +0,0 @@
|
||||
import { type } from "arktype";
|
||||
import { apiFetch } from "../utils/apiFetch.ts";
|
||||
import type { ToolContext } from "./server.ts";
|
||||
import { execute, tool } from "./shared.ts";
|
||||
|
||||
const UpdateLearningsParams = type({
|
||||
learnings: type.string.describe(
|
||||
"the FULL merged learnings as a flat bullet list. each line starts with `- `. one discrete, actionable fact per bullet. combine existing bullets from the prompt with your new discoveries. deduplicate — if an existing bullet covers the same fact, update it in place rather than adding a new one. drop bullets that are clearly wrong or no longer relevant to the current codebase. keep the list focused and concise."
|
||||
),
|
||||
});
|
||||
|
||||
export function UpdateLearningsTool(ctx: ToolContext) {
|
||||
return tool({
|
||||
name: "update_learnings",
|
||||
description:
|
||||
"persist operational learnings about this repository (setup steps, test commands, key conventions, patterns). ONLY call this when you have high confidence the information is correct and broadly useful for future runs — not for one-off findings or uncertain observations. format: flat bullet list (`- ` per line, one fact per bullet). pass the FULL merged list — combine existing learnings from the prompt with new discoveries. deduplicate, and drop bullets that are clearly wrong or no longer relevant to the current codebase.",
|
||||
parameters: UpdateLearningsParams,
|
||||
execute: execute(async (params) => {
|
||||
const response = await apiFetch({
|
||||
path: `/api/repo/${ctx.repo.owner}/${ctx.repo.name}/learnings`,
|
||||
method: "PATCH",
|
||||
headers: {
|
||||
authorization: `Bearer ${ctx.apiToken}`,
|
||||
"content-type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
learnings: params.learnings,
|
||||
model: ctx.toolState.model,
|
||||
}),
|
||||
signal: AbortSignal.timeout(10_000),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.text();
|
||||
throw new Error(`failed to update learnings: ${error}`);
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
}),
|
||||
});
|
||||
}
|
||||
@@ -48,6 +48,9 @@ export function UpdatePullRequestBodyTool(ctx: ToolContext) {
|
||||
pull_number: params.pull_number,
|
||||
body: bodyWithFooter,
|
||||
});
|
||||
log.info(`» updated pull request #${result.data.number}`);
|
||||
|
||||
ctx.toolState.wasUpdated = true;
|
||||
|
||||
return {
|
||||
success: true,
|
||||
@@ -78,6 +81,7 @@ export function CreatePullRequestTool(ctx: ToolContext) {
|
||||
base: params.base,
|
||||
draft: params.draft ?? false,
|
||||
});
|
||||
log.info(`» created pull request #${result.data.number} (id ${result.data.id})`);
|
||||
|
||||
// best-effort: request review from the user who triggered the workflow
|
||||
const reviewer = ctx.payload.triggerer;
|
||||
|
||||
+2
-2
@@ -648,8 +648,8 @@ describe("reviewSkipDecision", () => {
|
||||
describe("duplicateReviewDecision", () => {
|
||||
// regression: colinhacks/zod#5897 had two reviews submitted from the same
|
||||
// workflow run 8 seconds apart — a substantive review followed by an empty
|
||||
// "Reviewed — no issues found." follow-up. the agent re-classified the
|
||||
// first review's non-blocking observations as "no actionable issues" and
|
||||
// "No new issues found." follow-up. the agent re-classified the first
|
||||
// review's non-blocking observations as "no actionable issues" and
|
||||
// submitted the canonical body per modes.ts. this guard makes the second
|
||||
// call a no-op without burning a GitHub API call or polluting the PR.
|
||||
|
||||
|
||||
+78
-12
@@ -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";
|
||||
@@ -11,18 +12,43 @@ import {
|
||||
} from "../utils/diffCoverage.ts";
|
||||
import { fixDoubleEscapedString } from "../utils/fixDoubleEscapedString.ts";
|
||||
import { patchWorkflowRunFields } from "../utils/patchWorkflowRunFields.ts";
|
||||
import { retry } from "../utils/retry.ts";
|
||||
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;
|
||||
return typeof status === "number" ? status : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* detect GitHub's generic server-side 422 ("An internal error occurred,
|
||||
* please try again.") that sometimes fires on `POST /pulls/{n}/reviews`.
|
||||
*
|
||||
* the body is stable across occurrences and distinct from every other 422
|
||||
* cause we care about (anchor validation, body length, malformed suggestion
|
||||
* blocks) — those all cite the specific problem. treating this as a
|
||||
* transient server error unlocks bounded in-tool retry instead of surfacing
|
||||
* it to the agent with the generic "likely causes (1)(2)(3)" prompt, which
|
||||
* induces whack-a-mole comment dropping on content that was never the issue.
|
||||
*/
|
||||
export function isTransientReviewError(err: unknown): boolean {
|
||||
if (getHttpStatus(err) !== 422) return false;
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
return /internal error occurred, please try again/i.test(msg);
|
||||
}
|
||||
|
||||
// backoff schedule for transient GitHub 422 "internal error" responses on the
|
||||
// reviews endpoint. 3 attempts total (initial + 2 retries) with 1s/3s delays
|
||||
// — most transient GH errors clear within a few seconds, and longer delays
|
||||
// push review submission past agent-perceived responsiveness.
|
||||
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
|
||||
@@ -187,8 +213,8 @@ export type DuplicateReviewDecision = {
|
||||
* the agent is instructed to call create_pull_request_review exactly once per
|
||||
* Review-mode session (see action/modes.ts), but in practice it sometimes
|
||||
* submits twice — once with substantive feedback, then again with the
|
||||
* canonical "Reviewed — no issues found." body when the prompt's branch
|
||||
* logic re-classifies non-blocking observations. the second submission is
|
||||
* canonical "No new issues found." body when the prompt's branch logic
|
||||
* re-classifies non-blocking observations. the second submission is
|
||||
* always redundant: the first review is the record, and the duplicate just
|
||||
* adds noise to the PR.
|
||||
*
|
||||
@@ -290,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
|
||||
@@ -483,16 +509,50 @@ export function CreatePullRequestReviewTool(ctx: ToolContext) {
|
||||
|
||||
// no body → single-step createReview (no footer needed)
|
||||
// has body → pending + submit so we can build footer with Fix links using review ID
|
||||
//
|
||||
// wrap the submission in `retry` so GitHub's transient 422 "internal
|
||||
// error" body (distinct from anchor / body-length / suggestion 422s,
|
||||
// which all cite the specific cause) clears on its own instead of
|
||||
// surfacing through the generic 422 handler — that framing sent the
|
||||
// agent dropping valid inline comments chasing a non-issue.
|
||||
// `shouldRetry` scopes retries to the transient body only, so real
|
||||
// validation 422s still fail fast.
|
||||
let result;
|
||||
try {
|
||||
result = body
|
||||
? await createAndSubmitWithFooter(ctx, params, {
|
||||
body,
|
||||
approved: approved ?? false,
|
||||
hasComments: (params.comments?.length ?? 0) > 0,
|
||||
})
|
||||
: await createReviewWithStrandedRecovery(ctx, params);
|
||||
result = await retry(
|
||||
() =>
|
||||
body
|
||||
? createAndSubmitWithFooter(ctx, params, {
|
||||
body,
|
||||
approved: approved ?? false,
|
||||
hasComments: (params.comments?.length ?? 0) > 0,
|
||||
})
|
||||
: createReviewWithStrandedRecovery(ctx, params),
|
||||
{
|
||||
delaysMs: TRANSIENT_REVIEW_RETRY_DELAYS_MS,
|
||||
shouldRetry: isTransientReviewError,
|
||||
label: "review submission",
|
||||
}
|
||||
);
|
||||
} catch (err: unknown) {
|
||||
// GitHub's transient 422 "internal error" is distinct from anchor /
|
||||
// body-length / suggestion validation failures — framing it with the
|
||||
// generic "likely causes (1)(2)(3)" prompt sends the agent dropping
|
||||
// comments that were never the problem. after bounded in-tool retry
|
||||
// we surface a dedicated message that tells the agent to wait-and-
|
||||
// retry or fall back to a body-only review.
|
||||
if (isTransientReviewError(err)) {
|
||||
const rawMsg = err instanceof Error ? err.message : String(err);
|
||||
throw new Error(
|
||||
`GitHub returned a transient 422 "internal error" on the reviews endpoint after ${TRANSIENT_REVIEW_RETRY_DELAYS_MS.length + 1} attempts. ` +
|
||||
`This is a GitHub-side issue, not a problem with your review content. ` +
|
||||
`Do NOT modify or drop inline comments — their content is not the cause. ` +
|
||||
`Wait ~30 seconds and call this tool once more with the SAME arguments. ` +
|
||||
`If it still fails, submit a body-only review (move all inline feedback into \`body\` as text) so nothing is lost. ` +
|
||||
`GitHub said: ${rawMsg}`,
|
||||
{ cause: err }
|
||||
);
|
||||
}
|
||||
if (getHttpStatus(err) !== 422 || !params.comments?.length) throw err;
|
||||
|
||||
const details = params.comments.map((c) => {
|
||||
@@ -526,6 +586,7 @@ export function CreatePullRequestReviewTool(ctx: ToolContext) {
|
||||
}
|
||||
const reviewId = result.data.id;
|
||||
const reviewNodeId = result.data.node_id;
|
||||
log.info(`» created review ${reviewId} on pull request #${pull_number}`);
|
||||
|
||||
// reviewedSha = what the agent actually reviewed (checkout SHA), not the
|
||||
// submission anchor (current HEAD). this ensures postReviewCleanup dispatches
|
||||
@@ -537,10 +598,12 @@ export function CreatePullRequestReviewTool(ctx: ToolContext) {
|
||||
reviewedSha: actuallyReviewedSha,
|
||||
};
|
||||
|
||||
ctx.toolState.wasUpdated = true;
|
||||
|
||||
// a submitted review obsoletes the progress comment — the review IS the
|
||||
// durable artifact. owned here (not in main.ts) so cleanup is atomic with
|
||||
// submission and survives any path out of the run (success, timeout,
|
||||
// crash). deleteProgressComment sets progressCommentId = null, so a later
|
||||
// crash). deleteProgressComment sets progressComment = null, so a later
|
||||
// report_progress call short-circuits to a no-op.
|
||||
// best-effort: a cleanup failure must not turn a successful review into
|
||||
// a tool-call failure visible to the agent.
|
||||
@@ -781,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();
|
||||
|
||||
+30
-33
@@ -1,43 +1,40 @@
|
||||
import { Octokit } from "@octokit/rest";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { acquireNewToken } from "../utils/github.ts";
|
||||
import { getReviewData } from "./reviewComments.ts";
|
||||
import { type FormatReviewDataInput, formatReviewData } from "./reviewComments.ts";
|
||||
|
||||
async function getToken(): Promise<string> {
|
||||
if (process.env.GH_TOKEN) return process.env.GH_TOKEN;
|
||||
return await acquireNewToken();
|
||||
// fixtures captured by action/scripts/refresh-test-fixtures.ts; re-run
|
||||
// (with creds) when GitHub's review/threads/listFiles response shape
|
||||
// changes, then review the snapshot diff.
|
||||
type ReviewFixture = FormatReviewDataInput & {
|
||||
owner: string;
|
||||
name: string;
|
||||
};
|
||||
|
||||
function loadFixture(file: string): ReviewFixture {
|
||||
return JSON.parse(
|
||||
readFileSync(resolve(import.meta.dirname, "__fixtures__", file), "utf-8")
|
||||
) as ReviewFixture;
|
||||
}
|
||||
|
||||
describe("getFormattedReviewThreads", () => {
|
||||
it("formats thread blocks with TOC and correct line numbers", { timeout: 30000 }, async () => {
|
||||
const token = await getToken();
|
||||
const octokit = new Octokit({ auth: token });
|
||||
describe("formatReviewData", () => {
|
||||
it("formats thread blocks with TOC and correct line numbers", () => {
|
||||
const fx = loadFixture("pullfrog-scratch-pr-49-review-3485940013.json");
|
||||
const result = formatReviewData(fx);
|
||||
expect(result).toBeDefined();
|
||||
if (!result) return;
|
||||
|
||||
const { formatted } = (await getReviewData({
|
||||
octokit,
|
||||
owner: "pullfrog",
|
||||
name: "scratch",
|
||||
pullNumber: 49,
|
||||
reviewId: 3485940013,
|
||||
}))!;
|
||||
|
||||
expect(formatted.toc).toMatchSnapshot("toc");
|
||||
expect(formatted.content).toMatchSnapshot("content");
|
||||
expect(result.formatted.toc).toMatchSnapshot("toc");
|
||||
expect(result.formatted.content).toMatchSnapshot("content");
|
||||
});
|
||||
|
||||
it("formats body-only review", { timeout: 30000 }, async () => {
|
||||
const token = await getToken();
|
||||
const octokit = new Octokit({ auth: token });
|
||||
it("formats body-only review", () => {
|
||||
const fx = loadFixture("pullfrog-scratch-pr-64-review-3531000326.json");
|
||||
const result = formatReviewData(fx);
|
||||
expect(result).toBeDefined();
|
||||
if (!result) return;
|
||||
|
||||
const { formatted } = (await getReviewData({
|
||||
octokit,
|
||||
owner: "pullfrog",
|
||||
name: "scratch",
|
||||
pullNumber: 64,
|
||||
reviewId: 3531000326,
|
||||
}))!;
|
||||
|
||||
expect(formatted.toc).toMatchSnapshot("toc");
|
||||
expect(formatted.content).toMatchSnapshot("content");
|
||||
expect(result.formatted.toc).toMatchSnapshot("toc");
|
||||
expect(result.formatted.content).toMatchSnapshot("content");
|
||||
});
|
||||
});
|
||||
|
||||
+77
-29
@@ -497,6 +497,67 @@ interface GetReviewDataInput {
|
||||
approvedBy?: string | undefined;
|
||||
}
|
||||
|
||||
// pure formatter: takes already-fetched GitHub responses and produces the
|
||||
// review data the MCP tool returns. extracted from getReviewData so tests
|
||||
// can drive it from checked-in fixtures without live API access.
|
||||
//
|
||||
// `prFiles` may be empty when `threads` is empty — callers that hit the
|
||||
// network should skip the listFiles call in that case as a perf
|
||||
// optimization. when both are empty and `review.body` is also empty, the
|
||||
// formatter returns undefined just like getReviewData.
|
||||
export interface FormatReviewDataInput {
|
||||
review: ReviewResponse;
|
||||
threads: ReviewThread[];
|
||||
prFiles: ReviewPrFile[];
|
||||
pullNumber: number;
|
||||
reviewId: number;
|
||||
}
|
||||
|
||||
export type ReviewResponse = {
|
||||
body: string | null | undefined;
|
||||
user: { login: string } | null | undefined;
|
||||
};
|
||||
|
||||
export type ReviewPrFile = {
|
||||
filename: string;
|
||||
patch?: string | undefined;
|
||||
};
|
||||
|
||||
export function formatReviewData(input: FormatReviewDataInput):
|
||||
| {
|
||||
threadBlocks: Array<{ path: string; lineRange: string; content: string[] }>;
|
||||
reviewer: string;
|
||||
formatted: { toc: string; content: string };
|
||||
}
|
||||
| undefined {
|
||||
const rawReviewBody = input.review.body;
|
||||
const reviewBody = rawReviewBody ? stripExistingFooter(rawReviewBody) : "";
|
||||
const reviewer = input.review.user?.login ?? "unknown";
|
||||
|
||||
if (input.threads.length === 0 && !reviewBody) return undefined;
|
||||
|
||||
let threadBlocks: Array<{ path: string; lineRange: string; content: string[] }> = [];
|
||||
|
||||
if (input.threads.length > 0) {
|
||||
const filePatchMap = new Map<string, ParsedHunk[]>();
|
||||
for (const file of input.prFiles) {
|
||||
if (file.patch) {
|
||||
filePatchMap.set(file.filename, parseFilePatches(file.patch));
|
||||
}
|
||||
}
|
||||
threadBlocks = buildThreadBlocks(input.threads, filePatchMap, input.reviewId);
|
||||
}
|
||||
|
||||
const formatted = formatReviewThreads(threadBlocks, {
|
||||
pullNumber: input.pullNumber,
|
||||
reviewId: input.reviewId,
|
||||
reviewer,
|
||||
reviewBody,
|
||||
});
|
||||
|
||||
return { threadBlocks, reviewer, formatted };
|
||||
}
|
||||
|
||||
export async function getReviewData(input: GetReviewDataInput): Promise<
|
||||
| {
|
||||
threadBlocks: Array<{ path: string; lineRange: string; content: string[] }>;
|
||||
@@ -515,38 +576,25 @@ export async function getReviewData(input: GetReviewDataInput): Promise<
|
||||
getReviewThreads(input),
|
||||
]);
|
||||
|
||||
const rawReviewBody = review.data.body;
|
||||
const reviewBody = rawReviewBody ? stripExistingFooter(rawReviewBody) : "";
|
||||
const reviewer = review.data.user?.login ?? "unknown";
|
||||
// skip listFiles when there are no threads — prFiles is only used for
|
||||
// building thread blocks, and an empty array short-circuits below.
|
||||
const prFiles =
|
||||
threads.length > 0
|
||||
? await input.octokit.paginate(input.octokit.rest.pulls.listFiles, {
|
||||
owner: input.owner,
|
||||
repo: input.name,
|
||||
pull_number: input.pullNumber,
|
||||
per_page: 100,
|
||||
})
|
||||
: [];
|
||||
|
||||
if (threads.length === 0 && !reviewBody) return undefined;
|
||||
|
||||
let threadBlocks: Array<{ path: string; lineRange: string; content: string[] }> = [];
|
||||
|
||||
if (threads.length > 0) {
|
||||
const prFiles = await input.octokit.paginate(input.octokit.rest.pulls.listFiles, {
|
||||
owner: input.owner,
|
||||
repo: input.name,
|
||||
pull_number: input.pullNumber,
|
||||
per_page: 100,
|
||||
});
|
||||
const filePatchMap = new Map<string, ParsedHunk[]>();
|
||||
for (const file of prFiles) {
|
||||
if (file.patch) {
|
||||
filePatchMap.set(file.filename, parseFilePatches(file.patch));
|
||||
}
|
||||
}
|
||||
threadBlocks = buildThreadBlocks(threads, filePatchMap, input.reviewId);
|
||||
}
|
||||
|
||||
const formatted = formatReviewThreads(threadBlocks, {
|
||||
return formatReviewData({
|
||||
review: review.data,
|
||||
threads,
|
||||
prFiles,
|
||||
pullNumber: input.pullNumber,
|
||||
reviewId: input.reviewId,
|
||||
reviewer,
|
||||
reviewBody,
|
||||
});
|
||||
|
||||
return { threadBlocks, reviewer, formatted };
|
||||
}
|
||||
|
||||
export function GetReviewCommentsTool(ctx: ToolContext) {
|
||||
@@ -687,7 +735,7 @@ export function ResolveReviewThreadTool(ctx: ToolContext) {
|
||||
});
|
||||
|
||||
const thread = response.resolveReviewThread.thread;
|
||||
log.debug(`resolved thread ${thread.id}, isResolved=${thread.isResolved}`);
|
||||
log.info(`» resolved review thread ${thread.id}`);
|
||||
|
||||
return {
|
||||
thread_id: thread.id,
|
||||
|
||||
+44
-65
@@ -1,14 +1,13 @@
|
||||
import { type } from "arktype";
|
||||
import { formatMcpToolRef } from "../external.ts";
|
||||
import { type Mode, PR_SUMMARY_FORMAT } from "../modes.ts";
|
||||
import type { Mode } from "../modes.ts";
|
||||
import { apiFetch } from "../utils/apiFetch.ts";
|
||||
import { log } from "../utils/log.ts";
|
||||
import type { ToolContext } from "./server.ts";
|
||||
import { execute, tool } from "./shared.ts";
|
||||
|
||||
export const SelectModeParams = type({
|
||||
mode: type.string.describe(
|
||||
"the name of the mode to select (e.g., 'Build', 'Plan', 'Review', 'IncrementalReview', 'Fix', 'AddressReviews', 'Task', 'ResolveConflicts', 'Summarize')"
|
||||
"the name of the mode to select (e.g., 'Build', 'Plan', 'Review', 'IncrementalReview', 'Fix', 'AddressReviews', 'Task', 'ResolveConflicts')"
|
||||
),
|
||||
"issue_number?": type("number").describe(
|
||||
"optional issue number; when provided with Plan mode, used to look up an existing plan comment for this issue (edit vs create)"
|
||||
@@ -25,25 +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...".`,
|
||||
|
||||
SummaryUpdate: `### Checklist (updating existing summary)
|
||||
|
||||
An existing summary comment was found for this PR. Update it rather than creating a new one.
|
||||
|
||||
1. Use \`previousSummaryBody\` from this response as the current summary to revise.
|
||||
2. Checkout the PR via \`${t("checkout_pr")}\` — this returns PR metadata and a \`diffPath\`.
|
||||
3. Read the diff using the TOC to selectively read relevant sections. Produce an updated summary reflecting the current state of the PR, using the existing summary (\`previousSummaryBody\`) as a starting point. If EVENT INSTRUCTIONS specify a custom format, follow that instead of the default format below.
|
||||
4. Call \`${t("edit_issue_comment")}\` with \`commentId: existingSummaryCommentId\` (from this response) and the updated summary body.
|
||||
5. Call \`${t("report_progress")}\` with a brief note (e.g., "Updated PR summary.").
|
||||
|
||||
${PR_SUMMARY_FORMAT}`,
|
||||
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...".`,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -78,10 +66,7 @@ function buildOrchestratorGuidance(
|
||||
// matches the API response for /repo/[owner]/[repo]/issue/[issueNumber]/plan-comment
|
||||
export type PlanCommentResponsePayload = { error: string } | { commentId: number; body: string };
|
||||
|
||||
// matches the API response for /repo/[owner]/[repo]/pr/[prNumber]/summary-comment
|
||||
export type SummaryCommentResponsePayload = { error: string } | { commentId: number; body: string };
|
||||
|
||||
// IMPORTANT: these routes authenticate via GitHub installation token (getEnrichedRepo),
|
||||
// IMPORTANT: this route authenticates via GitHub installation token (getEnrichedRepo),
|
||||
// NOT the Pullfrog API JWT (ctx.apiToken). use ctx.githubInstallationToken here.
|
||||
// see wiki/api-auth.md for the two auth patterns.
|
||||
async function fetchExistingPlanComment(
|
||||
@@ -103,33 +88,30 @@ async function fetchExistingPlanComment(
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchExistingSummaryComment(
|
||||
ctx: ToolContext,
|
||||
prNumber: number
|
||||
): Promise<Extract<SummaryCommentResponsePayload, { commentId: number }> | null> {
|
||||
if (!ctx.githubInstallationToken) {
|
||||
log.warning("fetchExistingSummaryComment: no token, skipping");
|
||||
return null;
|
||||
}
|
||||
const path = `/api/repo/${ctx.repo.owner}/${ctx.repo.name}/pr/${prNumber}/summary-comment`;
|
||||
try {
|
||||
const response = await apiFetch({
|
||||
path,
|
||||
method: "GET",
|
||||
headers: { authorization: `Bearer ${ctx.githubInstallationToken}` },
|
||||
signal: AbortSignal.timeout(10_000),
|
||||
});
|
||||
const data = (await response.json()) as SummaryCommentResponsePayload;
|
||||
if (response.ok && "commentId" in data) {
|
||||
return data;
|
||||
}
|
||||
const errMsg = "error" in data ? data.error : "(no error body)";
|
||||
log.warning(`fetchExistingSummaryComment: ${response.status} ${path} — ${errMsg}`);
|
||||
return null;
|
||||
} catch (error) {
|
||||
log.warning("fetchExistingSummaryComment failed:", error);
|
||||
return null;
|
||||
}
|
||||
const SUMMARY_MODES = new Set(["Review", "IncrementalReview", "Task"]);
|
||||
|
||||
/** modes that gain the PR summary edit step when toolState.summaryFilePath is set.
|
||||
*
|
||||
* NOTE: this snapshot is an internal artifact consumed by future agent runs. it is
|
||||
* deliberately NOT shaped by user-supplied summary instructions — those would warp
|
||||
* the durable agent context. user-facing summarization (e.g. the review body's
|
||||
* "Reviewed changes" section) is governed by review-mode prompts and review
|
||||
* instructions, separately from this snapshot. */
|
||||
function buildSummaryAddendum(t: (name: string) => string, ctx: ToolContext): string {
|
||||
const filePath = ctx.toolState.summaryFilePath;
|
||||
if (!filePath) return "";
|
||||
return `### PR summary snapshot — required step
|
||||
|
||||
A rolling PR summary lives at \`${filePath}\`. It is your durable cross-run agent context — a functional summary of what this PR does, the subsystems and files it touches, the material behavior of its changes, and any risks or open questions worth carrying forward. It is NOT a chronological log of past review runs; commit-level history can already be reconstructed from \`${t("list_pull_request_reviews")}\`.
|
||||
|
||||
How to use it:
|
||||
|
||||
- read \`${filePath}\` at the START of the run, alongside the diff. it represents what previous agent runs already understood about this PR — absorb it before picking lenses or crafting subagent dispatch prompts. if it's a fresh seed (file is one or two lines), this is a first review and you'll be filling it in from the diff.
|
||||
- let the snapshot inform triage and dispatch. when it already tracks a risk, your lens prompts to subagents are stronger when they reference that context (e.g. "the JSDoc explicitly scopes to code points — do not flag grapheme-cluster issues" if the snapshot already documents that contract). when something the snapshot tracks is now resolved by new commits, note that. when new commits introduce something the snapshot doesn't yet describe, that's exactly where your fan-out should focus.
|
||||
- update the file in place to reflect the PR's CURRENT state. revise stale claims, drop resolved risks, add new behavior or risks. accuracy over breadth — every claim must be grounded in the diff. write for the next agent run, not for a human.
|
||||
- structure however serves THIS PR. there is no required section template. a refactor might organize by renamed export and call-site impact; a feature by capability; a billing change by money path. a compact note of which commit ranges have been reviewed should always be present so future runs scope correctly, but the rest is your call. when the structure works across runs, keep it stable so range-diffs are clean; when the PR's character changes (e.g. scope expands), reshape.
|
||||
|
||||
Do NOT call \`${t("create_issue_comment")}\` for the summary — the server reads this file at end-of-run and persists it. The file edit is mandatory regardless of whether a review is submitted; the snapshot feeds the next run.`;
|
||||
}
|
||||
|
||||
export function SelectModeTool(ctx: ToolContext) {
|
||||
@@ -180,22 +162,19 @@ export function SelectModeTool(ctx: ToolContext) {
|
||||
}
|
||||
}
|
||||
|
||||
if (selectedMode.name === "Summarize") {
|
||||
const prNumber = ctx.payload.event.issue_number;
|
||||
if (prNumber !== undefined) {
|
||||
const existing = await fetchExistingSummaryComment(ctx, prNumber);
|
||||
if (existing !== null) {
|
||||
ctx.toolState.existingSummaryCommentId = existing.commentId;
|
||||
return {
|
||||
...buildOrchestratorGuidance(ctx, selectedMode, overrides.SummaryUpdate),
|
||||
existingSummaryCommentId: existing.commentId,
|
||||
previousSummaryBody: existing.body,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
const summaryAddendum = SUMMARY_MODES.has(selectedMode.name)
|
||||
? buildSummaryAddendum(t, ctx)
|
||||
: "";
|
||||
|
||||
return buildOrchestratorGuidance(ctx, selectedMode);
|
||||
const base = buildOrchestratorGuidance(ctx, selectedMode);
|
||||
if (summaryAddendum.length > 0) {
|
||||
return {
|
||||
...base,
|
||||
orchestratorGuidance: `${base.orchestratorGuidance}\n\n${summaryAddendum}`,
|
||||
summaryFilePath: ctx.toolState.summaryFilePath,
|
||||
};
|
||||
}
|
||||
return base;
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
+9
-110
@@ -3,17 +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 { 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 {
|
||||
@@ -33,11 +30,9 @@ import { GetIssueCommentsTool } from "./issueComments.ts";
|
||||
import { GetIssueEventsTool } from "./issueEvents.ts";
|
||||
import { IssueInfoTool } from "./issueInfo.ts";
|
||||
import { AddLabelsTool } from "./labels.ts";
|
||||
import { UpdateLearningsTool } from "./learnings.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,
|
||||
@@ -49,108 +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, number = active comment, null = deliberately deleted
|
||||
progressCommentId: number | 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;
|
||||
// set by select_mode when Summarize mode and summary-comment API returns existing summary
|
||||
existingSummaryCommentId?: number;
|
||||
output?: string;
|
||||
usageEntries: AgentUsage[];
|
||||
model?: string | undefined;
|
||||
todoTracker?: TodoTracker | undefined;
|
||||
diffCoverage?: DiffCoverageState | undefined;
|
||||
}
|
||||
|
||||
interface InitToolStateParams {
|
||||
progressCommentId: string | undefined;
|
||||
}
|
||||
|
||||
export function initToolState(params: InitToolStateParams): ToolState {
|
||||
const parsed = params.progressCommentId ? parseInt(params.progressCommentId, 10) : NaN;
|
||||
const resolvedId = Number.isNaN(parsed) || parsed <= 0 ? undefined : parsed;
|
||||
|
||||
if (resolvedId) {
|
||||
log.info(`» using pre-created progress comment: ${resolvedId}`);
|
||||
}
|
||||
|
||||
return {
|
||||
progressCommentId: resolvedId,
|
||||
hadProgressComment: !!resolvedId,
|
||||
backgroundProcesses: new Map(),
|
||||
usageEntries: [],
|
||||
};
|
||||
}
|
||||
|
||||
export interface ToolContext {
|
||||
agentId: AgentId;
|
||||
repo: RunContextData["repo"];
|
||||
@@ -169,6 +62,13 @@ export interface ToolContext {
|
||||
jobId: string | undefined;
|
||||
mcpServerUrl: string;
|
||||
tmpdir: string;
|
||||
// repo-level OSS flag + account-level billing plan. together they decide
|
||||
// whether pullfrog is paying for marginal infra — see isInfraCovered in
|
||||
// utils/runContext.ts. plan gating for endpoints like the learnings PATCH
|
||||
// is enforced server-side via 402, so we pass plan along mostly for future
|
||||
// use / observability. see wiki/pricing.md.
|
||||
oss: boolean;
|
||||
plan: AccountPlan;
|
||||
// resolved upstream model specifier (e.g. "google/gemini-3.1-pro-preview").
|
||||
// undefined when payload.proxyModel is set or when the alias is unresolvable.
|
||||
// used by the schema sanitizer to detect Gemini-routed traffic.
|
||||
@@ -263,7 +163,6 @@ function buildOrchestratorTools(ctx: ToolContext, outputSchema?: JsonSchema): To
|
||||
DeleteBranchTool(ctx),
|
||||
CreatePullRequestTool(ctx),
|
||||
UpdatePullRequestBodyTool(ctx),
|
||||
UpdateLearningsTool(ctx),
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import * as path from "node:path";
|
||||
import { type } from "arktype";
|
||||
import { fileTypeFromBuffer } from "file-type";
|
||||
import { apiFetch } from "../utils/apiFetch.ts";
|
||||
import { log } from "../utils/cli.ts";
|
||||
import type { ToolContext } from "./server.ts";
|
||||
import { execute, tool } from "./shared.ts";
|
||||
|
||||
@@ -65,6 +66,8 @@ export function UploadFileTool(ctx: ToolContext) {
|
||||
throw new Error(`failed to upload file: ${uploadResponse.statusText}`);
|
||||
}
|
||||
|
||||
log.info(`» uploaded file ${publicUrl}`);
|
||||
|
||||
return { success: true, publicUrl, filename, contentLength, contentType };
|
||||
}),
|
||||
});
|
||||
|
||||
+1
-1
@@ -55,13 +55,13 @@ describe("getModelEnvVars", () => {
|
||||
|
||||
it("returns empty env vars for free opencode models", () => {
|
||||
expect(getModelEnvVars("opencode/big-pickle")).toEqual([]);
|
||||
expect(getModelEnvVars("opencode/gpt-5-nano")).toEqual([]);
|
||||
expect(getModelEnvVars("opencode/mimo-v2-pro-free")).toEqual([]);
|
||||
expect(getModelEnvVars("opencode/minimax-m2.5-free")).toEqual([]);
|
||||
});
|
||||
|
||||
it("still requires OPENCODE_API_KEY for non-free opencode models", () => {
|
||||
expect(getModelEnvVars("opencode/claude-opus")).toEqual(["OPENCODE_API_KEY"]);
|
||||
expect(getModelEnvVars("opencode/gpt-5-nano")).toEqual(["OPENCODE_API_KEY"]);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -140,14 +140,14 @@ export const providers = {
|
||||
models: {
|
||||
grok: {
|
||||
displayName: "Grok",
|
||||
resolve: "xai/grok-4",
|
||||
openRouterResolve: "openrouter/x-ai/grok-4",
|
||||
resolve: "xai/grok-4.3",
|
||||
openRouterResolve: "openrouter/x-ai/grok-4.3",
|
||||
preferred: true,
|
||||
},
|
||||
"grok-fast": {
|
||||
displayName: "Grok Fast",
|
||||
resolve: "xai/grok-4-fast",
|
||||
openRouterResolve: "openrouter/x-ai/grok-4-fast",
|
||||
resolve: "xai/grok-4-1-fast",
|
||||
openRouterResolve: "openrouter/x-ai/grok-4.1-fast",
|
||||
},
|
||||
"grok-code-fast": {
|
||||
displayName: "Grok Code Fast",
|
||||
@@ -271,8 +271,7 @@ export const providers = {
|
||||
"gpt-5-nano": {
|
||||
displayName: "GPT Nano",
|
||||
resolve: "opencode/gpt-5-nano",
|
||||
envVars: [],
|
||||
isFree: true,
|
||||
openRouterResolve: "openrouter/openai/gpt-5-nano",
|
||||
},
|
||||
"mimo-v2-pro-free": {
|
||||
displayName: "MiMo V2 Pro",
|
||||
@@ -354,8 +353,8 @@ export const providers = {
|
||||
},
|
||||
grok: {
|
||||
displayName: "Grok",
|
||||
resolve: "openrouter/x-ai/grok-4",
|
||||
openRouterResolve: "openrouter/x-ai/grok-4",
|
||||
resolve: "openrouter/x-ai/grok-4.3",
|
||||
openRouterResolve: "openrouter/x-ai/grok-4.3",
|
||||
},
|
||||
"deepseek-pro": {
|
||||
displayName: "DeepSeek Pro",
|
||||
|
||||
@@ -10,6 +10,12 @@ export interface Mode {
|
||||
prompt?: string | undefined;
|
||||
}
|
||||
|
||||
// Default user-facing summary format embedded in Review mode review bodies.
|
||||
// Deliberately scoped to Review (initial PR review). IncrementalReview keeps
|
||||
// its own terser bullet-list "Reviewed changes" shape since re-review bodies
|
||||
// are deltas, not introductions. Distinct from the agent-internal snapshot
|
||||
// (action/utils/prSummary.ts) which has its own stable scaffold and is never
|
||||
// shaped by user instructions — see selectMode.ts for the firewall.
|
||||
export const PR_SUMMARY_FORMAT = `### Default format
|
||||
|
||||
Follow this structure exactly:
|
||||
@@ -59,10 +65,6 @@ Rules:
|
||||
- Focus on *intent*, not *what* — the diff already shows what changed
|
||||
- Get the file count and commit count from the checkout_pr metadata, not by counting manually`;
|
||||
|
||||
function learningsStep(t: (toolName: string) => string, n: number): string {
|
||||
return `${n}. **learnings** (only if high confidence): if you discovered something about repo setup, test commands, conventions, or patterns that you are confident is correct and would reliably help future runs, call \`${t("update_learnings")}\` to persist it. skip this step if you are unsure or the finding is speculative/one-off. format as a flat bullet list (\`- \` per line, one fact per bullet). merge with existing learnings from the prompt — pass the FULL merged list. deduplicate, and drop bullets that are clearly wrong or no longer relevant to the current codebase.`;
|
||||
}
|
||||
|
||||
export function computeModes(agentId: AgentId): Mode[] {
|
||||
const t = (toolName: string) => formatMcpToolRef(agentId, toolName);
|
||||
return [
|
||||
@@ -72,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
|
||||
@@ -112,15 +116,13 @@ export function computeModes(agentId: AgentId): Mode[] {
|
||||
- Do NOT defect-hunt the diff yourself in parallel with the subagent. Your role is dispatch + evaluation; doing the review yourself reintroduces the implementation bias the subagent is meant to mitigate.
|
||||
- For diffs that rely on third-party API contracts, SDK semantics, framework directives, or DB engine specifics, instruct the subagent to verify load-bearing claims via web search and quote source URLs rather than trust training data — this is the single most common review-quality failure 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 not enough — a fix that improves correctness while degrading elegance still degrades the codebase. 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 "..."\`).
|
||||
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
|
||||
|
||||
${learningsStep(t, 6)}
|
||||
|
||||
### Notes
|
||||
|
||||
For simple, well-defined tasks, skip the plan phase and go straight to build.`,
|
||||
@@ -131,27 +133,27 @@ 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 not enough; improving correctness while degrading elegance still degrades the code.
|
||||
- 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)
|
||||
|
||||
${learningsStep(t, 6)}`,
|
||||
- call \`${t("report_progress")}\` with a brief summary (or the exact push error if push failed)`,
|
||||
},
|
||||
// Review and IncrementalReview use the multi-lens orchestrator pattern
|
||||
// (canonical source: .claude/commands/anneal.md). The orchestrator does
|
||||
@@ -171,11 +173,13 @@ ${learningsStep(t, 6)}`,
|
||||
"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 \`Reviewed — no issues found.\` 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
|
||||
@@ -220,7 +224,7 @@ ${learningsStep(t, 6)}`,
|
||||
- **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\`.
|
||||
@@ -235,49 +239,66 @@ ${learningsStep(t, 6)}`,
|
||||
- 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.
|
||||
|
||||
- **critical issues** (blocks merge — bugs, security, data loss):
|
||||
\`approved: false\`. Body begins with a GitHub alert blockquote, e.g.:
|
||||
\`> [!CAUTION]\\n> This PR introduces a race condition in ...\`
|
||||
Follow with a brief summary if needed. Include all inline comments.
|
||||
- **recommended changes** (non-critical):
|
||||
\`approved: false\`. Body begins with a GitHub alert blockquote, e.g.:
|
||||
\`> [!IMPORTANT]\\n> Consider adding input validation for ...\`
|
||||
Follow with a brief summary if needed. Include all inline comments.
|
||||
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.
|
||||
|
||||
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\`.
|
||||
- **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: "Reviewed — no issues found."`,
|
||||
\`approved: true\`. Body opens with \`No new issues found.\` followed by the PR summary.
|
||||
|
||||
${PR_SUMMARY_FORMAT}`,
|
||||
},
|
||||
// IncrementalReview shares Review's multi-lens orchestrator pattern but
|
||||
// scopes the target to the incremental diff and adds prior-review-feedback
|
||||
// tracking. 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 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 body-format rules
|
||||
// (Reviewed changes / Prior review feedback) are unchanged from the prior
|
||||
// version. Same severity-table omission as Review.
|
||||
// scopes the target to the incremental diff. The "issues must be NEW
|
||||
// since the last Pullfrog review" filter lives at aggregation time
|
||||
// (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
|
||||
// "Reviewed changes" — a separate "Prior review feedback" checklist
|
||||
// would duplicate the rolling PR summary snapshot's record of what
|
||||
// earlier runs already addressed and add noise to the user-facing
|
||||
// body. Same severity-table omission as Review.
|
||||
{
|
||||
name: "IncrementalReview",
|
||||
description:
|
||||
"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 need this in step 6 to track which prior comments were 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 @@ ${learningsStep(t, 6)}`,
|
||||
|
||||
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,22 +321,22 @@ ${learningsStep(t, 6)}`,
|
||||
- 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.
|
||||
|
||||
then check: which prior review comments were addressed by the new commits? track the addressed ones for step 6b.
|
||||
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.
|
||||
|
||||
6. **build the review body** — two distinct sections:
|
||||
a. **Reviewed changes**: 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.
|
||||
b. **Prior review feedback** (only if any were addressed): list only the prior review comments that WERE addressed by the new commits (\`- [x] safeParse instead of parse — addressed\`). omit unaddressed comments. omit this entire section if nothing was addressed. a change can appear in both sections.
|
||||
- no headings, no tables, no prose paragraphs in either section — just 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.
|
||||
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.
|
||||
|
||||
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. the review body always includes the reviewed changes from step 6a. append \`Prior review feedback:\\n\` with the checklist from step 6b only if any prior comments were addressed. Follow these rules:
|
||||
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 and prior feedback (if any).
|
||||
- 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 and prior feedback (if any).
|
||||
- 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 and prior feedback (if any).`,
|
||||
- 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.`,
|
||||
},
|
||||
{
|
||||
name: "Plan",
|
||||
@@ -323,15 +344,15 @@ ${learningsStep(t, 6)}`,
|
||||
"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.
|
||||
|
||||
${learningsStep(t, 4)}`,
|
||||
4. Call \`${t("report_progress")}\` with the plan.`,
|
||||
},
|
||||
{
|
||||
name: "Fix",
|
||||
@@ -339,46 +360,48 @@ ${learningsStep(t, 4)}`,
|
||||
"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)
|
||||
|
||||
${learningsStep(t, 6)}`,
|
||||
- call \`${t("report_progress")}\` with the diagnosis and fix summary (or the exact push error if push failed)`,
|
||||
},
|
||||
{
|
||||
name: "ResolveConflicts",
|
||||
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*)
|
||||
@@ -390,36 +413,38 @@ ${learningsStep(t, 6)}`,
|
||||
"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
|
||||
|
||||
${learningsStep(t, 4)}`,
|
||||
},
|
||||
{
|
||||
name: "Summarize",
|
||||
description:
|
||||
"Summarize a PR with a structured comment that is updated in place on subsequent pushes",
|
||||
prompt: `### Checklist
|
||||
|
||||
1. Checkout the PR via \`${t("checkout_pr")}\` — this returns PR metadata and a \`diffPath\`.
|
||||
2. Read the diff using the TOC to selectively read relevant sections (not the entire file). Produce a structured summary. If EVENT INSTRUCTIONS specify a custom format, follow that instead of the default format below.
|
||||
3. Call \`${t("create_issue_comment")}\` with \`type: "Summary"\` and the summary body.
|
||||
4. Call \`${t("report_progress")}\` with a brief note (e.g., "Posted PR summary.").
|
||||
|
||||
${PR_SUMMARY_FORMAT}`,
|
||||
- if the task involved labeling, commenting, or other GitHub operations, perform those directly`,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
// 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.0.203",
|
||||
"version": "0.1.5",
|
||||
"type": "module",
|
||||
"bin": {
|
||||
"pullfrog": "dist/cli.mjs",
|
||||
|
||||
@@ -12,7 +12,6 @@ import { log } from "./utils/cli.ts";
|
||||
import { runInDocker } from "./utils/docker.ts";
|
||||
import { ensureGitHubToken } from "./utils/github.ts";
|
||||
import { isInsideDocker } from "./utils/globals.ts";
|
||||
import { runPostCleanup } from "./utils/postCleanup.ts";
|
||||
import { setupTestRepo } from "./utils/setup.ts";
|
||||
|
||||
/**
|
||||
@@ -78,13 +77,7 @@ export async function run(inputsOrPrompt: Inputs | string): Promise<AgentResult>
|
||||
}
|
||||
}
|
||||
|
||||
// wrap main() so post cleanup runs even on failure (mirrors action.yml post-if: "failure() || cancelled()")
|
||||
let result: AgentResult;
|
||||
try {
|
||||
result = await main();
|
||||
} finally {
|
||||
await runPostCleanup();
|
||||
}
|
||||
const result: AgentResult = await main();
|
||||
|
||||
process.chdir(originalCwd);
|
||||
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { runPullfrogCli } from "./runCli.ts";
|
||||
|
||||
runPullfrogCli({
|
||||
cliArgs: ["gha", "--post"],
|
||||
swallowErrors: true,
|
||||
});
|
||||
@@ -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}`],
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -7,7 +7,6 @@ const scriptDir = dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
const entryPoints = [
|
||||
resolve(scriptDir, "../entry.ts"),
|
||||
resolve(scriptDir, "../post.ts"),
|
||||
resolve(scriptDir, "../get-installation-token/entry.ts"),
|
||||
resolve(scriptDir, "../get-installation-token/post.ts"),
|
||||
];
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* refresh checked-in test fixtures for mcp/checkout.test.ts and
|
||||
* mcp/reviewComments.test.ts.
|
||||
*
|
||||
* those tests used to hit live GitHub on every run, which made them
|
||||
* cred-gated (GH_TOKEN or GITHUB_APP_ID + GITHUB_PRIVATE_KEY) and
|
||||
* non-deterministic. they now read from action/mcp/__fixtures__/*.json,
|
||||
* which this script regenerates on demand.
|
||||
*
|
||||
* run with creds set (locally via .env, or in a CI cron with secrets):
|
||||
*
|
||||
* GH_TOKEN=… node action/scripts/refresh-test-fixtures.ts
|
||||
* # or
|
||||
* GITHUB_APP_ID=… GITHUB_PRIVATE_KEY=… node action/scripts/refresh-test-fixtures.ts
|
||||
*
|
||||
* commit the resulting fixture changes; review the diff before merging
|
||||
* (anything unexpected indicates real GitHub API drift).
|
||||
*/
|
||||
|
||||
import { mkdirSync, writeFileSync } from "node:fs";
|
||||
import { dirname, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { Octokit } from "@octokit/rest";
|
||||
import { config as loadDotenv } from "dotenv";
|
||||
import {
|
||||
REVIEW_THREADS_QUERY,
|
||||
type ReviewThread,
|
||||
type ReviewThreadsQueryResponse,
|
||||
} from "../mcp/reviewComments.ts";
|
||||
import { acquireNewToken } from "../utils/github.ts";
|
||||
|
||||
const scriptDir = dirname(fileURLToPath(import.meta.url));
|
||||
const repoRoot = resolve(scriptDir, "../..");
|
||||
const fixturesDir = resolve(scriptDir, "../mcp/__fixtures__");
|
||||
|
||||
loadDotenv({ path: resolve(repoRoot, ".env") });
|
||||
|
||||
type DiffFixture = {
|
||||
owner: string;
|
||||
name: string;
|
||||
pullNumber: number;
|
||||
files: unknown;
|
||||
};
|
||||
|
||||
type ReviewFixture = {
|
||||
owner: string;
|
||||
name: string;
|
||||
pullNumber: number;
|
||||
reviewId: number;
|
||||
review: { body: string | null | undefined; user: { login: string } | null | undefined };
|
||||
threads: ReviewThread[];
|
||||
prFiles: Array<{ filename: string; patch?: string | undefined }>;
|
||||
};
|
||||
|
||||
const DIFF_TARGETS: Array<Pick<DiffFixture, "owner" | "name" | "pullNumber">> = [
|
||||
{ owner: "pullfrog", name: "test-repo", pullNumber: 1 },
|
||||
];
|
||||
|
||||
const REVIEW_TARGETS: Array<Pick<ReviewFixture, "owner" | "name" | "pullNumber" | "reviewId">> = [
|
||||
{ owner: "pullfrog", name: "scratch", pullNumber: 49, reviewId: 3485940013 },
|
||||
{ owner: "pullfrog", name: "scratch", pullNumber: 64, reviewId: 3531000326 },
|
||||
];
|
||||
|
||||
async function getToken(): Promise<string> {
|
||||
if (process.env.GH_TOKEN) return process.env.GH_TOKEN;
|
||||
return await acquireNewToken();
|
||||
}
|
||||
|
||||
async function refreshDiffFixture(
|
||||
octokit: Octokit,
|
||||
target: (typeof DIFF_TARGETS)[number]
|
||||
): Promise<void> {
|
||||
const files = await octokit.paginate(octokit.rest.pulls.listFiles, {
|
||||
owner: target.owner,
|
||||
repo: target.name,
|
||||
pull_number: target.pullNumber,
|
||||
per_page: 100,
|
||||
});
|
||||
const fixture: DiffFixture = { ...target, files };
|
||||
const path = resolve(
|
||||
fixturesDir,
|
||||
`${target.owner}-${target.name}-pr-${target.pullNumber}.diff.json`
|
||||
);
|
||||
writeFileSync(path, `${JSON.stringify(fixture, null, 2)}\n`);
|
||||
console.log(`wrote ${path}`);
|
||||
}
|
||||
|
||||
async function refreshReviewFixture(
|
||||
octokit: Octokit,
|
||||
target: (typeof REVIEW_TARGETS)[number]
|
||||
): Promise<void> {
|
||||
const [review, threadsResp] = await Promise.all([
|
||||
octokit.rest.pulls.getReview({
|
||||
owner: target.owner,
|
||||
repo: target.name,
|
||||
pull_number: target.pullNumber,
|
||||
review_id: target.reviewId,
|
||||
}),
|
||||
octokit.graphql<ReviewThreadsQueryResponse>(REVIEW_THREADS_QUERY, {
|
||||
owner: target.owner,
|
||||
name: target.name,
|
||||
prNumber: target.pullNumber,
|
||||
}),
|
||||
]);
|
||||
|
||||
const allThreads = threadsResp.repository?.pullRequest?.reviewThreads?.nodes ?? [];
|
||||
const threads = allThreads.filter((thread): thread is ReviewThread => {
|
||||
if (!thread?.comments?.nodes) return false;
|
||||
return thread.comments.nodes.some((c) => c?.pullRequestReview?.databaseId === target.reviewId);
|
||||
});
|
||||
|
||||
// skip listFiles entirely when there are no threads — prFiles is only
|
||||
// used for thread blocks, so an empty array short-circuits in the
|
||||
// formatter. mirrors getReviewData's runtime perf optimization and
|
||||
// keeps body-only-review fixtures small.
|
||||
const prFiles =
|
||||
threads.length > 0
|
||||
? await octokit.paginate(octokit.rest.pulls.listFiles, {
|
||||
owner: target.owner,
|
||||
repo: target.name,
|
||||
pull_number: target.pullNumber,
|
||||
per_page: 100,
|
||||
})
|
||||
: [];
|
||||
|
||||
// strip prFiles down to the fields the formatter actually reads. keeps
|
||||
// fixtures small and avoids capturing volatile fields (sha, blob_url,
|
||||
// contents_url, etc.) that would churn unrelated to formatter behavior.
|
||||
const trimmedFiles = prFiles.map((f) => ({
|
||||
filename: f.filename,
|
||||
...(f.patch ? { patch: f.patch } : {}),
|
||||
}));
|
||||
|
||||
const fixture: ReviewFixture = {
|
||||
...target,
|
||||
review: {
|
||||
body: review.data.body,
|
||||
user: review.data.user ? { login: review.data.user.login } : null,
|
||||
},
|
||||
threads,
|
||||
prFiles: trimmedFiles,
|
||||
};
|
||||
const path = resolve(
|
||||
fixturesDir,
|
||||
`${target.owner}-${target.name}-pr-${target.pullNumber}-review-${target.reviewId}.json`
|
||||
);
|
||||
writeFileSync(path, `${JSON.stringify(fixture, null, 2)}\n`);
|
||||
console.log(`wrote ${path}`);
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const token = await getToken();
|
||||
const octokit = new Octokit({ auth: token });
|
||||
mkdirSync(fixturesDir, { recursive: true });
|
||||
|
||||
for (const t of DIFF_TARGETS) await refreshDiffFixture(octokit, t);
|
||||
for (const t of REVIEW_TARGETS) await refreshReviewFixture(octokit, t);
|
||||
}
|
||||
|
||||
await main();
|
||||
@@ -1,38 +0,0 @@
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`latest model per provider snapshot > matches snapshot 1`] = `
|
||||
{
|
||||
"anthropic": {
|
||||
"modelId": "claude-opus-4-7",
|
||||
"releaseDate": "2026-04-16",
|
||||
},
|
||||
"deepseek": {
|
||||
"modelId": "deepseek-v4-pro",
|
||||
"releaseDate": "2026-04-24",
|
||||
},
|
||||
"google": {
|
||||
"modelId": "gemma-4-31b-it",
|
||||
"releaseDate": "2026-04-02",
|
||||
},
|
||||
"moonshotai": {
|
||||
"modelId": "kimi-k2.6",
|
||||
"releaseDate": "2026-04-21",
|
||||
},
|
||||
"openai": {
|
||||
"modelId": "gpt-5.5-pro",
|
||||
"releaseDate": "2026-04-23",
|
||||
},
|
||||
"opencode": {
|
||||
"modelId": "gpt-5.5-pro",
|
||||
"releaseDate": "2026-04-24",
|
||||
},
|
||||
"openrouter": {
|
||||
"modelId": "poolside/laguna-xs.2:free",
|
||||
"releaseDate": "2026-04-28",
|
||||
},
|
||||
"xai": {
|
||||
"modelId": "grok-4.3",
|
||||
"releaseDate": "2026-05-01",
|
||||
},
|
||||
}
|
||||
`;
|
||||
+74
-32
@@ -1,54 +1,96 @@
|
||||
/**
|
||||
* 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_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"]);
|
||||
|
||||
// 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;
|
||||
// opencode FREE models (big-pickle, mimo-v2-pro-free, minimax-m2.5-free)
|
||||
// are unique to opencode and used in prod — keep them. only prune the keyed
|
||||
// mirrors.
|
||||
return alias.provider === "opencode" && !alias.isFree;
|
||||
}
|
||||
|
||||
const filter = process.env.MATRIX_FILTER?.trim() ?? "";
|
||||
const includeAllPassthroughs = process.env.INCLUDE_ALL_PASSTHROUGHS === "1";
|
||||
|
||||
const matrix = modelAliases
|
||||
.filter((alias) => (filter ? alias.slug.toLowerCase().includes(filter.toLowerCase()) : true))
|
||||
.filter((alias) => includeAllPassthroughs || !isPrunablePassthrough(alias))
|
||||
.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);
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { type ModelProvider, modelAliases, providers } from "../models.ts";
|
||||
import { modelAliases, resolveDisplayAlias } from "../models.ts";
|
||||
|
||||
// ── catalog drift tests — main-only ─────────────────────────────────────────────
|
||||
//
|
||||
@@ -8,6 +8,12 @@ import { type ModelProvider, modelAliases, providers } from "../models.ts";
|
||||
// catalog drift (new model ships, old model deprecated, etc.) causes failures
|
||||
// that are unrelated to any code change in the PR — so these run only on main.
|
||||
//
|
||||
// the registry is kept in sync with upstreams by the `models-bump` cron
|
||||
// (`.github/workflows/models-bump.yml`), which scans models.dev every 12h and
|
||||
// opens a PR bumping `resolve` / `openRouterResolve` for any alias whose
|
||||
// upstream has shipped a newer GA version. these tests are the integrity gate
|
||||
// for that PR — they catch typos, removed models, and openrouter mismatches.
|
||||
//
|
||||
// run locally with `pnpm test:catalog`.
|
||||
// in CI, gated to push events on main.
|
||||
|
||||
@@ -15,6 +21,7 @@ type ModelsDevModel = {
|
||||
name: string;
|
||||
status?: string;
|
||||
release_date?: string;
|
||||
cost?: { input?: number; output?: number };
|
||||
};
|
||||
|
||||
type ModelsDevProvider = {
|
||||
@@ -107,39 +114,69 @@ describe("openRouterResolve OpenRouter API validity", async () => {
|
||||
}
|
||||
});
|
||||
|
||||
describe("latest model per provider snapshot", async () => {
|
||||
const data = await api;
|
||||
const providerKeys = Object.keys(providers) as ModelProvider[];
|
||||
// ── OpenCode Zen served-list + free-cost checks ────────────────────────────────
|
||||
//
|
||||
// these enforce the two dynamic conditions for "this opencode alias works for a
|
||||
// user without OPENCODE_API_KEY" — the gap that let issue #691 ship:
|
||||
// 1. the alias's terminal-fallback resolve appears in Zen's /v1/models (Zen
|
||||
// actually serves it). caught nothing in #691 because mimo had a fallback
|
||||
// to big-pickle which IS served, but would catch any future alias that
|
||||
// points at a Zen-removed model without a fallback.
|
||||
// 2. for isFree aliases, the terminal-fallback's models.dev `cost.input` is
|
||||
// zero. caught the gpt-5-nano regression: $0.05/M input on models.dev,
|
||||
// marked isFree in our catalog.
|
||||
//
|
||||
// we check the terminal-fallback (via resolveDisplayAlias) because deprecated
|
||||
// aliases legitimately point at dead resolve targets — the terminal is what
|
||||
// actually runs at the agent CLI.
|
||||
|
||||
const latestByProvider: Record<string, { modelId: string; releaseDate: string }> = {};
|
||||
type ZenModel = { id: string };
|
||||
type ZenModelsResponse = { data: ZenModel[] };
|
||||
|
||||
for (const key of providerKeys) {
|
||||
const providerData = data[key];
|
||||
if (!providerData) continue;
|
||||
const zenApi = fetch("https://opencode.ai/zen/v1/models").then(
|
||||
(r) => r.json() as Promise<ZenModelsResponse>
|
||||
);
|
||||
|
||||
let latest: { modelId: string; releaseDate: string } | undefined;
|
||||
for (const [modelId, model] of Object.entries(providerData.models)) {
|
||||
// skip non-GA models so beta/nightly churn doesn't break the snapshot
|
||||
if (model.status) continue;
|
||||
const rd = model.release_date;
|
||||
if (!rd) continue;
|
||||
// tiebreak by modelId for stable ordering when release dates match
|
||||
if (
|
||||
!latest ||
|
||||
rd > latest.releaseDate ||
|
||||
(rd === latest.releaseDate && modelId > latest.modelId)
|
||||
) {
|
||||
latest = { modelId, releaseDate: rd };
|
||||
}
|
||||
}
|
||||
if (latest) {
|
||||
latestByProvider[key] = latest;
|
||||
}
|
||||
describe("opencode Zen served list", async () => {
|
||||
const zenData = await zenApi;
|
||||
const zenIds = new Set(zenData.data.map((m) => m.id));
|
||||
const seen = new Set<string>();
|
||||
|
||||
for (const alias of modelAliases) {
|
||||
const terminal = resolveDisplayAlias(alias.slug);
|
||||
if (!terminal) continue;
|
||||
const parsed = parseResolve(terminal.resolve);
|
||||
if (parsed.provider !== "opencode") continue;
|
||||
if (seen.has(terminal.resolve)) continue;
|
||||
seen.add(terminal.resolve);
|
||||
|
||||
it(`${alias.slug} terminal resolve ${terminal.resolve} is served by Zen`, () => {
|
||||
expect(
|
||||
zenIds.has(parsed.modelId),
|
||||
`terminal resolve "${terminal.resolve}" for alias "${alias.slug}" is not in https://opencode.ai/zen/v1/models — Zen no longer serves it. either point a fallback at a Zen-served alias or remove the entry.`
|
||||
).toBe(true);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
describe("isFree models.dev cost", async () => {
|
||||
const data = await api;
|
||||
const seen = new Set<string>();
|
||||
|
||||
for (const alias of modelAliases.filter((a) => a.isFree)) {
|
||||
const terminal = resolveDisplayAlias(alias.slug);
|
||||
if (!terminal) continue;
|
||||
const parsed = parseResolve(terminal.resolve);
|
||||
if (seen.has(terminal.resolve)) continue;
|
||||
seen.add(terminal.resolve);
|
||||
|
||||
it(`${alias.slug} terminal resolve ${terminal.resolve} has cost.input === 0`, () => {
|
||||
const model = data[parsed.provider]?.models[parsed.modelId];
|
||||
expect(model, `terminal resolve "${terminal.resolve}" missing on models.dev`).toBeDefined();
|
||||
expect(
|
||||
model?.cost?.input,
|
||||
`isFree alias "${alias.slug}" walks to "${terminal.resolve}" which reports cost.input=${model?.cost?.input} on models.dev — either repoint the fallback or drop \`isFree\``
|
||||
).toBe(0);
|
||||
});
|
||||
}
|
||||
|
||||
// when this fails, a provider shipped a new model. check whether we need
|
||||
// to add or update an alias in models.ts before updating the snapshot.
|
||||
it("matches snapshot", () => {
|
||||
expect(latestByProvider).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
|
||||
+47
-1
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { modelAliases, resolveCliModel } from "../models.ts";
|
||||
import { getModelEnvVars, modelAliases, resolveCliModel, resolveDisplayAlias } from "../models.ts";
|
||||
|
||||
// ── pure alias-registry invariants ──────────────────────────────────────────────
|
||||
//
|
||||
@@ -42,3 +42,49 @@ describe("fallback chain resolution", () => {
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// ── isFree invariants — sanity-check the catalog data shape ─────────────────────
|
||||
//
|
||||
// these catch the latent regressions that produced issue #691:
|
||||
// - opencode/gpt-5-nano was marked `isFree` despite costing $0.05/M
|
||||
// (no static check existed; demoted to paid in the same PR adding these tests)
|
||||
// - opencode/mimo-v2-pro-free was free + fallback to big-pickle (correct shape),
|
||||
// but nothing enforced that the terminal of an isFree fallback chain is itself
|
||||
// free. if someone repointed big-pickle's fallback at a paid model, all of mimo
|
||||
// and big-pickle's users would silently start hitting a paid endpoint.
|
||||
//
|
||||
// the cost.input check itself is network-dependent (lives in
|
||||
// models-catalog.main.test.ts); these are the static sibling that runs on every PR.
|
||||
describe("isFree invariants", () => {
|
||||
for (const alias of modelAliases.filter((a) => a.isFree)) {
|
||||
it(`${alias.slug} lives under the opencode provider`, () => {
|
||||
expect(
|
||||
alias.provider,
|
||||
`isFree alias "${alias.slug}" must be under "opencode" (Zen's keyless gate is opencode-only)`
|
||||
).toBe("opencode");
|
||||
});
|
||||
|
||||
it(`${alias.slug} has empty envVars`, () => {
|
||||
expect(
|
||||
getModelEnvVars(alias.slug),
|
||||
`isFree alias "${alias.slug}" must declare \`envVars: []\` so validateAgentApiKey doesn't demand OPENCODE_API_KEY`
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it(`${alias.slug} has no openRouterResolve`, () => {
|
||||
expect(
|
||||
alias.openRouterResolve,
|
||||
`isFree alias "${alias.slug}" must omit \`openRouterResolve\` — free Zen models don't exist on OpenRouter`
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it(`${alias.slug} fallback chain terminates at an isFree alias`, () => {
|
||||
const terminal = resolveDisplayAlias(alias.slug);
|
||||
expect(terminal, `fallback chain for "${alias.slug}" is broken`).toBeDefined();
|
||||
expect(
|
||||
terminal?.isFree,
|
||||
`isFree alias "${alias.slug}" walks to "${terminal?.slug}" which is NOT isFree — users would silently start paying`
|
||||
).toBe(true);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
+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: [],
|
||||
};
|
||||
}
|
||||
@@ -37,6 +37,18 @@ export async function apiFetch(options: ApiFetchOptions): Promise<Response> {
|
||||
headers["x-vercel-protection-bypass"] = bypassSecret;
|
||||
}
|
||||
|
||||
// never send Content-Type on body-less requests. empirically, Vercel's
|
||||
// Next.js lambda adapter (Next 16.1.x) throws `SyntaxError: Unexpected
|
||||
// end of data` before delegating to the route handler — returning a 500 —
|
||||
// when Content-Type is set but no body is present. exact mechanism is
|
||||
// unverified (minified runtime frame), but Content-Type on a body-less
|
||||
// request has no defined semantics per RFC 9110 §8.3 anyway. see #692.
|
||||
if (!options.body) {
|
||||
for (const key of Object.keys(headers)) {
|
||||
if (key.toLowerCase() === "content-type") delete headers[key];
|
||||
}
|
||||
}
|
||||
|
||||
log.debug(`api fetch: ${options.method ?? "GET"} ${url.pathname}`);
|
||||
|
||||
const init: RequestInit = {
|
||||
|
||||
@@ -27,11 +27,7 @@ describe("validateAgentApiKey", () => {
|
||||
});
|
||||
|
||||
it("passes for other free opencode models", () => {
|
||||
for (const slug of [
|
||||
"opencode/gpt-5-nano",
|
||||
"opencode/mimo-v2-pro-free",
|
||||
"opencode/minimax-m2.5-free",
|
||||
]) {
|
||||
for (const slug of ["opencode/mimo-v2-pro-free", "opencode/minimax-m2.5-free"]) {
|
||||
expect(() => validateAgentApiKey({ ...base, model: slug })).not.toThrow();
|
||||
}
|
||||
});
|
||||
@@ -59,6 +55,12 @@ describe("validateAgentApiKey", () => {
|
||||
"no API key found"
|
||||
);
|
||||
});
|
||||
|
||||
it("throws for opencode/gpt-5-nano without OPENCODE_API_KEY (paid Zen alias)", () => {
|
||||
expect(() => validateAgentApiKey({ ...base, model: "opencode/gpt-5-nano" })).toThrow(
|
||||
"no API key found"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("no model (auto-select)", () => {
|
||||
|
||||
@@ -25,3 +25,18 @@ export function getApiUrl(): string {
|
||||
log.debug(`resolved API_URL: ${raw}`);
|
||||
return raw;
|
||||
}
|
||||
|
||||
/**
|
||||
* true when the action is configured to talk to a localhost API server (i.e.
|
||||
* `pnpm dev` running on the developer's box). signals we can use dev-only
|
||||
* affordances like the `x-dev-repo` proxy-token bypass — the corresponding
|
||||
* server-side dev gates (`NODE_ENV === "development"`) ensure these paths
|
||||
* never activate against prod regardless of what the action does.
|
||||
*/
|
||||
export function isLocalApiUrl(): boolean {
|
||||
try {
|
||||
return isLocalUrl(new URL(getApiUrl()));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
+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 { resolveDisplayAlias } from "../models.ts";
|
||||
import { modelAliases, resolveDisplayAlias } from "../models.ts";
|
||||
|
||||
export const PULLFROG_DIVIDER = "<!-- PULLFROG_DIVIDER_DO_NOT_REMOVE_PLZ -->";
|
||||
|
||||
@@ -28,7 +28,13 @@ export interface BuildPullfrogFooterParams {
|
||||
function formatModelLabel(slug: string): string {
|
||||
// walk the fallback chain so a deprecated stored slug shows the model the
|
||||
// run actually executed against (e.g. "GPT", not "GPT Codex").
|
||||
const alias = resolveDisplayAlias(slug);
|
||||
const alias =
|
||||
resolveDisplayAlias(slug) ??
|
||||
// reverse-lookup: when the caller passes an effective model (proxy or
|
||||
// resolved target like "openrouter/anthropic/claude-opus-4.7") instead of
|
||||
// a stored alias slug, find the alias whose resolve target matches so we
|
||||
// still render a friendly display name.
|
||||
modelAliases.find((a) => a.resolve === slug || a.openRouterResolve === slug);
|
||||
if (!alias) return `\`${slug}\``;
|
||||
return alias.isFree ? `\`${alias.displayName}\` (free)` : `\`${alias.displayName}\``;
|
||||
}
|
||||
|
||||
@@ -98,6 +98,24 @@ describe("diff coverage line checker", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("carries forward coveragePreflightRan from a previous state across checkout refreshes", () => {
|
||||
const previous = createDiffCoverageState({ diffPath, totalLines: 30, toc });
|
||||
previous.coveragePreflightRan = true;
|
||||
previous.coveredRanges = [{ startLine: 5, endLine: 10 }];
|
||||
|
||||
const next = createDiffCoverageState({ diffPath, totalLines: 50, toc, previous });
|
||||
|
||||
expect(next.coveragePreflightRan).toBe(true);
|
||||
// coveredRanges are tied to the previous diff content and must not leak forward
|
||||
expect(next.coveredRanges).toEqual([]);
|
||||
expect(next.totalLines).toBe(50);
|
||||
});
|
||||
|
||||
it("defaults coveragePreflightRan to false when no previous state is provided", () => {
|
||||
const state = createDiffCoverageState({ diffPath, totalLines: 30, toc });
|
||||
expect(state.coveragePreflightRan).toBe(false);
|
||||
});
|
||||
|
||||
it("computes per-file unread ranges from tracked reads", () => {
|
||||
const state = createDiffCoverageState({
|
||||
diffPath,
|
||||
|
||||
@@ -78,13 +78,17 @@ export function createDiffCoverageState(params: {
|
||||
diffPath: string;
|
||||
totalLines: number;
|
||||
toc: string;
|
||||
previous?: DiffCoverageState | undefined;
|
||||
}): DiffCoverageState {
|
||||
return {
|
||||
diffPath: params.diffPath,
|
||||
totalLines: params.totalLines,
|
||||
tocEntries: parseDiffTocEntries({ toc: params.toc }),
|
||||
coveredRanges: [],
|
||||
coveragePreflightRan: false,
|
||||
// carry forward across checkout_pr refreshes so the nudge stays "once per
|
||||
// review session". coveredRanges are intentionally not carried because
|
||||
// line numbers are tied to the previous diff's content.
|
||||
coveragePreflightRan: params.previous?.coveragePreflightRan ?? false,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
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";
|
||||
import { updateProgressComment } from "./progressComment.ts";
|
||||
import { getGitHubInstallationToken } from "./token.ts";
|
||||
|
||||
interface ReportErrorParams {
|
||||
@@ -13,8 +14,8 @@ interface ReportErrorParams {
|
||||
export async function reportErrorToComment(ctx: ReportErrorParams): Promise<void> {
|
||||
const formattedError = ctx.title ? `${ctx.title}\n\n${ctx.error}` : ctx.error;
|
||||
|
||||
const commentId = ctx.toolState.progressCommentId;
|
||||
if (!commentId) {
|
||||
const comment = ctx.toolState.progressComment;
|
||||
if (!comment) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -39,12 +40,11 @@ export async function reportErrorToComment(ctx: ReportErrorParams): Promise<void
|
||||
model: ctx.toolState.model,
|
||||
});
|
||||
|
||||
await octokit.rest.issues.updateComment({
|
||||
owner: repoContext.owner,
|
||||
repo: repoContext.name,
|
||||
comment_id: commentId,
|
||||
body: `${formattedError}${footer}`,
|
||||
});
|
||||
await updateProgressComment(
|
||||
{ octokit, owner: repoContext.owner, repo: repoContext.name },
|
||||
comment,
|
||||
`${formattedError}${footer}`
|
||||
);
|
||||
|
||||
// mark as updated so exit handler doesn't try to update again
|
||||
ctx.toolState.wasUpdated = true;
|
||||
|
||||
+13
-2
@@ -154,8 +154,19 @@ export async function $git(
|
||||
|
||||
if (result.exitCode !== 0) {
|
||||
const stderr = result.stderr.trim();
|
||||
log.info(`git ${subcommand} failed: ${stderr}`);
|
||||
throw new Error(`git ${subcommand} failed: ${stderr}`);
|
||||
const stdout = result.stdout.trim();
|
||||
// stderr is the primary channel for git diagnostics, but in rare cases
|
||||
// (e.g. some HTTPS smart-protocol failures) the only useful detail is
|
||||
// on stdout — without it the agent / operator sees an empty error.
|
||||
// include exit code so we can distinguish e.g. signal-killed (1 with
|
||||
// empty output) from a genuine git-level rejection.
|
||||
const detail =
|
||||
stderr && stdout
|
||||
? `${stderr}\n--- stdout ---\n${stdout}`
|
||||
: stderr || stdout || "(no output)";
|
||||
const message = `git ${subcommand} failed (exit ${result.exitCode}): ${detail}`;
|
||||
log.info(message);
|
||||
throw new Error(message);
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
+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;
|
||||
|
||||
+28
-12
@@ -12,7 +12,10 @@ interface InstructionsContext {
|
||||
modes: Mode[];
|
||||
agentId: AgentId;
|
||||
outputSchema?: Record<string, unknown> | undefined;
|
||||
learnings: string | null;
|
||||
/** absolute path to the seeded learnings tmpfile, or null when the file
|
||||
* couldn't be seeded for some reason. main.ts always seeds, so in
|
||||
* practice this is always set; the null case keeps the type honest. */
|
||||
learningsFilePath: string | null;
|
||||
}
|
||||
|
||||
interface PromptContext extends InstructionsContext {
|
||||
@@ -29,6 +32,7 @@ function buildRuntimeContext(ctx: InstructionsContext): string {
|
||||
"~pullfrog": _,
|
||||
prompt: _p,
|
||||
eventInstructions: _ei,
|
||||
previousRunsNote: _prn,
|
||||
event: _e,
|
||||
...payloadRest
|
||||
} = ctx.payload;
|
||||
@@ -143,17 +147,23 @@ In case of conflict between instructions, follow this precedence (highest to low
|
||||
// section builders
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// the user's task: blockquoted user prompt, or event-level instructions for auto-triggers
|
||||
// the user's task: blockquoted user prompt, or event-level instructions for auto-triggers.
|
||||
// `previousRunsNote` is system-injected context (e.g. prior runs superseded by a
|
||||
// comment edit); it's appended regardless of which branch wins so it survives
|
||||
// user-prompt precedence over eventInstructions.
|
||||
function buildTaskSection(ctx: PromptContext): string {
|
||||
const previousRunsNote = ctx.payload.previousRunsNote?.trim() ?? "";
|
||||
|
||||
if (ctx.userQuoted) {
|
||||
const parts = [ctx.userQuoted, previousRunsNote].filter(Boolean);
|
||||
return `************* YOUR TASK *************
|
||||
|
||||
${ctx.userQuoted}`;
|
||||
${parts.join("\n\n")}`;
|
||||
}
|
||||
|
||||
const eventInstructions = ctx.payload.eventInstructions ?? "";
|
||||
if (eventInstructions) {
|
||||
const parts = [ctx.eventTitle, eventInstructions].filter(Boolean);
|
||||
if (eventInstructions || previousRunsNote) {
|
||||
const parts = [ctx.eventTitle, eventInstructions, previousRunsNote].filter(Boolean);
|
||||
return `************* YOUR TASK *************
|
||||
|
||||
${parts.join("\n\n")}`;
|
||||
@@ -289,7 +299,7 @@ When embedding images (e.g. uploaded screenshots) in comments or PR bodies, alwa
|
||||
|
||||
**\`report_progress\`**: call this exactly once at the end of every run with a brief final summary (1-3 sentences) unless the mode guidance instructs otherwise. Never call it for intermediate status updates (e.g., "Checking for changes...", "Starting review...") — the task list handles live progress automatically. Calling \`report_progress\` replaces the task list with your summary and preserves the current task list in a collapsible section. Keep the summary concise — do not repeat what the task list already shows. Focus on the outcome (what was accomplished, links to artifacts) rather than listing individual steps. If something failed, include the tool's error text even when that makes the summary longer.
|
||||
|
||||
Never use \`create_issue_comment\` for task progress — that creates duplicate comments and leaves the progress comment stuck in its initial state. \`create_issue_comment\` is only for standalone comments unrelated to your current task (e.g., Plan comments, PR Summary comments).
|
||||
Never use \`create_issue_comment\` for task progress — that creates duplicate comments and leaves the progress comment stuck in its initial state. \`create_issue_comment\` is only for standalone comments unrelated to your current task (e.g., Plan comments).
|
||||
|
||||
### If you get stuck
|
||||
|
||||
@@ -350,11 +360,17 @@ function assembleFullPrompt(ctx: {
|
||||
procedure: string;
|
||||
eventContext: string;
|
||||
system: string;
|
||||
learnings: string | null;
|
||||
learningsFilePath: string | null;
|
||||
runtime: string;
|
||||
}): string {
|
||||
const learningsSection = ctx.learnings
|
||||
? `************* LEARNINGS *************\n\n${ctx.learnings}`
|
||||
// the LEARNINGS section is intentionally tiny — just the file path and a
|
||||
// one-line "read it" instruction. embedding the contents would re-inflate
|
||||
// the prompt every run (the previous design's failure mode) and clutter
|
||||
// CI logs. the agent reads the file with its native file tool; the
|
||||
// post-run reflection turn (action/agents/postRun.ts) is where editing
|
||||
// is encouraged, with the prune-stale framing.
|
||||
const learningsSection = ctx.learningsFilePath
|
||||
? `************* LEARNINGS *************\n\nRepo-level learnings accumulated by previous agent runs live at \`${ctx.learningsFilePath}\`. Read this file early and let the entries inform your approach (test commands, conventions, gotchas, etc.). The file may be empty if no learnings have been collected yet.`
|
||||
: "";
|
||||
|
||||
const runtimeSection = `************* RUNTIME *************\n\n${ctx.runtime}`;
|
||||
@@ -389,8 +405,8 @@ export function resolveInstructions(ctx: InstructionsContext): ResolvedInstructi
|
||||
if (eventContext)
|
||||
tocEntries.push({ label: "EVENT CONTEXT", description: "related PR/issue data" });
|
||||
tocEntries.push({ label: "SYSTEM", description: "persona, security, tools, workflow rules" });
|
||||
if (pctx.learnings)
|
||||
tocEntries.push({ label: "LEARNINGS", description: "repo-specific knowledge" });
|
||||
if (pctx.learningsFilePath)
|
||||
tocEntries.push({ label: "LEARNINGS", description: "repo-specific knowledge file path" });
|
||||
tocEntries.push({ label: "RUNTIME", description: "environment metadata" });
|
||||
|
||||
const toc = buildToc(tocEntries);
|
||||
@@ -401,7 +417,7 @@ export function resolveInstructions(ctx: InstructionsContext): ResolvedInstructi
|
||||
procedure,
|
||||
eventContext,
|
||||
system,
|
||||
learnings: pctx.learnings,
|
||||
learningsFilePath: pctx.learningsFilePath,
|
||||
runtime: pctx.runtime,
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import { stripExistingFooter } from "./buildPullfrogFooter.ts";
|
||||
|
||||
/**
|
||||
* The prefix text for the initial "leaping into action" comment.
|
||||
* Used to detect whether a progress comment is still in its initial state
|
||||
* and hasn't been updated with real progress or error messages.
|
||||
*
|
||||
* Lives in `utils/` (not `mcp/`) so it can be re-exported via `pullfrog/internal`
|
||||
* without dragging the MCP server's transitive imports into the Next.js app's
|
||||
* type-check graph.
|
||||
*/
|
||||
export const LEAPING_INTO_ACTION_PREFIX = "Leaping into action";
|
||||
|
||||
export function isLeapingIntoActionCommentBody(body: string): boolean {
|
||||
const content = stripExistingFooter(body).trimStart();
|
||||
const firstLine = content.split(/\r?\n/, 1)[0]?.trimEnd() ?? "";
|
||||
return new RegExp(`(^|\\s)${LEAPING_INTO_ACTION_PREFIX}(\\.\\.\\.)?$`).test(firstLine);
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
LEARNINGS_FILE_NAME,
|
||||
learningsFilePath,
|
||||
readLearningsFile,
|
||||
seedLearningsFile,
|
||||
} from "./learnings.ts";
|
||||
|
||||
describe("learnings tmpfile round-trip", () => {
|
||||
let dir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
dir = await mkdtemp(join(tmpdir(), "pullfrog-learnings-test-"));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("seeds with existing learnings and reads them back verbatim", async () => {
|
||||
const current = "- run tests with `pnpm -r test`\n- default branch is `main`";
|
||||
const path = await seedLearningsFile({ tmpdir: dir, current });
|
||||
expect(path).toBe(learningsFilePath(dir));
|
||||
expect(path.endsWith(LEARNINGS_FILE_NAME)).toBe(true);
|
||||
const read = await readLearningsFile(path);
|
||||
expect(read).toBe(current);
|
||||
});
|
||||
|
||||
it("seeds an empty file when the repo has no learnings yet", async () => {
|
||||
// empty seed (vs scaffold-with-comment) keeps the byte-trim equality
|
||||
// gate clean: an untouched first run reads back as "" and persistLearnings
|
||||
// skips the API round-trip rather than writing a placeholder string into
|
||||
// Repo.learnings.
|
||||
const path = await seedLearningsFile({ tmpdir: dir, current: null });
|
||||
const read = await readLearningsFile(path);
|
||||
expect(read).toBe("");
|
||||
});
|
||||
|
||||
it("returns null when the file is missing (treated as no-change by persist)", async () => {
|
||||
const path = learningsFilePath(dir);
|
||||
const read = await readLearningsFile(path);
|
||||
expect(read).toBeNull();
|
||||
});
|
||||
|
||||
it("trims whitespace so trailing newlines never trigger a spurious PATCH", async () => {
|
||||
// editors commonly add a trailing newline on save. without trimming, a
|
||||
// round-trip "read seed → save unchanged" would fail byte-equality and
|
||||
// burn a LearningsRevision row on every run.
|
||||
const current = "- one fact";
|
||||
const path = await seedLearningsFile({ tmpdir: dir, current });
|
||||
await writeFile(path, `${current}\n\n `, "utf8");
|
||||
const read = await readLearningsFile(path);
|
||||
expect(read).toBe(current);
|
||||
});
|
||||
|
||||
it("truncates content over the 10k server-side cap", async () => {
|
||||
// server enforces MAX_LEARNINGS_LENGTH = 10_000. truncating client-side
|
||||
// avoids a 400 round-trip and keeps the bytes the agent will see in the
|
||||
// next run aligned with what the server actually stored.
|
||||
const oversized = "x".repeat(11_000);
|
||||
const path = await seedLearningsFile({ tmpdir: dir, current: null });
|
||||
await writeFile(path, oversized, "utf8");
|
||||
const read = await readLearningsFile(path);
|
||||
expect(read).toBeTruthy();
|
||||
expect(read?.length).toBe(10_000);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,64 @@
|
||||
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
||||
import { dirname, join } from "node:path";
|
||||
|
||||
/**
|
||||
* Repo-level learnings — operational facts about a repo (setup steps, test
|
||||
* commands, conventions, gotchas) that accumulate across agent runs and feed
|
||||
* back into future runs as durable context. Modeled on the PR-summary tmpfile
|
||||
* pattern (see action/utils/prSummary.ts):
|
||||
*
|
||||
* 1. server seeds `pullfrog-learnings.md` from `Repo.learnings` (or empty
|
||||
* when the repo has none yet)
|
||||
* 2. the agent reads the file at startup as part of its context, and may
|
||||
* edit it in place at end-of-run when prompted by the reflection turn
|
||||
* 3. main.ts reads the file back at end-of-run and PATCHes
|
||||
* `/api/repo/[owner]/[repo]/learnings` if it changed (byte-trim equality
|
||||
* against the seed determines change detection)
|
||||
*
|
||||
* Edit-in-place avoids stuffing the entire learnings list into both the
|
||||
* prompt context and an `update_learnings` MCP tool call (which previously
|
||||
* required passing the FULL merged list as a string parameter — an
|
||||
* output-token tax that grew linearly with the learnings size).
|
||||
*/
|
||||
|
||||
export const LEARNINGS_FILE_NAME = "pullfrog-learnings.md";
|
||||
|
||||
/** server-side cap mirrors `MAX_LEARNINGS_LENGTH` in
|
||||
* `app/api/repo/[owner]/[repo]/learnings/route.ts`. truncating client-side
|
||||
* keeps the PATCH from being rejected with a 400. */
|
||||
const MAX_LEARNINGS_LENGTH = 10_000;
|
||||
|
||||
export function learningsFilePath(tmpdir: string): string {
|
||||
return join(tmpdir, LEARNINGS_FILE_NAME);
|
||||
}
|
||||
|
||||
/** seed the learnings file with the repo's current learnings, or an empty
|
||||
* file when the repo has none yet. returns the absolute path. */
|
||||
export async function seedLearningsFile(params: {
|
||||
tmpdir: string;
|
||||
current: string | null;
|
||||
}): Promise<string> {
|
||||
const path = learningsFilePath(params.tmpdir);
|
||||
await mkdir(dirname(path), { recursive: true });
|
||||
// empty file when no learnings exist yet — the agent reads it, sees
|
||||
// nothing, and the LEARNINGS prompt section explains what the file is for.
|
||||
// a header comment would risk being persisted as part of the first real
|
||||
// edit, polluting the DB row with placeholder text.
|
||||
await writeFile(path, params.current ?? "", "utf8");
|
||||
return path;
|
||||
}
|
||||
|
||||
/** read the agent-edited learnings file. returns null when the file is
|
||||
* missing or unreadable (treated as "no change"). caps content at the
|
||||
* server's max length to avoid a 400 round-trip. */
|
||||
export async function readLearningsFile(path: string): Promise<string | null> {
|
||||
let raw: string;
|
||||
try {
|
||||
raw = await readFile(path, "utf8");
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
const trimmed = raw.trim();
|
||||
if (trimmed.length > MAX_LEARNINGS_LENGTH) return trimmed.slice(0, MAX_LEARNINGS_LENGTH);
|
||||
return trimmed;
|
||||
}
|
||||
@@ -14,7 +14,7 @@ export type WorkflowRunArtifactPatchKey =
|
||||
| "issueNodeId"
|
||||
| "reviewNodeId"
|
||||
| "planCommentNodeId"
|
||||
| "summaryCommentNodeId";
|
||||
| "summarySnapshot";
|
||||
|
||||
/**
|
||||
* Usage fields — aggregated across all agent calls and PATCHed once at
|
||||
@@ -37,7 +37,7 @@ const STRING_KEYS: WorkflowRunArtifactPatchKey[] = [
|
||||
"issueNodeId",
|
||||
"reviewNodeId",
|
||||
"planCommentNodeId",
|
||||
"summaryCommentNodeId",
|
||||
"summarySnapshot",
|
||||
];
|
||||
|
||||
const NUMBER_KEYS: WorkflowRunUsagePatchKey[] = [
|
||||
|
||||
+9
-2
@@ -21,9 +21,14 @@ export const JsonPayload = type({
|
||||
"triggerer?": "string | undefined",
|
||||
|
||||
"eventInstructions?": "string",
|
||||
"previousRunsNote?": "string",
|
||||
"event?": "object",
|
||||
"timeout?": "string | undefined",
|
||||
"progressCommentId?": "string | undefined",
|
||||
"progressComment?": type({
|
||||
id: "string",
|
||||
type: "'issue' | 'review'",
|
||||
}).or("undefined"),
|
||||
"generateSummary?": "boolean | undefined",
|
||||
});
|
||||
|
||||
// permission levels that indicate collaborator status (have push access)
|
||||
@@ -153,10 +158,12 @@ export function resolvePayload(
|
||||
// it's not a common use case but GITHUB_ACTOR can be a user when the workflow is manually triggered by a user through GitHub Actions UI
|
||||
(!isPullfrog(process.env.GITHUB_ACTOR) ? process.env.GITHUB_ACTOR : undefined),
|
||||
eventInstructions: jsonPayload?.eventInstructions,
|
||||
previousRunsNote: jsonPayload?.previousRunsNote,
|
||||
event,
|
||||
timeout: inputs.timeout ?? jsonPayload?.timeout,
|
||||
cwd: resolveCwd(inputs.cwd),
|
||||
progressCommentId: jsonPayload?.progressCommentId,
|
||||
progressComment: jsonPayload?.progressComment,
|
||||
generateSummary: jsonPayload?.generateSummary,
|
||||
|
||||
// permissions: inputs > repoSettings > fallbacks
|
||||
push: inputs.push ?? repoSettings.push ?? "restricted",
|
||||
|
||||
@@ -1,185 +0,0 @@
|
||||
import { isLeapingIntoActionCommentBody } from "../mcp/comment.ts";
|
||||
import { getApiUrl } from "./apiUrl.ts";
|
||||
import { buildPullfrogFooter } from "./buildPullfrogFooter.ts";
|
||||
import { log } from "./cli.ts";
|
||||
import { createOctokit, parseRepoContext } from "./github.ts";
|
||||
import { type ResolvedPromptInput, resolvePromptInput } from "./payload.ts";
|
||||
import { getJobToken } from "./token.ts";
|
||||
|
||||
type JsonPromptInput = Extract<ResolvedPromptInput, object>; // not string
|
||||
|
||||
interface PostCleanupContext {
|
||||
repoContext: ReturnType<typeof parseRepoContext>;
|
||||
octokit: ReturnType<typeof createOctokit>;
|
||||
runId: number | undefined;
|
||||
promptInput: JsonPromptInput | null;
|
||||
}
|
||||
|
||||
// controls whether the script should check the reason for the workflow termination.
|
||||
// it can be either canceled or failed.
|
||||
// YAML file cannot supply it (not in ENV), so an extra request is required to check it.
|
||||
const SHOULD_CHECK_REASON = true;
|
||||
|
||||
function buildErrorCommentBody(ctx: PostCleanupContext, isCancellation: boolean): string {
|
||||
let errorMessage = isCancellation
|
||||
? `This run was cancelled 🛑\n\nThe workflow was cancelled before completion.`
|
||||
: `This run croaked 😵\n\nThe workflow encountered an error before any progress could be reported.`;
|
||||
|
||||
if (ctx.runId) {
|
||||
errorMessage += " Please check the link below for details.";
|
||||
}
|
||||
|
||||
const customParts: string[] = [];
|
||||
if (!isCancellation && ctx.runId) {
|
||||
const apiUrl = getApiUrl();
|
||||
customParts.push(
|
||||
`[Rerun failed job ➔](${apiUrl}/trigger/${ctx.repoContext.owner}/${ctx.repoContext.name}/${ctx.runId}?action=rerun)`
|
||||
);
|
||||
}
|
||||
const footer = buildPullfrogFooter({
|
||||
triggeredBy: true,
|
||||
workflowRun: ctx.runId
|
||||
? {
|
||||
owner: ctx.repoContext.owner,
|
||||
repo: ctx.repoContext.name,
|
||||
runId: ctx.runId,
|
||||
}
|
||||
: undefined,
|
||||
customParts,
|
||||
});
|
||||
return `${errorMessage}${footer}`;
|
||||
}
|
||||
|
||||
async function validateStuckProgressComment(ctx: PostCleanupContext): Promise<number | null> {
|
||||
if (!ctx.promptInput?.progressCommentId) {
|
||||
log.info("[post] no progressCommentId in prompt input, skipping cleanup");
|
||||
return null;
|
||||
}
|
||||
|
||||
const commentId = parseInt(ctx.promptInput.progressCommentId, 10);
|
||||
log.info(`[post] validating progressCommentId from prompt input: ${commentId}`);
|
||||
|
||||
try {
|
||||
const commentResult = await ctx.octokit.rest.issues.getComment({
|
||||
owner: ctx.repoContext.owner,
|
||||
repo: ctx.repoContext.name,
|
||||
comment_id: commentId,
|
||||
});
|
||||
|
||||
const body = commentResult.data.body ?? "";
|
||||
|
||||
if (isLeapingIntoActionCommentBody(body)) {
|
||||
log.info(`[post] comment ${commentId} is stuck on "Leaping into action"`);
|
||||
return commentId;
|
||||
}
|
||||
|
||||
// detect stranded todo checklists left by the tracker when the process was killed
|
||||
// before the agent could call report_progress with a final summary
|
||||
if (/^- \[[ x]\] |^- \*\*→\*\* |^- ~~/.test(body)) {
|
||||
log.info(`[post] comment ${commentId} is stuck on a todo checklist`);
|
||||
return commentId;
|
||||
}
|
||||
|
||||
log.info(`[post] comment ${commentId} is not stuck (already updated or different content)`);
|
||||
return null;
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
log.info(`[post] failed to get comment ${commentId}: ${errorMessage}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function getIsCancelled(ctx: PostCleanupContext): Promise<boolean> {
|
||||
if (!ctx.runId) return false; // can't check without a run ID — assume failure
|
||||
try {
|
||||
const jobsResult = await ctx.octokit.rest.actions.listJobsForWorkflowRun({
|
||||
owner: ctx.repoContext.owner,
|
||||
repo: ctx.repoContext.name,
|
||||
run_id: ctx.runId,
|
||||
});
|
||||
|
||||
// find current job by matching GITHUB_JOB env var.
|
||||
// GITHUB_JOB is the job ID (yaml key), but job.name is the display name.
|
||||
// for matrix jobs, the name includes matrix values like "build (ubuntu-latest, node-18)"
|
||||
// so we match jobs that START with the job ID
|
||||
const currentJobName = process.env.GITHUB_JOB;
|
||||
const currentJob = currentJobName
|
||||
? jobsResult.data.jobs.find(
|
||||
(j) => j.name === currentJobName || j.name.startsWith(`${currentJobName} (`)
|
||||
)
|
||||
: jobsResult.data.jobs[0]; // fallback to first job
|
||||
|
||||
if (!currentJob) {
|
||||
log.warning("[post] could not find current job");
|
||||
return false;
|
||||
}
|
||||
|
||||
log.info(`[post] job status: ${currentJob.status}, conclusion: ${currentJob.conclusion}`);
|
||||
if (currentJob.conclusion === "cancelled") return true; // whole job explicit cancellation
|
||||
|
||||
// but if it's still null, check steps for cancellation:
|
||||
const cancelledStep = currentJob.steps?.find((step) => step.conclusion === "cancelled");
|
||||
if (cancelledStep) {
|
||||
log.info(`[post] found cancelled step: ${cancelledStep.name}`);
|
||||
return true;
|
||||
}
|
||||
log.info("[post] no cancellation found, assuming failure");
|
||||
} catch (error) {
|
||||
log.info(
|
||||
`[post] failed to get job status: ${error instanceof Error ? error.message : String(error)}`
|
||||
);
|
||||
}
|
||||
return false; // assuming failure
|
||||
}
|
||||
|
||||
export async function runPostCleanup(): Promise<void> {
|
||||
log.info("» [post] starting post cleanup");
|
||||
|
||||
const runId = process.env.GITHUB_RUN_ID
|
||||
? Number.parseInt(process.env.GITHUB_RUN_ID, 10)
|
||||
: undefined;
|
||||
|
||||
// resolve prompt input once and use it for both issue number and comment ID extraction
|
||||
// only use the object form (JSON payload), not plain string prompts
|
||||
let promptInput: JsonPromptInput | null = null;
|
||||
try {
|
||||
const resolved = resolvePromptInput();
|
||||
if (typeof resolved !== "string") promptInput = resolved;
|
||||
} catch (error) {
|
||||
log.info(
|
||||
`[post] failed to resolve prompt input: ${error instanceof Error ? error.message : String(error)}`
|
||||
);
|
||||
}
|
||||
|
||||
// get job token for API calls
|
||||
const token = getJobToken();
|
||||
const repoContext = parseRepoContext();
|
||||
const octokit = createOctokit(token);
|
||||
|
||||
const ctx: PostCleanupContext = { repoContext, octokit, runId, promptInput };
|
||||
|
||||
const commentId = await validateStuckProgressComment(ctx);
|
||||
|
||||
if (!commentId) return log.info("» [post] no stuck progress comment to update, skipping cleanup");
|
||||
|
||||
log.info(`» [post] validated stuck comment: ${commentId}, updating with error message`);
|
||||
|
||||
try {
|
||||
const body = buildErrorCommentBody(
|
||||
ctx,
|
||||
SHOULD_CHECK_REASON ? await getIsCancelled(ctx) : false
|
||||
);
|
||||
|
||||
await ctx.octokit.rest.issues.updateComment({
|
||||
owner: ctx.repoContext.owner,
|
||||
repo: ctx.repoContext.name,
|
||||
comment_id: commentId,
|
||||
body,
|
||||
});
|
||||
|
||||
log.info("» [post] successfully updated progress comment");
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
log.info(`[post] failed to update comment: ${errorMessage}`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
||||
import { dirname, join } from "node:path";
|
||||
|
||||
/**
|
||||
* The PR-level summary snapshot is a markdown file the agent edits in place
|
||||
* during a Review / IncrementalReview run. The server seeds the file with
|
||||
* either the previous run's snapshot (incremental) or a stub scaffold (first
|
||||
* run), lets the agent edit it with its native file-editing tools, then
|
||||
* reads it back at end-of-run and persists it to `WorkflowRun.summarySnapshot`.
|
||||
*
|
||||
* The snapshot is an internal artifact — it is consumed by future agent runs
|
||||
* as durable cross-run context, not surfaced to humans. User-visible summary
|
||||
* content lives in the Review / IncrementalReview review bodies, governed by
|
||||
* `action/modes.ts`.
|
||||
*
|
||||
* Edit-in-place avoids the output-token tax of a tool call that regurgitates
|
||||
* the full snapshot, and gives incremental runs a clean surface that
|
||||
* range-diffs cleanly across runs because the section headings are stable.
|
||||
*/
|
||||
|
||||
export const SUMMARY_FILE_NAME = "pullfrog-summary.md";
|
||||
|
||||
/**
|
||||
* minimal seed for first-run PRs. just a header + a one-line note about
|
||||
* what this file is for. structure is intentionally NOT prescribed —
|
||||
* different PRs warrant different organization, and the agent should pick
|
||||
* a shape that fits this PR. the agent's prompt (see selectMode.ts
|
||||
* `buildSummaryAddendum`) carries the actual instructions for what to
|
||||
* capture and how.
|
||||
*
|
||||
* keeping the seed short also makes the unchanged-from-seed gate more
|
||||
* sensitive — any meaningful edit moves the file off the seed, so
|
||||
* `persistSummary` can reliably skip the DB write when the agent didn't
|
||||
* touch the file.
|
||||
*/
|
||||
export const SUMMARY_SCAFFOLD = `# PR summary
|
||||
|
||||
<!-- durable cross-run context. edit in place; the next agent run reads this
|
||||
before reviewing new commits. structure however serves the PR best. -->
|
||||
`;
|
||||
|
||||
const MIN_SNAPSHOT_LENGTH = 60;
|
||||
/** PG TEXT can hold ~1GB but a sane cap protects the DB / API payloads. */
|
||||
const MAX_SNAPSHOT_LENGTH = 32_768;
|
||||
|
||||
export function summaryFilePath(tmpdir: string): string {
|
||||
return join(tmpdir, SUMMARY_FILE_NAME);
|
||||
}
|
||||
|
||||
/** seed the summary file with previous snapshot (incremental) or scaffold (first run). */
|
||||
export async function seedSummaryFile(params: {
|
||||
tmpdir: string;
|
||||
previousSnapshot: string | null;
|
||||
}): Promise<string> {
|
||||
const path = summaryFilePath(params.tmpdir);
|
||||
await mkdir(dirname(path), { recursive: true });
|
||||
const seed =
|
||||
params.previousSnapshot && params.previousSnapshot.trim().length >= MIN_SNAPSHOT_LENGTH
|
||||
? params.previousSnapshot
|
||||
: SUMMARY_SCAFFOLD;
|
||||
await writeFile(path, seed, "utf8");
|
||||
return path;
|
||||
}
|
||||
|
||||
/** read + validate the summary file written by the agent.
|
||||
* returns null when the file is missing or fails sanity checks. */
|
||||
export async function readSummaryFile(path: string): Promise<string | null> {
|
||||
let raw: string;
|
||||
try {
|
||||
raw = await readFile(path, "utf8");
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
const trimmed = raw.trim();
|
||||
if (trimmed.length < MIN_SNAPSHOT_LENGTH) return null;
|
||||
if (trimmed.length > MAX_SNAPSHOT_LENGTH) return trimmed.slice(0, MAX_SNAPSHOT_LENGTH);
|
||||
return trimmed;
|
||||
}
|
||||
@@ -0,0 +1,261 @@
|
||||
/**
|
||||
* Single source of truth for reading, updating, deleting, and creating "progress comments" —
|
||||
* the GitHub comments Pullfrog uses to surface a run's status.
|
||||
*
|
||||
* A progress comment can be one of two distinct GitHub entities with non-overlapping IDs and
|
||||
* distinct REST endpoints:
|
||||
* - "issue": a top-level issue/PR timeline comment (octokit.rest.issues.*Comment)
|
||||
* - "review": an inline PR review-thread comment (octokit.rest.pulls.*ReviewComment)
|
||||
*
|
||||
* Callers carry a `ProgressComment` (id + type) value end-to-end so the right endpoint is always
|
||||
* picked. Adding a third comment type later means one new branch in this file, not six.
|
||||
*/
|
||||
|
||||
export type ProgressCommentType = "issue" | "review";
|
||||
|
||||
export type ProgressComment = {
|
||||
id: number;
|
||||
type: ProgressCommentType;
|
||||
};
|
||||
|
||||
/**
|
||||
* Parse the on-the-wire `{ id: string; type }` shape (the form carried in `JsonPayload`)
|
||||
* into the in-memory `ProgressComment` shape. Returns undefined when the id isn't a
|
||||
* positive integer so callers can short-circuit cleanly. Callers handle logging.
|
||||
*/
|
||||
export function parseProgressComment(
|
||||
raw: { id: string; type: ProgressCommentType } | null | undefined
|
||||
): ProgressComment | undefined {
|
||||
if (!raw?.id) return undefined;
|
||||
const id = parseInt(raw.id, 10);
|
||||
if (Number.isNaN(id) || id <= 0) return undefined;
|
||||
return { id, type: raw.type };
|
||||
}
|
||||
|
||||
// minimal Octokit shape needed by the progress-comment helpers. structural so the helper
|
||||
// can be called from both the action package (@octokit/rest v22) and the root project
|
||||
// (@octokit/rest v21) without a nominal type clash. only the methods used here are listed.
|
||||
interface CommentResponse {
|
||||
data: { id: number; body?: string | null | undefined; html_url: string; node_id?: string };
|
||||
}
|
||||
export interface ProgressCommentOctokit {
|
||||
rest: {
|
||||
issues: {
|
||||
createComment: (params: {
|
||||
owner: string;
|
||||
repo: string;
|
||||
issue_number: number;
|
||||
body: string;
|
||||
}) => Promise<CommentResponse>;
|
||||
getComment: (params: {
|
||||
owner: string;
|
||||
repo: string;
|
||||
comment_id: number;
|
||||
}) => Promise<CommentResponse>;
|
||||
updateComment: (params: {
|
||||
owner: string;
|
||||
repo: string;
|
||||
comment_id: number;
|
||||
body: string;
|
||||
}) => Promise<CommentResponse>;
|
||||
deleteComment: (params: {
|
||||
owner: string;
|
||||
repo: string;
|
||||
comment_id: number;
|
||||
}) => Promise<unknown>;
|
||||
};
|
||||
pulls: {
|
||||
createReplyForReviewComment: (params: {
|
||||
owner: string;
|
||||
repo: string;
|
||||
pull_number: number;
|
||||
comment_id: number;
|
||||
body: string;
|
||||
}) => Promise<CommentResponse>;
|
||||
getReviewComment: (params: {
|
||||
owner: string;
|
||||
repo: string;
|
||||
comment_id: number;
|
||||
}) => Promise<CommentResponse>;
|
||||
updateReviewComment: (params: {
|
||||
owner: string;
|
||||
repo: string;
|
||||
comment_id: number;
|
||||
body: string;
|
||||
}) => Promise<CommentResponse>;
|
||||
deleteReviewComment: (params: {
|
||||
owner: string;
|
||||
repo: string;
|
||||
comment_id: number;
|
||||
}) => Promise<unknown>;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
interface ApiCtx {
|
||||
octokit: ProgressCommentOctokit;
|
||||
owner: string;
|
||||
repo: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch a progress comment via the appropriate REST endpoint for its type.
|
||||
* Returns the common subset of fields callers actually use.
|
||||
*/
|
||||
export async function getProgressComment(
|
||||
ctx: ApiCtx,
|
||||
comment: ProgressComment
|
||||
): Promise<{ id: number; body: string | undefined; html_url: string }> {
|
||||
const result = await (comment.type === "review"
|
||||
? ctx.octokit.rest.pulls.getReviewComment({
|
||||
owner: ctx.owner,
|
||||
repo: ctx.repo,
|
||||
comment_id: comment.id,
|
||||
})
|
||||
: ctx.octokit.rest.issues.getComment({
|
||||
owner: ctx.owner,
|
||||
repo: ctx.repo,
|
||||
comment_id: comment.id,
|
||||
}));
|
||||
return {
|
||||
id: result.data.id,
|
||||
body: result.data.body ?? undefined,
|
||||
html_url: result.data.html_url,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a progress comment in place via the appropriate REST endpoint.
|
||||
* Returns the common subset of fields callers actually use.
|
||||
*/
|
||||
export async function updateProgressComment(
|
||||
ctx: ApiCtx,
|
||||
comment: ProgressComment,
|
||||
body: string
|
||||
): Promise<{
|
||||
id: number;
|
||||
body: string | undefined;
|
||||
html_url: string;
|
||||
node_id: string | undefined;
|
||||
}> {
|
||||
const result = await (comment.type === "review"
|
||||
? ctx.octokit.rest.pulls.updateReviewComment({
|
||||
owner: ctx.owner,
|
||||
repo: ctx.repo,
|
||||
comment_id: comment.id,
|
||||
body,
|
||||
})
|
||||
: ctx.octokit.rest.issues.updateComment({
|
||||
owner: ctx.owner,
|
||||
repo: ctx.repo,
|
||||
comment_id: comment.id,
|
||||
body,
|
||||
}));
|
||||
return {
|
||||
id: result.data.id,
|
||||
body: result.data.body ?? undefined,
|
||||
html_url: result.data.html_url,
|
||||
node_id: result.data.node_id,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a progress comment via the appropriate REST endpoint.
|
||||
* Lower-level than `deleteProgressComment` in mcp/comment.ts — that one also clears
|
||||
* tool state. Callers that don't have a ToolContext (post cleanup, error handlers)
|
||||
* should use this directly; the higher-level wrapper delegates here.
|
||||
*/
|
||||
export async function deleteProgressCommentApi(
|
||||
ctx: ApiCtx,
|
||||
comment: ProgressComment
|
||||
): Promise<void> {
|
||||
if (comment.type === "review") {
|
||||
await ctx.octokit.rest.pulls.deleteReviewComment({
|
||||
owner: ctx.owner,
|
||||
repo: ctx.repo,
|
||||
comment_id: comment.id,
|
||||
});
|
||||
return;
|
||||
}
|
||||
await ctx.octokit.rest.issues.deleteComment({
|
||||
owner: ctx.owner,
|
||||
repo: ctx.repo,
|
||||
comment_id: comment.id,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Discriminated target for `createLeapingProgressComment`. The two variants map to the two
|
||||
* distinct GitHub create endpoints; review-reply additionally needs the parent comment ID.
|
||||
*/
|
||||
export type CreateProgressCommentTarget =
|
||||
| { kind: "issue"; issueNumber: number }
|
||||
| { kind: "reviewReply"; pullNumber: number; replyToCommentId: number };
|
||||
|
||||
export interface CreatedProgressComment {
|
||||
comment: ProgressComment;
|
||||
body: string | undefined;
|
||||
html_url: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the initial "Leaping into action..." progress comment.
|
||||
*
|
||||
* Reliability: when `kind: "reviewReply"` fails (e.g. the parent comment was deleted or the
|
||||
* thread is otherwise unreachable), falls back to a top-level issue comment on the same PR
|
||||
* rather than leaving the run with no progress surface. The fallback is logged.
|
||||
*
|
||||
* (PR # === issue # in GitHub's number space, so `pullNumber` doubles as the fallback target.)
|
||||
*/
|
||||
export async function createLeapingProgressComment(
|
||||
ctx: ApiCtx,
|
||||
target: CreateProgressCommentTarget,
|
||||
body: string
|
||||
): Promise<CreatedProgressComment> {
|
||||
if (target.kind === "reviewReply") {
|
||||
try {
|
||||
const result = await ctx.octokit.rest.pulls.createReplyForReviewComment({
|
||||
owner: ctx.owner,
|
||||
repo: ctx.repo,
|
||||
pull_number: target.pullNumber,
|
||||
comment_id: target.replyToCommentId,
|
||||
body,
|
||||
});
|
||||
return {
|
||||
comment: { id: result.data.id, type: "review" },
|
||||
body: result.data.body ?? undefined,
|
||||
html_url: result.data.html_url,
|
||||
};
|
||||
} catch (error) {
|
||||
// console.warn (not the action-flavored log.warning) because this helper runs in
|
||||
// both the action runtime and the Next.js webhook context, and we don't want a
|
||||
// ::warning:: GitHub Actions annotation leaking into Vercel logs.
|
||||
console.warn(
|
||||
`[progressComment] review reply failed (parent ${target.replyToCommentId} on PR #${target.pullNumber}), falling back to issue comment:`,
|
||||
error
|
||||
);
|
||||
const fallback = await ctx.octokit.rest.issues.createComment({
|
||||
owner: ctx.owner,
|
||||
repo: ctx.repo,
|
||||
issue_number: target.pullNumber,
|
||||
body,
|
||||
});
|
||||
return {
|
||||
comment: { id: fallback.data.id, type: "issue" },
|
||||
body: fallback.data.body ?? undefined,
|
||||
html_url: fallback.data.html_url,
|
||||
};
|
||||
}
|
||||
}
|
||||
const result = await ctx.octokit.rest.issues.createComment({
|
||||
owner: ctx.owner,
|
||||
repo: ctx.repo,
|
||||
issue_number: target.issueNumber,
|
||||
body,
|
||||
});
|
||||
return {
|
||||
comment: { id: result.data.id, type: "issue" },
|
||||
body: result.data.body ?? undefined,
|
||||
html_url: result.data.html_url,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
import { detectProviderError, isRouterKeylimitExhaustedError } from "./providerErrors.ts";
|
||||
|
||||
describe("detectProviderError", () => {
|
||||
describe("false positives previously seen in production", () => {
|
||||
it("returns null for commit SHAs containing 429", () => {
|
||||
expect(detectProviderError("hash=7a46d89f505b36df49b4f54429daffa1a9459b11")).toBeNull();
|
||||
expect(detectProviderError("commit f609cc89e84596ab125d60dac568bfb2ef398396 429")).toBeNull();
|
||||
});
|
||||
|
||||
it("classifies 401 + x-ratelimit-* headers as auth, not rate-limited", () => {
|
||||
// OpenRouter 401 responses bundle `x-ratelimit-*` rate-limit headers
|
||||
// alongside the auth error. the auth patterns must win — pre-fix this
|
||||
// got tagged as `rate limited` because of the loose `\brate[_ ]limit`
|
||||
// match against header names like `ratelimit-limit-requests`. note: in
|
||||
// OpenRouter's actual format the header name is `ratelimit` (one word),
|
||||
// but the dumped JSON sometimes contains `rate-limit` separators too.
|
||||
const stderr = JSON.stringify({
|
||||
error: { name: "APIError", statusCode: 401, message: "Invalid authentication credentials" },
|
||||
headers: {
|
||||
"x-ratelimit-limit-requests": 50,
|
||||
"x-ratelimit-remaining-requests": 49,
|
||||
"x-ratelimit-reset-tokens": "2025-01-01T00:00:00Z",
|
||||
},
|
||||
});
|
||||
expect(detectProviderError(stderr)).toBe("auth error (401)");
|
||||
});
|
||||
|
||||
it("returns null for INTERNAL_SERVER_ERROR substring", () => {
|
||||
expect(detectProviderError("HTTP/1.1 500 INTERNAL_SERVER_ERROR")).toBeNull();
|
||||
expect(detectProviderError("expected: not INTERNAL_SERVER_ERROR")).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for INTERNALS substring", () => {
|
||||
expect(detectProviderError("debugging INTERNALS of the parser")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("auth errors", () => {
|
||||
it("detects 401 / 403 status codes as auth errors", () => {
|
||||
expect(detectProviderError('{"statusCode": 401}')).toBe("auth error (401)");
|
||||
expect(detectProviderError('{"statusCode": 403}')).toBe("auth error (403)");
|
||||
expect(detectProviderError("status_code: 401")).toBe("auth error (401)");
|
||||
});
|
||||
|
||||
it("detects OpenRouter 'User not found' (disabled/invalid key)", () => {
|
||||
// bare `"code":401` lacks a status-key prefix so the 401 status pattern
|
||||
// intentionally doesn't fire; the User-not-found pattern catches it.
|
||||
expect(detectProviderError('{"error":{"message":"User not found","code":401}}')).toBe(
|
||||
"auth error (invalid/disabled key)"
|
||||
);
|
||||
expect(detectProviderError("APIError: User not found.")).toBe(
|
||||
"auth error (invalid/disabled key)"
|
||||
);
|
||||
});
|
||||
|
||||
it("detects 'Invalid authentication' phrasing", () => {
|
||||
expect(detectProviderError("Invalid authentication credentials")).toBe(
|
||||
"auth error (invalid credentials)"
|
||||
);
|
||||
});
|
||||
|
||||
it("detects 'No auth credentials found' phrasing", () => {
|
||||
expect(detectProviderError("AI_APICallError: No auth credentials found")).toBe(
|
||||
"auth error (missing credentials)"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("real provider errors", () => {
|
||||
it("detects 429 only when adjacent to a status key", () => {
|
||||
expect(detectProviderError('{"statusCode": 429}')).toBe("rate limited (429)");
|
||||
expect(detectProviderError('{"status_code": 429, "message": "..."}')).toBe(
|
||||
"rate limited (429)"
|
||||
);
|
||||
expect(detectProviderError("http_status: 429")).toBe("rate limited (429)");
|
||||
expect(detectProviderError("status=429")).toBe("rate limited (429)");
|
||||
});
|
||||
|
||||
it("detects rate_limit_error and rate_limit_exceeded", () => {
|
||||
expect(detectProviderError('{"type":"rate_limit_error"}')).toBe("rate limited");
|
||||
expect(detectProviderError("rate_limit_exceeded")).toBe("rate limited");
|
||||
expect(detectProviderError("plain rate limit reached")).toBe("rate limited");
|
||||
});
|
||||
|
||||
it("detects rate-limit phrasing with trailing inflection", () => {
|
||||
expect(detectProviderError("Error: rate limited by provider")).toBe("rate limited");
|
||||
expect(detectProviderError("rate limits exceeded for this key")).toBe("rate limited");
|
||||
});
|
||||
|
||||
it("detects RESOURCE_EXHAUSTED", () => {
|
||||
expect(detectProviderError('"status": "RESOURCE_EXHAUSTED"')).toBe("quota exhausted");
|
||||
});
|
||||
|
||||
it("detects gRPC INTERNAL status as a whole word", () => {
|
||||
expect(detectProviderError('"status": "INTERNAL"')).toBe("provider internal error");
|
||||
});
|
||||
|
||||
it("detects UNAVAILABLE as a whole word", () => {
|
||||
expect(detectProviderError('"status": "UNAVAILABLE"')).toBe("provider unavailable");
|
||||
});
|
||||
|
||||
it("detects 500 / 503 only when adjacent to a status key", () => {
|
||||
expect(detectProviderError('"statusCode": 500')).toBe("provider 500 error");
|
||||
expect(detectProviderError('"statusCode": 503')).toBe("provider unavailable (503)");
|
||||
expect(detectProviderError("v1.503.0 release notes")).toBeNull();
|
||||
});
|
||||
|
||||
it("detects quota and zero-quota responses", () => {
|
||||
expect(detectProviderError('"message": "quota exceeded"')).toBe("quota error");
|
||||
expect(detectProviderError('{"code":"insufficient_quota"}')).toBe("quota error");
|
||||
expect(detectProviderError('"error":"quota_exceeded"')).toBe("quota error");
|
||||
expect(detectProviderError('{"reason":"quotaExceeded"}')).toBe("quota error");
|
||||
expect(detectProviderError('{"limit": 0, "remaining": 0}')).toBe("zero quota");
|
||||
expect(detectProviderError('"time_limit": 0')).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("isRouterKeylimitExhaustedError", () => {
|
||||
it("matches the canonical OpenRouter mid-run error", () => {
|
||||
expect(
|
||||
isRouterKeylimitExhaustedError(
|
||||
"APIError: This request requires more credits, or fewer max_tokens. " +
|
||||
"You requested up to 32000 tokens, but can only afford 22800. " +
|
||||
"To increase, visit https://openrouter.ai/settings/keys and create a key with a higher total limit"
|
||||
)
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("matches the 'requires more credits' phrasing on its own", () => {
|
||||
expect(
|
||||
isRouterKeylimitExhaustedError("This request requires more credits, or fewer max_tokens.")
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("matches the 'requested up to ... can only afford' phrasing on its own", () => {
|
||||
expect(
|
||||
isRouterKeylimitExhaustedError("You requested up to 8000 tokens but can only afford 1234")
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("does not match generic out-of-credit text", () => {
|
||||
expect(isRouterKeylimitExhaustedError("Your account has insufficient credits")).toBe(false);
|
||||
expect(isRouterKeylimitExhaustedError("rate_limit_exceeded")).toBe(false);
|
||||
expect(isRouterKeylimitExhaustedError('{"limit": 0}')).toBe(false);
|
||||
});
|
||||
|
||||
it("does not match unrelated mentions of max_tokens", () => {
|
||||
expect(isRouterKeylimitExhaustedError("max_tokens parameter must be a positive integer")).toBe(
|
||||
false
|
||||
);
|
||||
});
|
||||
|
||||
it("matches across newlines (defends against upstream wrapping/reformatting)", () => {
|
||||
expect(
|
||||
isRouterKeylimitExhaustedError(
|
||||
"APIError: This request requires more credits, or\nfewer max_tokens. You requested up to 32000 tokens"
|
||||
)
|
||||
).toBe(true);
|
||||
expect(
|
||||
isRouterKeylimitExhaustedError("You requested up to 32000 tokens,\nbut can only afford 22800")
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
+68
-11
@@ -1,18 +1,75 @@
|
||||
const PROVIDER_ERROR_PATTERNS = [
|
||||
{ pattern: "429", label: "rate limited (429)" },
|
||||
{ pattern: "RESOURCE_EXHAUSTED", label: "quota exhausted" },
|
||||
{ pattern: "quota", label: "quota error" },
|
||||
{ pattern: "status: 500", label: "provider 500 error" },
|
||||
{ pattern: "INTERNAL", label: "provider internal error" },
|
||||
{ pattern: "status: 503", label: "provider unavailable (503)" },
|
||||
{ pattern: "UNAVAILABLE", label: "provider unavailable" },
|
||||
{ pattern: "rate limit", label: "rate limited" },
|
||||
{ pattern: "limit: 0", label: "zero quota" },
|
||||
type ProviderErrorPattern = { regex: RegExp; label: string };
|
||||
|
||||
// status codes are only treated as provider errors when they are adjacent to
|
||||
// a recognised status key. this rejects commit SHAs that happen to contain
|
||||
// "429", version strings, file hashes, etc.
|
||||
const statusKey = `\\b(?:status[_ ]?code|http[_ ]?status|status)["']?\\s*[:=]\\s*["']?`;
|
||||
|
||||
const PROVIDER_ERROR_PATTERNS: ProviderErrorPattern[] = [
|
||||
// auth patterns must come BEFORE rate-limit patterns. OpenRouter 401 error
|
||||
// payloads carry `x-ratelimit-*` response headers in the dump, and the
|
||||
// free-form rate-limit regex below would otherwise win on word-boundary
|
||||
// matches inside header names. canonical 401 messages: OpenRouter returns
|
||||
// `{"error":{"message":"User not found","code":401}}` for disabled or
|
||||
// invalid keys (https://openai.luzhipeng.com/docs/api/reference/errors-and-debugging).
|
||||
{ regex: new RegExp(`${statusKey}401\\b`, "i"), label: "auth error (401)" },
|
||||
{ regex: new RegExp(`${statusKey}403\\b`, "i"), label: "auth error (403)" },
|
||||
{ regex: /\bUser not found\b/i, label: "auth error (invalid/disabled key)" },
|
||||
{ regex: /\bInvalid authentication\b/i, label: "auth error (invalid credentials)" },
|
||||
{ regex: /\bNo auth credentials found\b/i, label: "auth error (missing credentials)" },
|
||||
{ regex: new RegExp(`${statusKey}429\\b`, "i"), label: "rate limited (429)" },
|
||||
{ regex: new RegExp(`${statusKey}500\\b`, "i"), label: "provider 500 error" },
|
||||
{ regex: new RegExp(`${statusKey}503\\b`, "i"), label: "provider unavailable (503)" },
|
||||
// matches `rate limit`, `rate limited`, `rate limits exceeded`,
|
||||
// `rate_limit_error`, `rate_limit_exceeded`. the leading `\b` + `[_ ]`
|
||||
// separator rejects `x-ratelimit-*` / `anthropic-ratelimit-*` response
|
||||
// headers (no separator between "rate" and "limit") which routinely
|
||||
// appear in dumped 401 / 4xx error JSON.
|
||||
{ regex: /\brate[_ ]limit/i, label: "rate limited" },
|
||||
{ regex: /\bRESOURCE_EXHAUSTED\b/, label: "quota exhausted" },
|
||||
// Google gRPC `INTERNAL` status. word-boundary anchors reject
|
||||
// `INTERNAL_SERVER_ERROR` (HTTP 500 message that may appear in unrelated
|
||||
// log lines) and identifiers like `INTERNALS`.
|
||||
{ regex: /\bINTERNAL\b/, label: "provider internal error" },
|
||||
{ regex: /\bUNAVAILABLE\b/, label: "provider unavailable" },
|
||||
// matches `quota`, `insufficient_quota`, `quota_exceeded`, `quotaExceeded`.
|
||||
// word-character lookarounds would reject `_quota` / `quotaX`; `quota` is
|
||||
// specific enough that a plain substring match is safe.
|
||||
{ regex: /quota/i, label: "quota error" },
|
||||
// explicit zero-quota response, e.g. `{"limit": 0}`. the `\b` anchor
|
||||
// around `limit` rejects keys like `time_limit` or `field_limit`.
|
||||
{ regex: /["']?\blimit\b["']?\s*:\s*0\b/, label: "zero quota" },
|
||||
];
|
||||
|
||||
export function detectProviderError(text: string): string | null {
|
||||
for (const entry of PROVIDER_ERROR_PATTERNS) {
|
||||
if (text.includes(entry.pattern)) return entry.label;
|
||||
if (entry.regex.test(text)) return entry.label;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* OpenRouter's response when the per-run key's remaining budget can't cover
|
||||
* the agent's `max_tokens` reservation. Distinct from a generic provider error
|
||||
* because it's a Pullfrog billing concern, not an upstream outage — the user's
|
||||
* Router wallet ran out (or the key budget was undersized at mint time and the
|
||||
* agent ran out of headroom partway through).
|
||||
*
|
||||
* Match must be specific to this exact OpenRouter error class. Generic "credits"
|
||||
* or "limit" text shows up in unrelated errors and would mis-classify them.
|
||||
*
|
||||
* Sample:
|
||||
* `APIError: This request requires more credits, or fewer max_tokens.
|
||||
* You requested up to 32000 tokens, but can only afford 22800.`
|
||||
*/
|
||||
// `/s` (dotAll) lets `.*?` cross newlines so we still detect the error if any
|
||||
// upstream layer reformats the message onto multiple lines. Without it, a
|
||||
// single inserted `\n` would silently bypass the BillingError reclassification
|
||||
// and the user would see the generic `❌ Pullfrog failed` dump instead of the
|
||||
// actionable top-up CTA.
|
||||
const ROUTER_KEYLIMIT_EXHAUSTED_PATTERN =
|
||||
/requires more credits.*?fewer max_tokens|requested up to \d+ tokens.*?can only afford/is;
|
||||
|
||||
export function isRouterKeylimitExhaustedError(text: string): boolean {
|
||||
return ROUTER_KEYLIMIT_EXHAUSTED_PATTERN.test(text);
|
||||
}
|
||||
|
||||
+14
-3
@@ -4,6 +4,12 @@ import { log } from "./cli.ts";
|
||||
export type RetryOptions = {
|
||||
maxAttempts?: number;
|
||||
delayMs?: number;
|
||||
/**
|
||||
* explicit delay schedule — one entry per retry (length N ⇒ N+1 attempts).
|
||||
* when set, overrides `maxAttempts` and `delayMs`. e.g. `[1_000, 3_000]`
|
||||
* means up to 3 attempts, sleeping 1s before retry 2 and 3s before retry 3.
|
||||
*/
|
||||
delaysMs?: readonly number[];
|
||||
shouldRetry?: (error: unknown) => boolean;
|
||||
label?: string;
|
||||
};
|
||||
@@ -20,10 +26,15 @@ const defaultShouldRetry = (error: unknown): boolean => {
|
||||
};
|
||||
|
||||
export async function retry<T>(fn: () => Promise<T>, options: RetryOptions = {}): Promise<T> {
|
||||
const maxAttempts = options.maxAttempts ?? 3;
|
||||
const delayMs = options.delayMs ?? 1000;
|
||||
const shouldRetry = options.shouldRetry ?? defaultShouldRetry;
|
||||
const label = options.label ?? "operation";
|
||||
const delays = options.delaysMs
|
||||
? Array.from(options.delaysMs)
|
||||
: Array.from(
|
||||
{ length: (options.maxAttempts ?? 3) - 1 },
|
||||
(_, i) => (options.delayMs ?? 1000) * (i + 1)
|
||||
);
|
||||
const maxAttempts = delays.length + 1;
|
||||
|
||||
let lastError: unknown;
|
||||
|
||||
@@ -37,7 +48,7 @@ export async function retry<T>(fn: () => Promise<T>, options: RetryOptions = {})
|
||||
throw error;
|
||||
}
|
||||
|
||||
const delay = delayMs * attempt;
|
||||
const delay = delays[attempt - 1]!;
|
||||
log.info(`» ${label} failed (attempt ${attempt}/${maxAttempts}), retrying in ${delay}ms...`);
|
||||
await sleep(delay);
|
||||
}
|
||||
|
||||
+12
-1
@@ -88,8 +88,19 @@ async function dispatchFollowUpReReview(ctx: ToolContext, reviewedSha: string):
|
||||
await ctx.octokit.rest.actions.createWorkflowDispatch({
|
||||
owner: ctx.repo.owner,
|
||||
repo: ctx.repo.name,
|
||||
workflow_id: "pullfrog.yml",
|
||||
workflow_id: getCurrentWorkflowFilename(),
|
||||
ref: pr.data.base.repo.default_branch,
|
||||
inputs: { prompt: JSON.stringify(payload) },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* derive the running workflow's filename from `GITHUB_WORKFLOW_REF`, which has the form
|
||||
* `<owner>/<repo>/.github/workflows/<filename>@<ref>` (e.g. `.../pullfrog.yaml@refs/heads/main`).
|
||||
* falls back to `pullfrog.yml` if the env var is missing or malformed (shouldn't happen in CI).
|
||||
*/
|
||||
function getCurrentWorkflowFilename(): string {
|
||||
const ref = process.env.GITHUB_WORKFLOW_REF ?? "";
|
||||
const match = ref.match(/\/([^/]+)@/);
|
||||
return match?.[1] ?? "pullfrog.yml";
|
||||
}
|
||||
|
||||
+9
-9
@@ -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";
|
||||
|
||||
@@ -19,14 +19,14 @@ export async function handleAgentResult(ctx: HandleAgentResultParams): Promise<M
|
||||
};
|
||||
}
|
||||
|
||||
// Review and IncrementalReview modes intentionally never set wasUpdated:
|
||||
// the prompt forbids report_progress (the review IS the durable record),
|
||||
// and IncrementalReview's non-substantive path produces no review at
|
||||
// all by design. wasUpdated staying false is also load-bearing for the
|
||||
// stranded-comment cleanup in main.ts which deletes the "Leaping into
|
||||
// action" orphan via `(!wasUpdated || trackerWasLastWriter)`. Skip the
|
||||
// strict completion check for these modes — the agent's exit code is
|
||||
// the completion signal, not a progress-comment write.
|
||||
// IncrementalReview's non-substantive path exits cleanly without
|
||||
// submitting any review, so no MCP write tool flips wasUpdated and the
|
||||
// strict completion check below would otherwise fail the run. The
|
||||
// isReviewMode skip is load-bearing for that path: the agent's exit
|
||||
// code is the completion signal, not a progress-comment write.
|
||||
// (Review mode that submits a real review now flips wasUpdated via
|
||||
// create_pull_request_review, so the skip is redundant for the
|
||||
// substantive-review path but kept for symmetry with IncrementalReview.)
|
||||
// See plans/review_progress_comment_cleanup_b0120f6c.plan.md.
|
||||
const mode = ctx.toolState.selectedMode;
|
||||
const isReviewMode = mode === "Review" || mode === "IncrementalReview";
|
||||
|
||||
+20
-1
@@ -24,10 +24,27 @@ export interface RepoSettings {
|
||||
envAllowlist: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Account-level billing plan. Orthogonal to repo-level OSS status. Mirrors
|
||||
* the server's `AccountPlan` in `utils/billing.ts`. `"none"` = free tier,
|
||||
* `"payg"` = card on file / pay-as-you-go.
|
||||
*/
|
||||
export type AccountPlan = "none" | "payg";
|
||||
|
||||
/**
|
||||
* "Is Pullfrog absorbing marginal infra cost for this repo?" — composite
|
||||
* predicate over the two orthogonal dimensions (repo-level OSS, account-level
|
||||
* plan). Mirrors `isInfraCovered` in the server's `utils/billing.ts`.
|
||||
*/
|
||||
export function isInfraCovered(params: { isOss: boolean; plan: AccountPlan }): boolean {
|
||||
return params.isOss || params.plan === "payg";
|
||||
}
|
||||
|
||||
export interface RunContext {
|
||||
settings: RepoSettings;
|
||||
apiToken: string;
|
||||
oss: boolean;
|
||||
plan: AccountPlan;
|
||||
proxyModel?: string | undefined;
|
||||
dbSecrets?: Record<string, string> | undefined;
|
||||
}
|
||||
@@ -51,6 +68,7 @@ const defaultRunContext: RunContext = {
|
||||
settings: defaultSettings,
|
||||
apiToken: "",
|
||||
oss: false,
|
||||
plan: "none",
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -70,7 +88,6 @@ export async function fetchRunContext(params: {
|
||||
try {
|
||||
const headers: Record<string, string> = {
|
||||
Authorization: `Bearer ${params.token}`,
|
||||
"Content-Type": "application/json",
|
||||
};
|
||||
if (params.oidcToken) {
|
||||
headers["X-GitHub-OIDC-Token"] = params.oidcToken;
|
||||
@@ -92,6 +109,7 @@ export async function fetchRunContext(params: {
|
||||
settings: RepoSettings | null;
|
||||
apiToken: string;
|
||||
oss?: boolean;
|
||||
plan?: AccountPlan;
|
||||
proxyModel?: string;
|
||||
dbSecrets?: Record<string, string>;
|
||||
} | null;
|
||||
@@ -112,6 +130,7 @@ export async function fetchRunContext(params: {
|
||||
},
|
||||
apiToken: data.apiToken,
|
||||
oss: data.oss ?? false,
|
||||
plan: data.plan ?? "none",
|
||||
proxyModel: data.proxyModel,
|
||||
dbSecrets: data.dbSecrets,
|
||||
};
|
||||
|
||||
@@ -3,7 +3,7 @@ import type { Octokit } from "@octokit/rest";
|
||||
import packageJson from "../package.json" with { type: "json" };
|
||||
import { log } from "./cli.ts";
|
||||
import { type OctokitWithPlugins, parseRepoContext } from "./github.ts";
|
||||
import { fetchRunContext, type RepoSettings } from "./runContext.ts";
|
||||
import { type AccountPlan, fetchRunContext, type RepoSettings } from "./runContext.ts";
|
||||
|
||||
export interface RunContextData {
|
||||
repo: {
|
||||
@@ -14,6 +14,7 @@ export interface RunContextData {
|
||||
repoSettings: RepoSettings;
|
||||
apiToken: string;
|
||||
oss: boolean;
|
||||
plan: AccountPlan;
|
||||
proxyModel?: string | undefined;
|
||||
dbSecrets?: Record<string, string> | undefined;
|
||||
}
|
||||
@@ -54,6 +55,7 @@ export async function resolveRunContextData(
|
||||
repoSettings: runContext.settings,
|
||||
apiToken: runContext.apiToken,
|
||||
oss: runContext.oss,
|
||||
plan: runContext.plan,
|
||||
proxyModel: runContext.proxyModel,
|
||||
dbSecrets: runContext.dbSecrets,
|
||||
};
|
||||
|
||||
+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";
|
||||
|
||||
+2
-2
@@ -68,7 +68,7 @@ export function installBundledSkills(params: { home: string }): void {
|
||||
writeFileSync(join(skillDir, "SKILL.md"), content);
|
||||
}
|
||||
}
|
||||
log.info(`installed bundled skills: ${BUNDLED_SKILL_NAMES.join(", ")}`);
|
||||
log.success(`installed bundled skills: ${BUNDLED_SKILL_NAMES.join(", ")}`);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -108,7 +108,7 @@ export function addSkill(params: {
|
||||
}
|
||||
);
|
||||
if (result.status === 0) {
|
||||
log.info(`installed ${params.skill} skill (${params.agent})`);
|
||||
log.success(`installed ${params.skill} skill (${params.agent})`);
|
||||
} else {
|
||||
const stderr = (result.stderr?.toString() || "").trim();
|
||||
const errorMsg = result.error ? result.error.message : stderr;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { performance } from "node:perf_hooks";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { spawn } from "./subprocess.ts";
|
||||
|
||||
@@ -48,6 +49,36 @@ describe("spawn error path", () => {
|
||||
expect(afterHandles).toBeLessThanOrEqual(beforeHandles);
|
||||
});
|
||||
|
||||
it("killGroup: true propagates SIGKILL to grandchildren so close fires promptly", async () => {
|
||||
// regression: node_modules/opencode-ai/bin/opencode is a Node shim that
|
||||
// spawnSyncs the native binary with stdio:"inherit". without killGroup,
|
||||
// child.kill("SIGKILL") hit only the shim — the native binary was
|
||||
// reparented to PID 1, kept holding our stdout pipe via the inherited
|
||||
// fds, and `child.on("close")` never fired (because pipes stayed open).
|
||||
// a 5-min outer safety-net timer eventually rejected the agent promise,
|
||||
// but the grandchild kept running until the GitHub Actions job-level
|
||||
// timeout. this test replicates the shape with bash + a backgrounded
|
||||
// sleep grandchild: with killGroup, close fires promptly after SIGKILL;
|
||||
// without it, the parent would wait for sleep to exit (30s).
|
||||
//
|
||||
// the activity-check interval is fixed at 5s so the earliest the kill
|
||||
// can fire is ~5s after start. budget 15s end-to-end.
|
||||
const before = performance.now();
|
||||
const result = await spawn({
|
||||
cmd: "bash",
|
||||
args: ["-c", "sleep 30 & wait"],
|
||||
env: { PATH: process.env.PATH ?? "", HOME: process.env.HOME ?? "" },
|
||||
activityTimeout: 1000,
|
||||
killGroup: true,
|
||||
}).catch((err) => err);
|
||||
const elapsed = performance.now() - before;
|
||||
|
||||
expect(result).toBeInstanceOf(Error);
|
||||
// 10s ceiling: 5s activity-check tick + signal delivery. a regression
|
||||
// here (no killGroup) would hang for the full 30s sleep.
|
||||
expect(elapsed).toBeLessThan(10_000);
|
||||
}, 20_000);
|
||||
|
||||
it("reports signal-killed subprocesses as failures, not success", async () => {
|
||||
// regression: before the fix, `child.on("close", (exitCode) => ...)`
|
||||
// discarded the signal parameter and `exitCode || 0` coerced the
|
||||
|
||||
+34
-5
@@ -106,6 +106,15 @@ export interface SpawnOptions {
|
||||
stdio?: ("pipe" | "ignore" | "inherit")[];
|
||||
onStdout?: (chunk: string) => void;
|
||||
onStderr?: (chunk: string) => void;
|
||||
// when true, spawn the child detached (its own process group) and route all
|
||||
// kill paths (timeout, activity timeout, ctrl-c) through `process.kill(-pid, ...)`
|
||||
// so signals reach grandchildren too. critical for binaries that fork through
|
||||
// a shim (e.g. node_modules/opencode-ai/bin/opencode is a Node shim that
|
||||
// spawnSync's the native binary; without killGroup, SIGKILL only hits the
|
||||
// shim and the native binary is reparented to PID 1, holds our stdout pipe
|
||||
// open, keeps emitting NDJSON, and `child.on("close")` never fires —
|
||||
// producing zombie runs that hang until the GitHub Actions job timeout).
|
||||
killGroup?: boolean;
|
||||
}
|
||||
|
||||
export interface SpawnResult {
|
||||
@@ -127,6 +136,8 @@ export async function spawn(options: SpawnOptions): Promise<SpawnResult> {
|
||||
let stdoutBuffer = "";
|
||||
let stderrBuffer = "";
|
||||
|
||||
const killGroup = options.killGroup ?? false;
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
// security: caller must provide complete env object, not merged with process.env
|
||||
const child = nodeSpawn(options.cmd, options.args, {
|
||||
@@ -136,10 +147,28 @@ export async function spawn(options: SpawnOptions): Promise<SpawnResult> {
|
||||
},
|
||||
stdio: options.stdio || ["pipe", "pipe", "pipe"],
|
||||
cwd: options.cwd || process.cwd(),
|
||||
detached: killGroup,
|
||||
});
|
||||
|
||||
// sends `signal` to the entire process group when killGroup is set, so
|
||||
// grandchildren (e.g. the native opencode binary spawned by the
|
||||
// opencode-ai Node shim) die with the parent. falls back to a direct
|
||||
// child kill if the process-group send fails (common when the child
|
||||
// already exited or was never made a process group leader).
|
||||
const killSelf = (signal: NodeJS.Signals): void => {
|
||||
if (killGroup && child.pid) {
|
||||
try {
|
||||
process.kill(-child.pid, signal);
|
||||
return;
|
||||
} catch {
|
||||
// fall through to direct kill
|
||||
}
|
||||
}
|
||||
child.kill(signal);
|
||||
};
|
||||
|
||||
// track child for cleanup on Ctrl+C
|
||||
trackChild({ child });
|
||||
trackChild({ child, killGroup });
|
||||
|
||||
let timeoutId: NodeJS.Timeout | undefined;
|
||||
let sigkillEscalatorId: NodeJS.Timeout | undefined;
|
||||
@@ -157,7 +186,7 @@ export async function spawn(options: SpawnOptions): Promise<SpawnResult> {
|
||||
if (options.timeout) {
|
||||
timeoutId = setTimeout(() => {
|
||||
isTimedOut = true;
|
||||
child.kill("SIGTERM");
|
||||
killSelf("SIGTERM");
|
||||
|
||||
// track the escalator so a graceful SIGTERM response (close fires
|
||||
// before the 5s elapses) can clear it. without capture, this timer
|
||||
@@ -165,7 +194,7 @@ export async function spawn(options: SpawnOptions): Promise<SpawnResult> {
|
||||
// past a timed-out subprocess's clean exit.
|
||||
sigkillEscalatorId = setTimeout(() => {
|
||||
if (!child.killed) {
|
||||
child.kill("SIGKILL");
|
||||
killSelf("SIGKILL");
|
||||
}
|
||||
}, 5000);
|
||||
}, options.timeout);
|
||||
@@ -186,9 +215,9 @@ export async function spawn(options: SpawnOptions): Promise<SpawnResult> {
|
||||
killedAtIdleMs = idleMs;
|
||||
const idleSec = Math.round(idleMs / 1000);
|
||||
log.info(
|
||||
`no output for ${idleSec}s from pid=${child.pid} (${options.cmd}), killing process`
|
||||
`no output for ${idleSec}s from pid=${child.pid} (${options.cmd}), killing process${killGroup ? " group" : ""}`
|
||||
);
|
||||
child.kill("SIGKILL");
|
||||
killSelf("SIGKILL");
|
||||
clearInterval(activityCheckIntervalId);
|
||||
try {
|
||||
options.onActivityTimeout?.();
|
||||
|
||||
Reference in New Issue
Block a user