f3d18401ac
* eager signup credit + free-OpenCode fallback when BYOK has no key
addresses the silent-churn pattern that took out 15 first-run-failure
accounts post-launch: GH Actions secret references resolved to empty
strings (because the secrets didn't exist on the repo), the action
launched Claude Code with no key, the LLM provider 401'd, and the run
died in seconds with a synthetic "Invalid API key" message. those
accounts had no Router credits to fall back to because the lazy claim
required a dashboard visit they never made.
three changes, one PR:
1. Eager $10 signup credit at account creation. Both account-creation
sites (`upsertAccountByClerkId` for dashboard signin, `fetchOrCreateRepo`
for CLI / GH-App-only) now insert the `CreditGrant { reason: "signup" }`
in the same transaction as the `accounts` row. CLI installers who
never sign in get the credit. The dashboard `/signup-credit/claim`
POST stays as an idempotent backstop for accounts created before
this shipped.
2. Free-OpenCode fallback in the action. When the configured BYOK slug
needs a provider key the runner doesn't have, swap to
`opencode/minimax-m2.5-free` before agent selection so the run still
succeeds. Surfaced via a `» fell back from <slug> to <free>` warning
in the action log. Skipped on Router runs (Pullfrog mints the key)
and when no model is configured (auto-select-with-throw still fires
for the genuinely-misconfigured case).
3. New action-test fixture `byok-no-keys-fallback` that empty-strings
every known provider key (matching how GH Actions handles missing
secrets) and asserts the run succeeds with the fallback log line
present. plus a unit test for the helper covering each skip case.
skipping the schema flip from `byok` to `router` — that's coming via
the onboarding-stepper PR (#762).
* fallback: skip Bedrock + surface in PR-comment footer
addresses copilot review on #789 (real bug — parseModel throws on
Bedrock raw IDs that have no slash, would crash before
validateBedrockSetup could surface its own error) and the user-side
ask to make the fallback visible in PR comments.
- selectFallbackModelIfNeeded skips when resolvedModel has no '/' so
Bedrock routing IDs (e.g. us.anthropic.claude-opus-4-7) don't crash
inside hasProviderKey -> parseModel. unit test covers it.
- toolState.modelFallback records the configured slug we fell back
from. set in main.ts when fallback engages.
- buildPullfrogFooter accepts fallbackFrom and renders
"Using `MiniMax M2.5` (free) (credentials for Claude Opus not
configured)" so the substitution is visible in PR comments,
reviews, PR bodies, and error reports.
- threaded through all four action-side footer call sites
(mcp/comment, mcp/pr, mcp/review, utils/errorReport). server-side
call sites in triggerWorkflow.ts / handleWorkflowRunWebhook.ts
fire pre-action and don't have toolState — left as-is.
* fallback footer: use provider display name + document email asymmetry
addresses pullfrog reviewer findings on #789:
- footer now shows 'credentials for Anthropic not configured' (provider
display name from `providers.anthropic.displayName`) instead of the
per-model name. credentials are provider-scoped (ANTHROPIC_API_KEY
covers all Anthropic models), so this matches what the user actually
needs to fix.
- document the intentional asymmetry between eager and lazy signup
credit paths: eager skips both the signupCreditClaimedEmail and the
per-grant team@ alert. comment explains why (the 'new account
created' alert already covers it on the eager path; the user-facing
email assumes a user-initiated action that hasn't happened yet for
CLI/GH-App-only signups).
- skipping the backfill for the 15 historical accounts per user's
earlier decision — they all uninstalled, so the cohort self-selected
out of being reachable.
* fallback: gate on resolvedModel + skip resolveModel re-resolve post-swap
local agnostic fixture run surfaced two real bugs the unit tests didn't
catch:
1. fallback gate was on configuredSlug (=payload.model) but the test
uses PULLFROG_MODEL to set the model, which is read by resolveModel
AFTER its slug arg. configuredSlug stayed undefined → fallback never
fired. drop configuredSlug from the helper signature; gate purely on
resolvedModel since that's the same value regardless of how the
model was specified (DB config vs PULLFROG_MODEL env).
2. when fallback engaged, the post-swap resolveModel({slug: fallback.to})
call was ALSO honoring PULLFROG_MODEL, re-overriding the fallback
target back to the unkeyed model. validateAgentApiKey then threw
"no API key found" against the original model. fix: skip the
re-resolve. fallback.to is already a CLI-ready specifier.
unit tests updated for the new helper signature (8 tests, all pass).
fallback log line confirmed emitted in the local run pre-second-fix;
the second fix unblocks the validation that previously threw.
193 lines
9.1 KiB
TypeScript
193 lines
9.1 KiB
TypeScript
import type { AgentUsage } from "./agents/shared.ts";
|
|
import type { PrepResult } from "./prep/types.ts";
|
|
import type { AgentDiagnostic } from "./utils/agentHangReport.ts";
|
|
import { log } from "./utils/cli.ts";
|
|
import type { DiffCoverageState } from "./utils/diffCoverage.ts";
|
|
import {
|
|
type ProgressComment,
|
|
type ProgressCommentType,
|
|
parseProgressComment,
|
|
} from "./utils/progressComment.ts";
|
|
import type { TodoTracker } from "./utils/todoTracking.ts";
|
|
|
|
export type BackgroundProcess = {
|
|
pid: number;
|
|
outputPath: string;
|
|
pidPath: string;
|
|
};
|
|
|
|
export type BrowserDaemon = { binDir: string; error?: never } | { binDir?: never; error: string };
|
|
|
|
export type StoredPushDest = {
|
|
remoteName: string;
|
|
remoteBranch: string;
|
|
localBranch: string;
|
|
};
|
|
|
|
/**
|
|
* Valid inline-comment anchor lines per side at a particular checkout SHA.
|
|
* Lives here (not in `mcp/review.ts`) so `ToolState` — which caches
|
|
* `Map<path, CommentableLines>` per checkout — does not pull the MCP server
|
|
* graph into every consumer of run state (the action's main loop, agent
|
|
* harnesses, cf-worker indexing).
|
|
*/
|
|
export type CommentableLines = { RIGHT: Set<number>; LEFT: Set<number> };
|
|
|
|
/**
|
|
* mutable per-run record of facts that occurred during execution. shared
|
|
* between the action process and the MCP server (one process — toolState is
|
|
* just a JS object passed by reference into both surfaces).
|
|
*
|
|
* design rule: ToolState is LITERAL. each field records a thing that
|
|
* happened — `review` is set when `create_pull_request_review` succeeded,
|
|
* `finalSummaryWritten` flips when `report_progress` wrote a non-plan body,
|
|
* `selectedMode` is set when `select_mode` was called. fields should never
|
|
* encode the absence of an event ("unsubmittedReview", "missingArtifact"),
|
|
* speculative state, or values derived from other fields.
|
|
*
|
|
* any predicate the rest of the code needs ("the agent picked review mode but
|
|
* never produced a review or progress write") is computed inline at the call
|
|
* site, not stored. derived state in this struct invariably drifts from the
|
|
* literal fields under refactors and is the wrong layer for the check.
|
|
*
|
|
* write narrowly: prefer adding state inside the tool that mutates it (e.g.
|
|
* `create_pull_request_review` populates `toolState.review`) and reading
|
|
* narrowly elsewhere. don't introduce flags from main.ts that mirror what an
|
|
* MCP tool already records.
|
|
*/
|
|
export interface ToolState {
|
|
// where we're allowed to push - base repo initially, fork URL for fork PRs
|
|
// set by setupGit, updated by checkout_pr. always set before push validation.
|
|
pushUrl?: string;
|
|
// push destination set by checkout_pr - used as primary source in push_branch
|
|
// because git config reads can fail in certain environments
|
|
pushDest?: StoredPushDest;
|
|
// issue or PR number (same number space in GitHub)
|
|
issueNumber?: number;
|
|
// PR HEAD sha at checkout time — used to detect new commits pushed during a review
|
|
checkoutSha?: string;
|
|
// commentable lines per file at checkoutSha — captured during checkout_pr so
|
|
// review-time inline-comment validation matches the diff GitHub will anchor
|
|
// to (commit_id=checkoutSha). without this, a PR update between checkout and
|
|
// review would make listFiles (latest HEAD) disagree with the anchor,
|
|
// silently dropping valid comments or letting invalid ones through.
|
|
//
|
|
// commentableLinesPullNumber records WHICH PR this snapshot belongs to. if
|
|
// the agent checks out PR B and then reviews PR A in the same session, the
|
|
// cached snapshot for B would silently mis-validate A's comments — keying
|
|
// by PR number forces a re-fetch when the target changes.
|
|
//
|
|
// commentableLinesCheckoutSha pins the snapshot to the SHA it was built
|
|
// against. if a second checkout_pr for the SAME PR bumps checkoutSha but
|
|
// fails before repopulating the cache (e.g., listFiles rate-limits), the
|
|
// stale snapshot would silently mis-validate comments against the new SHA.
|
|
// comparing both fields forces a re-fetch when either moves.
|
|
commentableLinesByFile?: Map<string, CommentableLines>;
|
|
commentableLinesPullNumber?: number;
|
|
commentableLinesCheckoutSha?: string | undefined;
|
|
// SHA to diff incrementally against — set from event payload on first checkout,
|
|
// then from checkoutSha when review.ts detects new commits mid-review
|
|
beforeSha?: string;
|
|
selectedMode?: string;
|
|
backgroundProcesses: Map<string, BackgroundProcess>;
|
|
browserDaemon?: BrowserDaemon | undefined;
|
|
review?: {
|
|
id: number;
|
|
nodeId: string;
|
|
reviewedSha: string | undefined;
|
|
};
|
|
// dedupe key: parent review comment_id → most-recent reply written this
|
|
// session by reply_to_review_comment. used by duplicateReplyDecision to
|
|
// skip identical-body re-emissions of the same call (PR #610 root cause).
|
|
// body-keyed (not just id-keyed) so legitimate follow-up replies with
|
|
// different content still go through.
|
|
reviewReplies?: Map<
|
|
number,
|
|
{ commentId: number; url: string | undefined; bodyWithFooter: string }
|
|
>;
|
|
dependencyInstallation?: {
|
|
status: "not_started" | "in_progress" | "completed" | "failed";
|
|
promise: Promise<PrepResult[]> | undefined;
|
|
results: PrepResult[] | undefined;
|
|
};
|
|
// undefined = no comment yet, object = active comment, null = deliberately deleted
|
|
progressComment: ProgressComment | null | undefined;
|
|
// immutable snapshot: true if a progress comment was pre-created at init time.
|
|
// survives deleteProgressComment so handleAgentResult can still detect "expected but never reported".
|
|
hadProgressComment: boolean;
|
|
lastProgressBody?: string;
|
|
wasUpdated?: boolean;
|
|
// set after a non-plan report_progress successfully writes the final summary.
|
|
// decoupled from todoTracker.enabled so cleanup detection survives API failures.
|
|
finalSummaryWritten?: boolean;
|
|
// set by select_mode when Plan + issue_number and plan-comment API returns existing plan (for report_progress target_plan_comment)
|
|
existingPlanCommentId?: number;
|
|
previousPlanBody?: string;
|
|
// absolute path to the PR summary markdown file the agent edits in place.
|
|
// seeded by main.ts before the agent starts when payload.generateSummary is set;
|
|
// read back at end-of-run to persist to DB.
|
|
summaryFilePath?: string;
|
|
// exact bytes of the seeded snapshot file at run start. compared against
|
|
// the file content at end-of-run to detect "agent never touched it" — in
|
|
// that case persistSummary skips the DB write (saving the seed verbatim
|
|
// would either re-write what the DB already has, on incremental runs, or
|
|
// serialize the placeholder scaffold, on first runs).
|
|
summarySeed?: string;
|
|
// set to true after persistSummary completes once. prevents the error-path
|
|
// call (which exists so a successful agent edit before a crash still gets
|
|
// persisted) from redundantly re-running the DB PATCH on the
|
|
// success-then-late-throw path.
|
|
summaryPersistAttempted?: boolean;
|
|
// absolute path to the rolling repo-level learnings markdown file the
|
|
// agent reads at startup and may edit at end-of-run. seeded by main.ts
|
|
// for every run from `Repo.learnings` (empty file when no learnings
|
|
// exist yet); read back at end-of-run to persist any edits.
|
|
learningsFilePath?: string;
|
|
// exact bytes of the seeded learnings file at run start. compared
|
|
// against the file content at end-of-run to detect "agent never touched
|
|
// it" — in that case persistLearnings skips the DB PATCH (saving the
|
|
// identical content would be a no-op write that wastes a LearningsRevision
|
|
// row and the API round-trip).
|
|
learningsSeed?: string;
|
|
// mirror of `summaryPersistAttempted` for the learnings tmpfile — guards
|
|
// the error-path / exit-signal callers from a redundant second PATCH
|
|
// after the success path already persisted.
|
|
learningsPersistAttempted?: boolean;
|
|
output?: string;
|
|
usageEntries: AgentUsage[];
|
|
model?: string | undefined;
|
|
// set by main.ts when the BYOK fallback engaged (configured model needed
|
|
// a provider key the runner didn't have). carried into PR-comment footers
|
|
// so users can see "Using <free model> (credentials for <configured> not
|
|
// configured)" rather than just being silently downgraded. literal record
|
|
// of an event that happened — matches the ToolState design rule.
|
|
modelFallback?: { from: string } | undefined;
|
|
todoTracker?: TodoTracker | undefined;
|
|
diffCoverage?: DiffCoverageState | undefined;
|
|
// mutable handle the agent harness writes to as a run progresses (recent
|
|
// stderr ring buffer reference, last provider-error label, event count).
|
|
// read by main.ts's outer catch so a watchdog-fired activity timeout still
|
|
// surfaces the same agent-side context the harness's own catch path returns
|
|
// via `result.error`. see `utils/agentHangReport.ts`.
|
|
agentDiagnostic?: AgentDiagnostic | undefined;
|
|
}
|
|
|
|
interface InitToolStateParams {
|
|
progressComment: { id: string; type: ProgressCommentType } | undefined;
|
|
}
|
|
|
|
export function initToolState(params: InitToolStateParams): ToolState {
|
|
const resolved = parseProgressComment(params.progressComment);
|
|
|
|
if (resolved) {
|
|
log.info(`» using pre-created progress comment: ${resolved.id} (${resolved.type})`);
|
|
}
|
|
|
|
return {
|
|
progressComment: resolved,
|
|
hadProgressComment: !!resolved,
|
|
backgroundProcesses: new Map(),
|
|
usageEntries: [],
|
|
};
|
|
}
|