b6df2860c347bdcfc098907bfdfcf96a967d34b2
285 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
d495f0b984 |
surface BYOK failures + chronic-failures card + WorkflowRunStatus mirrors GitHub conclusions (#722)
- Migrates `WorkflowRunStatus` from `running | completed | cancelled` to a 9-state mirror of `workflow_run.conclusion`. Backfill: old `completed → success`, `cancelled → failure`. New rows write `hook.workflow_run.conclusion` verbatim via `statusFromConclusion`. - Adds Discord links to `formatApiKeyErrorSummary` (both missing-key and 401 invalid-key shapes). - Repo console: `<ChronicFailuresCard>` fires when the last 3 terminal-state runs are all `failure`. Pure DB read; latest-run button hidden for pre-dispatch failures (`runId: null`). - `StatusIcon` distinguishes `cancelled` (gray X, intentional stop) from `failure` (red X) so the visual matches the chronic-card threshold. - Pre-dispatch failures (workflow lookup miss, dispatch API error) write `failure` instead of `cancelled` so they feed the card. - Cascade: every `status: "completed"` filter in billing routes / cron / cohort queries / analyzer becomes `status: "success"`. Verified end-to-end on `pullfrog/preview-722-failure-surfaces` — Better Stack logs confirm webhooks reached the preview deploy and all three e2e runs got `marked as failure (conclusion=failure)` via the new mapper. Closes #679, #702. |
||
|
|
8f9208bd3f |
feat: Amazon Bedrock support via routing slug (#720)
* add Amazon Bedrock as a routing slug introduces a single `bedrock/byok` catalog entry that the harness translates to the appropriate Bedrock model ID at run time via `BEDROCK_MODEL_ID`. routes Anthropic IDs through claude-code (with `CLAUDE_CODE_USE_BEDROCK=1`) and everything else through opencode's `amazon-bedrock` provider — keeps the catalog flat for an audience that needs version pinning rather than aliasing. accepts either `AWS_BEARER_TOKEN_BEDROCK` or `AWS_ACCESS_KEY_ID` + `AWS_SECRET_ACCESS_KEY` for auth; both validated alongside `AWS_REGION` and `BEDROCK_MODEL_ID` in `validateAgentApiKey`. catalog drift tests, the bumps cron, and per-alias smoke scripts all skip routing slugs since there's no fixed `resolve` to validate. docs/bedrock.mdx walks through setup; wiki/model-resolution.md has a section explaining why bedrock breaks the usual alias pattern. closes pullfrog/pullfrog#40 * ci: add bedrock env vars to test workflows mirrors the new bedrock provider's required env vars (AWS_BEARER_TOKEN_BEDROCK inherited from org secret + AWS_REGION + BEDROCK_MODEL_ID hardcoded) into both .github/workflows/test.yml files so the ci.test "env vars cover all provider API keys" assertion passes. * docs(bedrock): clearer setup flow + screenshot of model selector restructures the setup section into three concrete steps in execution order: select Bedrock from the dropdown, store the bearer token as a secret (Pullfrog or GitHub — links to keys.mdx for the trade-off), then add region + model id directly in pullfrog.yml since neither is sensitive. enable-model-access in the Bedrock console moved to step 4 (only required once per model and only when AWS rejects the call, not blocking on first run). adds a screenshot of the console model selector with Amazon Bedrock selected so readers can recognize the UI state they're aiming for. * fix(bedrock): tolerate raw Bedrock model IDs in validateAgentApiKey main.ts passes the resolved model into validateAgentApiKey (`payload.proxyModel ?? resolvedModel ?? payload.model`). For Bedrock, `resolveModel` translates `bedrock/byok` into the raw AWS model ID (e.g. `us.anthropic.claude-opus-4-6-v1`), which has no `/` and so trips parseModel inside getModelEnvVars. Detect the no-slash case and re-run the bedrock setup check (auth + region; BEDROCK_MODEL_ID is already enforced upstream by resolveModel). Caught by PR #720 e2e dispatch on pullfrog/preview-720-bedrock — "invalid model slug 'us.anthropic.claude-opus-4-6-v1' — expected 'provider/model'". Two regression tests cover the raw-ID path. * fix(bedrock): always prepend amazon-bedrock/ prefix when bedrock-routed opencode.ts was gating the prefix-injection on `!isBedrockAnthropicId(rawModel)`, on the theory that Anthropic Bedrock IDs always go through claude-code. But `PULLFROG_AGENT=opencode` is a documented escape hatch — when it forces opencode for an Anthropic Bedrock model, the prefix still has to be added or opencode fails with 'Model not found: <modelId>/.'. The Anthropic-vs-other discriminant only belongs in resolveAgent. Once an agent is selected, it should consistently honor the bedrock route. Caught by the PULLFROG_AGENT=opencode + Opus 4.6 e2e on pullfrog/preview-720-bedrock — run 25823437606. * ui+docs(bedrock): bespoke setup callout + clearer docs UI: - BedrockSetupCallout in components/AgentSettings.tsx covers both the Model costs section and the onboarding card. Detects bedrock via resolveDisplayAlias().routing === "bedrock", shows a dedicated message ("store AWS_BEARER_TOKEN_BEDROCK as a secret, then put AWS_REGION + BEDROCK_MODEL_ID directly in pullfrog.yml") + link to the setup guide. Replaces the generic "X, Y, or Z is required" prompt that misrepresented the three values as three separate secrets to add (and used the wrong "or" connector for what's actually an AND). - OnboardingCard re-uses the same callout with the gradient-card variant. Docs: - Drop the obsolete "Enable model access" step. AWS retired the manual enrollment page; foundation models auto-enable on first invocation. Anthropic models still need a one-time use-case form for first-time users — surfaced under the AccessDenied troubleshooting entry. - Drop the "Testing a different model in one run" PULLFROG_MODEL note. It introduced the secrets-vs-vars distinction we want to keep out of the bedrock setup story. - Step 3 already recommends hardcoding region + model id in pullfrog.yml. Workflow template: - The default pullfrog.yml customers receive (utils/github/pullfrog.yml.ts) now references AWS_BEARER_TOKEN_BEDROCK from secrets but inlines AWS_REGION and BEDROCK_MODEL_ID as plain values. Matches the docs. * fix(bedrock): three review-caught edges in routing + UI copy Addresses three real issues from PR #720 review: 1. agent.ts: PULLFROG_MODEL=bedrock/byok no longer leaks the literal sentinel "bedrock" downstream. resolveCliModel returns the alias's resolve field verbatim, which for routing entries IS the sentinel. Refactored both the env-override and slug-lookup paths through a shared resolveSlug() that recognizes routing aliases and defers to their backing env var (BEDROCK_MODEL_ID). 2. models.ts: isBedrockAnthropicId() now anchors on a discrete dot/slash/colon-segment match (case-insensitive) instead of a substring contains. The substring check was fragile in both directions for inference-profile ARNs (BEDROCK_MODEL_ID accepts ARNs per AWS docs) — a non-Anthropic profile whose user-chosen name contained "anthropic" would mis-route to claude-code, and an Anthropic profile whose name omitted it would miss CLAUDE_CODE_USE_BEDROCK=1. 3. AgentSettings.tsx: BedrockSetupCallout's configured-state copy showed "AWS_BEARER_TOKEN_BEDROCK configured" even when the user satisfied the gate via AWS_ACCESS_KEY_ID + AWS_SECRET_ACCESS_KEY, gaslighting access-key users about a secret they never set. Detect which auth method is actually present and name the right secret(s) in the success message. Regression tests in models.test.ts (5 new isBedrockAnthropicId cases including positive and negative ARN forms) and agent.test.ts (2 new PULLFROG_MODEL=bedrock/byok cases). 171/171 action tests pass. * yml template: add commented AWS access-key alternative for Bedrock auth Mirrors the IAM access-key path verified end-to-end on PR #720 e2e run 25830764987. Bearer token stays as the primary nudge; the access-key pair is the fallback for users who can't mint Bedrock API keys. * yml template: drop redundant 'or, alternatively' annotation * ui+docs(bedrock): rewrite callout copy + refresh screenshot Reframes the BedrockSetupCallout away from generic BYOK language to a Bedrock-specific message: leads with "Amazon Bedrock is configured entirely via environment variables", lists all four (auth, region, model id), and ends with the requested CTA sentence ("click below to learn more about Bedrock support in Pullfrog"). Promotes the "Bedrock setup guide" docs link from an inline anchor to a prominent button (always visible, regardless of auth state). The "Add AWS_BEARER_TOKEN_BEDROCK" affordance is now a secondary chip shown only when no auth secret is configured. Refreshes docs/images/model-selector-bedrock.png to capture the new callout — the prior screenshot still showed the old generic "BYOK / X, Y, or Z required" wording. |
||
|
|
868576a474 |
audit: format byok auth errors actionably + tighten audit prompt
- `action/utils/apiKeys.ts`: rewrite the missing-key body as Markdown with linked CTAs (repo secrets / model settings / docs). add `isApiKeyAuthError` + `formatApiKeyErrorSummary` covering both shapes: missing key (#679) and revoked/invalid 401 key (#702). - `action/main.ts`: reclassify in the result-failure branch and the catch block so the PR progress comment surfaces the actionable CTA instead of the raw `Invalid API key · Fix external API key` / numbered-list dump. - `scripts/analyze-logs.ts`: split `failure:user-misconfig` into `:no-key` and `:invalid-key` so both buckets are visible separately and the audit can ignore them as user-correctable. - `.github/workflows/run-audit.yml`: add three explicit prompt rules — cross-customer signal required (≥3 distinct accounts; single-customer concentration is not enough), recovered failures are not actionable, user misconfig is out of scope. closes the loop on #679 / #702 being filed in the first place. |
||
|
|
5518890b18 |
learnings: TOC + section taxonomy + 100k cap, hygiene rules, tool-quirk descriptions (#717)
* audit learnings: reshape reflection prompt + bake tool quirks into descriptions (#619) Cross-repo audit of the 48 repos with non-null learnings turned up two recurring failure modes: 1. ~25-30% of bullets across the most-active repos are pullfrog-tool quirks ("shell timeout is in milliseconds", "git args must be a JSON array", "create_pull_request_review drops out-of-hunk comments", "push_branch may report timeout when push succeeded", "checkout_pr shallow.lock retries", "commit_id needs full 40-char SHA"). These are universal across repos and should live in tool descriptions, not be rediscovered and stored 48 times. Tool descriptions now surface them. 2. Bullets are routinely 200-1000 chars (paragraph-length), and 12 of 48 repos are at the 10k cap. The reflection prompt now: caps bullets at ~240 chars (one specific fact), bans PR/review/commit/date-anchored facts that decay within weeks, bans tool-quirk learnings, and tells the agent that cap pressure means compress+prune existing bullets, not skip new findings. Co-authored-by: Cursor <cursoragent@cursor.com> * learnings: add server-generated TOC, fixed section taxonomy, raise cap to 100k (#707) Cap goes 10k → 100k. Reads stay bounded because the seeded file now opens with a server-generated table of contents listing every `## ` section's line range — agents read the TOC, then `read_file offset/limit` just the sections relevant to the current task instead of slurping the whole file. ## Section taxonomy (fixed) `## Build & test`, `## CI`, `## Conventions`, `## Architecture`, `## Gotchas`. Free-form `### ` sub-headings inside a section are fine. Pre-taxonomy free-text rows get wrapped in a `## Legacy` carve-out on first seed so they remain visible while the agent gradually re-curates them during reflection turns. ## Storage shape unchanged `Repo.learnings` still holds raw markdown (no schema migration). The TOC is a pure read-side affordance: prepended at seed time, stripped from the agent-edited file before persist. Markers `<!-- pullfrog-learnings-toc:* -->` delimit the strip region. Agent edits inside the markers are discarded. ## Round-trip semantics `seedLearningsFile` now returns `{ path, canonicalSeed }` where `canonicalSeed` is the post-TOC body — same shape `readLearningsFile` returns at end-of-run, so `persistLearnings` byte-compares them directly to skip the no-op PATCH. Empty-repo first runs end up with the section scaffold both as seed and as read-back, so untouched runs still short-circuit cleanly. ## Reflection prompt Adds explicit section-placement guidance (place each new bullet under the most relevant `## `; do NOT add new top-level headings; do NOT edit anything between the TOC markers). Carries forward the bullet hygiene from the previous commit: ≤240 chars per bullet, no pullfrog-tool quirks (those belong in tool descriptions), no PR/review/commit/date references. The "near cap" framing is replaced with "compress and prune within a section when it grows noisy" since the cap pressure that drove cramming is gone. Co-authored-by: Cursor <cursoragent@cursor.com> * anneal round 1: line-anchored taxonomy detect, partial-merge, line-boundary truncation, scaffold-empty UI Multi-lens review of the TOC + taxonomy diff surfaced a cluster of correctness and operational bugs. Fixes: - `hasAnyTaxonomyHeading` used `String.includes("## X")` which false-positives on `### X` (the `## ` substring sits inside `### `), prose containing `## CI`, fenced code documenting markdown, etc. Replaced with a line-anchored predicate that reuses `parseHeadings` so detection and TOC construction stay consistent. - The "any heading present → pass through verbatim" rule meant a body with one taxonomy heading would seed without the other four. Worse, requiring all five would flip a body back into Legacy when the agent legitimately pruned a section to empty. New `partial` kind: keep existing content in place, append missing sections in canonical order so the agent always has the full scaffold without losing pruning intent. - `stripLearningsToc` collapsed `\n{3,}` globally; `canonicalSeed` doesn't, so an untouched body with intentional triple-newline spacing would compare unequal and burn a spurious LearningsRevision row each run. Drop the global collapse — only the leading newlines that the strip itself introduces are normalized. - 100k truncation via `slice(0, 100_000)` could cut mid-line, breaking `parseHeadings` (whole-line `^## `) on the next seed and flipping a cut body back into Legacy. New `truncateAtLineBoundary` cuts at the last newline before the cap. - `LearningsSection.tsx` rendered a scaffold-only body as "has learnings" instead of the empty placeholder. Added a `hasOnlyEmptyScaffold` guard so the console behaves the same as pre-PR for the empty case. - Seed log line distinguishes `kind=structured/partial/legacy-wrapped/ empty` instead of `existing=yes/no`, so operators can spot legacy migration activity in logs. - New tests cover: substring false-positive (`### Build & test`, in-prose mentions), partial-taxonomy merge (no Legacy wrap), full-taxonomy structured pass-through, last-newline truncation, triple-newline preservation. Deferred (documented in PR body): deploy-ordering footgun (action before API), rollback for rows >10k, Gemini sanitizer dropping `description` on `anyOf` branches, reflection-on-failed-runs. Co-authored-by: Cursor <cursoragent@cursor.com> * anneal r2: hard-truncate fallback when line boundary discards >4k Round-2 review caught a regression in `truncateAtLineBoundary`: when the only newline within the first 100k chars sits near the start (e.g. one heading + 100k+ char single line — pathological pasted log dumps), the line-boundary cut discards almost all of the body. losing one partial line is preferable to losing kilobytes; threshold the fallback at 4k. Co-authored-by: Cursor <cursoragent@cursor.com> * move TOC out of file: prompt-side rendering, server-parsed headings drops the in-file TOC + fixed taxonomy in favor of: - file on disk = verbatim Repo.learnings (no markers, no scaffold) - server parses headings (mdast-util-from-markdown) at run-context time and returns them as RepoSettings.learningsHeadings - action renders heading TOC into the LEARNINGS prompt section as parenthesized line ranges like `Build & test (L1-L42)` with hierarchy via 2-space indent off the shallowest depth - reflection prompt teaches agent-curated structure with a soft 300-line per-section cap and explicit guidance to restructure flat legacy lists cuts 8 helpers (ensureSections, stripLearningsToc, assembleFile, buildTocBlock, parseHeadings, buildSectionScaffold, hasAnyTaxonomyHeading, LEARNINGS_SECTIONS) and the canonicalSeed round-trip dance. action seedLearningsFile is now { path } only; main.ts byte-compares the trimmed read-back against (current ?? "").trim() to gate the persist PATCH. truncateAtLineBoundary kept for safety. new tests: - test/learningsToc.test.ts (11 parser cases incl. fenced-code, blockquote, arbitrary h1-h6 nesting, startLine-points-at-heading invariant) - action/utils/learningsTocRender.test.ts (7 renderer cases) --------- Co-authored-by: Cursor <cursoragent@cursor.com> Co-authored-by: Colin McDonnell <colinmcd94@gmail.com> |
||
|
|
ae976e7159 |
parallel tool execution: enable opencode batch + nudge agents to parallelize (#719)
opencode: opt into `experimental.batch_tool` (anomalyco/opencode#2983) so the `batch` tool registers and the model can bundle 1-25 independent calls into one round trip. edit calls are excluded upstream. instructions.ts: add a "Parallel tool execution" section to the SYSTEM Workflow block, agent-specialized via ctx.agentId. uses Anthropic's canonical wording ("invoke all relevant tools simultaneously...") so Claude reliably emits multiple tool_use blocks per message; tells OpenCode about the new `batch` affordance. verified end-to-end against haiku-class models (sonnet for claude, default for opencode) with a "read 3 files and report first lines" fixture. results: - opencode used `batch` with 3 nested reads AND emitted 3 native parallel read calls in the same assistant turn - claude went from 3 serial turns (1 read each) to 1 message with 3 parallel Read tool_use blocks |
||
|
|
5aabd1e4a9 |
fix(action): cap subprocess stdout/stderr retention to prevent RangeError crashes (#680) (#715)
* fix(action): cap subprocess stdout/stderr retention to prevent RangeError crashes (#680) unbounded `stdoutBuffer += chunk` / `stderrBuffer += chunk` in `action/utils/subprocess.ts` previously crashed the wrapper with `RangeError: Invalid string length` once V8's ~1 GiB kMaxLength was breached on long-lived agent runs. multi-lens opencode Reviews on large monorepos (e.g. tambo-ai/buildy) hit this consistently — 23 runs in the last 24h, 100% of Review-mode hard failures on that repo. - add `retain: "tail" | "none"` to SpawnOptions, defaulting to "tail" with an 8 MiB cap. tail-mode prepends a `... [N MiB truncated] ...` sentinel so downstream consumers can detect truncation. - export `TailBuffer` helper for callers that need the same bounded accumulator semantics at their own layer. - wrap stream `data` listeners in try/catch as defense in depth — any synchronous throw inside a stream handler is otherwise fatal. - opencode + claude pass `retain: "none"` (they drain via onStdout / onStderr) and switch their own `output` accumulators to TailBuffer. their error paths read the agent-layer bounded mirrors instead of the now-empty `result.stdout` / `result.stderr`. - add `failure:string-length-overflow` heuristic to scripts/analyze-logs.ts so post-fix recurrences are visible at a glance instead of bucketing into `failure:unknown`. - regression tests cover >1 MiB stderr without crash, retain:"none" contract, and TailBuffer truncation semantics. * fix: avoid TS parameter property syntax in TailBuffer for strip-only node loader * address review: clarify try/catch scope + lock retain default to "tail" - the original comment claimed the try/catch caught "any synchronous throw" in the data listener, but `options.onStdout?.(chunk)` returns a Promise in the agent callers (claude.ts:569, opencode.ts:933) — a throw inside an async user callback surfaces as an unhandled Promise rejection, not a synchronous exception. reword to describe the actual protection: defense-in-depth for synchronous throws in the listener body, which is exactly the shape of the original RangeError on `+= chunk`. - add a test that locks `retain` default to "tail" by spawning without the option and asserting `result.stderr` is non-empty. a future refactor that flipped the default to "none" would silently break gitAuth, package installs, and lifecycle hooks that read result.stderr for failure messages, and the rest of the suite wouldn't catch it. |
||
|
|
60cc8772a6 |
fix(log-audit): kill 404 noise from /api/github/installation-token at source (#693) (#708)
* fix(log-audit): kill 404 noise from `/api/github/installation-token` at source (#693) Closes #693. Issue diagnosed a surface symptom (`log.error` on expected 404s) but missed the actual root causes. Investigation revealed two distinct populations producing identical 3-call 404 bursts: 1. **Fork-CI on `pullfrog/pullfrog`**: `test-token.yml` and `trigger-sync.yml` ship with `on: push: main`, so every fork inherits them and 404s our token endpoint on first push. Self-inflicted noise that scales with fork count. 2. **Real users hitting the full action without installing the App**: `/api/repo/.../run-context` uses the caller's `GITHUB_TOKEN` to read the repo from GitHub and then unconditionally lazy-provisions Account+Repo rows via `fetchOrCreateRepo`, even when the App isn't installed. Generates phantom DB rows and false `new account created` team@ alerts. (Confirmed via Prisma: `ezcorp-org` has an Account row with `installerLogin: null`, never installed our App.) Both populations then trip the client retry loop in `acquireTokenViaOIDC`, which matched `"Token exchange failed"` and retried 3× on terminal 4xx — tripling log volume and wasting CI time. ## Changes - `action/.github/workflows/{test-token,trigger-sync}.yml`: gate jobs with `if: github.repository == 'pullfrog/pullfrog'`. Forks inherit the files but the jobs no-op. - `app/api/repo/[owner]/[repo]/run-context/route.ts`: call `getRepoInstallation` first; return 404 with install URL if the App isn't installed, before any DB writes or GitHub repo fetch. - `action/utils/github.ts`: introduce `TokenExchangeError` for non-2xx server responses; `acquireNewToken` no longer retries it. Retry now fires only on genuine network/timeout failures. 404 surfaces a user-actionable error pointing at the install URL. - `app/api/github/installation-token/route.ts`: move `log.error` inside the 500 branch only. 404 branch is silent (expected user-state) and returns the same install URL message for consistency. ## Effect - Better Stack `level=error` lines from this path: 6/day → 0. - Failed user-trial CI time: 3 wasted token requests → 1. - User-facing error: opaque `Token exchange failed: 404` → actionable install URL. - No more phantom Account rows from never-installed callers. Skipped per design discussion: phantom-account cleanup (conservative — stop the bleed, leave history), `AGENTS.md` rule (overgeneralized). * review: address oracle leak + per-env install URL + retryable 5xx Addresses pullfrog[bot] (IMPORTANT) and Copilot review findings on #708: - **Install-status oracle in `run-context`** [pullfrog, Copilot]: `getRepoInstallation` runs with our App's JWT, *before* the caller's bearer token is validated against the repo. Pre-PR the route was uniformly bad-token-shaped; the new install-specific 404 turned it into an unauthenticated oracle distinguishing "Pullfrog installed here" from "not installed". Collapsed the 404 message to match the outer catch's ambiguous "repository not found or token lacks access". Legit runners still get the actionable install URL from `/api/github/installation-token`, which IS gated by OIDC. - **Hardcoded `github.com/apps/pullfrog`** [Copilot]: server-side `installation-token` now uses `GITHUB_APP_INSTALL_URL` from `app/globals.ts`, so dev/staging deployments with a different `GITHUB_APP_SLUG` direct users to the correct app. Action-side echoes the server's `error` body when present (single source of truth) and falls back to a generic message only if the body isn't JSON. - **Transient 5xx/429 made terminal** [Copilot]: `shouldRetry` now returns `true` for `TokenExchangeError` with `status >= 500` or `status === 429`. 4xx remains terminal (the actual #693 fix). Real outages no longer fail the workflow immediately. - **Stale comment** [pullfrog, Copilot]: reworded the comment at `installation-token/route.ts:141` to reflect the new retry policy ("the action surfaces this once (no retry)" instead of "the action retries on this"). * review: restore caller-token-first auth in run-context Pre-PR, `getEnrichedRepo({owner, repo, token})` used the caller's token as the auth boundary — `getRepo({token})` succeeding was the proof-of-access check. My initial install-gate inverted the order and ran the App-credentialed `getRepoInstallation` first, which is how it became: - an install-status oracle (pullfrog bot, addressed previously by matching the outer-catch wording), and - an outbound amplifier against our App JWT for arbitrary `owner/repo` (pullfrog bot, this commit). Reordered so `getRepo({token})` runs first. Garbage / unauthorized bearers get rejected by github (mapped to 403 by the outer catch) before any App-credentialed call fires. `getRepo` is cached 5min, so `getEnrichedRepo` below remains a free re-hit. |
||
|
|
4260984257 |
attribute claude subagent log lines + per-session thinking timer; tighten lens calibration (#700)
* attribute claude subagent log lines + per-session thinking timer; tighten lens calibration three orthogonal fixes diagnosed from the 10m PR-699 review run: 1. wire SessionLabeler into the Claude Code harness. claude-agent-sdk stamps every Assistant/User/System message with session_id and a non-null parent_tool_use_id when emitted from a subagent context, so the same FIFO labeler the OpenCode harness uses works here too. parallel reviewfrog dispatches now log with [lens:correctness] / [lens:operational-readiness] / etc. prefixes instead of being indistinguishable from the orchestrator. matches both "Task" and "Agent" tool names per the v2.1.63 rename. 2. one ThinkingTimer per session. the global timer treated cross-session interleaving (parent thinks → child tool_call, child returns → parent dispatches next) as parent thinking time, so individual "thought for Xs" numbers were untrustworthy. each session now owns its own timer and prefixes its own log line. 3. tighten the Review/IncrementalReview lens-add discipline. PR-699 triggered 4 lenses on a typical refactor (no auth/billing/schema) when the prompt's own calibration says 2-3 is typical; the research-validated lens went deep on Resend idempotency window + prisma updateMany lost-updates without either being load-bearing. adds an explicit "name the failure mode this lens would catch that the diff plausibly introduces" bar, and tightens research-validated specifically: only when correctness depends on the third-party contract, not when the API is merely used. side benefits from #1: subagents' TodoWrite events no longer clobber the orchestrator's progress comment; subagent text no longer overwrites finalOutput; system-event handler safely routes through eventLabel even though SDK only emits system:init for the top-level query today. * fix node strip-only mode: declare formatLine as field, not parameter property * key claude subagent labels by parent_tool_use_id, not session_id claude-agent-sdk runs subagents inside the orchestrator's session — they share session_id — and stamps subagent messages with parent_tool_use_id pointing at the Agent tool_use that spawned them. e2e on PR-700 with preview-700-claude-labeling#1 confirmed the original session_id-keyed wiring never differentiated subagent activity (only the dispatch line got [lens:correctness] in the log; the subagent's reads, writes, and todos all rendered as orchestrator). extend SessionLabeler so labelFor accepts an optional parent_tool_use_id and short-circuits to a direct map keyed by Agent tool_use id when set. recordTaskDispatch optionally takes the Agent tool_use id (block.id at dispatch time) and binds it. orchestrator events keep flowing through the sessionID/FIFO path unchanged so opencode wiring is untouched. * drop weak timer test that asserted only field isolation per pullfrog review on PR-700: the 'two timers do not bleed timestamps' test only verified that two ThinkingTimer instances have separate private fields, which has always been true. doesn't earn its keep — the per-session behavior is exercised by integration through claude.ts + opencode.ts. |
||
|
|
d5f881e9fc |
action: trim sensitive env values before GitHub Actions log masking (#698)
* action: trim sensitive env values before GitHub Actions log masking GitHub Actions' log masking is line-based: a secret value containing a newline only registers the first line as a mask, leaving the remainder exposed verbatim in logs. A trailing newline copied from a terminal into a GitHub Actions secret (e.g. ANTHROPIC_API_KEY) was enough to leak "a large part of the key" in run logs (pullfrog/pullfrog#41). normalizeEnv now trims leading/trailing whitespace from any value whose key matches the sensitive name pattern, masks the cleaned value, and warns when whitespace was stripped so the user notices the source. sanitizeSecret is reused for dbSecrets injection in main.ts. The three secret-store PUT/POST routes also trim values defensively, matching the existing name.trim() pattern. Real multi-line secrets are not used in practice — even GITHUB_PRIVATE_KEY PEMs are stored single-line with escaped \n and unescaped at the point of use — so a straight trim() is safe. * action: address review — use core.setSecret for masking, don't zero whitespace-only Pullfrog's review of #698 caught two real issues in the original fix: 1. `console.log(\`::add-mask::\${trimmed}\`)` doesn't escape \r/\n. If a value survives trim with an embedded newline (PEMs, kubeconfigs, JSON), the runner only registers the first line as a mask and the rest leaks. `core.setSecret(trimmed)` routes through @actions/core which percent-encodes \r/\n so the runner V2 parser decodes back to the full value and registers every non-empty line as a separate mask. Removes the load-bearing "no embedded newlines" invariant from the fix. 2. Whitespace-only sensitive values silently became "". Downstream truthy checks would flip from "set" to "missing" with no log. Now sanitizeSecret returns null in that case and callers skip the process.env write, surfacing a clear missing-key error instead. Tests rewritten to assert process.env state directly — no stdout spies. Masking correctness is delegated to @actions/core (trusted dependency). |
||
|
|
d5d8a0d7ac |
fix(#691): drop opencode/gpt-5-nano + opencode/mimo-v2-pro-free (not actually keyless on Zen) (#695)
* remove opencode/gpt-5-nano and opencode/mimo-v2-pro-free from catalog #7 delete aliases. both were listed as `isFree: true, envVars: []` but neither is keyless on opencode zen, producing a hard-fail `UnknownError: Model not found: opencode/<id>` on every run without an opencode_api_key. fixes pullfrog/app#691 (5 runs across 3 repos, 100% failure rate in the last 24h). root cause: opencode's provider gate (`packages/opencode/src/provider/provider.ts` `opencode:` loader) keeps a zen model only when models.dev reports `cost.input === 0` for it, then signs requests with `apiKey: "public"`. paid zen models get deleted from the autoloaded set and opencode surfaces the deletion as "model not found". - `opencode/gpt-5-nano`: models.dev reports `cost: {input: 0.05, output: 0.4, cache_read: 0.005}`. paid → requires `OPENCODE_API_KEY`. - `opencode/mimo-v2-pro-free`: free on models.dev but not in `https://opencode.ai/zen/v1/models` — zen never served it, so even the public-key path fails. remaining free aliases (`opencode/big-pickle`, `opencode/minimax-m2.5-free`) both pass both checks (cost.input === 0 in models.dev AND present in zen's served list) and continue to work without a key — verified against the opencode source. callers swept: `action/utils/apiKeys.test.ts`, `action/models.test.ts`, `action/test/list-aliases.ts`, `action/test/model-smoke.ts`, `components/ModelSelector.tsx` (`modelIdToUpstream`), `wiki/model-resolution.md`, `wiki/models-catalog.md`. wrote up the free-zen verification rule in models-catalog so the next maintainer can sanity-check both conditions before adding any `isFree` alias. users with a stored `opencode/gpt-5-nano` or `opencode/mimo-v2-pro-free` will now fall through `resolveCliModel → undefined` into the auto-select path — a strict improvement over today's hard fail. no DB migration needed; the slugs are simply unknown and treated like any other unrecognized stored value. * rework: keep mimo deprecated, demote gpt-5-nano to paid, add free-zen invariants revised approach after the first commit over-corrected. mimo was never broken at runtime — `fallback: "opencode/big-pickle"` already routes stored values through to a real free model before any zen call. the literal `opencode/mimo-v2-pro-free` being absent from zen's served list is irrelevant because `resolveCliModel` walks the chain first. restoring it as-is. the actual bug was `opencode/gpt-5-nano`: marked `isFree: true, envVars: []` but `models.dev` reports `cost: {input: 0.05, output: 0.4}` on the opencode provider, so opencode's keyless gate (`packages/opencode/src/provider/provider.ts` `opencode:`) deletes it when `OPENCODE_API_KEY` is missing and the run hard-fails with `UnknownError: Model not found: opencode/gpt-5-nano`. demoting it to a regular paid zen alias (drop `isFree`/`envVars: []`, add `openRouterResolve: "openrouter/openai/gpt-5-nano"` — verified to exist on openrouter at the same price). users without `OPENCODE_API_KEY` now get our explicit "no API key found" error pointing at the secrets page instead of opencode's cryptic upstream error. confirmed via `https://opencode.ai/zen/v1/models` that zen serves no free GPT variants, so there's no cheaper-than-`gpt-mini` free option to suggest in its place. CI gap analysis (why this slipped through): - `models-catalog.main.test.ts` only checked existence + `status !== "deprecated"` on models.dev. paid-model-marked-free regressions and zen-served-list drift both passed. - `models-live` (`model-smoke.ts`) runs with `OPENCODE_API_KEY` in env, so the keyless deletion gate never fires. `gpt-5-nano` returned "OK" in CI even though end users hit a hard fail. - `model-smoke.ts` walks the fallback chain, so mimo would have been smoked as big-pickle anyway — the dead resolve target was never exercised directly. (this is the right design; the gap is at the catalog layer, not the smoke layer.) new tests: - PR-blocking, static (`action/test/models.test.ts`, `isFree invariants`): every `isFree` alias must live under `opencode`, have `envVars: []`, omit `openRouterResolve`, AND have a fallback chain whose terminal alias is also `isFree` (catches "deprecate a free alias to a paid target" — the worst silent-charge regression). - main-only, network (`action/test/models-catalog.main.test.ts`, `opencode Zen served list`): every alias whose terminal-fallback resolve is `opencode/*` must appear in `https://opencode.ai/zen/v1/models`. catches zen dropping a model from its served list. - main-only, network (same file, `isFree models.dev cost`): every `isFree` alias's terminal-fallback resolve must have `cost.input === 0` in the `opencode` provider block on `models.dev`. would have caught `gpt-5-nano` at the next models-bump run. both network tests dedupe on terminal resolve, so deprecated aliases sharing a target aren't double-counted. `pnpm vitest run`: 113 static tests pass. `pnpm test:catalog`: 142 network tests pass against the live `models.dev`, `openrouter.ai`, and `opencode.ai/zen/v1/models` endpoints. wiki/models-catalog.md: rewrote the new "Free-Zen aliases need Zen-side verification" section to (a) describe the two conditions, (b) note that a fallback to an isFree alias is the legitimate escape hatch (mimo's pattern), and (c) point at the three tests by name so the next maintainer can find the enforcement surface. wiki/model-resolution.md points at the new section. * make gpt-5-nano a deprecated free alias falling back to big-pickle revising the previous "demote to paid" approach. the user-facing ergonomics are cleaner: anyone who picked gpt-5-nano under the "Free" badge gets transparent-upgraded to a real free model (big-pickle) instead of suddenly being asked to set OPENCODE_API_KEY. matches the existing mimo pattern exactly. the dropdown already filters `!a.fallback`, so the slug disappears from the picker on its own and the trigger renders it as "Big Pickle" via `resolveDisplayAlias`. no other catalog or test surface changes — the isFree invariants and the main-only zen/cost checks still pass (gpt-5-nano's terminal is now big-pickle, which is both isFree and zero-cost on models.dev, deduping with big-pickle's own row in both network tests). * revise: keep gpt-5-nano as paid alias, backfill affected DB rows instead dropping the deprecated-alias approach. `opencode/gpt-5-nano` is a legitimate cheap paid model people may want with BYOK (`OPENCODE_API_KEY`) — giving it `fallback: "opencode/big-pickle"` would foreclose that for everyone going forward. correct fix is two parts: (a) reclassify in the catalog as a regular paid OpenCode alias: - drop `isFree: true` and `envVars: []` so the local validator demands `OPENCODE_API_KEY` - add `openRouterResolve: "openrouter/openai/gpt-5-nano"` to satisfy the completeness test and route BYOK-via-OpenRouter users - no `fallback` — slug stays visible in the picker as a paid option (b) one-shot DB backfill of provably-affected repos (`scripts/backfill-gpt5-nano-affected.ts`). scope: - `Repo.model = "opencode/gpt-5-nano"` - AND at least one `WorkflowRun` with `inputTokens IS NULL` (evidence of an attempted run that didn't get past the model-init gate) skipped intentionally: - repos whose runs have `inputTokens > 0` — they have a key, gpt-5- nano works for them - repos with zero WorkflowRun rows — never dispatched; touching them would be presumptuous - `LearningsRevision.model` — audit trail of which model authored a revision, rewriting it would falsify history ran against .env.prod: 2 repos stored the slug; 1 was provably affected (sodown4thecause/seobot, 5/5 zero-token runs — matches #691's 3 failed runs from this repo plus 2 outside the 24h audit window). 1 was an internal test account that never dispatched (left as-is). applied: 1 row updated. confirmed idempotent on re-run. the other two repos in #691 (Nantiee/ALTA-breast-pump-tool, keksiqc/ansible-setup-linux) don't store the slug in `Repo.model`; their failed dispatches passed the model inline in the `workflow_dispatch` `prompt` payload, so the catalog fix alone (no longer offering it as free) is what helps them. tests: - models.test.ts: `getModelEnvVars("opencode/gpt-5-nano")` now returns `["OPENCODE_API_KEY"]`, moved into the keyed-model group - apiKeys.test.ts: added "throws without OPENCODE_API_KEY" case - isFree invariants from the previous commit still pass — gpt-5-nano no longer triggers them since it's no longer isFree - main-only catalog tests still pass (gpt-5-nano served by Zen, just paid; no isFree cost check applies) * docs: drop stale GPT Nano + MiMo V2 Pro from free-tier lists addressing pullfrog auto-review feedback on #695. three mintlify pages still advertised both as keyless after the catalog pivot, which now makes the docs affirmatively wrong rather than merely stale: - gpt nano is paid in the catalog (no `isFree`, inherits `OPENCODE_API_KEY`); a user following the docs would hit the same "missing API key" failure that's described 4 lines below in `docs/keys.mdx`. - mimo v2 pro is hidden from the picker (`fallback` triggers `ModelSelector`'s `!a.fallback` filter); the alias only exists for legacy stored-value resolution. a user reading the docs cannot actually pick it. surviving picker-visible free set: Big Pickle and MiniMax M2.5. - `docs/keys.mdx`: drop both bullets from the "Free models" list - `docs/billing.mdx`: drop both bullets from the "Free models" list - `docs/getting-started.mdx`: collapse the inline mention from a 4-model list to "Big Pickle and MiniMax M2.5" * address third review: picker grouping + backfill classifier honesty i had not pulled the third pullfrog review (`02:17:28Z`) when i declared reviews triaged after the docs sweep — the fourth review flagged that three findings remained pending. addressing them now. 1. picker grouping for now-selectable paid gpt-5-nano. when i removed `"gpt-5-nano": "OpenAI"` from `modelIdToUpstream` in the previous pivot-to-paid commit, i mistook it for dead code. it's not — the map IS consulted for paid opencode aliases via `groupByUpstream → getUpstreamLabel` inside the OpenCode submenu's `renderSubContent`. without the entry, `gpt-5-nano` falls back to `getProviderDisplayName("opencode")` = "OpenCode" and gets dropped into its own sub-header instead of joining opencode/gpt, opencode/gpt-pro, opencode/gpt-mini under the "OpenAI" upstream group. re-added with an explanatory comment so the next refactor doesn't make the same mistake. 2. JSDoc / code mismatch in `scripts/backfill-gpt5-nano-affected.ts`. the JSDoc said "at least one `WorkflowRun` with `inputTokens IS NULL`" but the code is `no WorkflowRun has inputTokens > 0` — a strictly broader filter (catches `null` AND `0`). rewrote the scope block to describe what the code actually does, with the operative classifier spelled out: "a billable run with `inputTokens > 0` is proof the agent successfully reached and called the model". 3. classifier breadth (raised in the same review). honest answer: the "no positive-token run" filter IS a heuristic — a repo whose only dispatches happened to fail or cancel for unrelated reasons would get false-positive-classified A. for THIS one-shot population (2 repos, 1 with 5/5 zero-token runs — strong systematic-failure signal) the heuristic was good enough and the dry-run inspection confirmed before APPLY. for any larger reuse of this pattern, you need to cross-reference the runtime error string (`UnknownError: Model not found: opencode/gpt-5-nano`) from GitHub Actions logs or Better Stack — that error doesn't live on `WorkflowRun` rows. added a "Classifier limitations" section to the JSDoc making this explicit. nothing about the actual applied backfill changes — the prod write (1 repo: sodown4thecause/seobot → opencode/big-pickle) is unchanged and re-running the script remains idempotent. |
||
|
|
43bb14bf87 |
action: strip Content-Type on body-less apiFetch requests (#692) (#694)
* action: strip Content-Type on body-less apiFetch requests (#692) Vercel's Next.js lambda adapter (Next 16.1.x) attempts to decode a request body when Content-Type is set and throws `SyntaxError: Unexpected end of data` before delegating to the route handler, returning a 500. Hit /run-context exclusively because it was the only body-less GET that sent `Content-Type: application/json`. - Drop `Content-Type: application/json` from the GET in `action/utils/runContext.ts` (meaningless on a body-less request). - Defensively strip any `content-type` header in `action/utils/apiFetch.ts` when no body is present so future callers can't reintroduce this. * apiFetch: soften comment — empirical observation, RFC 9110 §8.3 framing |
||
|
|
8c6cd2bda2 |
cancel + restart workflow run when @pullfrog mention is edited (#612)
* cancel + restart workflow run when @pullfrog mention is edited - add `WorkflowRun.triggeringCommentId` (BigInt?, indexed) so the webhook handler can find the run that was fired by a given comment - thread `triggeringCommentId` through `reserveRun` / `triggerWorkflow` - factor `dispatchMentionRun` out of `issue_comment_created` so the same shape is reused on edit - replace the `issue_comment_edited` stub: re-evaluates the trigger gate, cancels prior runs (`octokit.rest.actions.cancelWorkflowRun` + DB status='cancelled'), then re-dispatches with a `previousRunsNote` appended to `eventInstructions` so the agent acknowledges the prior run/PR/artifacts in its summary - if the edit removes `@pullfrog`, cancel only (no restart) Co-authored-by: Cursor <cursoragent@cursor.com> * thread previousRunsNote via dedicated payload field user prompt has precedence over eventInstructions, so stuffing the prior-runs note into eventInstructions made it vanish whenever the trigger comment contained an @pullfrog mention (which is always for the edit path). pass it as its own payload field and render it alongside the user's task so the agent actually sees it. * delete cancelled run's progress comment on edit-restart so the issue thread doesn't accumulate "This run was cancelled" stubs on every edit. only deletes for runs we actively cancel; runs that were already terminal (e.g. completed before the edit) keep their summary comment in the thread, and `previousRunsNote` links to it so the new agent can reference prior work. post-cleanup is race-safe: the action's `validateStuckProgressComment` swallows the 404 from the deleted comment and exits cleanly, so the old run's post step cannot clobber the new run's leaping comment. Co-authored-by: Cursor <cursoragent@cursor.com> * also cancel + delete progress comment when triggering comment is deleted mirrors the edit-removes-@pullfrog path: when an @pullfrog comment that fired a run is hard-deleted, look up any prior runs by triggeringCommentId, GH-cancel running ones, and delete their leaping progress comments. skips trigger-gate re-eval (we're tearing down a run, not firing one) and performs no restart. reuses the existing cancelRunsForTriggeringComment helper; the returned previousRunsNote is discarded since no dispatch follows. Co-authored-by: Cursor <cursoragent@cursor.com> * fix: move cancellation before trigger gate in issue_comment_edited cancelRunsForTriggeringComment now runs before the triggerEnabled check, so edits that remove @pullfrog still cancel in-flight runs even when the repo mention trigger is currently disabled (e.g. for non-collaborators). * anneal: scope cancel updates per-row + simplify edit gate - replace blanket updateMany on (triggeringCommentId, repoId) with per-row, status-guarded updates so a parallel handler's freshly-reserved run cannot be clobbered into cancelled by a racing edit delivery. - drop wasMention/isMention early-break in issue_comment_edited; always run cancelRunsForTriggeringComment (DB is the canonical "did this comment ever trigger a run" source). closes the missing-changes.body.from edge and lets us tear down a still-running prior run even if the admin disabled the mention trigger mid-flight. - buildPreviousRunsNote returns undefined (not "") when no link lines materialize. - doc cleanups + wiki/modes.md addendum noting issue_comment_edited / _deleted now drive cancel + restart. Co-authored-by: Cursor <cursoragent@cursor.com> * address review feedback on cancel/restart semantics - guard workflow_run.completed update against status='cancelled' so a successful-but-uncancellable GH Actions job can't resurrect a cancelled row (and re-bill it) via the completed webhook. - bucket only status='completed' runs into `preserved` in cancelRunsForTriggeringComment; cancelled/failed prior runs have stubs as their progress comment, not summaries worth referencing. - emit previousRunsNote for the runId-null cancel case so the restarted agent always knows when it's superseding a prior dispatch. - drop the agent-forbidden `gh pr list` hint and soften 'was cancelled' to 'was signalled to cancel' in the note body. - post a fallback comment when the edit-path dispatch fails (prior run already torn down and progress comment already deleted). - symmetrize the delete-handler's pullfrog guard with the edit handler (key off hook.comment.user, not hook.sender). - trim misleading comments on the per-row DB update guard. --------- Co-authored-by: Cursor <cursoragent@cursor.com> Co-authored-by: pullfrog[bot] <226033991+pullfrog[bot]@users.noreply.github.com> Co-authored-by: Colin McDonnell <colinmcd94@gmail.com> |
||
|
|
8e36f76cfa |
postrun: thread AgentRunContext through the retry loop instead of repackaging (#652)
* postrun: thread AgentRunContext through the retry loop instead of repackaging
drop the per-gate plumbing in `runPostRunRetryLoop`: the loop now receives
`ctx: AgentRunContext` whole and reads `ctx.stopScript` + `ctx.toolState.*`
directly. `getUnsubmittedReview` becomes a pure utility in postRun.ts
instead of a closure shipped over `AgentRunContext`. `AgentRunContext`
loses 4 fields that duplicated `toolState` (`summaryFilePath`,
`summarySeed`, `learningsFilePath`, `getUnsubmittedReview`) and gains
`toolState: ToolState`. both harness call sites collapse from 11 lines to
7; main.ts deletes the inline closure.
`ToolState` and friends move from `action/mcp/server.ts` to
`action/toolState.ts` so non-MCP code (agents, post-run loop) stops
importing run-state types from the MCP server module.
no behavior change. 503/503 tests green.
* toolState: relocate `CommentableLines` to break dep cycle with mcp/review
`action/toolState.ts` was importing `CommentableLines` from
`mcp/review.ts`, which pulled the entire MCP server compile graph (24
files) into any consumer of `ToolState` — including `cf-worker-indexing`
via the `pullfrog/internal` re-export chain through `utils/log.ts` →
`agents/shared.ts` → `toolState.ts`. that exposed a pre-existing TS
error in `mcp/issueEvents.ts` (octokit types resolve differently under
cf-worker's `moduleResolution: bundler`).
move `CommentableLines` (a small `{ RIGHT: Set<number>; LEFT: Set<number> }`
state-shape type) to `toolState.ts` where it's used; re-export from
`mcp/review.ts` for back-compat with test and call-site imports. cuts
cf-worker's mcp/ compile inclusion from 24 files back to 0.
* postRun: drop mock-heavy retry-loop tests; keep pure gate predicate
`runPostRunRetryLoop` and `executeStopHook` were covered by ~560 lines
of mock-heavy regression-gate tests that stubbed `spawn` / `getGitStatus`
and fabricated `AgentRunContext` to drive orchestration paths. per
AGENTS.md ("prefer no test over a mock-heavy test that only catches the
most obvious form of regression") and the empirical track record — the
one real production failure of this code path (#646) was a missing npm
release, not a logic bug a unit test could catch — the value-to-ceremony
ratio is poor. delete them.
keep only the pure predicate: `getUnsubmittedReview(toolState)` is a
decision function whose four input conditions have user-visible
consequences when wrong. 5 assertions, no mocks, no ctx fabrication.
488 tests still pass.
* toolState: import PrepResult from prep/types.ts, not the barrel
same dep-cycle class as the previous CommentableLines fix. importing
PrepResult from prep/index.ts pulled prep/installNodeDependencies.ts
into the Next.js production build's typecheck graph (via
pullfrog/internal → utils/log.ts → agents/shared.ts → toolState.ts →
prep/index.ts → installNodeDependencies.ts), and Next.js's stricter
NODE_ENV-required ProcessEnv shape rejected an existing
`env: { PATH: ... }` literal.
prep/types.ts is a leaf module with zero imports — re-routing the type
import severs the chain. Vercel preview deploy goes from Error → Ready;
preview-sync stops racing the deploy.
|
||
|
|
dee13b160f |
console: case-insensitive owner/repo slug resolution (#649)
* console: case-insensitive owner/repo slug resolution
URL slugs may be any case but GitHub treats logins and repo names as
case-insensitive (and 301-redirects to canonical case). Internal
find/filter sites compared with `===`, so mixed-case slugs (e.g.
`/console/Pullfrog`) hard-403'd in resolveOwnerAccess and silently
redirected from the per-repo console when currentRepo lookup missed.
Lowercase both sides at every slug comparison: resolveOwnerAccess
installation lookup, currentRepo lookup in repo + history pages,
ConsoleHeader installation/repo lookups, getInstallations personal
split, getOrgMembership user/org checks, getInstallationRepos node
filter, getUserRole owner-as-collaborator check, and the action
runtime's installation-repo access check.
Caches keyed by raw input remain case-split across casings; that's
fine since both entries resolve to the same canonical GitHub data and
TTLs are short.
* api: resolve targetAccountId by gh node id
getAuthenticatedAccountContext was looking up Account by `name` using
the raw URL slug, but `Account.name` is plain String populated from
canonical GitHub login. Mixed-case URLs would render the page (since
resolveOwnerAccess is now case-insensitive) but every billing/secrets
API call would 403 on the find-by-name miss.
Resolve by gh_${access.installation.account.node_id} instead — invariant
to case-folding and login renames. Same pattern as the sibling owner
page route already uses.
|
||
|
|
b0274e3265 |
local proxy-key testing via x-dev-repo bypass (#629)
* local proxy-key testing via x-dev-repo bypass `pnpm play` previously couldn't exercise the proxy/router/oss code path — `resolveProxyModel` early-exits without OIDC credentials, and `mintProxyKey` always sends an OIDC bearer to `/api/proxy-token`. since GitHub Actions OIDC only exists in real workflow runs, billing flows (auto-reload, balance gates, key rotation, OSS subsidy) had no local feedback loop. a server-side dev bypass already exists at `app/api/proxy-token/route.ts` that accepts an `x-dev-repo: owner/repo` header instead of an OIDC bearer when `NODE_ENV === "development"`. wire the action side so it sends that header when there are no OIDC credentials AND `API_URL` resolves to localhost (i.e. the developer is talking to their own `pnpm dev` server). production is unreachable through this path because vercel never sets `NODE_ENV=development`. document the affordance in `wiki/action-tests.md` so the next person doesn't have to re-discover it (the server bypass had been sitting there undocumented since the WIP billing rewrite). verified end-to-end: `PLAY_LOCAL=1 GITHUB_REPOSITORY=pullfrog/app API_URL=http://localhost:3100 pnpm play …` now logs `» proxy: dev bypass (x-dev-repo) for pullfrog/app` → `» proxy: router → openrouter/ anthropic/claude-opus-4.7` → `» model: …(proxy)`, mints a real OpenRouter key against the dev DB, and the agent runs through the proxy. * wiki: cross-reference dev proxy-key affordance from main/e2e/stripe action-tests.md already documents the localhost+x-dev-repo path; mention it from the natural discovery points so the next person finds it without spelunking through git history again: - main.md: resolveProxyModel row in the dependencies table notes the two auth paths (OIDC bearer in prod, x-dev-repo in dev). - e2e-testing.md: "When to use this" calls out the lighter-weight alternative for proxy-only changes. - stripe.md: new "Loop including the action" subsection in the Dev workflow section, alongside the existing dev-script and cron-endpoint loops. |
||
|
|
8f36eca62a | action: use log.success for skill install confirmations | ||
|
|
5f3e46c42d |
fix: don't reuse disabled proxy key on workflow re-runs; non-fatal title-gen errors (#636)
* fix: don't reuse disabled proxy key on workflow re-runs; non-fatal title-gen errors Three small surgical fixes addressing run https://github.com/pullfrog/app/actions/runs/25580969379: 1. **`/api/proxy-token` idempotency now checks `finalizedAt`.** GitHub re-runs share the same `run_id` (only `run_attempt` increments), so attempt N+1's action calls /api/proxy-token and inherits attempt N's `proxyKeyId`. The `workflow_run.completed` webhook between attempts retires that key on OpenRouter (`disableKey`), so attempt N+1 was getting back a disabled key and OpenRouter responded with `401 User not found` on every call. Falling through when finalized routes through the same billing gate (`handleRouterBilling` balance check), so no new attack surface. 2. **OpenCode title-gen / small-model errors no longer fatal.** OpenCode auto-spawns a small `agent=title small=true` background call at session start to name the thread, defaulting to `anthropic/claude-haiku-4.5` (anomalyco/opencode#1243). Pre-fix, the wrapper's `error` event handler treated any `type=error` as fatal, so a cosmetic title failure killed the run before primary inference even started. Now: stderr matching `small=true` sets a one-shot suppression flag for the next stdout `error` event, which is logged as a warning instead. 3. **Provider-error classifier puts auth patterns above rate-limit.** OpenRouter 401 payloads bundle `x-ratelimit-*` response headers, and the loose `\brate[_ ]limit/i` pattern was winning. Added 401/403 status, `User not found`, `Invalid authentication`, `No auth credentials found` patterns ahead of rate-limit. Updated the existing 401-headers regression test to assert correct auth classification rather than `null`. * opencode: correlate small-model error suppression by message, not by next-event Pullfrog self-review on #636 flagged a real concurrency hole. OpenCode forks the title-gen call (`session/prompt.ts:1452-1457` via `Effect.forkIn(scope)`) so it races primary inference. The previous one-shot `suppressNextErrorEvent` boolean had no per-call correlation: it was consumed by whichever stdout `type=error` event landed next, regardless of which subagent produced it. Under concurrent failures, a primary-agent error landing first could be silently downgraded to a warning while the small-model error then propagated fatally — the inverse of the bug the suppression was meant to prevent. Replaced the boolean with a `Set<string>` of pending small-model error messages. stderr extracts the inner `"message":"..."` from any classified provider error tagged `small=true`; the stdout `error` handler suppresses only when `event.error.data.message` matches a pending entry. Set is capped at 32 entries so a long stream of small-model failures can't wedge memory. Also corrected the comment that referenced "session summarizer" — verified in opencode source that summarize() does NOT use `small: true`; only the title generator does today (only `small: true` match in the codebase). * revert: drop opencode title-gen suppression We have no evidence — and can't construct a realistic scenario — where title-gen fails on an otherwise-successful run. Title-gen and primary share the same OPENROUTER_API_KEY and hit the same proxy/upstream; whatever breaks one breaks the other. The original repro on run 25580969379 is fully explained by the stale proxy key (fix #1) — title-gen happened to be the first call that surfaced the auth error, but every subsequent primary call would have died the same way. Suppression code adds complexity (cross-stream correlation logic, message matching, set capping) and a real failure mode of its own (a small-model error with a unique message could mask an unrelated primary error landing shortly after). Net negative. Removing. |
||
|
|
3d393c36a3 |
opencode: surface subagent events via injected plugin (#634)
* opencode: surface subagent events via injected plugin opencode's cli/cmd/run.ts event loop filters all message.part.updated events to the orchestrator's session id (`part.sessionID !== sessionID` continue), so subagent-internal tool_use / text / step events were silently discarded by the CLI in --format json mode. opencode plugins, by contrast, receive every bus event via bus.subscribeAll() regardless of session. ship a per-run plugin (action/agents/opencodePlugin.ts) that re-emits non-orchestrator message.part.updated events as `pullfrog_bus_event` envelopes on opencode's stdout. the plugin is staged into <XDG_CONFIG_HOME>/opencode/plugin/pullfrog-events.ts which is already redirected to ctx.tmpdir — never the user's repo working tree. the plugin also forwards the orchestrator's task tool dispatch at state.status="running" — that's the first moment state.input is populated with description / subagent_type / prompt and it lands BEFORE the subagent's first message.part.updated. forwarding this lets SessionLabeler register the lens label early, so subagent events bind to the correct lens name (e.g. lens:correctness) instead of the subagent#N fallback. the existing tool_use handler dedupes on callID so the late status=completed event from the CLI doesn't double-record. the parent's pullfrog_bus_event handler synthesizes the equivalent CLI-style event for each part type (tool/step-start/step-finish/text) and dispatches through the same handlers used by orchestrator events, so labeling, tool-call rendering, and the formatWithLabel magenta prefix all share one code path. verified end-to-end via `pnpm play --local --raw` with a prompt that dispatches a reviewfrog subagent: orchestrator's task call now logs "» dispatching subagent: lens:read-readme-and-report-purpose" before the subagent runs, the subagent's read tool call surfaces with [lens:...] magenta prefix, and the run-end "subagent finished" attribution shows the lens name. also adds an AGENTS.md rule formalizing the no-write-to-repo invariant: action runtime must never write into the user's working tree; auxiliary files go in ctx.tmpdir via HOME / XDG_CONFIG_HOME. * drop opencodePlugin.test.ts — bullshit-test cleanup these tests spied on process.stdout.write, loaded the plugin source into a temp file via dynamic import, and asserted the output strings matched the plugin source i'd just hand-written. zero unique signal over the e2e run in preview repo, plus they violate AGENTS.md's "mocks tend to add ceremony and brittleness" rule. real signal lives in the e2e: lens label rendering, dispatch attribution, no double events. if a syntactic regression in the plugin source ever ships, opencode logs it on plugin load and the e2e fails fast — the unit tests would catch the same regression no faster. * remove isPausedExternally — plugin makes it unnecessary empirical proof from PR #634's e2e debug trace: ~3.3 pullfrog_bus_event lines per second arrive on the parent's child.stdout pipe during a typical subagent run. each one fires updateActivity() and resets lastActivityTime, so the inner spawn activity timer naturally stays armed-but-not-fired throughout the subagent's lifetime — no suspend predicate needed. drop: - SpawnOptions.isPausedExternally + the check in spawn()'s activity loop - isSubagentInFlight() in opencode.ts + its callsite - two isPausedExternally unit tests in subprocess.test.ts keep: - killGroup (the actual zombie-prevention fix; still tested) - the plugin (action/agents/opencodePlugin.ts; the architectural fix) - everything in opencode.ts that derives lens labels from task dispatches the only edge case isPausedExternally covered that the plugin doesn't is a non-streaming provider going silent for >5min during a single LLM call inside a subagent. that's a provider-behavior question, not a harness-architecture one — best fixed at the provider level if it shows up. defense-in-depth that adds indirection is harmful when the upstream architectural fix is already in place. * opencode: address review feedback on bus envelope routing three findings from PR #634 review (2026-05-08T22:13:44Z): 1. token/cost double-count: routing subagent step_finish through the orchestrator's handler folded subagent tokens/cost into the run-wide accumulators that flow to logTokenTable + AgentUsage. neighbouring init/text handlers all gate on ORCHESTRATOR_LABEL for exactly this reason. fix: drop step_start AND step_finish from the bus envelope handler — those carry orchestrator-scoped state (currentStepId, stepHistory, token accumulators) that subagent events shouldn't touch. tool calls and text from subagents still surface — that's the user-visible activity. 2. subagent tool errors invisible: routed status="error" tool parts into handlers.tool_use which only emits "» <tool>(...)" with no error indication. fix: extend handlers.tool_use itself to log "» tool call failed: <msg>" when state.status==="error". benefits the orchestrator path too — opencode CLI also emits failed tool calls as tool_use at status=error and we were swallowing the failure signal there as well. 3. stale comments + leaked local paths: plugin source had /tmp/opencode-investigate/... paths from my local clone, specific line numbers from opencode's dev branch that don't match v1.1.56, forkDetach claim that's wrong for the pinned version, and JSDoc that still listed message.updated/session.error in the forwarded set after the runtime filter narrowed to message.part.updated only. fix: drop machine-local paths, drop version-fragile line numbers, correct the forwarded-set list, generalize the "why no @opencode-ai/plugin import" rationale to be version-agnostic. second review (2026-05-08T22:27:58Z) confirms these are the only findings still open — no new issues from the isPausedExternally removal. |
||
|
|
d6de1c369a |
learnings: edit-in-place tmpfile (drop update_learnings tool) (#635)
* learnings: edit-in-place tmpfile (drop update_learnings tool)
learnings now follow the PR-summary file pattern: server seeds
`pullfrog-learnings.md` from `Repo.learnings` at startup, agent reads
it as part of context, may edit in place during the post-run reflection
turn, server reads back at end-of-run and PATCHes if changed.
motivation: `update_learnings` required the agent to pass the FULL
merged list as a string parameter — an output-token tax that grew
linearly with the learnings size, and a constant prompt-context
expansion since the contents were also inlined into the LEARNINGS
section. for repos with mature learnings the prompt was getting
visibly noisy in CI logs.
key changes:
- new `action/utils/learnings.ts` (seed/read helpers + 10k cap)
- `main.ts`: always seed; `persistLearnings` mirrors `persistSummary`
(success path, error path, exit-signal handler, idempotent guard,
byte-trim equality skip); forwards `model` for `LearningsRevision.model`
- `LEARNINGS` prompt section now contains only the file path + a
one-line "read it" instruction (no contents inlined)
- `update_learnings` MCP tool deleted; `action/mcp/learnings.ts` removed
- reflection turn (`buildLearningsReflectionPrompt`) reframed around
file editing with explicit prune-stale + leave-alone-if-nothing-new
framing
- `learningsStep` removed from every mode checklist — surface lives only
in the LEARNINGS prompt section + the reflection turn now
* learnings: harden seed step + refresh stale docs (review feedback)
Three findings from PR review, all implemented:
1. wrap learnings seed in best-effort try/catch (action/main.ts) —
the always-on seed block ran unconditionally and an unwrapped
`seedLearningsFile` (mkdir + writeFile) 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 started. asymmetric with `persistLearnings`'s own
best-effort contract. wrap and log on failure; downstream
consumers (`persistLearnings`, agent harnesses, `resolveInstructions`)
already handle `learningsFilePath: undefined` cleanly.
2. refresh wiki/main.md — `resolveInstructions` parameter renamed
from `learnings` to `learningsFilePath` in this PR; the data-flow
diagram and the resolver dependency table both still showed the
pre-refactor signature.
3. drop deleted `learnings.ts` from ROADMAP.md + RESEARCH.md
"missing MCP tool tests" bullets — `action/mcp/learnings.ts` was
removed in this PR; the bullets are otherwise still accurate.
|
||
|
|
ca913c76ea |
spawn: kill process group + heartbeat subagent activity (#631)
* spawn: kill process group + heartbeat subagent activity two compounding bugs produced zombie agent runs that stalled until the GitHub-Actions job-level timeout (observed on PR #622, run 25577068620). 1. SIGKILL hit the wrong process. node_modules/opencode-ai/bin/opencode is a Node shim that spawnSyncs the native opencode-<plat>-<arch> binary with stdio:"inherit". our spawn() ran without detached, so child.kill("SIGKILL") killed only the shim. the native binary was reparented to PID 1, kept holding our stdout pipe via inherited fds, and child.on("close") never fired — leaving the agent promise pending past the 5min outer safety-net timer ("agent still pending 5min after inner activity kill — forcing exit") and the grandchild running until the runner timed out. fix: SpawnOptions gains killGroup; when set, we spawn detached and route all kill paths (timeout, activity timeout, ctrl-c) through process.kill(-pid, signal). opencode + claude opt in. 2. inner activity timer false-fired during long task subagents. opencode's `task` tool encapsulates subagent execution in-process — subagent-internal events don't reach the parent NDJSON stream — so the parent looked idle for the full subagent duration even when real work was happening, and the 5min DEFAULT_ACTIVITY_TIMEOUT_MS would fire mid-subagent. fix: SpawnOptions gains externalActivitySource; the timer fires on min(local stdout idle, external idle). opencode passes getIdleMs() from the global activity tracker and runs a 30s heartbeat (markActivity()) while at least one task dispatch is in flight. action/utils/subprocess.test.ts covers both: a bash+sleep grandchild that proves close fires <10s with killGroup, and externalActivitySource keeping the timer armed during 8s of stdout silence. * opencode: suspend activity timer instead of heartbeat during subagent runs addresses review on prior commit: replace the 30s markActivity() heartbeat with a boolean isPausedExternally predicate keyed off opencode's existing taskDispatchByCallID + pendingTaskDispatches. no fake activity, no race window between a 30s tick and a subagent that finishes between ticks. while the predicate returns true, spawn's activity check skips the kill decision *and* advances lastActivityTime so a clean unpause can't fire on a stale baseline. tests cover both the suspended case (8s of stdout silence + activityTimeout=1s but paused → process exits cleanly) and the resume case (paused for 500ms then unpaused → 30s sleep gets killed by activity timeout as normal). |
||
|
|
ec43c0e0d1 |
router: fix bugs from PR #616 review (#625)
Three real defects flagged in the post-merge review of #616, plus one cheap hardening: 1. OpenCode `limit.output` override was a silent no-op on opencode-ai@1.1.56. Top-level `limit.output` has no read site in OpenCode (verified against the v1.1.56 source: `OUTPUT_TOKEN_MAX = Flag.OPENCODE_EXPERIMENTAL_OUTPUT_TOKEN_MAX || 32_000` in session/llm.ts; per-model `model.limit.output` has its own scope). Plumbed via `OPENCODE_EXPERIMENTAL_OUTPUT_TOKEN_MAX=5000` env var on the OpenCode spawn instead. Drops dead `OpenCodeConfig.limit?` type field and the corresponding config write in `buildSecurityConfig`. This was the headline mechanism of #616 — without the env var, the upfront `max_tokens` reservation stayed at 32_000 and low-wallet runs continued failing the way #616 was supposed to prevent. 2. Phantom auto-reload buffer for detached-card accounts. DELETE /payment-method clears `stripeCustomerId` but leaves `autoReloadEnabled` intact, so an account with welcome-credit residue and a detached card could mint a key with `keyLimitCents = balance + autoReloadAmountCents` ($50 default, schema-cap $100K) of free spend headroom we have no way to bill. Conjunctive `account.autoReloadEnabled && hasCard` in the buffer selection closes this. Defense-in-depth follow-up worth doing: clear `autoReloadEnabled` in the card-detach handler. 3. The autoReloadEnabled 402 branch fired for phase-1 noop paths (`!stripeCustomerId`, `reloadAmountCents < 50`, `balance >= threshold`) where `result.failure == null`, returning `"insufficient balance"` with no actionable code. Gated on `result.status === "failed"` so non-charge paths fall through to the `hasCard` / no-card branches and emit `router_balance_exhausted` / `router_requires_card` instead. 4. (cheap) `ROUTER_KEYLIMIT_EXHAUSTED_PATTERN` now uses `/is` instead of `/i` so `.*?` crosses newlines. Defends the BillingError reclassification against any upstream layer that wraps the OpenRouter error onto multiple lines. Trivial. Test plan: 488/488 unit tests pass (1 new test for newline regex behavior). |
||
|
|
93cc7b1a44 |
show effective model in agent comment/review footers (#618)
`toolState.model` was set only to `payload.model` (the stored slug, often undefined for router/oss runs that derive the target from `proxyModel`). the footer's "Using `…`" segment is gated on a truthy model, so router runs on repos without an explicit model setting shipped reviews/comments with no model badge — e.g. PR #614's review showed no model despite running `openrouter/anthropic/claude-opus-4.7` via proxy. now mirror the priority used by `resolveModelForLog` and `isGeminiRouted`: `payload.proxyModel ?? resolvedModel ?? payload.model`. also reverse-look up by `resolve`/`openRouterResolve` in `formatModelLabel` so a proxy target like "openrouter/anthropic/claude-opus-4.7" still renders as "Claude Opus". |
||
|
|
851e49e2d7 |
action: retry transient GitHub 422 "internal error" on review submission (#610)
* action: retry transient GitHub 422 "internal error" on review submission
GitHub sometimes 422s POST /pulls/{n}/reviews with body
"An internal error occurred, please try again." — a server-side hiccup
that the existing 422 handler framed with the generic
"likely causes (1)(2)(3)" prompt listing affected comments. the agent
dutifully refetched the diff, dropped comments, and resubmitted, hitting
the same transient error on a shifting affected-comments list until
GitHub accepted. some runs logged 8+ spurious retries with ~11 minutes
of wall-clock, dropping valid inline comments along the way.
detect the transient body explicitly, retry in-tool twice with 1s/3s
backoff, and surface a distinct error on exhaustion that tells the agent
this is a GitHub-side issue — do not modify inline comments, wait and
retry or fall back to a body-only review. closes #584.
* action: use retry util for transient review 422, drop isTransientReviewError tests
---------
Co-authored-by: pullfrog[bot] <226033991+pullfrog[bot]@users.noreply.github.com>
|
||
|
|
4101df566b |
router: decouple per-run key budget from wallet, add overdraft buffer (#616)
Replaces today's `keyLimitUsd = min(walletBalance, $25)` with population-aware buffers so users can use 100% of their credits before being paywalled, and opaque mid-run "more credits" failures (e.g. https://github.com/pullfrog/app/actions/runs/25531633203) get a clear PR comment instead of a generic stack-trace dump. Policy matrix: - Auto-reload accounts: `wallet + autoReloadAmountCents` (default $50, no cap) - Card + no-autoreload: `wallet + $5` overdraft buffer - No card: `wallet` (no buffer; existing zero-balance 402 stays) - OSS: `$10` (unchanged) Removes the $25 per-run cap entirely. Long Build runs at high-balance accounts no longer silently cap at $25. Other changes: - Classify mid-run OpenRouter "requires more credits, or fewer max_tokens" errors as `router_keylimit_exhausted` BillingError so users get an actionable PR comment. - Override OpenCode `max_tokens: 32000` default to `5000` via OpenCodeConfig.limit.output. Drops Opus per-call upfront budget reservation from ~$2.40 to ~$0.38 — what makes low-wallet runs viable at all. - Switch `findInitialComment` and `findExistingPaywallComment` to GraphQL `issueOrPullRequest(number:) { comments(last: 100) }` (single round trip, actually returns newest-100; REST listComments doesn't support sort/direction). Also fixes a latent `comments.find()` returning the OLDEST match instead of the most recent — now selects max(databaseId). - Wrap `syncAccountUsage` in `prisma.$transaction` with `SELECT ... FOR UPDATE` on the account row. Pre/post-balance reads inside the transaction enable deterministic low-balance edge detection (currently logs; will push the outreach.low_balance task once #592 lands). Plan: .cursor/plans/router-low-balance-paywall.plan.md (in companion wiki-billing branch) |
||
|
|
9d04cad360 |
drop legacy summaryCommentNodeId column (#617)
Was retained on `workflow_runs` after PR #568 replaced the comment-based summary path with the snapshot architecture, with a "kept for backfill of pre-snapshot runs" annotation. No backfill is planned: pre-snapshot summary comments were written in the user-facing PR_SUMMARY_FORMAT (TL;DR + key changes blockquote + before/after sections), not the agent-context functional-summary format the snapshot now expects. Backfilling them would prime new runs with the wrong shape and pollute the agent context. Old comments stay on github.com as historical artifacts; the column on the DB row is dead weight. Strips the field from: - prisma schema + new migration `20260508190000_drop_summary_comment_node_id` - `app/api/workflow-run/[runId]/route.ts` STRING_FIELDS allowlist - `action/utils/patchWorkflowRunFields.ts` type union + STRING_KEYS - `utils/db/selectActiveWorkflowRuns.ts` select clause - `utils/github/enrichWorkflowRunsWithArtifactUrls.ts` node-id type, URL resolution, collectUniqueNodeIds + urlsForRun - `utils/webhooks/handleWorkflowRunWebhook.ts` two select clauses, the hasRecordedArtifact param, and the orphaned-leaping-comment alert text - `components/RunArtifactPills.tsx` ArtifactKey union + ARTIFACT_KEYS + switch cases (drops the "View summary" chip from the workflow run list) Verified: pnpm typecheck clean, pnpm lint clean (537 files), action build clean. Dev DB reset against production parent and the migration applied cleanly — column is gone from the workflow_runs table. |
||
|
|
e4e93ea6d3 |
PR summary as agent-edited tmpfile snapshot (#568)
* PR summary as agent-edited tmpfile snapshot Replaces the comment-based PR summary path (and the in-progress update_pr_summary tool from #534) with a snapshot file the agent edits in place during Review / IncrementalReview / pr-summary Task runs. The server seeds the tmpfile with the previous snapshot (incremental) or a stable scaffold (first run), exposes the path via select_mode, and reads it back at end-of-run to persist to WorkflowRun.summarySnapshot and (when the prSummaryComment toggle is on) splice into the PR description body. Why a tmpfile rather than a tool call: incremental snapshot edits are output-token-cheap when the agent uses native file-editing tools, and range-diff cleanly across runs because section headings are stable. The agent never has to regurgitate the full snapshot to update it. Gating: snapshot generation is opt-in via either prSummaryComment="enabled" (splice into PR body) or prReReview="enabled" (snapshot feeds future incremental review runs as context). Users who disable both pay nothing end-to-end — no seeding, DB write, or body splice. Behavior changes: - Drop the Summarize mode and the Summary comment type entirely; the rolling summary is no longer a separate run shape. - pull_request_synchronize with re-review off and summary on still dispatches a silent pr-summary Task, but it edits the snapshot file instead of posting a fresh comment. - /api/repo/.../pr/.../summary-comment now returns { snapshot: string | null } from the DB instead of fetching a comment via GraphQL. URL kept stable so deployed older actions degrade gracefully. - summaryCommentNodeId is retained on WorkflowRun for legacy data and a future backfill of pre-snapshot comment-based summaries. Supersedes #534. The commit-tool/sub-agent direction in that PR is abandoned in favor of this file-based shape. * address review pass #1: synchronize fallback, splice idempotency, docs * address review pass #2: in-flight skip should not race summary fallback * address review pass #3: signal-handler flush, doc clarifications * address review pass #4: in-flight persist promise + bounded body-splice timeout * address review pass #5: defensive catch on persist worker, doc nit * add summary-stale post-run gate When generateSummary is set, we capture the bytes of the seeded snapshot file and pass them to the agent's post-run loop alongside the file path. After each agent attempt, the loop diffs the current file against the seed; if they're byte-identical the agent never touched it, and we nudge once via a resume turn (similar to the dirty-tree gate, but soft and fire-once so smaller models that legitimately decide no edit is warranted don't burn the retry budget). Mostly defends against forgetful smaller models on the Review path — their mode prompt asks them to edit the snapshot file, but the multi-step instruction can fall through when the diff is large. * trigger: retry vercel preview build * fix(action): drop unused re-export that pulled node:fs/promises into next bundle action/internal/index.ts was re-exporting DEFAULT_PR_SUMMARY_INSTRUCTIONS from action/utils/prSummary.ts, but nothing in the next.js app imports it. prSummary.ts uses node:fs/promises, and pullfrog/internal is aliased into the next bundle by next.config.ts, which made turbopack try to resolve node:fs/promises in client chunks and fail with: the chunking context (unknown) does not support external modules (request: node:fs/promises) drop the re-export — selectMode.ts (the only real consumer) already imports it directly from action/utils/prSummary.ts. * firewall PR summary snapshot from user instructions; resurrect rich format for Review The agent-internal snapshot (the markdown file the agent edits in place across runs) is exclusively durable context for future agent runs — user-supplied summarization instructions warp it and degrade that context. Drop the prSummaryCommentInstructions read path end-to-end: - handleWebhook: stop reading prSummaryCommentInstructions, stop passing prSummaryInstructions through dispatch options - action payload + ToolState + selectMode addendum: drop the instructions appendix; the snapshot prompt is fixed, not user-shaped - TriggersSettings: drop the InstructionsEditor for prSummaryCommentInstructions - prSummary.ts: reframe DEFAULT_PR_SUMMARY_INSTRUCTIONS as agent-targeted (durable context, not human-facing prose) Prisma columns (prSummaryComment, prSummaryCommentInstructions) and the matching zod schema entry stay for graceful retreat. Separately, resurrect PR_SUMMARY_FORMAT (deleted along with the Summarize mode in the original PR) and wire it into Review mode only. Initial PR reviews now include a structured summary section in the review body using the rich format (TL;DR, key changes, ## sections with before/after, file-link trails). IncrementalReview keeps its existing terser bullet-list shape since re-review bodies are deltas, not introductions. The user-facing review summary and the agent-internal snapshot are deliberately separate artifacts with separate prompts and zero shared content. * address review comments: prompt self-consistency + stale-doc cleanup PR 568 self-review (4232488109) flagged a self-contradiction the firewall commit introduced and three stale doc references that survived. - action/modes.ts: Review-mode step 2's trivial-PR shortcut said `submit "Reviewed — no issues found." per step 5`, but step 5's rewrite removed exactly that preamble. Aligned both: trivial PRs and no-actionable-issues PRs now produce a body that opens with "No new issues found." followed by the PR summary, so the user gets the headline up front and still sees what was reviewed. - docs/pr-reviews.mdx: dropped the "customize the summary style with Summary instructions in the console" sentence (the editor was removed in the firewall commit). Replaced with a note that the snapshot uses Pullfrog's built-in format and is not user-customizable. - wiki/prompt.md, wiki/modes.md: rewrote the snapshot-prompt entries to reflect the firewall — DEFAULT_PR_SUMMARY_INSTRUCTIONS is the entire prompt, prSummaryCommentInstructions is no longer wired in. * drop orphaned prSummaryCommentInstructions column Prod audit (455 repos): 5 non-null rows on a single account, all containing the literal placeholder text from the InstructionsEditor we removed in the firewall commit. No account has an intentional preference set, so silent-ignore (the keep-for-retreat option) costs us nothing meaningful while leaving an orphan column in the schema. Drop it. - prisma/schema.prisma: remove the column - prisma/migrations/20260506000000_drop_pr_summary_comment_instructions: ALTER TABLE ... DROP COLUMN - utils/schemas/triggers.ts: drop the matching zod entry * drop body splicing; snapshot is internal-only User-visible PR summarization continues to ship in Review and IncrementalReview review bodies (which already render PR_SUMMARY_FORMAT and "Reviewed changes" respectively). The snapshot tmpfile is now purely durable cross-run agent context — seed, edit-in-place, save to DB, feed the next run. Massive simplification: the body splice mechanics, the two-toggle gating matrix, the summaryHandlingCovered race tracking, and the synchronize summary-only Task fallback all go away. Code: - prSummary.ts: drop splice/strip/marker code (`splicePrSummary`, `stripExistingSummaryBlock`, `buildSummaryBlock`, `extractPrSummary`, PULLFROG_SUMMARY_START/END). keep scaffold, instructions, seed/read. - main.ts: rename persistAndPostSummary -> persistSummary; collapse to a single DB PATCH. drop pulls.get/pulls.update, drop AbortSignal timeout, drop in-flight promise machinery, drop prSummaryToBody plumbing. - ToolState: add summarySeed (replaces local var in main.ts so persist can compare). drop prSummaryToBody and summaryPersistInFlight. - persistSummary now compares against the seed and skips the DB write with a warning when unchanged — saving the seed verbatim is either a no-op or persists the placeholder scaffold, neither useful. - postRun.ts: when summary-stale is the only failing gate and the resume turn itself fails, restore the pre-resume successful result and break. symmetric with the existing reflection-failure preservation. summary-stale can no longer flip a successful run to failed. Webhook: - pull_request_opened: generateSummary follows prReReview only (the snapshot has no consumer when re-review is off). - pull_request_synchronize: collapses to "if prReReview enabled, dispatch IncrementalReview". the summaryHandlingCovered flag, the same-SHA/in-flight coordination it was protecting, and the summary-only Task fallback all delete cleanly. UI / config: - drop SummarizePRsTrigger (the toggle gated body splice; with that gone it has no behavior). drop sidebar entry, console import, Text icon import. - drop prSummaryComment from triggers zod schema, prisma schema, preview settings script. Migration: squash the two existing migrations into one timestamped 20260507000000_pr_summary_snapshot covering all three column changes (add summarySnapshot on workflow_runs, drop prSummaryCommentInstructions and prSummaryComment on repos). repo convention is one migration per PR. Action: bump 0.0.203 -> 0.0.205 (payload contract changed: prSummaryToBody removed; main is at 0.0.204). Out-of-diff cleanup: - review.ts:190 + review.test.ts:651 — "Reviewed — no issues found." -> "No new issues found." to match the canonical body in modes.ts. Verified: pnpm typecheck clean, pnpm lint clean, postRun + review tests pass, dev DB reset against production and the squashed migration applied cleanly (summarySnapshot present, prSummaryComment / prSummaryCommentInstructions both gone). * re-orient snapshot toward functional summary; drop prior-review-feedback section Empirical audit on preview-568 PR #5 showed the snapshot IS load-bearing for the orchestrator: lens-dispatch prompts on incremental runs carried forward context from the snapshot's risk register (e.g. "the JSDoc explicitly scopes to code points — do not flag grapheme-cluster issues" on the surrogate-pair fix run, "consistency with native padStart" on the padStart-added run). The orchestrator was reading the snapshot, reasoning about it, and using it to anti-prime / focus subagents — exactly the high-leverage path. My earlier "snapshot is write-only" claim was wrong. The shape, however, was steering it toward review-history-log instead of functional summary. This commit re-orients: - prSummary.ts: replace the four-section scaffold (~580 chars of placeholder italics under "What this PR does / Key changes / Risk / Reviewed in prior runs") with a minimal seed (~150 chars: just a header + a one-line comment about what the file is for). different PRs warrant different organization; forcing a refactor and a feature into the same template is procrustean. minimal seed also makes the unchanged-from-seed gate in persistSummary more sensitive. - selectMode.ts addendum: rewrite around three principles. (1) the snapshot is a FUNCTIONAL summary of what the PR does and the risks it carries, not a chronological review log — commit history can already be reconstructed from list_pull_request_reviews. (2) the orchestrator should USE the snapshot during triage and dispatch — concrete example given of carrying snapshot context into subagent lens prompts. (3) structure is the agent's call; stable headings make snapshots range-diff cleanly when they fit, but riff when they don't. - modes.ts IncrementalReview: drop the "Prior review feedback" checklist from the user-facing review body (step 6b gone, step 7 ELSE IFs cleaned up). It duplicated content that's already covered by the Reviewed-changes bullets and tracked durably in the snapshot for the next agent run; in the user-facing body it was noise. step 3 still fetches prior reviews but its role is now just filtering aggregation in step 5, not rendering. - AGENTS.md: codify "no follow-ups" rule. when an issue is identified during code review, fix it in this PR — PR scope does not constrain quality. follow-up TODOs are forbidden as a substitute for doing the work now. Empirical evidence supporting the re-orientation: - Run 25568912293 (PR#5 incr1, surrogate-pair fix): orchestrator's correctness lens dispatch said "Do NOT flag grapheme-cluster issues — the JSDoc scopes to code points." The grapheme-cluster framing was not in the diff; it was downstream of the snapshot's prior risk-section framing of truncate's contract. Snapshot influencing dispatch. - Run 25569054779 (PR#5 incr2, padStart added): orchestrator's correctness lens dispatch enumerated edge cases including "consistency with native String.prototype.padStart contract" and "fill = multi-code-point string (e.g. emoji)". Both threads carried over from the snapshot's prior truncate code-point-vs-code-unit discussion. Snapshot informing the shape of what was looked for. The cost of maintaining the snapshot (~800 tokens, ~$0.005/run) is trivially affordable when it materially improves orchestrator triage on the 1-5 lenses dispatched per review. |
||
|
|
f87e0f878c |
action: minimize pullfrog.yml permissions and drop actions:read (#594)
* action: minimize pullfrog.yml permissions and drop actions:read
The recommended pullfrog.yml workflow asked for a permissions block that's
broader than what the action actually uses with the workflow GITHUB_TOKEN —
all real work (git push, PR comments, reviews) goes through installation
tokens that the action mints via OIDC. Customer security scanners flagged
the workflow-level block as too permissive.
- Move permissions to the job level and reduce to id-token: write,
pull-requests: write, issues: write. contents:read is the implicit default
and covers actions/checkout; contents:write, checks:read are unused by
any GITHUB_TOKEN consumer; actions:read was only used by post-cleanup's
listJobsForWorkflowRun call.
- Replace listJobsForWorkflowRun with a SIGTERM/SIGINT handler in main.ts
that calls core.saveState("cancelled", "true"); post-cleanup reads it
back via core.getState. Same cancel-vs-failure UX, no extra scope needed.
- Sync the docs (headless-action, getting-started, action/README) and the
two dogfood pullfrog.yml workflows to the new minimal block. Update the
post-cleanup wiki to describe the saveState approach.
* action: drop pull-requests/issues from required workflow scopes
Switch postCleanup.ts to mint its own short-lived installation token via OIDC
(acquireNewToken with issues:write + pull_requests:write) instead of using the
workflow GITHUB_TOKEN. Same comment-update behavior, but the workflow no longer
needs those scopes — the only permissions Pullfrog ever asks for are id-token:write
(OIDC exchange) and contents:read (actions/checkout).
Also fixes a bug from the previous commit: setting an explicit permissions block
drops every unlisted scope to none (with metadata as the only exception), so
omitting contents would have broken actions/checkout. Restored at both workflow
and job level.
* action: scope id-token:write to pullfrog job, not workflow level
id-token:write is the powerful one — it lets a job mint OIDC tokens that can
be exchanged for cloud credentials or our installation tokens. Keeping it at
workflow level means any future job added to this file silently inherits it.
Move it to the job level where it's actually used; leave only contents:read
at workflow level as a safe baseline for any future jobs.
* action: move stuck-comment cleanup server-side, drop write perms entirely
The action's post-cleanup step lived inside the runner and used the workflow
GITHUB_TOKEN to update the "Leaping into action…" progress comment when a run
failed/cancelled, requiring pull-requests:write + issues:write at the workflow
level. Move that responsibility to the workflow_run.completed webhook handler:
it already has installation-token access via the GitHub App, runs server-side
(no Pullfrog API dependency loop on failure), and lets us drop both write perms.
Recommended workflow permissions block is now truly minimal:
permissions:
contents: read
jobs:
pullfrog:
permissions:
id-token: write
contents: read
Server side
- handleWorkflowRunCompleted: when conclusion != "success" and the WorkflowRun
has progressCommentId, mint installation octokit and update the stuck comment
in place. Try issues.getComment first, fall back to pulls.getReviewComment on
404 (we don't store comment type — one wasted GET on the rarer review case).
- Reuses buildPullfrogFooter and updateProgressComment from pullfrog/internal,
matching the wording the action used to write client-side.
Client side
- Delete action/utils/postCleanup.ts and action/post.ts.
- Remove post: + post-if: from action/action.yml.
- Drop runPostCleanup wiring from action/commands/gha.ts and action/play.ts.
- Remove the SIGTERM/saveState handler I added in main.ts in the previous commit
(no longer needed; cancel/fail signal comes from the webhook hook payload).
Plumbing
- Extract isLeapingIntoActionCommentBody into action/utils/leapingComment.ts so
the predicate can be re-exported via pullfrog/internal without dragging the
MCP server's transitive type graph into the Next.js app's typecheck.
- mcp/comment.ts re-exports from the new location for backward compat.
Wiki
- Delete wiki/post-cleanup.md (obsolete; cleanup is now a one-liner branch in
the workflow_run webhook handler).
* chore: ignore .worktrees in biome config
Recently-added pnpm worktree feature creates nested git worktrees under
.worktrees/, each with their own biome.jsonc declaring root. Biome's
recursive scan trips on the nested config and fails pnpm lint. Excluding
the directory matches the existing .gitignore entry.
* fix: address PR #594 review findings
Two real bugs caught by code review:
1. handleWorkflowRunWebhook.ts:323 — drop the /m flag on the stuck-comment
detection regex. With /m, ^ matches any line start, so any finalized
progress comment that embeds a task list (report_progress writes
`- [x]`/`- [ ]` lines via todoTracking.ts) would be flagged as "stuck"
and silently overwritten with the "This run croaked" boilerplate
whenever the workflow concluded non-success after the agent's final
summary already landed. Restores the body-start anchoring the original
in-process postCleanup.ts:90 had.
2. action/scripts/check-entrypoint-imports.ts — drop ../post.ts from the
esbuild entry-point list (the file was deleted in aa43b9af). The
`pnpm check:entrypoints` step in test.yml would have failed on every
run with an unresolvable-entry-point error.
Plus three small follow-ups:
- main.ts:580 — comment said "post-cleanup has its own verify-retry loop"
but post-cleanup is gone. Updated to describe the new server-side path.
- mcp/comment.ts:443 — comment said "so post script doesn't think the run
failed". Updated to describe the actual current consumers of wasUpdated.
- commands/gha.ts:84 — `--post` help text said "run post-cleanup flow" but
with the post-cleanup path removed, --post is only valid alongside the
`token` subcommand for installation-token revocation. Updated wording.
* fix(action): scope --post help text to gha token subcommand
Root gha help text was documenting --post, but --post only makes sense
paired with the token subcommand (it's how the post step revokes the
installation token previously acquired in the main step). Move it to a
dedicated gha token help section and add a parser layer that rejects
--post on the bare gha command.
$ pullfrog gha --help
usage: pullfrog gha [subcommand]
...
options:
-h, --help show help
$ pullfrog gha token --help
usage: pullfrog gha token [--post]
...
options:
-h, --help show help
--post revoke the previously-acquired token (post-step usage only)
* webhook: artifact-aware cleanup of stranded leaping comments on success
Previously the workflow_run.completed cleanup only handled non-success
conclusions. Extend it to also catch the rare case where a successful
run leaves a "Leaping into action…" comment stuck (in-process cleanup at
action/main.ts:723 normally handles this, but can be skipped on SIGKILL,
runner host crash, or any exit path that bypasses main()'s finally block).
New behavior in cleanupStuckProgressComment:
- cancelled → update with "cancelled 🛑" body (unchanged)
- failure (other) → update with "croaked 😵" body (unchanged)
- success + artifact recorded → delete the comment (the artifact is the
user-facing surface; the leaping comment
is just stale UI noise at this point)
- success + no artifact recorded → delete the comment AND alert
team@pullfrog.com via emailAlert
The "success + no artifact" path is "should never happen" territory: the
run claims success but produced no review, PR, issue, plan, or summary
comment. The team alert helps us catch in-process cleanup regressions or
artifact-tracking gaps. hasRecordedArtifact reads {review,pr,issue,
planComment,summaryComment}NodeId off the WorkflowRun row to make the call.
* webhook: narrow stuck-comment detection to leaping prefix only
Drop the stranded-todo-pattern branch from cleanupStuckProgressComment.
The leaping prefix is highly specific and impossible to confuse with a
legitimate summary; a leading todo line is not — the agent's
error-reporting paths can produce useful explanatory comments whose
body leads with a checklist (e.g. "here's what I was working on" + the
incomplete todo list), and we don't want to silently overwrite those
with the generic "croaked" boilerplate.
In-process cleanup at action/main.ts:723 still handles the stranded-todo
case in the common path (gated on !finalSummaryWritten with full access
to the in-memory tool state). Missing the rare runner-died-mid-todo case
server-side is a worthwhile trade vs. the false-positive risk on real
explanatory comments.
|
||
|
|
e2e29a19fc |
accept pullfrog.yaml as well as pullfrog.yml (#596)
* accept pullfrog.yaml as well as pullfrog.yml
centralize the accepted workflow filenames in `utils/github/pullfrogWorkflow.ts`
(`PULLFROG_WORKFLOW_FILES = ["pullfrog.yml", "pullfrog.yaml"]`) and use the new
`findExistingWorkflowFile` helper at every read path: `getWorkflow` (cached),
the verify-workflow API route, and the audit/sync/download/update scripts. `.yml`
is always tried first so the common case still costs exactly one API call.
webhook handlers (push cache-bust, `workflow_run_*`) now use the shared
`isPullfrogWorkflowPath` matcher.
action runtime (`reviewCleanup.ts`) derives the running workflow's filename from
`process.env.GITHUB_WORKFLOW_REF` instead of hardcoding `.yml`, so the safety-net
follow-up dispatch targets whichever file the user actually has — strictly more
correct than today.
write paths (`createWorkflowForRepo`, `createWorkflowPR`) intentionally still
create `.yml`; existing 422 collision handling covers the rare double-install
case. UI/wiki/onboarding copy keeps saying `pullfrog.yml`; one callout in
`docs/getting-started.mdx` mentions `.yaml` works too.
also drops dead code (`utils/github/findWorkflow.ts`, parallel single-file
implementation with no importers) and the now-unused `WORKFLOW_FILENAME` export.
* rename pullfrogWorkflow.ts -> findPullfrogWorkflow.ts (verb form)
* add pre-flight check to workflow create paths
`createWorkflowForRepo` and `createWorkflowPR` now check for any existing
pullfrog workflow file (`.yml` or `.yaml`) before doing work, preventing the
degenerate state where a repo with `pullfrog.yaml` ends up with both files
dispatching on every event.
costs one `getContent` call per first-time install. existing 422 branch in
`createWorkflowForRepo` is retained as a race-condition safety net; the 409
branch now also handles the case where `createWorkflowPR` discovers an
existing file in flight.
`createWorkflowPR` return shape becomes a discriminated union; the standalone
`/api/create-workflow-pr` route returns `{ alreadyInstalled: true }` instead
of creating a redundant PR.
* promote repo to active when /api/create-workflow-pr finds existing workflow
extracts `promoteRepoToActive` from `createWorkflowForRepo`'s closure to a
shared module-level function, and wires it into the standalone PR route's
`alreadyInstalled` branch so a `needs_setup` repo with an existing `.yaml`
file doesn't go stale (was only handled by the dashboard's own create path).
addresses pullfrog review on #596.
|
||
|
|
6f76a6a9da |
fix(action): tighten provider error detection and propagate agent error events (#580)
* fix(action): tighten provider error detection and propagate agent error events Both bugs from #562: 1. detectProviderError used substring matches against "429", "rate limit", etc. — false-positives on commit SHAs containing 429 and on x-ratelimit-* response headers in dumped 401 error JSON. rewrote with anchored regexes: numeric status codes only match adjacent to a recognised status key, and `\brate[_ ]limit(?=[_ ]|\b)` rejects ratelimit-* headers (no separator). word-boundary anchors on INTERNAL / UNAVAILABLE / quota / limit:0 reject INTERNAL_SERVER_ERROR / time_limit:0 substrings. added 11-case regression test. 2. opencode 401s slipped through `eventCount === 0 && lastProviderError` because opencode's own type=error event increments eventCount before the guard runs. added an explicit `error:` handler that captures the event and propagates it to a non-success AgentResult. opencode emits the message under `error.data.message`, not the top level. mirror fix in claude.ts: error_max_turns / error_during_execution / any error* subtype on the result event now flips success: false. * fix(action): match quota inside identifiers like insufficient_quota \bquota\b missed insufficient_quota / quota_exceeded / quotaExceeded because _ is a word character and camelCase has no boundary. quota is specific enough to be matched as a plain substring. * fix(action): match `rate limited` and `rate limits exceeded` Drop the trailing `(?=[_ ]|\b)` lookahead from the rate-limit regex. The lookahead failed when `limit` was followed by another word character (`limited`, `limits`), so `rate limited` and `rate limits exceeded` were slipping past detection. The leading `\b` plus `[_ ]` separator already rejects `x-ratelimit-*` / `anthropic-ratelimit-*` headers without it. --------- Co-authored-by: pullfrog[bot] <226033991+pullfrog[bot]@users.noreply.github.com> Co-authored-by: David Blass <david@arktype.io> |
||
|
|
4c1413d925 |
fix(action): flip wasUpdated on substantive MCP write tools (#563) (#577)
* fix(action): flip wasUpdated on substantive MCP write tools (#563) Review/Respond/etc. agents that submit a `create_pull_request_review`, `create_issue_comment`, or `update_pull_request_body` and exit without calling `report_progress` were being marked as workflow failures by the strict completion check in handleAgentResult. Extend the set of tools that flip toolState.wasUpdated so a substantive user-visible artifact satisfies the check. The isReviewMode bypass is retained for IncrementalReview's non-substantive path. Flag is set BEFORE patchWorkflowRunFields / deleteProgressComment in each tool so a best-effort cleanup failure does not undo the signal. * fix(action): use finalSummaryWritten for stranded progress cleanup The stranded-progress-comment cleanup at the end of main() previously fired only when toolState.wasUpdated was false (or the tracker was the last writer). With wasUpdated now set by additional MCP write tools (create_issue_comment, update_pull_request_body), an agent that produced a substantive artifact via one of those tools and skipped report_progress would leave the placeholder "Leaping into action" comment intact — the post-script then converted it into an error message on a successful run. Key the cleanup off finalSummaryWritten instead. That flag is only set when report_progress actually wrote the progress comment, so it cleanly distinguishes "comment is finalized" from "agent did other work but never touched the progress comment". * refactor(mcp): extract markSubstantiveArtifact() helper replaces 4 inline `ctx.toolState.wasUpdated = true` flips in CreateCommentTool, UpdatePullRequestBodyTool, and CreatePullRequestReviewTool with a single helper in mcp/server.ts. JSDoc on the helper documents the contract (call BEFORE downstream patch/cleanup; gates the strict completion check and stranded-comment cleanup) so future MCP write tool authors only need to grep for one symbol. no behavioral change. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(mcp): only flip finalSummaryWritten after non-skipped write Previously the flag was set unconditionally on any non-plan call, including paths where reportProgress skipped (silent events, deleted comment, no issue/PR target). The cleanup check in main.ts is safeguarded by toolState.progressComment so the bug doesn't manifest today, but aligning the flag with actual writes matches the wasUpdated pattern and the design intent in the cleanup plan. * refactor(mcp): inline markSubstantiveArtifact helper --------- Co-authored-by: pullfrog[bot] <226033991+pullfrog[bot]@users.noreply.github.com> Co-authored-by: David Blass <david@arktype.io> Co-authored-by: Cursor <cursoragent@cursor.com> |
||
|
|
6db4a6d02e |
fix(mcp): preserve coveragePreflightRan across checkout_pr refreshes (#576)
checkout_pr unconditionally rebuilds ctx.toolState.diffCoverage via createDiffCoverageState, which initialised coveragePreflightRan to false. a second checkout_pr therefore reset the "one-time nudge per review session" guarantee in runDiffCoveragePreflight, and the next create_pull_request_review threw the diff-coverage pre-flight error again — even after the agent had already gone through the read-and-resubmit dance once. createDiffCoverageState now accepts an optional previous state and carries forward coveragePreflightRan. coveredRanges are intentionally not carried because their line numbers are tied to the previous diff's content (especially under incremental diffs). closes #566 Co-authored-by: pullfrog[bot] <226033991+pullfrog[bot]@users.noreply.github.com> Co-authored-by: David Blass <david@arktype.io> |
||
|
|
560e27bda5 |
refactor progress comments into a bundled type + helper module (#567)
* refactor progress comments into a single bundled type + helper module
introduce ProgressComment ({ id, type: "issue" | "review" }) as the canonical handle for
the GitHub comment a run uses to report progress, and route every read/update/delete/create
through a single helper module (action/utils/progressComment.ts). previously every site that
touched the progress comment hardcoded octokit.rest.issues.*Comment, which made adding a
second comment type (review-thread replies) require duplicating the same branch in 6+ places
— the same shape that bit pullfrog/app#445.
new capability: when the address-reviews trigger fires for a one-off review comment, the
"Leaping into action" comment is now posted as a reply in that review thread instead of as
a top-level PR timeline comment. the helper handles failure (e.g. parent comment deleted)
by silently falling back to a top-level issue comment, so the run never loses its progress
surface.
changes:
- action/utils/progressComment.ts (new) — ProgressComment type + getProgressComment,
updateProgressComment, deleteProgressCommentApi, createLeapingProgressComment. uses a
structural Octokit interface to bridge the @octokit/rest version mismatch between the
action package (v22) and the root project (v21).
- action/internal/index.ts — re-export the new types and helpers for cross-boundary use.
- action/external.ts, action/utils/payload.ts — replace progressCommentId: string with
progressComment: { id: string, type: "issue" | "review" } in WriteablePayload + JsonPayload.
wire-format breaking, no legacy fallback (in-flight runs across the deploy lose their
progress comment, fine).
- action/mcp/server.ts — ToolState.progressCommentId becomes
progressComment: ProgressComment | null | undefined (same tristate semantics).
- action/main.ts, action/mcp/comment.ts, action/utils/errorReport.ts,
action/utils/postCleanup.ts — every issues.*Comment call against the progress comment
routes through the helper module. zero hardcoded API branching outside the helper.
- utils/github/triggerWorkflow.ts — drop createLeapingComment + updateCommentToLeaping;
dispatchAndTrackWorkflow gains a resolution chain (existingComment → replyToReviewComment
→ triggeringIssue → none) and an existingComment: ProgressComment param plus
replyToReviewComment: { pullNumber, commentId }.
- utils/webhooks/handleWebhook.ts — dispatch closure threads replyToReviewComment through;
the one-off review comment branch passes it and skips the now-redundant eyes reaction
on the comment we're about to reply to.
- app/trigger/[owner]/[repo]/[number]/page.tsx, utils/github/runActionLocal.ts,
app/api/cli/dispatch/route.ts, app/api/dispatch-workflow/route.ts — call sites updated to
new shape.
no schema or DB column changes. the existing WorkflowRun.progressCommentId column is still
written by id only; type lives only on the in-flight payload, which is sufficient for
runtime since it's the only thing that needs to know which API to call.
* anneal pass 1: fallback visibility + stale doc/comment updates
- progressComment.ts: when reviewReply→issue fallback fires, prepend a [!NOTE] callout
with a permalink back to the original review comment. without this, the parent comment
showed no eyes reaction (deliberately skipped) and no reply, leaving the user with no
signal that anything happened.
- wiki/post-cleanup.md: update progressCommentId references to progressComment, document
the new helper-based dispatch by type.
- wiki/main.md: update initToolState({ progressCommentId }) → ({ progressComment })
in the resolver-chain diagram.
- action/main.ts, action/mcp/review.ts: update two stale comments that referenced the
old field name.
* anneal pass 2: post-cleanup detection through fallback notice + log cleanup
- isLeapingIntoActionCommentBody: strip a leading GFM blockquote/alert before
testing the leaping prefix. without this, the [!NOTE] callout that the
reviewReply→issue fallback prepends would prevent post-cleanup from
recognizing the stuck "Leaping into action..." comment, leaving it permanently
on the PR timeline if the workflow died before any progress update.
- progressComment helper: switch from log.warning (action-flavored, emits a
::warning:: GitHub Actions annotation) to console.warn so the helper doesn't
pollute Vercel logs when invoked from the webhook context.
- triggerWorkflow.ts: drop the duplicate caller-side log on review-reply
failure — the helper already speaks loudly. Reword the catch-branch log to
reflect that it now only fires when both the reply AND the helper's internal
fallback failed.
- progressComment.ts: document that the [!NOTE] fallback notice is overwritten
on the first report_progress call, and explain the trade-off vs persisting
it through the action payload + ToolState.
* debloat: drop the [!NOTE] fallback callout
Reverting two pieces from the prior anneal pass:
- progressComment.ts: drop the [!NOTE] callout that the reviewReply→issue fallback
prepended to the leaping body. It disappeared on the agent's first report_progress
call, which made it half-committed to visibility — worse than either properly
persisting it (real engineering) or leaving the fallback silent (current choice).
The console.warn diagnostic and the workflow-run footer link in the leaping
comment itself give us enough signal for the rare case where both API endpoints
fail at once.
- isLeapingIntoActionCommentBody: revert the leading-blockquote stripping; only
needed to compensate for the [!NOTE] callout.
Keeping: the console.warn-vs-log.warning fix (real cross-runtime concern), the
duplicate-log drop in triggerWorkflow.ts, the wiki updates, and the two stale
source-comment fixes.
* fix: prevent stranded task list overwriting post-cleanup message
When a run is cancelled, the action's todoTracker may have an HTTP write in
flight to GitHub when SIGTERM lands. The action process dies, but the request
data has already left the socket — GitHub processes it and updates the comment
body to the (stale) task list. Meanwhile post-cleanup, running in a separate
process, writes the "This run was cancelled 🛑" message. If the tracker's
in-flight write happens to land *after* post-cleanup's write, the user never
sees the cancellation message.
Two-layer fix:
- Action side: cancel the tracker in the SIGTERM signal handler so no new
debounced writes get scheduled. This shrinks the race window but can't
un-send a request already on the wire.
- Post-cleanup side: after writing, verify the body landed and re-issue if
another write clobbered ours. Loops up to 3× with a 3s settle delay so
delayed in-flight writes from the dying action have time to arrive before
our read-back check decides whether to retry.
* lint: import createLeapingProgressComment from pullfrog/internal in test script
* address bot review findings: reply-target root, version bump, GET error handling
Three real findings from the bot reviews on #567 plus a small DRY pass:
1. handleWebhook reply-target: `newComments[0]` may be a reply, not a
top-level review comment. `getReviewCommentsWithReplies` returns root +
replies for any thread the review touched, and `pull_request_review_id`
filtering only narrows by *which review submitted*, not *root vs reply*.
When a user submits a single reply as their entire review (e.g. replying
to someone else's comment to ping @pullfrog), the reply ID flowed through
to `createReplyForReviewComment`, which 422s on replies-to-replies and
degraded to a top-level issue comment — exactly the polluted-PR-timeline
behavior this PR was built to remove. Walk up `in_reply_to` from the
already-fetched thread data to find the root and reply there instead.
2. action/package.json: bumped 0.0.202 → 0.0.204. main is at 0.0.203 and
our wire format changed; without a bump validateCompatibility can't
surface the mismatch on the deploy boundary, and the merge would have
gone backwards.
3. postCleanup writeAndVerify: distinguish a thrown verify-GET from a
"body got overwritten" mismatch. Treating a transient 5xx/rate-limit GET
the same as a clobber wasted PUT attempts and printed a misleading
"in-flight writes kept clobbering us" warning. We trust our PUT (which
returned 200) and exit instead of amplifying writes against a flaky API.
4. Small DRY: extracted parseProgressComment for the
`{ id: string; type } -> ProgressComment` parse that had drifted across
server.ts and postCleanup.ts.
|
||
|
|
b6e2c61d30 |
fix(push_branch): retry transient push errors and surface full stderr/stdout (#573)
* fix(push_branch): retry transient push errors and surface full stderr/stdout issue #571 motivated three small improvements to `mcp__pullfrog__push_branch`: 1. classify push errors into `concurrent-push` / `transient` / `unknown`. - `concurrent-push` extends the existing `fetch first` / `non-fast-forward` matcher to also catch the server-side `cannot lock ref` form (the case #571 reports). all three route to the same fetch + integrate + retry recovery message; copy now mentions concurrent push as a likely cause. - `transient` covers RPC failed, early EOF, connection reset, dns flake, HTTP 5xx, HTTP/2 stream not closed, and unexpected sideband disconnect. these are retried in-tool with 2s + 5s backoff before surfacing the error. push is idempotent so verbatim retry is safe. - `unknown` (auth/permission/protected-branch/4xx) is rethrown unchanged — retrying these wastes time and noise. 2. surface stdout alongside stderr in `$git` failure messages and include the exit code. previously only `stderr.trim()` was forwarded, which could be empty in rare HTTPS failure modes (the agent on issue #571's run saw a one-line `failed to push some refs` and had nothing to diagnose with). 3. unit tests for the classifier covering all three branches plus the concurrent-push-wins-over-transient ordering. does not introduce auto fetch+rebase+retry inside the tool — that path is blocked under shell=disabled, can leave the working tree mid-conflict, and would create unwanted merge commits. the recovery message keeps the agent in the loop. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(push_branch): retry 429, jitter backoff, downgrade retry log to info - treat HTTP 429 (rate-limit / abuse detection) as transient — GitHub occasionally surfaces it on git push, where it is retry-safe unlike 401/403/404 - add ±25% jitter to backoff so concurrent agents hit by the same upstream blip don't retry in lockstep - log retries with log.info instead of log.warning to match retry.ts convention; a successful retry shouldn't leave a yellow GHA annotation behind in the job summary --------- Co-authored-by: Cursor <cursoragent@cursor.com> Co-authored-by: pullfrog[bot] <226033991+pullfrog[bot]@users.noreply.github.com> |
||
|
|
e58299740d |
Merge pull request #545 from pullfrog/billing
managed billing + stripe v1 |
||
|
|
b835d53d83 |
add /anneal + pullfrog-reviewer named subagent + Build self-review polish (#550)
* cherry-pick updated /anneal command from billing branch + add as Claude Code slash command mirrors origin/billing:.cursor/commands/anneal.md (commit 4f389a8f) into both .cursor/commands/ and .claude/commands/ so the parallel-lens annealing prompt is available in both editors. content is identical between the two files. * anneal: drop REVIEW.md pointer, surface-agnostic dispatch wording, fix modes.ts self-review contradictions Anneal pass over the /anneal slash command and the Build-mode self-review step: - Drop REVIEW.md references in both anneal.md copies. The file does not exist on the Claude Code surface (only .cursor/commands/), and its contents (correctness/security/impact framing) directly contradict the prescribed single-lens, no-pre-shaping discipline. - Replace "Task tool calls" with surface-agnostic "parallel subagent calls" so the meta-prompt does not couple to either CLI's tool naming. - Hedge the "verify via web search" instruction to acknowledge subagents may not have web search available. - modes.ts: drop "and the changed files" — the same step's don't-list forbids handing subagents a curated reading list (in-file contradiction). - modes.ts: restore the "skim only, don't pre-review" warning that the long-form treats as load-bearing. - modes.ts: drop "NO MCP tools" — overbroad; the actual safety property is captured by "no writes, no shell commands, no side effects". Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * anneal: two-round self-anneal of /anneal + modes.ts self-review Expand the multi-lens parallel-review protocol with fixes surfaced by running /anneal on this branch twice. Material additions: /anneal canonical (.claude/commands/anneal.md + .cursor mirror): - promote orientation-vs-defect-hunting distinction to a load-bearing framing in the opening paragraphs - add an empty-target early exit ("nothing to anneal" stop) at §1 - spell out the read-only constraint with the no-op-if-reverted test, and forbid recursive subagent dispatch (incl. agentic MCP tools) - add cleanup-and-debt sub-categories (env vars, feature flags, dangling symbols), supply-chain, test-integrity lenses to the catalog - §1 lens-count rule: explicit trivial/typical/high-risk tiers; "treat as typical" tiebreaker for the unsure case - §2 example uses bare `git diff <primary-branch>` to capture uncommitted edits (three-dot syntax is committed-only) - §5 targeted-follow-up cross-references the fresh-eyes carve-out in Delegation discipline - final-message format spells out coverage shape, findings-table shape, dry-run fix-plan branch, and plan/doc summary branch - stopping criteria distinguish "trivial" from "small / low-risk" action/modes.ts Build mode step 4 (self-review one-pass anneal): - empty-diff early exit; "step 4 mandatory whenever there is a diff" resolves the prior contradiction with the always-runs assertion - lens count by risk (2-3 typical / 4 high-risk single-round-cap / exactly 1 trivial) with separate Tiebreaker - expand swap-in lens menu (research-validated assumptions, security, user-journey, ops, integration, test integrity, supply chain, performance, holistic) so the catalog is a starting menu, not a closed set - rename `cleanup & scope` to `diff hygiene` to avoid colliding with the canonical's broader `cleanup & debt` - delegation discipline bulletized (don't lens-review yourself, don't summarize, don't curate, don't pre-shape, don't mention other lenses); independence rationale stated inline - explicit research-discipline reminder for any lens that touches external contracts (web search, quote URLs) - comment block enumerates deliberate omissions vs the canonical (dry-run, severity categorization, read-only shell) and the deliberate scope decision (sibling diff-producing modes stay solo) action/modes.ts Review + IncrementalReview subagent-dispatch wording: - propagate the no-recursive-dispatch rule (was missing) Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * add set_plan/get_plan + restructure Review/IncrementalReview as parallel-subagent orchestrators Build mode's self-review and Review/IncrementalReview now follow the multi-lens parallel-subagent fan-out pattern from the canonical /anneal protocol. New set_plan/get_plan MCP tools (orchestrator-only) persist the implementation plan in tool state so the self-review's plan-adherence lens can verify the diff against the original intent rather than reconstructing it post-hoc. Subagent "read-only / no further dispatch" is currently enforced via prompt prose only — neither claude-code's --disallowedTools nor opencode's per-agent tools allowlist is configured to scope subagent MCP access. Documented as a deferred ~30-50 LOC follow-up in the modes.ts header comment. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * revert Review/IncrementalReview mode prompts to main; keep Build self-review changes E2e testing on this branch only exercised the trivial-1-lens path for Review (preview repo had only docs PRs). Multi-lens Review fan-out was never directly validated against a real code PR. Splitting the Review/IncrementalReview restructure to its own branch (review-mode-orchestrator, draft PR #555) pending focused validation. Keep on this branch: - set_plan/get_plan MCP tools - Build mode multi-lens self-review (Test 3 directly validated 2-subagent parallel fan-out on a 2-file diff) - /anneal command updates (.claude/ and .cursor/ mirrors) Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * require plan parameter when selecting Build mode Adds an arktype .narrow on SelectModeParams that rejects select_mode({mode:"Build"}) unless a non-empty 'plan' string is also provided. When valid, the plan is stored into ctx.toolState.plan at mode-selection time, so step 4's plan-adherence lens always has a comparison target. This closes the e2e finding that agents never reached for set_plan on their own (5 of 6 runs in production). Build mode prompt updated to reflect that plan is already populated at mode selection; set_plan remains as the mid-task replan tool. Other modes are unaffected. Validation surfaces the error to the agent with a descriptive message including the path ('plan') and recovery instructions, so a failing call is recoverable on the next turn rather than a hard fail. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * move Build-mode plan-required check from arktype .narrow to execute() arktype .narrow predicates aren't JSON-Schema serializable — FastMCP's toJsonSchema() emitted a {code: "predicate", predicate: Function} object instead of a serialized schema. Effect: agents couldn't see select_mode in their tool list (verified by 5 consecutive runs across two models silently bypassing select_mode entirely after the prior commit). Fix: keep the param schema clean (.narrow removed) and check selectedMode.name === "Build" && !params.plan in the execute() body, returning a structured error response. The agent now sees select_mode normally, gets a clear actionable error if it forgets the plan, and can recover on the next turn by retrying with the plan included. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * flip lens architecture: Build = single fresh-eyes subagent, Review/IncrementalReview = multi-lens Build mode self-review previously fanned out 1-4 lenses on the agent's own diff. The bias-mitigation argument for fan-out is weaker for self-review than for reviewing someone else's PR — the orchestrator just wrote the code, so what matters is one fresh-eyes subagent that doesn't share the implementation context, not breadth across parallel angles. Build now dispatches exactly one subagent that gets the original user request and the diff and evaluates whether the diff fulfills the request. Review and IncrementalReview now use the multi-lens orchestrator pattern (triage → parallel read-only fan-out → aggregate → draft comments → submit). For someone else's PR, parallel lenses (correctness, security, research-validated, user-journey, etc.) provide breadth that a single subagent can't carry coherently. Was previously parked on the review-mode-orchestrator branch (PR #555). Removes set_plan/get_plan MCP tools, ToolState.plan field, and the plan parameter on select_mode. Validated end-to-end that those didn't cause agents to actually use plan tracking (5 of 6 e2e runs skipped them); the original user request from the prompt body is the source of truth and the orchestrator already has it. Drops timeout test plan-param workaround that was added for the prior validation. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * split Review/IncrementalReview multi-lens back out to review-mode-orchestrator branch The multi-lens orchestrator restructure for Review/IncrementalReview was bundled into this branch in commit e964ae0c, but it hasn't been validated against a real code-heavy PR (the e2e exercised it only on docs PRs). Splitting it back out keeps this branch focused on the validated half — Build → single fresh-eyes subagent — and lets the Review changes ship in a focused PR (#555 reopened). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * anneal: fix Build prompt contract bugs found by 3-lens review Major fixes: - checkout_pr returns the field as `base`, not `baseRef` (per checkout.ts:611-616). The prompt was telling agents to read `result.baseRef` which would be undefined. - The base-ref fallback "after fetching" is unreachable via the `git` MCP tool (it blocks `fetch` per AUTH_REQUIRED_REDIRECT). Now names `git_fetch` explicitly. - Boundary-tag wrapping for the user request had no escape rule for input that contains the literal close marker, and no fallback for an empty request. Both are now documented with a nonce-suffix mitigation. - PR reference updated #555 → #557 (the active PR for the multi-lens review-mode-orchestrator branch; #555 was closed after the rebase). Minor fixes: - Retry predicate tightened: "errors out (tool error) or returns an empty body", not "returns nothing usable" (which is unfalsifiable and lets an orchestrator declare any output not-usable to skip review). - Subagent read-only constraints rephrased as prescriptive ("MUST NOT call") rather than descriptive ("you have only"), since on inheriting runtimes the subagent does in fact have access to write tools and the constraint is prompt-only. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * anneal round 2: tighten Build prompt edge cases (workflow_dispatch, base-ref, footer-strip, skip marker) Cross-lens findings from holistic + user-journey + research-validated lenses: - workflow_dispatch + empty diff: report_progress silently no-ops when there's no parent issue/PR. Now also call set_output with a "no-op" summary so the user gets surfacable feedback. - base-ref resolution: clarified `base` from checkout_pr is a bare ref name, added explicit `git remote show origin` path for repos whose primary is not `main` (master, trunk, etc.). - bare `git diff` description: tightened from "shows working tree" to "shows unstaged working-tree changes" — bare diff misses staged changes too, not just committed ones. - prompt-body stripping: explicitly call out the leading `> ` blockquote prefix (added by the *YOUR TASK* section formatting) and the entire Pullfrog footer block, not just one example link. - boundary-tag nonce: always-on now, not conditional on detecting a close marker. Cost is one random short string; failure mode (prompt injection if input contains literal close marker) is silent. - subagent-skip marker: structured `Self-review: SKIPPED (subagent error: ...)` on its own commit-message line, so the gap is greppable. Header comment also documents: - AddressReviews/Fix/Task asymmetry (deliberately deferred) - Subagent-runtime-fence deferred fix must explicitly deny Skill / agentic MCP tools, not just destructive tools (claude-code blocks recursive Task spawn but not alternative dispatch paths). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * anneal round 3: targeted re-review of round-2 changes catches real regressions Round 2's "fixes" introduced two real bugs that round 3's targeted correctness re-review caught: CRITICAL (fixed): tier-3 base-ref resolution used `git remote show origin`, which requires network auth — the MCP `git` tool runs commands through plain spawn() without auth, so this hangs on private repos. Replaced with `git symbolic-ref refs/remotes/origin/HEAD` (local symref, no network), which actions/checkout populates. MAJOR (fixed): the eventInstructions fallback was incoherent — the agent has no separately-addressable eventInstructions field; whatever it received in *YOUR TASK* is its only input. Removed the misleading reference. MAJOR (fixed): per-line `> ` strip was ambiguous, could destructively flatten user-pasted markdown blockquotes. Now: "strip exactly one leading `> ` per line". MAJOR (fixed): tier-1 base-ref preferred bare `<base>` over `origin/<base>`, which fails on the rare alreadyOnBranch path in checkout_pr where the local ref isn't re-created. Now prefers `origin/<base>` (always populated post-fetch). MINOR (fixed): footer-strip anchor was `<sup>`/`<picture>`, both of which appear in legitimate user content (footnotes, etc.). Switched to the PULLFROG_DIVIDER sentinel which is purpose-built for this. MAJOR (acknowledged, partial fix): 4-hex nonce is theatrical security; bumped to 8 hex and explicitly noted it's a typo-guard, not a security boundary, and that the structural fix (separate task() argument) is the real solution. REJECTED (verified false positive): subagent claimed `set_output` is not registered for workflow_dispatch. Verified at action/utils/payload.ts:118 — workflow_dispatch from `gh workflow run` resolves to trigger:"unknown", which IS standalone, which IS registered with set_output. E2e logs from prior tests confirm agents successfully call pullfrog_set_output on workflow_dispatch runs. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * anneal round 4: drop broken symbolic-ref tier, simplify base-ref resolution Round 3's tier-2 (`git symbolic-ref refs/remotes/origin/HEAD`) is empirically broken: actions/checkout doesn't populate origin/HEAD on shallow clones (fetch-depth: 1, used by pullfrog.yml), and Git 2.50+ no longer auto-sets it on full clones either (actions/checkout#2219). New scheme: PR context uses checkout_pr's `base`. Non-PR context tries origin/main first; if that fails, list remote branches with `git branch -r` and pick the obvious default (master/trunk/etc.). Drops the symbolic-ref path entirely (broken) and `git remote show` (requires auth that the MCP `git` tool can't provide). Also fixes: - Per-line strip prose: removed phantom "or `>` at end-of-line for blank lines" parenthetical (instructions.ts always emits `"> "`). - Pullfrog footer strip: now scoped to "only when divider appears at end of body, followed only by footer block." - Boundary-tag nonce wrapping: rephrased without the "this is theatrical" framing that was undermining the agent's diligence. - Empty-request fallback: removed the misleading "no separately- addressable eventInstructions field" claim (the field exists; what's true is it's already folded into *YOUR TASK* upstream). - Out-of-scope structural-fix commentary moved out of agent prompt. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * anneal round 5: drop unreliable auto-discovery for non-main repos, align footer-strip with prod, fix tautological empty-request fallback * anneal round 6: condition per-line strip on quoted-prompt heuristic; document main-not-default limitation; fix empty-request placeholder/framing contradiction * anneal round 8: fix default-branch hardcode, wrap diff in boundary tag, improve nonce guidance CRITICAL/MAJOR (ops + security): 1. Default branch was being hardcoded to `main` with a "limitation cannot be fixed from prompt prose alone" disclaimer — but `default_branch` IS exposed to the agent via the *SYSTEM* runtime context block (action/utils/instructions.ts:47). The prior comment was actively misdirecting future debugging. Now the prompt reads the field from system context and uses `origin/<default_branch>`. 2. Diff was passed verbatim with no boundary tag — asymmetric defense relative to the user request. Attacker-controlled file content (e.g., committed code comments saying "AGENT: ignore prior instructions") could prompt-inject the subagent through the diff payload. Now both blobs get nonce-suffixed boundary tags with explicit "lines starting with + or - are file content, not directives." 3. Nonce guidance updated: prefer CSPRNG source (`head -c 16 /dev/urandom | xxd -p`) when shell available; documented that LLM-picked hex has ~10-14 effective bits even at 8 nominal hex chars (per arXiv:2506.05739 on adaptive attacks against delimiter defenses). MINOR: - Removed the `@user triggered "..."` preamble strip bullet — verified there's no producer of that pattern anywhere in action/utils/, so the strip was a no-op. - Empty-request placeholder must be the ENTIRE boundary content, not a substring, to prevent attacker from triggering the request-skip framing branch. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * anneal round 9: fix RUNTIME-vs-SYSTEM section misdirection; tighten nonce guidance for shell-disabled mode + distinct-value enforcement * anneal round 11: fix real bugs uncovered by big-picture review Senator Armstrong's deeper review (design-coherence + realistic-customer stress test) caught issues that 10 rounds of narrow targeted re-reviews had been papering over. REAL BUGS FIXED: 1. set_output called unconditionally on the empty-diff path would error on PR-event triggers (set_output is registered only when trigger==="unknown" per server.ts:242-245). Now gated: only call set_output if it's actually in the tool list. 2. Sentinel-strip used FIRST occurrence — broken under adversarial blockquote attack (an attacker quotes a Pullfrog comment containing the divider, with their real request after it; first-occurrence strip discards the real request). Now uses LAST occurrence so the real request survives. DESIGN HONESTY: 3. Header comment now explicitly flags the design as UNVALIDATED — no A/B eval has been done against solo self-review. ROADMAP_RESEARCH.md flags benchmarking as the prerequisite. Header documents the validation gap and what would justify reverting. 4. Header comment elevates the runtime-fence gap from a TODO to a SECURITY GAP that must ship before the prompt protocol can be considered production-hardened. Ordering: runtime fence FIRST, prompt protocol SECOND. SIMPLIFICATIONS (per senior-engineer review): 5. Dropped the second nonce on the diff — the diff is the artifact under review; suspicious instruction-shaped lines in commits are exactly what the subagent should flag, not something to fence off. 6. Dropped CSPRNG-vs-LLM-fallback branching prose — just "16+ hex chars, use /dev/urandom if shell available, otherwise pick." 7. Dropped the regenerate-if-collide rule (vanishingly unlikely with 16 hex chars, costs tokens to enforce). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * anneal round 12: revert round-11 regressions (sentinel-strip, set_output gate, diff nonce) Round 12's sharper review caught three regressions round 11 introduced: 1. Sentinel-strip last-occurrence was strictly worse than first-occurrence for the common "user references a prior Pullfrog comment" case. The adversarial-quote scenario it was defending against is contrived (an attacker can put hostile payload anywhere; strip discipline doesn't change attack surface). Reverted to first-occurrence to align with canonical stripExistingFooter() and avoid silently swallowing user reference context. 2. set_output "gate" via "if it's in your tool list" relied on tool introspection that LLMs cannot reliably perform. Replaced with: just call report_progress; document the workflow_dispatch limitation as acceptable (job log is feedback-of-last-resort) rather than asking the agent to conditional-call a tool that may not exist. 3. Diff was de-nonced in round 11 on the assumption runtime fence ships first, but until that runtime fence lands the plain label is forgeable (committed file content can include "--- END DIFF ---" + injection). Restored nonce wrapping. The cost is one extra hex string; the benefit is real until runtime fence ships. Also added explicit caveat on the self-attested skip marker: the proper fix is MCP-layer dispatch-counting, not commit-message annotation. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * ruthless cut: revert Build self-review elaboration to compact form main already had subagent dispatch (4 compact lines). This branch added 70+ lines of elaboration — header warnings, base-ref dance, footer-strip rules, nonce- suffixed boundary tags, retry-once skip markers, delegation-discipline list — all predicated on a runtime fence that doesn't exist and validation that never ran. Senior-engineer review (round 11) explicitly recommended cutting; ROADMAP_RESEARCH flags A/B benchmarking as the prerequisite for this design. Net change vs main now matches what the user actually asked for: - drop the optional plan step (and its "follow the plan" / Notes references) - subagent receives the original user request alongside the diff, evaluated against base ref, with explicit no-further-dispatch constraint Everything else reverts to main's prose. ~10 lines net change instead of 70+. * anneal round 13: tighten self-review prompt inputs to runtime-resolvable values Two underspecified inputs flagged by parallel holistic + mechanics review: 1. "the original user request" is empty for non-@pullfrog-tagged auto-triggers (sync, check_suite, opened, etc.); only YOUR TASK is reliably present in the assembled prompt across all event types. Replace. 2. "base ref (PR base or repo default branch)" requires the agent to resolve and fetch the default branch on non-PR runs (origin/<default> typically not fetched). Drop the elaboration — bare git diff captures all changes at step-3 time since step 2 doesn't commit. Aligns with 3ed2c55a's ruthless-cut philosophy: less elaboration, not more. Verified in round 14: YOUR TASK is the literal section header in instructions.ts (buildTaskSection); bare git diff scope is correct. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * restore plan step to Build mode prompt The plan step was removed alongside the MCP-contract plan-required work, but the user only wanted it gone from the MCP contract, not from the prompt itself. Restores step 1 (plan), the "follow the plan" build sub-bullet, the trailing Notes section, and renumbers learningsStep back to 6. Made-with: Cursor * add pullfrog-reviewer named subagent; standardize review fence to non-mutative+non-recursive Defines a constrained `pullfrog-reviewer` named subagent for the Build mode self-review and /anneal lens dispatch, with a single source of truth in action/agents/reviewer.ts (allowed tools, denied mutating MCP tools, system prompt). Enforcement: - opencode: real fence via agent.pullfrog-reviewer block in buildSecurityConfig — denies edit/bash/task and globs each mutating pullfrog_* MCP tool to false. - claude-code: forward-looking only. Per-agent disallowedTools is upstream-broken (anthropics/claude-agent-sdk-typescript#172, open as of latest update Mar 2026 — subagent child processes still see and can call disallowed tools, including Task). The --agents JSON is defined anyway so the fence becomes real when upstream fixes #172; until then the prompt prose constraint is the actual fence. The PreToolUse hook workaround that does enforce is out of scope. Read-only MCP tools (get_*, list_*) intentionally remain enabled so the reviewer can pull PR/issue/check context without dispatching state changes. Both modes.ts Build self-review and the two anneal.md files now share the same "non-mutative + non-recursive" framing — file reads, grep, search, web search/fetch, read-only shell, and read-only MCP queries allowed; writes, state-changing MCP, and nested subagent dispatch denied. Resolves the previous inconsistency where /anneal allowed read-only shell and Build self-review banned all shell. Made-with: Cursor * Build self-review: pass build-phase failure summary to reviewer subagent Adds an instruction in step 4's dispatch: along with YOUR TASK and git diff, pass a tight plain-text summary of any lint/typecheck/test failures fixed during build (what broke, root cause, the fix) — or "no build-phase failures" if clean. Goal: let the reviewer check that fixes addressed root causes rather than suppressed symptoms (e.g., editing a test to make it pass instead of fixing the bug). Implemented as agent self-summarization rather than piping raw build output to avoid context flooding — typecheck/test output can be hundreds to thousands of lines per failure. The agent has the failure trail in its own conversation history and summarizes from memory; the reviewer sees a few lines per failure, not raw stderr. Caveat: this is a plausible-but-unvalidated quality improvement. The mechanical justification (signal already produced, currently not passed on) is real; "this catches more bugs" is a hypothesis that will need actual run data to confirm. Downside is bounded (reviewer gets slightly more context, no behavior change if the summary is empty or ignored). Made-with: Cursor * Build self-review: distill /anneal delegation + research discipline into dispatch instructions Lifts the codified learnings from /anneal's "Delegation discipline" and "Research discipline" sections into Build mode step 4. These rules are about how-to-prompt the reviewer (not about parallelism), so they transfer losslessly to single-agent dispatch and address bias modes the prior prompt was silent on: - Don't summarize what you implemented (biases toward shape-validation) - Don't curate a reading list (your curation is itself a lens) - Don't pre-shape output with severity/category (leaks hypotheses) - Don't defect-hunt in parallel (reintroduces the implementation bias the subagent is meant to mitigate) - For diffs touching third-party API contracts / SDK semantics / framework directives / DB engine specifics, instruct the reviewer to verify load-bearing claims via web search and quote URLs rather than trust training data Restructures step 4 from one paragraph into three (constraints, inputs, discipline) plus a final review-and-commit paragraph for readability. These are validated learnings from many anneal rounds, not theoretical best practices — they're the single substantive piece this branch was missing. Made-with: Cursor * pullfrog-reviewer: drop MCP deny-list, rely on prose constraint Per-PR-review feedback: hand-maintaining MUTATING_MCP_TOOLS against action/mcp/server.ts was fragile — a future mutating tool added to the MCP server without updating this list would silently grant write access to the reviewer. Inverting to an allowlist or adding a structural test both keep the drift problem. Drop the list and all per-agent runtime denies (claude disallowedTools, opencode tools/permission map). Strengthen REVIEWER_SYSTEM_PROMPT to spell out the categories of state-changing MCP tools by example and explicitly tell the model to apply the no-op-if-reverted invariant to tools added after the prompt was written — the rule is the invariant, not the enumeration. Keep the named subagent so the prompt is reliably injected. Update modes.ts and both anneal.md copies to drop the runtime-enforces-where-supported claim. Co-authored-by: Cursor <cursoragent@cursor.com> * pullfrog-reviewer: fix description to allow read-only shell The description field was overstating the constraint as 'must not shell', but the system prompt explicitly allows read-only commands like git diff, git log, cat, ls. Align description with the actual contract. Co-authored-by: Cursor <cursoragent@cursor.com> * restructure Review/IncrementalReview as multi-lens parallel-subagent orchestrators For someone else's PR, parallel lenses (correctness, security, research-validated claims, user-journey, etc.) provide breadth across angles that a single subagent can't carry coherently. The orchestrator does triage → parallel read-only subagent fan-out → aggregate → draft comments → submit. Lens count by risk: 1 lens for trivial PRs, 2-3 for typical, 4 for high-risk surfaces (billing, auth, migrations). This branch contains ONLY the Review/IncrementalReview multi-lens prompts. Build mode keeps its single-fresh-eyes-subagent shape (different problem — orchestrator just wrote the code; bias-mitigation comes from one subagent that doesn't share the implementation context). The Build changes ship in a separate PR (self-review-subagents → main). Pending validation against a real code-heavy PR before merge — e2e on a docs-only preview repo only exercised the trivial-1-lens path. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * Review/IncrementalReview: dispatch fan-out via reviewfrog named subagent The fan-out steps previously said "launch one read-only subagent per lens" without naming the subagent. That bypassed the only enforcement layer the named subagent provides: a baked-in system prompt that restates the non-mutative + non-recursive contract regardless of what the orchestrator sends. Both modes now dispatch via REVIEWER_AGENT_NAME (matching Build mode's self-review wiring) and restate the constraint inline so the rule is present twice. * rename pullfrog-reviewer → reviewfrog Mechanical rename of the named subagent. Constant names (REVIEWER_AGENT_NAME, REVIEWER_SYSTEM_PROMPT) and file paths (action/agents/reviewer.ts) stay as-is — only the agent identifier string and prose references in anneal.md and code comments change. * modes/anneal: trivial PRs skip review entirely; lens count is judgment, not table; allow subsystem lenses Three coupled changes to Review/IncrementalReview/Build self-review and the canonical /anneal command: 1. Trivial-skip: trivial diffs (single-line, formatting/comment-only, doc typo, low-risk dep bump, no behavior change) skip the fan-out / self-review entirely. Build mode skips its self-review subagent; Review submits a bare "Reviewed — no issues found." without dispatching lenses; IncrementalReview takes the existing non-substantive submit path. Tiebreaker on uncertainty: treat as non-trivial. 2. Drop prescriptive lens counts. Replaces "2-3 typical / 4 high-risk cap / 1 trivial" with judgment-based guidance: pick as many lenses as the target has distinct surfaces of risk worth investigating independently; one is sometimes enough; bias toward more (and toward follow-up rounds in /anneal) for high-stakes subsystems; 5+ is a smell that lenses are overlapping rather than covering distinct ground. 3. Subsystem lenses. Adds an explicit second flavor of lens — domain-scoped frames like "the auth lens", "the billing lens", "the schema-migration lens" — alongside the existing themed lenses (correctness, security, user-journey, etc.). Stack themed + subsystem freely. modes.ts and anneal.md (.cursor/ + .claude/, kept byte-identical) move together so the canonical pattern doc and the orchestrator prompt agree on the protocol. * add SessionLabeler so parallel subagent log lines are differentiable When the orchestrator dispatches multiple `reviewfrog` subagents in a single assistant turn (the parallel fan-out the multi-lens prompt now requires), their tool_use / tool_result / text events arrive on opencode's NDJSON stream tagged with distinct `sessionID`s but go through a single `[Pullfrog]` log prefix. Result: log readers can't attribute which lens issued which tool call, making CI logs unreadable for any review with 2+ lenses. SessionLabeler: - Binds the first-seen sessionID to "orchestrator" and subsequent new sessionIDs to FIFO-popped lens labels seeded from task tool_use inputs. - Derives labels from `lens: <name>` markers in the dispatch prompt, the Task `description` field, the `subagent_type`, or `subagent#N` fallback. - Keeps state local to a single runOpenCode invocation. Wiring: - opencode.ts: every event handler (init, message, text, tool_use, tool_result) now looks up the per-event label and prefixes log output via formatWithLabel(). Subagent finalOutput/token-reset paths gated on ORCHESTRATOR_LABEL so child sessions can't clobber parent state. - claude.ts: claude rolls subagent activity into a single tool_result block (no per-event session_id), so it gets a minimal "» dispatching subagent: <label>" log line on Task tool_use as the only attribution. - modes.ts (Review + IncrementalReview): orchestrator instructed to set the Task `description` to the lens name, since that's what the labeler reads when no explicit `lens:` marker is in the prompt. Tests: 18 unit tests covering label derivation, FIFO binding, interleaved sessions, fallback paths, and a realistic four-lens parallel fan-out simulation. Full action test suite stays green (400 passing). This is the pre-flight instrumentation that the multi-lens validation runs depend on — without it, post-hoc log analysis can't tell two subagents apart. * log subagent dispatch + finish at info level for per-lens visibility 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 SessionLabeler I added in 0c4647f4 therefore can't actually differentiate concurrent subagent log lines (there are no concurrent log lines on the parent stream to differentiate). What CAN be observed on the parent stream is the dispatch and the result of each `task` tool call. This patch surfaces both at info level: » dispatching subagent: lens:security (subagent_type=reviewfrog) ... » subagent finished: lens:security (15.3s, status=completed) — ... Without this, a 4-lens parallel fan-out looks like 4 dispatches in close succession followed by a long quiet gap and then an aggregation turn — you can't see when each lens finished or how the durations overlapped. With it, parallel execution is visible from the timestamps on the "finished" lines. The dispatched label comes from SessionLabeler.recordTaskDispatch (so both lines share the same lens identity). taskDispatchInfo maps callID to {label, startedAt} so the matching tool_result can compute duration and emit the finished line. Also added a defensive comment on the SessionLabeler instantiation documenting that the per-event session-prefix path is currently dormant in the opencode runtime, but kept in place so attribution flips on automatically if/when opencode begins streaming subagent sessions. * fix subagent-finished log: hybrid exact+FIFO callID matching opencode does not consistently surface a tool_result callID matching the originating tool_use callID for the `task` tool, so the previous exact-match-only finish line never fired. Now we: - Dual-index task dispatches by callID AND in a FIFO queue. - Track non-task callIDs so we can identify "unrecognised callID" results as likely-task-with-mismatched-id. - On tool_result, exact-match first; fall back to FIFO when the output looks like a subagent reply (>300 chars) and the callID is unknown. - Flush leftover dispatches at run end with an "(inferred at run-end)" suffix so the gap is visible if subagent results arrive entirely off the tool_result event path (e.g. inlined into the next assistant message). * fix subagent-finished log: move run-end flush to post-subprocess block Investigation on T3 + finish-log-validation runs revealed two real issues with my prior attempt: 1. The `result` event handler is dead — opencode never emits a `result`-typed event over its NDJSON stream, so the inferred-at-run-end flush I had placed there never fired. Move the flush to right after `runSubprocess` returns where it actually executes. 2. The FIFO heuristic was too strict — the >300-char output check excluded short or empty outputs that opencode's `task` tool_result appears to carry (the subagent's full reply seems to arrive via a separate channel, not the result event itself). Drop the size check; rely solely on `knownNonTaskCallIDs` to keep genuinely-non-task tool_results from popping a pending task. Net effect: every `task` tool dispatch gets a matching `» subagent finished` line in the logs, either from the FIFO fallback during the run or from the run-end flush as a backstop. * modes/anneal: anchor lens calibration in worked examples The prior trivial-skip definition ("single-line fix, formatting-only, …") was anchored on diff size, but real-world risk is anchored on diff *shape*: a 5000-line lockfile regen IS trivial, and a 1-line SQL operator flip in a billing path is NOT. The prior lens-count guidance ("there's no fixed count, bias toward more for high-stakes subsystems") gave the agent no concrete shapes to anchor against, so runs varied between under-pick (4 generic lenses on a billing PR) and over-pick (5 overlapping themed lenses on a refactor). This commit hardens both: - Trivial definition gets explicit "looks trivial but isn't" anti-patterns: SQL operator flips, money/tax/timeout constants, feature-flag defaults, comparison operator changes, semantic 1-liners buried in whitespace, public-API renames, new direct deps. Skip lists get explicit "size doesn't matter" calibration for lockfile regens and mechanical renames. - Lens count gets a worked-example ladder: 1 lens (refactor / new test file / isolated fix), 2-3 lenses (typical features), 4-5 lenses (high-stakes subsystem touches), 6+ is a smell. - Subsystem lenses get an explicit recommendation to lead over generic themed equivalents for high-stakes domains, with the reasoning: domain framing primes the subagent for domain-specific failure modes (double-charges, refund races, dispute flows) the generic lens misses. Mirrored byte-identical into both anneal.md copies; modes.ts updates all three review surfaces (Build self-review, Review triage, IncrementalReview triage). * fix harness false-failure when Review submits without todowrite Review and IncrementalReview prompts explicitly forbid calling report_progress (the review IS the durable record). The post-run harness in action/utils/run.ts errors with "agent completed without reporting progress" when toolState.wasUpdated is false at exit. Until now, the only path that set wasUpdated for these modes was the todoTracker's debounced publish — which only fires if the agent happens to call todowrite during the run. Adversarial run on PR #16 (misleading-trivial billing tweak) hit exactly this case: agent went straight from triage → fan-out → review submission with no todowrite calls, and the harness reported failure even though the substantive review was successfully submitted with two inline comments. Fix: create_pull_request_review now marks wasUpdated=true (and finalSummaryWritten=true) on every terminal path — successful submit, empty-content skip, and all-comments-dropped skip. Submitting a review is unambiguously a "done" signal in these modes. Found via adversarial testing of the multi-lens orchestrator on a 1-line tax constant change. Logged in /tmp/pullfrog-validation/v3/. * fix harness false-failure when Review submits without todowrite (correctly) Replaces the prior fix (acc2bd65) which set wasUpdated=true inside create_pull_request_review. That approach worked for the harness check but broke the orphan-comment cleanup: with wasUpdated=true and finalSummaryWritten=true, the (!wasUpdated || trackerWasLastWriter) condition in main.ts evaluated false and the "Leaping into action" progress comment was left behind on every Review run — the exact behavior the cleanup logic was designed to prevent (see plans/review_progress_comment_cleanup_b0120f6c.plan.md). Correct fix: change the harness check in action/utils/run.ts to recognize a submitted PR review as an alternate completion signal alongside wasUpdated. wasUpdated stays false on purpose so cleanup deletes the orphan, but the run no longer false-fails when the agent followed the Review-mode contract (submit a review, never call report_progress). The bug was discovered during adversarial testing of PR #16 (misleading-trivial billing tweak) where the agent went straight from triage → fan-out → review submission without using todowrite, causing the harness to error even though the substantive review (a CAUTION blocking review with two inline comments catching a 10x tax cut) was successfully posted. * fix harness false-failure for Review modes (mode-based carve-out) Replaces the prior carve-out (4c0f69aa) which gated on toolState.review.id. That worked for runs where the review tool actually populated the toolState (validation-2 succeeded), but failed for runs that took a slightly different path where the assignment didn't propagate visibly to handleAgentResult — even when the review verifiably posted to GitHub. Found this empirically: PR #19 (pure mechanical rename across 20 files) opened with the prior fix in place, the agent picked exactly one impact lens (correct calibration!), confirmed no stale references, submitted "Reviewed — no issues found." successfully (visible in GitHub API), and the harness STILL errored with "agent completed without reporting progress." Same SHA, same branch, same code as validation-2 which passed. The toolState.review.id check turns out not to be reliably visible from the run.ts handler in all paths. Better fix: gate on toolState.selectedMode. Review and IncrementalReview modes are designed to never call report_progress (the review is the durable record, and IncrementalReview's non-substantive path produces no artifact at all by design). The harness completion check makes no sense for these modes — skip it entirely. The agent's clean subprocess exit is the completion signal. This also handles edge cases the previous fix missed: IncrementalReview's non-substantive path (no review submitted by design) and any future Review-flow shape that doesn't end at create_pull_request_review. * ci: trigger Test run to validate models-live timeout/concurrency changes * ci: prune passthrough models from live smoke matrix openrouter/* aliases and keyed opencode/* aliases are routing-layer wrappers around models we already smoke-test directly. running every passthrough burns CI minutes (~30 min/run) without catching anything the direct smoke doesn't — slug drift is already covered by the models-catalog job. keep one canary per routing layer (openrouter/claude-sonnet, opencode/claude-sonnet) to validate auth + tool-call translation. free opencode models stay in the matrix since they're unique to the provider. INCLUDE_ALL_PASSTHROUGHS=1 bypasses the prune for full validation. matrix size: 37 → 20 jobs. * fix isRateLimited false-positive on UUIDs/timestamps containing 429 The bare "429" substring pattern was matching MCP session IDs (e.g. `...-4429-...`) and microsecond timestamps in agent stdout, sending transient failures down the 60s rate-limit retry path. With the new 4-minute per-step CI timeout, that backoff plus a slow retry pushed the step past its budget and timed out. Switch to regex patterns and gate the numeric code on `\b429\b` so word boundaries prevent the substring false-match. Verified locally that the UUID `97287d2f-ae1d-4429-8627-73e2454e80ca` and timestamp `02:04:50.9429654` no longer match while real `HTTP 429` / `"status":429` strings still do. --------- Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com> Co-authored-by: Cursor <cursoragent@cursor.com> Co-authored-by: Colin McDonnell <colinmcd94@gmail.com> Co-authored-by: pullfrog[bot] <226033991+pullfrog[bot]@users.noreply.github.com> |
||
|
|
c6a757424c |
Stop hook + learnings reflection via post-run loop (#515) (#548)
* add stop hook + learnings reflection to post-run loop (#515) stop hook (#515): repo-configured script that runs after the agent finishes. non-zero exit resumes the agent with the hook output as guidance; persistent failure (3 attempts) marks the run failed. the dirty-tree and stop-hook gates share a single retry loop so a fix + push happen in one turn. learnings reflection: per Colin, the learnings step baked into mode checklists rarely fires — the agent stays focused on the task and the meta-ask falls through. the post-run loop now delivers a dedicated one-shot --continue turn asking the agent to call update_learnings if relevant, nothing else competing for attention. reflection doesn't consume the gate-retry budget; if it dirties the tree, the next loop iteration catches it via the dirty-tree gate. plumbing: Repo.stopScript column + migration, zod schema, run-context api, AgentSettings UI. RepoSettings.stopScript threads through to AgentRunContext and into each agent harness. subprocess-dependent logic lives in action/agents/postRun.ts to keep action/agents/shared.ts lean — shared.ts is reachable from pullfrog/internal, and pulling node:child_process through it leaks into root tsc (which uses bundler resolution, not NodeNext). * fix: preserve successful run when reflection turn fails The post-run reflection turn (update_learnings nudge) is a best-effort one-shot; its failure must not flip a successful run to failed. Prior code overwrote `result` with the reflection's return value, so a model API error during reflection caused the whole run to be reported as failed even though the gated work had already completed cleanly. Now: save the pre-reflection result, and if reflection returns `success: false`, log a warning, restore the prior success, and exit without re-invoking the gates (re-running a freshly-green stop hook risks a flaky false-positive failure). Adds action/agents/postRun.test.ts covering the reflection path — previously uncovered. * fix: surface both stop-hook stdout and stderr to the agent The `(stderr || stdout)` heuristic in executeStopHook dropped stdout entirely whenever stderr had any content. Scripts that emit a benign warning to stderr and the actionable error to stdout (common for wrapper scripts) starved the agent of the information it needed to fix the issue. Now concatenate both streams (stderr first, stdout second, skipping empty ones) before truncation. This keeps stdout's tail — usually where summaries and totals live — intact under the 4096-char cap. * test: lock in the core post-run retry + reflection invariants PR #548's test plan ships four manual verification scenarios. Convert three to vitest coverage, catching regressions on the hottest code paths: - persistent stop hook failure exhausts MAX_POST_RUN_RETRIES and surfaces as AgentResult.error with both the retry count and the verbatim hook output (so the GitHub-comment rendering stays actionable). - every gate retry is fed the hook output as the resume prompt. - usage aggregates across the initial run plus every retry (billing relies on this). - reflection turn still fires when no stop hook is configured and the tree is clean. Manual item remaining is the full UI round-trip of the settings form, which is out of scope for unit tests. * test: cover executeStopHook soft-fail and truncation invariants Three paths the PR documents but previously had no regression gates: - timeout (SPAWN_TIMEOUT_CODE) and activity-timeout (SPAWN_ACTIVITY_TIMEOUT_CODE) must return null, not a failure. a hook that times out is an infra problem; retrying with an agent turn risks an infinite loop. - spawn errors (ENOENT from a typoed binary, etc.) take the same soft-fail path for the same reason. - oversize hook output is truncated to the last 4096 chars with a "truncated" marker, keeping the tail (where summaries live) and protecting the 65535-char GitHub-comment budget downstream. Regression targets — a refactor that accidentally surfaces an infra failure as a gate failure, or blows the comment budget, will now fail loudly in CI. * test: cover soft-fail, no-resume, and short-circuit invariants Three more documented behaviors that previously had no regression gates: - dirty-tree-only is a soft-fail: persistent uncommitted changes log and warn but DO NOT flip the run to failed. a regression that started surfacing this as AgentResult.error would break every run that leaves a test fixture untracked. - canResume=false + stop hook failure still surfaces the hook failure as AgentResult.error. the retry budget is zero so "N retry attempts" is correctly omitted from the message, but the run still reports WHY it failed rather than silently reporting success. - initial result with success=false short-circuits the loop: no gate checks, no reflection, no resume calls. the original agent error flows through verbatim for clean triage. Also reset mockedSpawn in beforeEach so test state doesn't leak between cases. * test: lock in the reflection-dirties-tree → dirty-tree-gate path The PR description claims: "if the reflection turn dirties the tree, the loop picks that up on the next iteration via the normal dirty-tree gate." There was no regression gate on this invariant. Without it, a refactor that moved the reflection out of the retry loop (e.g., into a one-shot post-loop call) would silently bypass the commit-before-you-finish contract whenever the agent misbehaves during reflection — uncommitted changes would ship as part of the run's "success" state. The test sequences three getGitStatus returns (clean → dirty → clean) and asserts two resume calls: REFLECTION first, then UNCOMMITTED CHANGES with the dirtying file in the prompt. * fix: preserve pre-reflection task output when reflection succeeds the reflection turn's reply ("done" or "updated learnings with N bullets") is a meta-ask, not a task summary. before this fix, result = reflectionResult clobbered the original task's output on the returned AgentResult, so downstream consumers (handleAgentResult's fallback path when toolState is empty, programmatic callers of main()) saw the reflection's trivial reply instead of the real summary. spread reflectionResult to inherit fields subsequent gate retries need (e.g. the new sessionId claude emits per --resume invocation), but keep the pre-reflection output verbatim. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix: fall back to reflection's output when pre-reflection output is empty the prior fix used `??` which only fell through on null/undefined. runs that communicate exclusively through MCP tools (e.g. report_progress) and emit no plain text leave result.output = "", which `??` preserved as-is — dropping the reflection's reply and leaving handleAgentResult's fallback path with nothing to show. switch to `||` so empty-string pre-reflection output yields the reflection's output instead of ""; non-empty task output still wins as intended. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * test: drop reflection-failure-skips-hook test (over-specified control flow) the test pinned the literal `break` in the post-reflection failure branch with stopScript=null, asserting only that getGitStatus was called once. that's not a behavior contract — a reasonable refactor (e.g. `continue` to re-check gates with explicit flake guards) would fail this test even though the new behavior would be fine. the "does not flip a successful run to failed" test already covers the only thing callers depend on. * test: drop low-value mock-driven tests from postRun - "fires the reflection turn when no stop hook is configured" — fully subsumed by the output-preservation test (asserts task output survives, which is only possible if reflection fired). - "uses stdout alone" / "uses stderr alone" — pin format trivia (`filter(Boolean).join`) that LLMs ignore. - "returns empty output (not undefined) when both streams are empty" — guards a TS-impossible case; every consumer uses `output || "(no output)"`. - "returns null on activity-timeout" — duplicate of the timeout test; same `return null` branch with a different constant. --------- Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com> Co-authored-by: Colin McDonnell <colinmcd94@gmail.com> |
||
|
|
57f54e37c5 |
add bundled git-archaeology skill, auto-installed for opencode and claude (#565)
* add bundled git-archaeology skill, auto-installed for opencode and claude ships a SKILL.md teaching agents the underused git history primitives (pickaxe -S/-G, -L for function/line ranges, --reverse blame, deleted-file recovery) so they stop scrolling git log -p when blame comes up empty. introduces a lightweight bundled-skill path alongside the existing addSkill (npx skills add) flow used for external skills like agent-browser. SKILL.md is inlined into dist/cli.mjs via esbuild's text loader and written to <home>/.agents/skills/<name>/SKILL.md at runtime — no network, no version drift, no per-run install cost. * fix: register vitest plugin to load .md as text for bundled-skill tests * fix: drop vite type import from vitest plugin (vite isn't a direct dep) * fix: load bundled skills via readFileSync so source mode works esbuild's text loader only applies to the npm-bundled dist/cli.mjs path. the preview / oss path runs cli.ts directly with node (PULLFROG_FORCE_LOCAL_CLI=1 in runCli.ts#runLocalCli), where node has no idea how to import .md files — ERR_UNKNOWN_FILE_EXTENSION crashes the action before any agent starts. switch to runtime readFileSync that checks both candidate locations: - source mode: <actionRoot>/skills/<name>/SKILL.md (relative to utils/skills.ts) - bundled mode: <distDir>/skills/<name>/SKILL.md (esbuild copies the tree) drops the no-longer-needed esbuild text loader, vitest .md plugin, and ambient *.md type declaration. wiki/skills.md updated with the why. * fix: write bundled skills to per-agent dirs so claude actually registers them |
||
|
|
3bacf01e48 |
bump model registry for deepseek v4, kimi k2.6, claude opus 4.7 (#554)
* bump model registry for deepseek v4, kimi k2.6, claude opus 4.7
deepseek released v4 (pro/flash) on 2026-04-24 as a generational replacement
for v3-era reasoner/chat. deepseek will fully retire deepseek-chat and
deepseek-reasoner on 2026-07-24 — both already route server-side to v4-flash.
introduce deepseek-pro (preferred) and deepseek-flash slugs and mark the
legacy aliases deprecated via fallback so existing users transparently
upgrade. mirror on the openrouter side.
also bump moonshotai/kimi to k2.6 (from k2.5, 2026-04-21 release) and bump
the anthropic claude-opus openrouter resolves to 4.7 (we'd already moved the
native side to claude-opus-4-7 but openrouter resolves still pointed at 4.6).
update OSS_PROXY_MODEL fallback and stale doc reference accordingly.
snapshot regenerated; all 111 catalog tests + 66 unit tests pass.
* walk fallback chain when resolving the OSS proxy model
the OSS proxy path in run-context/route.ts read alias.openRouterResolve
directly, bypassing the fallback chain. so an OSS repo configured with
deepseek/deepseek-reasoner kept proxying to openrouter/deepseek/deepseek-v3.2
instead of resolving through the new fallback to openrouter/deepseek-v4-pro.
that worked today (v3.2 routes server-side to V4-Flash) but breaks when
deepseek and openrouter retire v3.2 alongside the 2026-07-24 deprecation.
extract the chain walk into a private resolveTerminalAlias helper and add
resolveOpenRouterModel that mirrors resolveCliModel but returns
openRouterResolve. fallback semantics now apply uniformly across both
runtime resolution paths.
* hide deprecated aliases from model selector dropdowns
aliases with a fallback (currently deepseek-reasoner / deepseek-chat /
openrouter/deepseek-chat) should not be selectable from the model dropdown
or the interactive cli model picker — they're a transition path, not a
choice. but if a repo already has a deprecated slug stored in the db, the
selector trigger still resolves it against the full alias registry so the
display name renders correctly until the user opens the menu and picks a
new model.
verified manually: deepseek submenu shows pro+flash only, openrouter submenu
shows pro+flash but no chat, and a deprecated stored value still renders
its full display name in the trigger.
* ci: run models-live on PRs that touch resolution files
Previously the per-alias smoke matrix only fired on push-to-main, so
resolution-affecting PRs (this one included) shipped without ever
exercising the agent harness against the real provider for each alias.
Loosen the gate on the `aliases` step in the `changes` job to fire
whenever the `models` paths-filter matches (action/models.ts,
action/package.json, action/agents/**) — same set that already drives
the comment about "resolution-affecting files". `models-live` itself
is unchanged: it still keys on a non-empty matrix.
`models-catalog` stays gated to main-push intentionally — its existing
comment justifies that (transient upstream catalog drift shouldn't
block PRs).
* relabel codex aliases as GPT, bump to 5.5 family, add gpt-pro
OpenAI retired the "-codex" model suffix on 2026-07-23 (gpt-5.3-codex,
gpt-5.1-codex-mini, gpt-5.2-codex et al all shut down) and unified the
codex+gpt lines into a single family at gpt-5.4. Per OpenAI's own
deprecation table, every "-codex" substitute is plain gpt-5.x — no
future Codex-suffixed frontier models are coming.
Keep the existing slugs for DB stability (no migration needed) but roll
displayName + resolve forward across openai, opencode, and openrouter:
- openai/gpt-codex → "GPT" → openai/gpt-5.5
- openai/gpt-codex-mini → "GPT Mini" → openai/gpt-5.4-mini
- openai/gpt-pro (new) → "GPT Pro" → openai/gpt-5.5-pro
Same relabel + new gpt-pro slug for opencode/* and openrouter/*.
gpt-5.5 (and gpt-5.5-pro) hit the OpenAI public API on 2026-04-24,
day after launch — both are live on OpenRouter as well.
There's no gpt-5.5-mini yet (analysts speculate late June – mid August
based on the gpt-5.4-mini cycle), so "GPT Mini" stays at gpt-5.4-mini
for now; one-line bump when the smaller variant ships.
Also pick up unrelated upstream catalog drift in the snapshot
(xai/grok-4.3 released 2026-05-01, openrouter/poolside laguna).
* deprecate gpt-codex aliases, mint gpt/gpt-pro/gpt-mini, render terminal alias in UI
The previous commit relabeled gpt-codex/gpt-codex-mini in place ("GPT" /
"GPT Mini") so a single slug carried two different identities. That worked
but was self-contradictory: the slug name no longer described the model.
Switch to the same shape we use for the deepseek V3→V4 transition:
- Mint new live slugs: openai/gpt, openai/gpt-pro, openai/gpt-mini
(mirrored on opencode/* and openrouter/*)
- Restore honest deprecated state on gpt-codex/gpt-codex-mini —
displayName "GPT Codex" / "GPT Codex Mini", original 5.3-codex /
5.1-codex-mini resolves, fallback set to the new gpt / gpt-mini slugs
- resolveCliModel + resolveOpenRouterModel walk the chain (existing
machinery), so DB rows holding "openai/gpt-codex" transparently route
to gpt-5.5 with no migration
UI render contract: display sites resolve to the *terminal* alias so a
deprecated stored slug shows the model the user is actually running, not
the historical name. Three call sites updated:
- components/ModelSelector.tsx (dropdown trigger label + provider label)
- action/utils/buildPullfrogFooter.ts (PR-comment "Using `X`" footer)
- action/commands/init.ts ("using model X" startup line)
Promoted internal resolveTerminalAlias → exported resolveDisplayAlias so
all three sites use the same primitive (also re-exported from external.ts
+ internal/index.ts so the Next.js app can import it).
Selectable lists (dropdown options, init picker) still filter on
!a.fallback so deprecated slugs never appear as fresh choices — only
deprecated stored values render.
wiki/model-resolution.md: replaced the muddled "slug names outlive
product names" bullet with a clear decision table for in-place bump
(generational, e.g. Opus 4.6 → 4.7) vs. deprecate+replace (vendor
restructures, e.g. codex → unified GPT, deepseek V3 → V4). Documents
the UI render contract too.
models-live CI matrix will smoke-test all 6 new slugs (gpt, gpt-pro,
gpt-mini × openai/opencode/openrouter) plus the 6 deprecated codex slugs
(which resolve through fallback to the same terminal targets) — 12 jobs
total against real provider APIs.
* wiki: slugs are evergreen, resolves are versioned
Document the slug-naming rule explicitly so future entries don't repeat
the deepseek-chat/deepseek-reasoner mistake (mirroring an upstream's
versioned/product-line-specific ID into the slug). Slugs should track
brand-style tier names that survive major version bumps; embedding
versions is the resolve string's job.
|
||
|
|
55c95e6f50 |
Fix Node 24 action bootstrap fallback (#556)
* Fix Node 24 action bootstrap fallback Resolve the published CLI launcher through PATH so runners missing a sibling Node 24 npx can still start, and make post cleanup recognize prefixed leaping comments. * Bump Pullfrog action package version Ensure the Node 24 bootstrap and post-cleanup fixes publish to npm and move the v0 action tag. * Walk PATH for corepack and npx in action bootstrap ensureActionDependencies and runPackageCli now resolve corepack/npx through PATH the same way as the npx-via-PATH fix, so Node 24 runner pools missing either sibling can still bootstrap. Also adds a Zod-mirror settings helper for the preview-556 repo and documents the per-PR settings workflow. * log when corepack PATH fallback is used |
||
|
|
f662b1a0c8 |
unify per-run token + cost accounting + persist to WorkflowRun (#547)
* unify per-run token + cost accounting across agents every agent harness now logs the same 5-column (or 6 with cost) table and populates the same AgentUsage contract, regardless of agent or upstream provider. previously OpenCode and the Claude fallback path emitted a 3-col table whose "Input Tokens" was actually only the non-cached delta, silently dropping cache read/write — real runs were being reported at ~0.4% of their true input (e.g. one baseline showed Input=30 while step_finish events summed to cache_read=724,753). changes: - add logTokenTable helper in action/agents/shared.ts with stable columns: Input | Cache Read | Cache Write | Output | Total | Cost ($). cost column renders only when a value is known. - action/agents/opencode.ts: accumulate step_finish.part.tokens AND step_finish.part.cost (sourced from models.dev inside opencode — confirmed working across Anthropic, OpenAI, Google, xAI, DeepSeek, Moonshot, and OpenRouter). drop the event.stats.total_tokens fallback since that payload has no cache breakdown. - action/agents/claude.ts: success-path now treats input_tokens as the non-cached field (matching OpenCode semantics), carries cache_read_input_tokens / cache_creation_input_tokens separately, and captures total_cost_usd from the final result event. the per-message fallback accumulator now captures cache fields too so it's no longer lossy when the result event never fires. - formatUsageSummary gains a Cost ($) column that matches the stdout table row-for-row; missing values render as "—". - scripts/token-usage.ts parses all three historical formats (new 5-col, legacy 4-col Claude success, legacy 3-col lossy) and explicitly flags the lossy runs instead of averaging misleading values. validation (pnpm play --local, identical "say hello" prompt): agent+model Input CacheR CacheW Output Total Cost OpenCode + Anthropic Sonnet 4.6 4 41,177 20,735 129 62,045 $0.0921 Claude CLI + Anthropic Sonnet 4.6 9 80,133 11,611 389 92,142 $0.0766 OpenCode + OpenAI codex-mini 10,893 46,976 0 606 58,475 $0.0059 OpenCode + Google Gemini 3 Flash — — — — — $0.0114 OpenCode + xAI Grok 4 Fast — — — — — $0.0035 OpenCode + DeepSeek Chat 18,854 0 0 1 18,855 $0.0053 OpenCode + Moonshot Kimi K2.5 — — — — — $0.0106 OpenCode + OpenRouter→Anthropic — — — — — $0.0617 OpenCode + OpenRouter→OpenAI — — — — — $0.0038 * isolate play.ts from developer gitconfig play.ts is a CI-emulator but inherits the developer's user- and system-scope gitconfig. a common local convenience — url."git@github.com:".insteadOf "https://github.com/" to force SSH auth — gets applied at read time on every git call inside the temp repo, causing `git remote get-url --push origin` to return an SSH URL instead of the stored HTTPS one. pullfrog_push_branch's validatePushDestination (correctly) treats that as tampering and blocks the push. the agent then burns the full MAX_COMMIT_RETRIES budget trying workarounds that can't beat a user-scope insteadOf rule, turning a trivial "say hello" run into a 1.35M-token session. point GIT_CONFIG_GLOBAL and GIT_CONFIG_SYSTEM at /dev/null inside run() so the play process and its spawned agent see the same empty gitconfig that a real CI runner would. CI has no rewrites, so this is a no-op there; dev machines get CI-identical git state. SSH client config (~/.ssh/config and keys) is separate from gitconfig and is unaffected, so setupTestRepo's SSH clone still works locally. setupGit only writes --local scope, so nothing downstream depends on user-scope values. verification: with the scratch repo cleaned up and this isolation in place, OpenCode + Anthropic on the same "say hello" prompt goes from 1,349,654 tokens / $2.00+ to 62,045 tokens / $0.0921 — no retry loop, no push blocks. * persist aggregated token + cost usage to WorkflowRun AgentUsage has been memory-only — rendered into the GitHub step summary and then discarded when the runner tears down. that made questions like "avg cost per customer per day" require log-spelunking. persist it: - add Int? columns for inputTokens / outputTokens / cacheReadTokens / cacheWriteTokens and a Decimal? costUsd column on workflow_runs. Int4's 2.1B ceiling is ~200x larger than any realistic run so BigInt would be overkill. costUsd uses the same default Decimal precision as existing money columns (accounts.usageUsd, proxy_keys.hwmUsage). - extend PATCH /api/workflow-run/[runId] to accept the new numeric fields alongside the existing artifact strings. per-field type validation ensures the allowlist stays scalar-safe and rejects negative / non-finite values. - generalize patchWorkflowRunFields in the action so it accepts a mixed string/number payload, and add an aggregateUsage(entries) helper that sums per-agent AgentUsage records into a single patch. - call the reporter from main.ts's outer finally block, gated on toolContext. this is the shared cleanup path that every agent implementation flows through — claude.ts, opencode.ts, and any future harness all push their AgentUsage into toolState.usageEntries via the same line 468, so one finally-block call covers them all. running in finally also means partial usage gets persisted even when the agent errored out mid-run. * anneal token + cost accounting follow-up polish from a review pass: - aggregate usage across commit-retry iterations inside each agent harness. previously runClaude / runOpenCode returned only the final retry's usage, so any run that hit the dirty-tree retry loop under-counted tokens and cost in both the stdout table and the WorkflowRun row. added a shared mergeAgentUsage helper in agents/shared.ts; both harnesses now fold each iteration's usage into a running total and return the sum. - scripts/token-usage.ts now handles the unified format with or without the Cost ($) column. previously the int-only number regex rejected decimals and the 5-cell length check rejected 6-cell rows, so logs from post-cost-tracking runs fell through to "no token table". the parser now accepts both 5- and 6-cell unified rows, splits int vs decimal cells, and averages reported Cost alongside the tokens. - PATCH /api/workflow-run/[runId] now rejects INT field values above INT4_MAX (2_147_483_647) so a malformed payload gets a clean 400 instead of propagating a Prisma error. also defends against a compromised runner sending a deliberately huge value. - clarifying comments: opencode.ts documents that step_finish.part.cost is a per-step delta (empirically verified), main.ts explains that toolState.usageEntries already carries merged per-retry usage so aggregateUsage just sums entries (one per agent.run()). - tests for aggregateUsage and mergeAgentUsage — 12 new cases covering empty / partial / multi-agent inputs and the "keep undefined" semantic that prevents spurious zeros from being persisted. - drop `as number` cast in logTokenTable — narrow via const instead. * anneal: clamp INT overflow + guarantee mergeAgentUsage immutability second review pass surfaced two defensive gaps: - a single token field exceeding INT4_MAX would pass the client but be rejected by the server's per-field validator, writing a partial row with some NULLs where sums belonged. clamp in aggregateUsage so the wire payload is always self-consistent across all numeric columns, with a loud warning so the clamp doesn't silently swallow weirdness. - mergeAgentUsage's single-sided branches returned the input reference. callers treat AgentUsage as immutable but future callers might not; always return a fresh shallow copy instead. two new tests guarantee the no-mutation-leak property. no behavior change in the happy path — INT4_MAX is ~200x the largest realistic per-run token count. * anneal: resilient usage persistence + cross-platform null device third review pass surfaced three small issues: - main.ts finally block: writeGitHubUsageSummaryToFile throwing would skip the WorkflowRun usage PATCH. both are independent best-effort cleanup tasks — wrap the former in catch so a filesystem failure doesn't block DB persistence. - AgentUsage.inputTokens had no jsdoc explaining that it's the full billable input (cached + non-cached). the same word "Input" means "non-cached only" in the stdout/markdown tables (derived by subtraction). document the semantic so dashboards querying WorkflowRun.inputTokens don't misinterpret it. - play.ts gitconfig isolation was hard-coded to "/dev/null" which doesn't exist on Windows. use `os.devNull` for cross-platform parity (resolves to `\\.\nul` on win32). the project is Linux-only in CI so this only helps local Windows contributors, but it's a zero-cost swap. also updated the finally-block caveat comment: usage is only pushed to toolState.usageEntries when agent.run() returns an AgentResult, not when the timeout race rejects — so timed-out runs don't persist partial usage. documented instead of trying to thread state through Promise.race. * anneal: NaN-guard cost accumulators + clarify inputTokens docs final polish from review round 4: - guard both cost accumulators (opencode step_finish.part.cost and claude result.total_cost_usd) with Number.isFinite. `typeof x === "number"` accepts NaN, and one NaN `+=` would poison the running total for the whole session. - reword prisma schema comment on WorkflowRun usage fields to call out that cacheReadTokens / cacheWriteTokens are SUB-totals within inputTokens (not additional tokens on top). prevents future dashboards from double-counting by ~2x when summing "total tokens used". |
||
|
|
57bd10d6dd |
run-issues fixes: #5, #11, #12, #15, #16/#25, #20, #21, #22, #31 (#546)
* fix(#15): precompute diff anchors in checkout_pr TOC * test(#15): update TOC snapshot for precomputed diff anchors * chore(tests): skip codex-mini-latest models.dev check + refresh latest-by-provider snapshot * fix(#22): add commitCount and commitLog to checkout_pr return * fix(#21): include PR body in checkout_pr return * fix(#5): force-fetch PR refspec to overwrite stale local branch * fix(#31): rename git tool parameter from subcommand to command * fix(#11): soft-fail post-checkout hook, bump timeout to 10min * fix(#16): strengthen diff file usage guidance Agent was bypassing diffPath and running `git diff` instead. Tighten instructions in `checkout_pr` result and remove the mixed-signal "log, diff" listing in the global Git guidance. `git log` and `git diff --stat` remain allowed for commit-range overview. * fix(#20): drop invalid inline review comments instead of failing review Previously, a single inline comment anchored outside a diff hunk would 422 the entire review submission. Pre-validate comments against the PR file patches via listFiles, drop the invalid ones, and append a note to the review body listing what was skipped. Include the dropped list in the tool response so the agent can retry targeted fixes. * fix(#12): stop MCP server on inner activity kill + filter reconnect noise Inner-activity-kill zombies were burning multi-hour runner time because mcp-proxy's SSE reconnect and provider-error retry lines kept the outer activity timer alive long after the agent subprocess was killed. - Filter [mcp-proxy] / "provider error detected" chunks so they don't count as outer-timer activity. - Add onActivityTimeout callback to spawn + thread through agent runs. - main.ts wires that callback to stop the MCP HTTP server (so reconnects finally fail instead of looping) and arms a 5min safety-net timer that force-rejects the outer timer if the agent promise is still pending. * audit: harden #12 lifecycle + cover #20/#12 with unit tests Bugs found during Ralph audit of the prior run-issues fixes: - main.ts's 5min safety-net setTimeout was never cleared on the happy path; also activityTimeout.stop() didn't null the internal rejectFn, so a late forceReject from the safety-net could still reject a long-resolved promise. Timer now cleared in finally; stop() now disarms forceReject. - mcp server disposal was non-idempotent, so the inner-kill path ran server.stop() twice once the outer `await using` block exited. Made the returned disposer idempotent. Tests: - action/mcp/review.test.ts: 14 tests for commentableLinesForFile (multi-hunk, no-count hunks, no-newline marker, empty) and validateInlineComments (file not in diff, wrong side, out-of-range line and start_line, partitioning batches, default side). - action/utils/activity.test.ts: 6 tests for isActivityNoise covering mcp-proxy lines, provider-error lines, mixed chunks, Buffer input. * audit(#22): cap commitLog at 200 + scope git-diff restriction to PR review - cap git log --oneline at 200 entries so a PR with thousands of commits cannot blow up the MCP tool response; expose commitLogTruncated so callers can warn the agent when the log was clipped - tighten instruction wording so `git diff` / `git diff --cached` remain available for inspecting an agent's own uncommitted changes, while PR review content must still come from diffPath Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * audit(#11,#22,#31): surface hook/commit warnings in instructions + polish git tool - append hookWarning + commitLogTruncated advisories to checkout_pr instructions so the agent actually sees the warning inline, not just as a field it may skip - fix stale 'subcommand' wording in git tool redirect for `pull` and in the `command` parameter description; the MCP parameter is named `command` now, and that's what the agent binds to Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix(#20): reassign params.comments even when all inline comments dropped if every inline comment fails pre-validation, the earlier guard skipped reassigning params.comments, so the submission still carried the bad comments and GitHub 422'd on the whole review. always reassign to validation.valid so the downstream 'nothing left to post' skip fires and an otherwise-empty review is no-oped cleanly. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * audit(#22): degrade gracefully when base ref isn't resolvable checkout_pr used to assume \`origin/<base>\` is always reachable, but it isn't guaranteed after a shallow fetch that only pulled down the PR head. Failing the whole checkout over metadata we added for ergonomics would be a regression, so wrap the rev-list / log in a try/catch and return empty commit metadata instead. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * audit(#12): anchor noise patterns to line start to avoid false positives before this, a line like "agent said: [mcp-proxy] was there" or "context: provider error detected in log" in real agent output would have been treated as noise and failed to reset the outer activity timer. both patterns now anchor at the start of the (optionally debug-timestamped) line, matching only lines mcp-proxy or our own log.info actually emit. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * audit(#20): export and unit-test formatDroppedCommentsNote covers single-line `path:N`, multi-line `path:start-end`, and startLine==line fallback so changes to the dropped-comments note format surface in test diffs instead of only in GitHub UI. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * audit(#20): cap dropped-comment note to stay under GitHub body limit a pathological run (agent emits hundreds of invalid inline comments on a huge PR and they all get dropped) would push the review body past GitHub's ~65KB limit and fail the whole submission with a body-too-long 422 — the exact all-or-nothing failure #20 was meant to prevent. cap the detail list at 50 entries with a "…and N more" line so the note stays bounded. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * audit(#20): distinguish binary/no-patch files in dropped-comment reason previously a comment on a binary file (or pure rename / mode-only change) was dropped with "line X is not inside a diff hunk", which misleads the agent into retrying with different line numbers. call out the no-textual-diff case explicitly so the agent knows to move that feedback to the review body instead. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * audit(#11): replace lifecycle timeout string-match with typed sentinel spawn() now rejects with SpawnTimeoutError (code === SPAWN_TIMEOUT_CODE or SPAWN_ACTIVITY_TIMEOUT_CODE) instead of a plain Error. executeLifecycleHook now branches on that code so rewording the error message in subprocess.ts can no longer silently misroute timeouts into the "transient — retry" warning. * audit(#12): route agent hung-vs-failed via typed SpawnTimeoutError claude.ts and opentoad.ts decide between "hung" and "failed" log wording based on the subprocess error. move them off the literal "activity timeout" substring match onto the same SPAWN_ACTIVITY_TIMEOUT_CODE sentinel used by lifecycle.ts so all three call sites agree on the source of truth. * audit(#20): delete leftover pending review when submit fails Why: `createAndSubmitWithFooter` creates a PENDING review first so we can mint Fix-links with the review ID, then submits. If submitReview fails (e.g. 422 from a race where the diff moved between pre-validation and submission), the draft was left on the PR. GitHub only allows one pending review per user, so the agent's retry would then fail with "already has a pending review" — an error the agent has no tools to clean up from. Best-effort cleanup: delete the pending draft on submit failure before re-throwing the original error, so retries start from a clean slate. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * audit(#31): point agent to concrete alternative when rebase/bisect blocked Why: in disabled-shell mode, `git rebase` and `git bisect` are blocked as arbitrary-code-execution escape hatches. Previous error messages explained *why* but left the agent without a next step — especially painful right after the `pull` redirect, which suggested "merge or rebase locally." The agent would follow that advice, hit the rebase block, and loop without knowing what to try next. Now: rebase block explicitly says "use 'merge' instead"; bisect block notes that manual bisect is also unavailable through this tool; pull redirect no longer recommends rebase in shell-disabled contexts. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * audit: import security tables into security.test to prevent drift Why: the security tests re-declared AUTH_REQUIRED_REDIRECT, NOSHELL_BLOCKED_SUBCOMMANDS, and NOSHELL_BLOCKED_ARGS inline with hand-copied message strings. When the runtime messages in git.ts were tightened (recent rebase/bisect guidance updates), the test copies drifted and tests validated a stale version of the logic while passing clean. A missing or mistyped entry in git.ts could therefore slip through. Now: export the tables from git.ts and import them into the test file. If a runtime message changes, the tests exercise the new string automatically; if an entry is added or removed, tests covering that command see the change without manual sync. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * audit: widen pending-review cleanup to cover pre-submit throws getApiUrl() (invoked in footer build) can throw if API_URL is misconfigured, which would leak a pending draft between createReview and the previous submitReview try/catch. Move the try/catch to wrap the entire post-create body so any throw routes through deletePendingReview cleanup. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * audit: reject leading-dash refs/branch names to block flag injection git's parseopt accepts options intermixed with positional args, so a ref like "--upload-pack=evil" passed to git_fetch could be parsed as a flag rather than a refspec. Add a narrow rejectIfLeadingDash helper to git_fetch (ref), delete_branch (branchName), and push_branch (branchName). HTTPS remotes ignore --upload-pack server-side, but the hygiene matters for defense in depth (ssh remotes, future code paths). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * audit: validate the resolved branch in push_branch too When branchName is omitted, rev-parse surfaces the current branch name, which could start with '-' if git state was tampered with. Move the leading-dash check to after the branch is resolved so both the explicit and derived paths go through validation. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * audit: cache commentable-lines snapshot at checkout to match review anchor Review comments are anchored to checkoutSha (commit_id), but validation was hitting pulls.listFiles at review time — latest HEAD, not the SHA the agent actually reviewed. If the PR was updated mid-run, valid comments could be silently dropped (or invalid ones admitted). Snapshot the commentable lines during checkout_pr so review-time validation matches the anchor exactly. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * audit(#12): route activity monitor's own debug output around the write wrap startProcessOutputMonitor monkey-patches process.stdout.write to mark activity, then called log.debug(...) every 5s to report idle time — which landed right back in its own wrapper, failed isActivityNoise, and called markActivity. with ACTIONS_STEP_DEBUG=true (common on reruns) the idle counter reset every interval and the timeout could never fire, re-creating the #12 zombie-run bug for any debug-enabled run. Fix: capture the original stdout.write and use it directly for the monitor's own diagnostics so they bypass the feedback loop. Added a tight-timeout regression test that asserts the timeout still rejects in debug mode. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * audit(#12): noise-filter subprocess.ts monitor logs so outer timer survives debug activity.ts's own monitor output already bypasses the wrap (c35cd3fb), but subprocess.ts's spawn activity timer uses log.debug — which goes straight through process.stdout.write and would still mark activity on every interval when debug logging is enabled. Pattern-filter those '(spawn|process) activity (check|timer|monitor)' lines in both local ([DEBUG] ...) and GH-runner (::debug::...) formats so they don't reset the outer agent-hang timer. Kept scoped to those specific monitor messages — a blanket [DEBUG] filter would silently classify any coincidentally-debug-prefixed agent output as idle, which is a worse failure mode than the one we're fixing. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * audit(#11): surface spawn ENOENT-style errors in stderr buffer spawn() resolved with exitCode=1 and an empty stderr when the command itself couldn't start (missing binary, bad permissions). lifecycle.ts then reported 'output: (empty)' to the user, who was explicitly told 'retry if the failure looks flaky' — so every run hit the same wall with no diagnostic trail. Append the '[spawn] <cmd>: <node error>' line to stderrBuffer before resolving so the real cause (ENOENT, EACCES, …) flows through to the hook-warning message. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * audit(#11,#12): cover executeLifecycleHook typed-timeout routing the typed SpawnTimeoutError + sentinel-code branching introduced in d7ee7fd2 / ea8dd2c4 classifies hung vs failed lifecycle hooks — critical for whether agents retry — but had no unit coverage. add tests for all four branches (no script, exit 0, non-zero exit with retry-if-flaky guidance, timeout with do-NOT-retry guidance, transient spawn failure). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * audit: re-verify clean tree after prepush hook the pre-prepush check guarantees we enter the hook with a clean tree, but if the hook writes tracked files (formatter, type generator, build artifacts), the push still only sends the pre-hook commit — the hook's edits silently disappear from the upstream branch while the tool reports "successfully pushed". add a post-hook status check so the agent sees the dropped mutations and can commit or discard them before retrying. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * audit: reject push_tags refspec injection via ':' in tag name without tag validation, a tag like "foo:refs/heads/main" concatenated into "refs/tags/${tag}" becomes a valid <src>:<dst> refspec — git pushes the local refs/tags/foo's commit to remote main, bypassing push_branch's default-branch guard. same shape blocks leading '-' (flag injection) and other refspec metacharacters (~ ^ ? * [ \) via an allow-list regex. only reachable in push=enabled today, so this is defense-in-depth, but hardens the tool in case push_tags is ever exposed in restricted mode. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * audit: stop pointing agents at an internal constant they can't change the lifecycle-hook timeout warning told agents to "bump LIFECYCLE_HOOK_TIMEOUT_MS" — but that's a hard-coded constant in the action, not something the agent or repo owner can tune. the agent would plausibly loop hunting for where to change it. redirect to the actual lever they control: ask the repo owner to simplify the hook. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * audit: drop inverted inline-comment ranges locally with precise reason validateInlineComments only checked that both line and start_line anchor inside a hunk, not that start_line <= line. an inverted range (e.g. start=44, line=42) would pass local validation and GitHub would 422 with "invalid line numbers" — opaque to the agent and unfixable without reading docs. reject locally with a reason that names the constraint. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * audit: don't let usage-summary write error mask main's outcome writeGitHubUsageSummaryToFile is called in main's finally block. it can throw on ENOSPC / EACCES / missing parent dir. a throw here propagates past the try's successful return or the catch's error return, hiding the actual run outcome behind an I/O failure on a purely informational file. swallow the write error (debug-logged) — the summary is nice-to-have, not load-bearing. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * audit: don't mislabel agent handler errors as JSON parse failures the onStdout event loop wrapped both JSON.parse and the handler call in one try/catch that logged every caught error as 'non-JSON stdout line'. if a handler threw (e.g. todowrite state shape drift), the error was silently classified as a parse error, making diagnosis impossible. split the try blocks so JSON errors and handler errors get distinct, identifying log lines. * audit: reject leading-dash PR refs before they reach git commands PR head/base refs come from GitHub and are attacker-controlled on fork PRs (the PR author picks headRef freely). they flow straight into `git fetch origin <ref>`, `git checkout -B <ref>`, and config writes. without a leading-dash check, a ref named like '-upload-pack=evil' could be parsed as a flag instead of a refspec. validate both refs at the top of checkoutPrBranch (before any async work) and cover the two attack shapes with unit tests. * audit: cover ActivityTimeout.stop()'s forceReject disarming main.ts's safety-net-timer path depends on ActivityTimeout.stop() nulling out rejectFn so a late safety-net fire after a successful agent run is a no-op. that behavior had no direct coverage — removing the \`rejectFn = null\` in stop() would silently break the happy path (unhandled rejection / spurious failure) without failing any test. add three tests covering: forceReject rejects with the reason, stop() disarms forceReject, and forceReject after timer rejection is an idempotent no-op. * audit: stabilize activity-timeout idleSec against late stdout race * audit: reject 0ms timeout parses to avoid insta-fail from '0m' * audit: surface raw GitHub error on review 422 instead of assuming anchor cause * audit: key commentable-lines cache by PR number to prevent cross-PR drift * audit: enumerate concrete 422 causes and name checkout_pr in review error * audit: stop shipping ralph-loop runtime state in PR history .claude/ralph-loop.local.md and .claude/ralph-loop-prompt.md were accidentally staged in an earlier audit commit. the .local.md suffix is conventional for gitignored runtime state, and the prompt file is per-run harness config — neither should merge to main. ignore the pattern and untrack the existing entries (files remain on disk so the active loop keeps working). * audit: pin commentable-lines cache to checkoutSha, not just PR number a second checkout_pr(N) call advances toolState.checkoutSha at line 305 or 334, then runs fetchAndFormatPrDiff + cache population at line 549. any throw between those two points (rate limit, 5xx, network blip) left the old snapshot keyed to (pullNumber=N) while checkoutSha now points at a different sha. review_pr(N) would reuse the stale snapshot, silently validating comments against the wrong anchor — the original failure this cache was meant to prevent. track commentableLinesCheckoutSha alongside the pull number and require both to match before returning the cache. if either has moved, fall back to listFiles like any other miss. * audit: auto-clear leftover pending review from killed prior runs a workflow timeout or OOM between createReview PENDING and submitReview leaves GitHub holding a pending draft. the next run hits GitHub's one-pending-per-user-per-PR limit and 422s at pending-create, with no way to recover short of a human cleaning up manually. catch 422 at pending-create, list the PR's reviews (GitHub only exposes our own pending to us, so the filter is safe), delete the leftover, and retry once. 404/422 on the cleanup are treated as no-ops (race with another concurrent cleanup or the draft was submitted); any other cleanup error rethrows so the real cause reaches the caller. * audit: extract + unit-test stranded-pending-review cleanup the recovery branch inside createAndSubmitWithFooter had no direct test coverage. a regression in any of its guards (status check, message match, listReviews filter, 404/422 tolerance, non-retryable rethrow) would silently cause either destructive deletes of unrelated reviews or the old failure mode where a stranded pending draft blocks every retry. extract to clearStrandedPendingReview so the cases can be exercised with a mocked octokit, and add tests for each branch — including the load-bearing negative cases (non-422 passthrough, non-pending-review 422 passthrough, no-leftover-found passthrough, non-retryable cleanup error passthrough). no behavior change at the call site. * audit: document concurrent-run race in clearStrandedPendingReview two runs on the same PR using the same GitHub App installation token would both see each other's PENDING draft via listReviews (GitHub exposes PENDING only to the author, and both runs share authorship). the loser's recovery path would delete the winner's active draft, causing the winner's submitReview to 404. no reliable in-request signal distinguishes a genuinely-stranded prior-run draft from an active peer's draft — PENDING reviews have no created_at, and the user field is the same bot in both cases. the correct fix is workflow-level concurrency (a per-PR concurrency key), not a heuristic here. document the limitation so future readers don't try to bolt on a broken heuristic. * audit: report signal-killed subprocesses as failures, not exit code 0 node's close event delivers (code=null, signal=<name>) when a child is killed by signal (OOM killer, segfault, external SIGTERM). the close handler captured only exitCode and coerced null to 0 via `exitCode || 0`, so lifecycle hooks killed by signal were silently reported as successful — lifecycle.ts's `if (result.exitCode !== 0)` check skipped the warning and callers proceeded as if setup/post-checkout/prepush had completed. now capture signal, append "killed by signal <name>" to stderr, and resolve with exitCode=1 when code is null but signal is set. adds a regression test that spawns `kill -KILL \$\$` and asserts a non-zero exit plus the signal-kill marker in stderr. * audit: untrack RUN_ISSUES*.md ralph-loop working docs same pattern called out in 4f14dbf1: these files are per-run harness state and analysis scratch, not merge-to-main deliverables. the TODO literally opens with "Ralph loop instructions:", so it's unambiguously in the same category as .claude/ralph-loop-prompt.md was. files stay on disk so the active loop keeps working. * audit: block refs/... + symbolic-ref bypass of default-branch guard push_branch's restricted-mode guard compared the resolved remoteBranch against defaultBranch with exact-string equality. an agent passing branchName "refs/heads/main" flowed through: rejectIfLeadingDash passed, getPushDestination's fallback preserved the refs/heads/main string as remoteBranch, so "refs/heads/main" !== "main" and the block was skipped, yet git push happily resolved refs/heads/main to the local main commit and pushed to the remote main branch. symbolic refs (HEAD / FETCH_HEAD / ORIG_HEAD / MERGE_HEAD) are the same class of bypass — they resolve to whatever commit they point at, unconstrained by the name-based guard. add rejectSpecialRef to enforce bare branch names at the tool entry, use it in push_branch and delete_branch. checkout_pr only ever assigns pr-<number> as the local branch, so nothing legitimate relied on the refs/... form here. * audit: keep original 422 visible when listReviews fails during pending-review cleanup if listReviews threw (e.g. transient 502, rate limit) during the stranded pending-review recovery path, the listing failure replaced the original 422 "pending review" error when it propagated up through the tool's outer catch. agents then saw a generic server error with no mention of the real blocker and stopped retrying the cleanup. now the listing failure is logged at debug but does not mask the original 422. the caller's retry re-attempts cleanup, which succeeds if the listing failure was transient. * audit: block default-branch deletion even under push: enabled delete_branch required push: enabled, but within that mode the agent could delete the default branch with no local guard. GitHub branch protection usually catches this at the remote, but not every repo has protection configured — and even when it does, relying on remote config for local safety is wrong. pushing to main is reversible (revert, force-push old HEAD); deleting main is not (reflog recovery only, 30-day window). block deletion of the resolved default_branch in DeleteBranchTool regardless of push permission. push: enabled authorizes pushes, not wholesale removal of the repository's primary branch. * audit: attach no-op catch to agentPromise so a late rejection can't crash cleanup agentPromise raced against activityTimeout.promise (and the --timeout timeoutPromise), both of which had .catch(() => {}) handlers. agentPromise did not. if a timeout won the race, agentPromise became stranded and its subsequent rejection was an unhandled rejection — under node 15+'s default unhandled-rejection policy that terminates the process, which would kill main() mid-cleanup and lose the error-reporting and usage-summary work queued in the catch/finally blocks. the race still sees the rejection (the original promise is shared); this catch only prevents node from treating a post-race rejection as unobserved. * audit: close push_branch refspec-injection via ':' / '+' in branchName rejectSpecialRef only forbade leading-dash, `refs/` prefix, and symbolic refs. git push accepts `[+]src[:dst]` refspec syntax, so an agent under push:restricted could smuggle a full refspec through branchName and bypass the downstream exact-string default-branch guard: "evil:refs/heads/main" → push local 'evil' to remote main ":refs/heads/main" → delete remote main ":other" → delete arbitrary branches (outside grant) "+main" → force-push refspec prefix reject ':', '+', '^', '~', '?', '*', '[', '\\', and whitespace — git's own check-ref-format forbids all of them in branch names, so the allow-list cannot false-positive against a legitimate branch. add regression tests. * audit: stop suggesting blocked 'rebase' in push_rejected advice under shell=disabled Why: when push fails with non-fast-forward, the advice told the agent to run 'git rebase origin/...'. In shell=disabled mode the git MCP tool blocks rebase (as an arbitrary-code-execution escape hatch), so the agent's only path forward was to hit the block, read the fallback message, and try merge — one wasted round trip. Now: under shell=disabled we directly suggest 'git merge origin/...', which always works. Under other modes the advice keeps the rebase/merge choice but leads with merge so the example is copy-pastable either way. * audit: harden includeIf cleanup against shell-injection via subsection names setupGit read `includeif.*` keys via `git config --get-regexp`, split on the first space, and fed the result into `execSync(\`git config --unset "${key}"\`)`. git config subsection values preserve arbitrary characters, so a crafted `[includeIf "gitdir:$(touch${IFS}/tmp/pwn)safe"]` entry round-trips through `--get-regexp` with its `$(...)` command substitution intact, survives the split-on-space filter (IFS-bypass leaves the payload space-free), and gets evaluated when interpolated into the shell command. Confirmed reachable as an RCE sink in local repro. Switch to `--get-regexp -z` (null-terminated, no ambiguity on whitespace) and call `$("git", ["config", "--unset-all", key])` which uses spawn-array and never hands the key to a shell. Extract the logic into `removeIncludeIfEntries` and add regression tests covering the injection payload, whitespace-in-subsection keys, benign entries, and the no-op case. * audit: clear SIGKILL escalator on clean SIGTERM exit the overall-timeout path scheduled a 5s SIGKILL follow-up without capturing the timer id. if the child cooperated with SIGTERM and `close` fired promptly, the escalator stayed pending in the event loop for up to 5s — delaying any subsequent clean shutdown (e.g. the main action exiting after an agent timeout) by that long. capture sigkillEscalatorId alongside timeoutId and clear it in both close and error handlers. regression test asserts the active-timer count does not grow past the pre-spawn baseline after a timed-out child exits on SIGTERM. * audit: correct rebase-availability hints to reflect shell=restricted the MCP git tool only blocks rebase when shell=disabled (NOSHELL_BLOCKED_SUBCOMMANDS check in GitTool). under shell=restricted, git({command: "rebase"}) works fine through the tool — NOSHELL_BLOCKED_SUBCOMMANDS doesn't apply. but two agent-facing messages implied rebase is only available with shell=enabled: - AUTH_REQUIRED_REDIRECT["pull"] said "rebase is only available when shell is enabled" - push-rejected integrateStep (non-disabled branch) said "(or 'rebase' if shell is enabled)" under shell=restricted, agents reading these would wrongly think they had to pick merge — pushing them toward merge commits when rebase would have been cleaner. the push-rejected branch is already ternary-gated on shell !== "disabled", so the qualifier there was just redundant noise. * audit: block difftool/mergetool under shell=disabled git difftool -x <cmd> is the short form of --extcmd. the args blocklist only matches --extcmd / --extcmd=*, so -x slipped through and let an agent run arbitrary commands even when shell=disabled. globally blocking -x would false-positive on git cherry-pick -x, which only appends metadata, so block difftool (and mergetool, same shape via mergetool.<name>.cmd) at the subcommand level instead. agents have no legitimate need for either — diffs go through diff/show and merges are resolved by file edits. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * audit: recover stranded PENDING drafts on no-body createReview too The body path already clears a stranded PENDING draft from a prior crashed run via createAndSubmitWithFooter's own try/catch. The no-body path (approve-with-no-feedback or comments-only) called createReview directly — so a PR whose previous body-path run crashed between createReview(PENDING) and submitReview would permanently 422 any subsequent no-body review with "already has a pending review" until a body-path run happened to clear it. Factored out createReviewWithStrandedRecovery so both paths get the same recovery treatment, and added regression tests covering the no-stranded / stranded-and-retry / non-stranded-422-no-retry cases. * audit: reject timeouts past node's setTimeout ceiling a user-supplied timeout like "999h" parses fine (parseTimeString has no upper cap) but falls off the 2^31-1 ms limit setTimeout clamps to 1ms. the agent run would reject with "timed out after 999h" in a single tick. extract a resolveTimeoutMs helper that centralizes the zero/overflow/ unparseable checks (previously scattered behind inline boolean logic in main.ts) and cover the behavior with unit tests including the boundary value. * fix(#22): replace parameter property in SpawnTimeoutError node --experimental-strip-types rejects readonly/public/private param properties in constructors. tests run via node directly (no tsc), so CI was hitting ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX on every action-agents / action-agnostic job before any test code ran. declare the field and assign in the body instead. * audit: tighten git tool description and delete_branch refspec - `git` tool description previously implied `pull` had a dedicated MCP tool alongside `push_branch`/`git_fetch`. it doesn't — the redirect sends the agent back to the same git tool with `command: "merge"` (or `rebase`). update the description to teach this directly instead of letting agents discover it through the redirect error. - `delete_branch` now passes `refs/heads/${branchName}` to `git push --delete` so a same-named tag can't be silently deleted when both exist on the remote. `rejectSpecialRef` already guarantees the bare-name invariant, so the template construction stays injection-safe. Made-with: Cursor * audit: polish review.ts per anneal findings - drop `as "LEFT" | "RIGHT"` cast in `validateInlineComments` — octokit types `side?: string` at the createReview endpoint, so narrow via `c.side === "LEFT" ? "LEFT" : "RIGHT"`. no cast, no redundant annotation — TS infers the literal union from the ternary. - consolidate `clearStrandedPendingReview` from 3 params to 2 by folding `originalErr` into `params`, per AGENTS.md "max 2 parameters" rule. updates both call sites (`createReviewWithStrandedRecovery`, `createAndSubmitWithFooter`) and all 7 test paths. - upgrade `listReviews`-during-cleanup failure log from `log.debug` to `log.info` so operators not running at debug still see that recovery was attempted before the original 422 bubbles up. message now reads "surfacing original 422" to make the intent unambiguous. Made-with: Cursor * audit: signal partial commit metadata in checkout_pr previously a rev-list/log failure (e.g. shallow fetch where `origin/<base>` isn't reachable) silently returned `commitCount: 0, commitLog: ""` — indistinguishable from "this PR has no commits past base", which could mislead review reasoning about scope. add a `commitLogUnavailable: boolean` field to `CheckoutPrResult`, set when the rev-list/log calls throw. instructions footer now tells the agent to treat the values as "unknown" rather than "no commits" in that case. message phrased to cover the rare case where rev-list succeeds but git log throws (partial, not strictly zero) metadata. Made-with: Cursor * audit: fix parseDiffTocEntries to match production ' · diff-<sha>' TOC suffix the regex required $ right after the line range, but formatFilesWithLineNumbers in checkout.ts appends ` · diff-<sha256>` so agents have the GitHub "Files Changed" anchor precomputed. result: tocEntries was always empty on real PR reviews, breakdown.files was empty, and runDiffCoveragePreflight never fired its one-time "read the diff" nudge. add an optional suffix to the regex and a regression test that uses the exact production TOC shape. Made-with: Cursor * audit(#20): skip empty downgraded-APPROVE reviews before they 422 GitHub rejects `event: "COMMENT"` reviews with no body and no inline comments (HTTP 422 "Unprocessable Entity", verified empirically on repos/pullfrog/preview-546-run-issues-fixes/pulls/1). the runtime `prApproveEnabled` downgrade folds approved=true into event=COMMENT when the repo flag is off, so an agent asking to APPROVE a PR with no other feedback produces exactly that rejected shape — but the existing empty-review skip only fired for !approved cases, so the tool POSTed the doomed COMMENT, octokit returned what looked like a success-with- no-persisted-review shape, and agents reported a phantom reviewId that 404s on any subsequent GET. extract the skip decision into `reviewSkipDecision` and add a second branch for approved + !prApproveEnabled + empty. the function returns null when the review should be submitted, so a real bare APPROVE (approved + prApproveEnabled + empty) still goes through unchanged — GitHub accepts empty APPROVE reviews because the stamp itself is the content. surfaced in the PR #546 preview e2e run 24678139563 (reviewId 4141786854 reported by the agent but absent from every reviews listing). TC13 run 24680349445 re-ran the same scenario with prApproveEnabled=enabled and the review persisted correctly, isolating the cause to the downgrade + empty interaction. * audit(#31): drop misleading rebase mention from pull redirect AUTH_REQUIRED_REDIRECT["pull"] and the git tool's top-level description both said "use git_fetch then this tool with command 'merge' (or 'rebase' unless shell is disabled)". the "(or 'rebase' unless shell is disabled)" qualifier is active misinformation when the agent is already running under shell=disabled: rebase is blocked there by NOSHELL_BLOCKED_SUBCOMMANDS, so the suggestion sends the agent into a second block on the next tool call. 3b83ee97 already fixed this pattern for the push-rejected advice at line 248, but the pull redirect at line 280 and the tool description at line 351 were missed. the right copy isn't a conditional qualifier that agents have to parse against their own shell mode — it's just naming the one alternative that works everywhere (merge). agents under shell=restricted/enabled who want rebase can invoke it directly; the redirect doesn't need to advertise it. verified in preview e2e run 24679728733 (TC8 probe 6) where the agent correctly captured the verbatim redirect message under shell=disabled and explicitly flagged the "(or 'rebase' unless shell is disabled)" clause as confusing — the new test in security.test.ts asserts the message names merge and never rebase in every shell mode. * audit: drop vestigial entry/post references + add preview-546 settings util followup to d79860c6 "refactor: flatten action entrypoints" (Apr 10), which moved action.yml from built `entry`/`post` files to source `entry.ts`/`post.ts` but left three stale references lying around: - .gitignore: `action/run/entry` / `action/dispatch/entry` paths no longer exist anywhere in the build. - .github/workflows/pull-from-action.yml: agent instruction told the upstream sync agent to "Ignore `entry` files (they are built artifacts and .gitignored in this repo)". there are no built entry artifacts anymore — entry.ts is source. - .cursor/settings.json: search.exclude pattern "**/entry" excluded the old built files that no longer exist. none of these were load-bearing on their own, but the same drift had already broken preview e2e end-to-end: the pullfrog/template workflow's three-file copy step (cp .../entry, cp .../post) silently failed with cp: no such file on every preview PR since Apr 10. that template fix went to pullfrog/template@7ec7c8d and the preview-546 mirror at @17ab585, which is what unblocked this PR's full e2e validation. also adds scripts/preview-546-settings.ts, the helper used during the e2e validation to show/set/reset DB-level repo settings on the Neon preview branch (push, shell, prApproveEnabled, hook scripts). scoped to this preview repo ID so it can't accidentally mutate prod. * audit(#11): scope removeIncludeIfEntries to repoDir under inherited GIT_* the function takes `repoDir` as the target, but plain execSync / $(...) inherit GIT_DIR, GIT_WORK_TREE, and GIT_INDEX_FILE from the parent process — and `git config --local` honors GIT_DIR over cwd. when this runs as a child of another git invocation (notably the pre-push hook, but also any future caller embedded inside a git subcommand), the cleanup silently targets the outer repo instead of repoDir. latent today because the real caller is ASKPASS setup, which runs before any git-subcommand ancestor exists, but the function's contract still promised the wrong thing — and the test suite hit exactly this bug when invoked through `git push`. - envScopedToRepo() strips GIT_* before both the get-regexp and unset calls, so cwd wins. - swap the $(...) shell helper for execFileSync on the unset call. $() would merge our scoped env with a "restricted" base that's tuned for hook execution (no tokens) — overkill here and it re-introduces the shell-vs-argv distinction this function was explicitly hardened against in a9aa3b2b. execFileSync with argv is the right tool for a call where the key can contain arbitrary characters. - setup.test.ts also strips GIT_* in its own execSync harness so the suite passes identically under `pnpm vitest run`, `pnpm -r test`, and `git push`'s pre-push hook. --------- Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com> Co-authored-by: Colin McDonnell <colinmcd94@gmail.com> |
||
|
|
a71567af90 |
fix models-live matrix: resolve alias in PULLFROG_MODEL + pass all provider keys through docker
two bugs blocked the live matrix from reaching real APIs: 1. resolveModel returned PULLFROG_MODEL raw without passing it through the alias registry. when CI set PULLFROG_MODEL=anthropic/claude-opus (alias), the bare alias slug was forwarded to the Anthropic API as a model id and 404'd. now resolves via resolveCliModel first, with raw specifiers (anthropic/claude-opus-4-6) still passing through unchanged. 2. the testEnvAllowList in docker.ts only forwarded Anthropic/OpenAI/Google keys into the test container. XAI/DeepSeek/OpenRouter/Moonshot/OpenCode keys got stripped, so every non-big-3 alias failed with "no API key found" even when the secret existed. add all five to the allowlist. Made-with: Cursor |
||
|
|
56a5d29598 |
add diff coverage preflight for PR review submissions (#544)
* add one-time diff coverage preflight for PR reviews track diff read coverage from agent tool-use events and run a one-time pre-flight before review submission, with explicit coverage skip reasons for low-value files like lockfiles. Made-with: Cursor * add manual dispatch fallback for preview deploy workflow allow preview repo and preview sync jobs to be run via workflow_dispatch with explicit PR number and branch inputs, so preview provisioning can be retriggered when pull_request events fail to fire. Made-with: Cursor * fix manual preview dispatch PR input wiring use normalized PR number and branch env values for comment creation and script env wiring so workflow_dispatch preview runs can create and update PR-specific preview resources. Made-with: Cursor * remove obsolete snapshots invalidated by checkout instructions change * fix diff coverage read offset handling and add local sanity-check guidance normalize read offset semantics for diff coverage tracking, reuse shared range counting in review preflight, add focused diff coverage unit tests, and document the local play.ts testing workflow in AGENTS.md. Made-with: Cursor * add regenerated mcp test snapshots capture snapshot files generated by the review comment and checkout formatting tests during pre-push validation so the branch remains clean and reproducible. Made-with: Cursor * add diff coverage preflight instrumentation logs log diff coverage initialization in checkout_pr and emit preflight state/breakdown diagnostics in create_pull_request_review to debug missing coverage enforcement in preview e2e runs. Made-with: Cursor * add env override to force local cli execution in action runtime support explicit local-cli execution via PULLFROG_FORCE_LOCAL_CLI so preview workflows can run branch action code instead of the npm fallback package during e2e debugging. Made-with: Cursor * add preview e2e debugging learnings for action runtime validation capture the preview execution-path gotchas and one-time preflight verification pattern in AGENTS.md so future investigations validate the real runtime and avoid npm fallback confusion. Made-with: Cursor * reduce diff coverage log noise while preserving failure visibility downgrade verbose diff coverage lifecycle diagnostics to debug, keep a concise info-level pre-flight failure signal, and document preview runtime debugging learnings in AGENTS.md. Made-with: Cursor * WIP * tune sync.md: ff override + softer overlap verification Made-with: Cursor * chore: bump models snapshot for claude-opus-4-7 Made-with: Cursor * rip out coverage_skips waiver from diff coverage pre-flight Made-with: Cursor --------- Co-authored-by: pullfrog[bot] <226033991+pullfrog[bot]@users.noreply.github.com> |
||
|
|
74b313e612 |
bump claude-opus alias to 4-7
anthropic shipped claude-opus-4-7 today; opencode also republished it. point the "claude-opus" alias at the new version for both providers so existing users get the upgrade automatically. openrouter hasn't published 4.7 yet, so leave openRouterResolve at 4.6 as the BYOR fallback. also clarify the latest-model snapshot comment: new model drops usually just mean bumping the `resolve` on an existing alias, not adding a new one. Made-with: Cursor |
||
|
|
569d34b0a9 |
lower startup verbosity for git binary fingerprint log.
switch the git binary fingerprint message to debug level and keep the chevron log prefix for consistency with action logs. Made-with: Cursor |
||
|
|
a607ac29e1 |
fix restricted env filtering precedence for safe prefixes
remove broad `PULLFROG_` passthrough from restricted shell env filtering and ensure sensitive names are blocked unless explicitly allowlisted, then align the restricted test fixture with allowed-prefix coverage. Made-with: Cursor |
||
|
|
2d1f1d33db |
replace suffix-based env filtering with default-deny allowlist (#543)
* replace suffix-based env filtering with default-deny allowlist filterEnv() now only passes known-safe GitHub Actions runner/system/toolchain vars plus user-configured allowlist entries to shell subprocesses. GITHUB_TOKEN and GH_TOKEN are always blocked, even from the user allowlist. adds envAllowlist field to repo settings with dashboard textarea UI (visible only when shell isolation is enabled) and wires it through run-context API to the action runtime. Made-with: Cursor * address review: blocked-name warning, JAVA_HOME prefix, stale waitlist copy - setEnvAllowlist now strips BLOCKED_ENV_NAMES from user input and returns them so main.ts can log a warning - move JAVA_HOME to exact names, use JAVA_HOME_ as prefix for clarity - update stale suffix-based description in waitlist email script Made-with: Cursor * fix wiki/security.md snippet: JAVA_HOME -> JAVA_HOME_ to match code Made-with: Cursor * UI polish: field-sizing-content on all textareas, rename env allowlist label - add field-sizing-content to all settings textareas so they auto-expand to fit content (AgentSettings, ModesSettings, WorkflowsSettings, FlagsSettings) - rename "Environment variable passthrough" to "Environment allowlist" with clearer popover copy - drop "e.g." prefix from env allowlist placeholder - update docs/security.mdx and wiki/security.md references to match Made-with: Cursor * tweak env allowlist popover wording Made-with: Cursor * document default allowed variables in security docs with link from popover Made-with: Cursor * Update action/utils/secrets.ts Co-authored-by: pullfrog[bot] <226033991+pullfrog[bot]@users.noreply.github.com> --------- Co-authored-by: pullfrog[bot] <226033991+pullfrog[bot]@users.noreply.github.com> |
||
|
|
a120160f42 |
clean up startup run configuration logs
remove duplicate model and agent log emitters, then print model, agent, push, shell, and timeout on separate startup lines so run settings stay concise and easy to scan. Made-with: Cursor |
||
|
|
18c8d34da6 |
remove task list from review bodies; keep in progress comments only (#542)
review bodies were embedding a task-list snapshot that could capture stale in-progress state due to timing between the agent's final TodoWrite and the review submission API call. progress comments are the authoritative checklist surface — remove the review-body embedding entirely so there is a single source of truth. also adds a `completeInProgress` option to `renderCollapsible` so the progress-comment path can finalize any in-progress items at render time without mutating tracker state. Made-with: Cursor |
||
|
|
4b3c5ca905 |
rename agent key to opencode and add skill invocation coverage (#541)
* rename agent key to opencode and add skill invocation coverage. add skill-invoke tests for claude and opencode with local play-based validation signals, update CI matrices, and include the current tracked refactors in this branch for review. Made-with: Cursor * exclude agent-specific skill-invoke tests from wrong agent in CI matrix * address review follow-up and preserve workflow run UI tweak. switch changed-agents ci coverage to exercise the opentoad implementation path while keeping the opencode expectation, and include the local workflow run client interaction updates requested on this branch. Made-with: Cursor * remove opentoad agent filename from runtime. rename the opencode harness implementation file from opentoad.ts to opencode.ts and update ci coverage input accordingly so action code no longer carries the old filename. Made-with: Cursor * ensure security prompt bypass is set on every test fixture. this keeps adversarial and permissions harnesses from being blocked by the default prompt-injection refusal path during CI security tests. Made-with: Cursor --------- Co-authored-by: pullfrog[bot] <226033991+pullfrog[bot]@users.noreply.github.com> |