toolState.ts
Rama main · réplica de lectura del repositorio original.
import type { AgentCredential, AgentUsage } from "./agents/shared.ts";
import type { PrepResult } from "./prep/types.ts";
import type { AgentDiagnostic } from "./utils/agentHangReport.ts";
import type { ModelClamp } from "./utils/buildPullfrogFooter.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).
*/
export type CommentableLines = { RIGHT: Set<number>; LEFT: Set<number> };
/** access tier of a checked-out repo, drives token routing + push gating. */
export type RepoAccess = "primary" | "write" | "read";
/**
* per-repo working-tree + PR state. the primary repo is keyed like any other
* in `toolState.repos`; secondaries are added by `checkout_repo`. this is the
* single source of truth for all repo-scoped git/PR state — there are no
* parallel inline fields on `ToolState`. read via `primaryRepoState(toolState)`
* for primary-only call sites, or `requireRepoState(toolState, owner, name)`
* when a `repo` param routes to a specific checkout.
*/
export interface RepoToolState {
owner: string;
name: string;
/** working directory: primary = `process.cwd()`; secondary = `ctx.tmpdir/xrepo/<name>` */
dir: string;
access: RepoAccess;
// default branch name. for the primary this comes from ctx.repo; for
// secondaries it's captured by checkout_repo and drives the restricted-mode
// default-branch push guard.
defaultBranch?: string;
// where we're allowed to push - base repo initially, fork URL for fork PRs
// set by configureRepoGit, 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;
// HEAD identity captured by configureRepoGit at run start. load-bearing for the
// checkout_pr initial-branch invariant: the only sanctioned HEAD positions
// when calling checkout_pr are the run-entry HEAD or the target `pr-N`.
// blocks the zed-style cross-PR clobber where a subagent left HEAD on
// someone else's `pr-X` and the orchestrator's next checkout_pr inherited
// that position.
//
// discriminated by `kind` because `git rev-parse --abbrev-ref HEAD` returns
// the literal sentinel string `"HEAD"` on detached entry, which is the
// default state from `actions/checkout` on `pull_request` events (it
// checks out the merge commit as a detached SHA). without the kind tag,
// detached-entry runs would trivially accept any future detached state.
initialHead?: { kind: "branch"; name: string } | { kind: "detached"; sha: string };
// 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 | undefined;
// 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;
diffCoverage?: DiffCoverageState | undefined;
}
/**
* unseeded = no comment yet (the first write creates one), open = active comment
* (writes edit it in place), closed = shut on purpose, whether or not a comment
* ever existed (writes are skipped).
*/
export type ProgressCommentState =
| { status: "unseeded" }
| { status: "open"; comment: ProgressComment }
| { status: "closed" };
/**
* 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 {
// single source of truth for per-repo git/PR working-tree state, keyed by
// "owner/name". the primary (triggering) repo is keyed like any other —
// seeded at init; secondaries are added on demand by checkout_repo. read via
// primaryRepoState() or requireRepoState() — never re-introduce inline
// per-repo fields here.
repos: Map<string, RepoToolState>;
// key into `repos` for the primary (triggering) repo.
primaryRepoKey: string;
// epoch ms this process began: what separates a review this run posted from an earlier run's.
startedAt: number;
selectedMode?: string;
// number of prepush hook failures this run. push_branch runs the hook
// while this is 0 and skips it once non-zero; never decremented within
// a run.
prepushFailureCount: number;
// consecutive create_pull_request_review calls this session that posted
// nothing (empty payload). bounded by MAX_CONSECUTIVE_NOOP_SUBMISSIONS so a
// model that cannot serialize `body` fails loudly instead of looping until it
// probes the tool with placeholder text. reset by any successful submission.
noopReviewSubmissions: number;
backgroundProcesses: Map<string, BackgroundProcess>;
browserDaemon?: BrowserDaemon | undefined;
review?: {
id: number;
nodeId: string;
reviewedSha: string | undefined;
};
// approval verdict recorded by create_pull_request_review: whether Pullfrog
// would approve this PR (the agent's `approved` intent AND no outstanding
// unresolved Pullfrog-originated review threads). drives the opt-in
// `pullfrog-approval` commit-status check posted at run end. distinct from
// `review` (which postReviewCleanup deletes) so it survives to finalize.
// `sha` is the sha the agent reviewed, so the check anchors to it rather
// than to a head that may have moved mid-run.
// `url` is the submitted review's html_url when one landed — the artifact a
// reader most wants from the `Pullfrog` check, so it is surfaced as a link in
// that check's summary. absent when the run produced no review.
approval?: { wouldApprove: boolean; sha: string | undefined; url?: string | undefined };
// the checkoutSha that report_progress's `no_review_needed` declared needs no review —
// IncrementalReview's non-substantive exit, which carries the prior verdict forward.
noReviewNeededSha?: 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;
};
progressComment: ProgressCommentState;
// 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 when create_issue_comment posted a standalone comment to the run's OWN
// target. that comment IS the run's deliverable, so a later report_progress
// would leave the user with two comments restating each other.
standaloneCommentId?: number;
// 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).
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;
// org-level cross-repo learnings tmpfile — same lifecycle as the repo-level
// learnings file but seeded from `Account.xrepoLearnings` and only on
// --xrepo runs. persisted to /api/account/[owner]/xrepo-learnings.
xrepoLearningsFilePath?: string;
xrepoLearningsSeed?: string;
xrepoLearningsPersistAttempted?: boolean;
// a run steered by someone who cannot push here reads learnings but never writes them:
// every later run, a maintainer's included, trusts them as context.
learningsLocked?: boolean;
output?: string | undefined;
usageEntries: AgentUsage[];
model?: string | undefined;
// the harness that actually ran (`resolveAgent`'s pick), persisted on the
// end-of-run PATCH. the run row separately records which arm the canary
// ASSIGNED, so the pair is what exposes an assignment the run didn't honor.
agent?: string | undefined;
// which credential the harness actually ran on, decided by the harness because
// it is the only place that knows: claude-code strips keys to force a
// precedence, and codex picks auth.json or an API key on mutually exclusive
// branches. left unset rather than guessed where a harness cannot tell.
credential?: AgentCredential | undefined;
// true when the run fell back to the default proxy model purely because the
// repo has no model selected (Router billing + "auto"). carried into footers
// so the user can see they're on the cost-optimized default — a weaker
// reviewer than a frontier model — and pick one. literal record of an event
// that happened, matching the ToolState design rule.
unselectedProxyDefault?: boolean | undefined;
// set in proxy.ts when a no-card Router account had a model (or the
// intelligent tier) selected and the server clamped it to the efficient
// default — custom picks are card-gated wholesale. carried into footers so
// the PR shows "Using <Kimi K2> (<Claude Opus> needs a payment method)"
// rather than silently presenting Kimi as the user's pick. literal record
// of an event — matches the ToolState design rule. `reason` distinguishes
// the card gate ("card") from a pick that has no openRouterResolve yet and
// no stored provider key ("noRouterPath", a model OpenRouter doesn't serve
// yet).
modelClamped?: ModelClamp | undefined;
// true when the run's model costs are covered by the Pullfrog for OSS
// program. carried into footers (incl. error comments built from toolState
// alone) so the "via Pullfrog for OSS" attribution is consistent everywhere.
oss?: boolean | undefined;
todoTracker?: TodoTracker | 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;
/**
* a `PATCH /api/workflow-run/:id` came back 404 — Pullfrog neither claimed a
* reservation for the run nor recorded it as a custom run, which is permanent for
* the life of the run (both happen at setup, strictly before any PATCH). latched so
* the remaining calls stop firing: one CI run spent 28 doomed round trips on it.
* see #1153.
*/
workflowRunUnclaimed?: boolean | undefined;
}
interface InitToolStateParams {
progressComment: { id: string; type: ProgressCommentType } | undefined;
// primary repo identity + working tree (process.cwd()), seeded into `repos`.
owner: string;
name: string;
dir: string;
}
export function initToolState(params: InitToolStateParams): ToolState {
const resolved = parseProgressComment(params.progressComment);
if (resolved) {
log.info(`» using pre-created progress comment: ${resolved.id} (${resolved.type})`);
}
const primaryRepoKey = repoKey(params.owner, params.name);
const repos = new Map<string, RepoToolState>([
[
primaryRepoKey,
{ owner: params.owner, name: params.name, dir: params.dir, access: "primary" },
],
]);
return {
repos,
primaryRepoKey,
startedAt: Date.now(),
progressComment: resolved ? { status: "open", comment: resolved } : { status: "unseeded" },
hadProgressComment: !!resolved,
prepushFailureCount: 0,
noopReviewSubmissions: 0,
backgroundProcesses: new Map(),
usageEntries: [],
};
}
/** stable "owner/name" key for the `repos` map. lowercased — GitHub repo names are case-insensitive. */
export function repoKey(owner: string, name: string): string {
return `${owner}/${name}`.toLowerCase();
}
/** the primary (triggering) repo's state. throws if init hasn't run. */
export function primaryRepoState(toolState: ToolState): RepoToolState {
const state = toolState.repos.get(toolState.primaryRepoKey);
if (!state) throw new Error("primary repo state not initialized");
return state;
}
/**
* state for a specific checked-out repo. throws when the repo isn't a
* registered checkout — keeps the push/PR allow-set honest (the only way a
* secondary becomes registered is via `checkout_repo`).
*/
export function requireRepoState(toolState: ToolState, owner: string, name: string): RepoToolState {
const state = toolState.repos.get(repoKey(owner, name));
if (!state) {
throw new Error(
`repo ${repoKey(owner, name)} is not a registered checkout — use checkout_repo first`
);
}
return state;
}
/** get-or-create a repo's state (used by checkout_repo when registering a secondary). */
export function ensureRepoState(
toolState: ToolState,
init: { owner: string; name: string; dir: string; access: RepoAccess }
): RepoToolState {
const key = repoKey(init.owner, init.name);
const existing = toolState.repos.get(key);
if (existing) return existing;
const created: RepoToolState = {
owner: init.owner,
name: init.name,
dir: init.dir,
access: init.access,
};
toolState.repos.set(key, created);
return created;
}