e4e93ea6d3
* 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.
425 lines
14 KiB
TypeScript
425 lines
14 KiB
TypeScript
// this must be imported first
|
|
import "./arkConfig.ts";
|
|
import { createServer } from "node:net";
|
|
import { setTimeout as sleep } from "node:timers/promises";
|
|
import { FastMCP, type Tool } from "fastmcp";
|
|
import type { AgentUsage } from "../agents/index.ts";
|
|
import { type AgentId, pullfrogMcpName } from "../external.ts";
|
|
import type { Mode } from "../modes.ts";
|
|
import type { PrepResult } from "../prep/index.ts";
|
|
import { closeBrowserDaemon } from "../utils/browser.ts";
|
|
import { log } from "../utils/cli.ts";
|
|
import type { DiffCoverageState } from "../utils/diffCoverage.ts";
|
|
import type { OctokitWithPlugins } from "../utils/github.ts";
|
|
import type { ResolvedPayload } from "../utils/payload.ts";
|
|
import {
|
|
type ProgressComment,
|
|
type ProgressCommentType,
|
|
parseProgressComment,
|
|
} from "../utils/progressComment.ts";
|
|
import type { AccountPlan } from "../utils/runContext.ts";
|
|
import type { RunContextData } from "../utils/runContextData.ts";
|
|
import type { TodoTracker } from "../utils/todoTracking.ts";
|
|
import { CheckoutPrTool } from "./checkout.ts";
|
|
import { GetCheckSuiteLogsTool } from "./checkSuite.ts";
|
|
import {
|
|
CreateCommentTool,
|
|
EditCommentTool,
|
|
ReplyToReviewCommentTool,
|
|
ReportProgressTool,
|
|
} from "./comment.ts";
|
|
import { CommitInfoTool } from "./commitInfo.ts";
|
|
import {
|
|
AwaitDependencyInstallationTool,
|
|
StartDependencyInstallationTool,
|
|
} from "./dependencies.ts";
|
|
import { DeleteBranchTool, GitFetchTool, GitTool, PushBranchTool, PushTagsTool } from "./git.ts";
|
|
import { IssueTool } from "./issue.ts";
|
|
import { GetIssueCommentsTool } from "./issueComments.ts";
|
|
import { GetIssueEventsTool } from "./issueEvents.ts";
|
|
import { IssueInfoTool } from "./issueInfo.ts";
|
|
import { AddLabelsTool } from "./labels.ts";
|
|
import { UpdateLearningsTool } from "./learnings.ts";
|
|
import { SetOutputTool } from "./output.ts";
|
|
import { CreatePullRequestTool, UpdatePullRequestBodyTool } from "./pr.ts";
|
|
import { PullRequestInfoTool } from "./prInfo.ts";
|
|
import type { CommentableLines } from "./review.ts";
|
|
import { CreatePullRequestReviewTool } from "./review.ts";
|
|
import {
|
|
GetReviewCommentsTool,
|
|
ListPullRequestReviewsTool,
|
|
ResolveReviewThreadTool,
|
|
} from "./reviewComments.ts";
|
|
import { SelectModeTool } from "./selectMode.ts";
|
|
import { addTools } from "./shared.ts";
|
|
import { KillBackgroundTool, ShellTool } from "./shell.ts";
|
|
import { UploadFileTool } from "./upload.ts";
|
|
|
|
export type BackgroundProcess = {
|
|
pid: number;
|
|
outputPath: string;
|
|
pidPath: string;
|
|
};
|
|
|
|
export type BrowserDaemon = { binDir: string; error?: never } | { binDir?: never; error: string };
|
|
|
|
export type StoredPushDest = {
|
|
remoteName: string;
|
|
remoteBranch: string;
|
|
localBranch: string;
|
|
};
|
|
|
|
export interface ToolState {
|
|
// where we're allowed to push - base repo initially, fork URL for fork PRs
|
|
// set by setupGit, updated by checkout_pr. always set before push validation.
|
|
pushUrl?: string;
|
|
// push destination set by checkout_pr - used as primary source in push_branch
|
|
// because git config reads can fail in certain environments
|
|
pushDest?: StoredPushDest;
|
|
// issue or PR number (same number space in GitHub)
|
|
issueNumber?: number;
|
|
// PR HEAD sha at checkout time — used to detect new commits pushed during a review
|
|
checkoutSha?: string;
|
|
// commentable lines per file at checkoutSha — captured during checkout_pr so
|
|
// review-time inline-comment validation matches the diff GitHub will anchor
|
|
// to (commit_id=checkoutSha). without this, a PR update between checkout and
|
|
// review would make listFiles (latest HEAD) disagree with the anchor,
|
|
// silently dropping valid comments or letting invalid ones through.
|
|
//
|
|
// commentableLinesPullNumber records WHICH PR this snapshot belongs to. if
|
|
// the agent checks out PR B and then reviews PR A in the same session, the
|
|
// cached snapshot for B would silently mis-validate A's comments — keying
|
|
// by PR number forces a re-fetch when the target changes.
|
|
//
|
|
// commentableLinesCheckoutSha pins the snapshot to the SHA it was built
|
|
// against. if a second checkout_pr for the SAME PR bumps checkoutSha but
|
|
// fails before repopulating the cache (e.g., listFiles rate-limits), the
|
|
// stale snapshot would silently mis-validate comments against the new SHA.
|
|
// comparing both fields forces a re-fetch when either moves.
|
|
commentableLinesByFile?: Map<string, CommentableLines>;
|
|
commentableLinesPullNumber?: number;
|
|
commentableLinesCheckoutSha?: string | undefined;
|
|
// SHA to diff incrementally against — set from event payload on first checkout,
|
|
// then from checkoutSha when review.ts detects new commits mid-review
|
|
beforeSha?: string;
|
|
selectedMode?: string;
|
|
backgroundProcesses: Map<string, BackgroundProcess>;
|
|
browserDaemon?: BrowserDaemon | undefined;
|
|
review?: {
|
|
id: number;
|
|
nodeId: string;
|
|
reviewedSha: string | undefined;
|
|
};
|
|
dependencyInstallation?: {
|
|
status: "not_started" | "in_progress" | "completed" | "failed";
|
|
promise: Promise<PrepResult[]> | undefined;
|
|
results: PrepResult[] | undefined;
|
|
};
|
|
// undefined = no comment yet, object = active comment, null = deliberately deleted
|
|
progressComment: ProgressComment | null | undefined;
|
|
// immutable snapshot: true if a progress comment was pre-created at init time.
|
|
// survives deleteProgressComment so handleAgentResult can still detect "expected but never reported".
|
|
hadProgressComment: boolean;
|
|
lastProgressBody?: string;
|
|
wasUpdated?: boolean;
|
|
// set after a non-plan report_progress successfully writes the final summary.
|
|
// decoupled from todoTracker.enabled so cleanup detection survives API failures.
|
|
finalSummaryWritten?: boolean;
|
|
// set by select_mode when Plan + issue_number and plan-comment API returns existing plan (for report_progress target_plan_comment)
|
|
existingPlanCommentId?: number;
|
|
previousPlanBody?: string;
|
|
// absolute path to the PR summary markdown file the agent edits in place.
|
|
// seeded by main.ts before the agent starts when payload.generateSummary is set;
|
|
// read back at end-of-run to persist to DB.
|
|
summaryFilePath?: string;
|
|
// exact bytes of the seeded snapshot file at run start. compared against
|
|
// the file content at end-of-run to detect "agent never touched it" — in
|
|
// that case persistSummary skips the DB write (saving the seed verbatim
|
|
// would either re-write what the DB already has, on incremental runs, or
|
|
// serialize the placeholder scaffold, on first runs).
|
|
summarySeed?: string;
|
|
// set to true after persistSummary completes once. prevents the error-path
|
|
// call (which exists so a successful agent edit before a crash still gets
|
|
// persisted) from redundantly re-running the DB PATCH on the
|
|
// success-then-late-throw path.
|
|
summaryPersistAttempted?: boolean;
|
|
output?: string;
|
|
usageEntries: AgentUsage[];
|
|
model?: string | undefined;
|
|
todoTracker?: TodoTracker | undefined;
|
|
diffCoverage?: DiffCoverageState | undefined;
|
|
}
|
|
|
|
interface InitToolStateParams {
|
|
progressComment: { id: string; type: ProgressCommentType } | undefined;
|
|
}
|
|
|
|
export function initToolState(params: InitToolStateParams): ToolState {
|
|
const resolved = parseProgressComment(params.progressComment);
|
|
|
|
if (resolved) {
|
|
log.info(`» using pre-created progress comment: ${resolved.id} (${resolved.type})`);
|
|
}
|
|
|
|
return {
|
|
progressComment: resolved,
|
|
hadProgressComment: !!resolved,
|
|
backgroundProcesses: new Map(),
|
|
usageEntries: [],
|
|
};
|
|
}
|
|
|
|
export interface ToolContext {
|
|
agentId: AgentId;
|
|
repo: RunContextData["repo"];
|
|
payload: ResolvedPayload;
|
|
octokit: OctokitWithPlugins;
|
|
githubInstallationToken: string;
|
|
gitToken: string;
|
|
apiToken: string;
|
|
modes: Mode[];
|
|
postCheckoutScript: string | null;
|
|
prepushScript: string | null;
|
|
prApproveEnabled: boolean;
|
|
modeInstructions: Record<string, string>;
|
|
toolState: ToolState;
|
|
runId: number | undefined;
|
|
jobId: string | undefined;
|
|
mcpServerUrl: string;
|
|
tmpdir: string;
|
|
// repo-level OSS flag + account-level billing plan. together they decide
|
|
// whether pullfrog is paying for marginal infra — see isInfraCovered in
|
|
// utils/runContext.ts. plan gating for things like update_learnings is
|
|
// enforced server-side via 402, so we pass plan along mostly for future
|
|
// use / observability. see wiki/pricing.md.
|
|
oss: boolean;
|
|
plan: AccountPlan;
|
|
// resolved upstream model specifier (e.g. "google/gemini-3.1-pro-preview").
|
|
// undefined when payload.proxyModel is set or when the alias is unresolvable.
|
|
// used by the schema sanitizer to detect Gemini-routed traffic.
|
|
resolvedModel: string | undefined;
|
|
}
|
|
|
|
const mcpPortStart = 3764;
|
|
const mcpPortAttempts = 100;
|
|
const mcpHost = "127.0.0.1";
|
|
const mcpEndpoint = "/mcp";
|
|
|
|
function readEnvPort(): number | null {
|
|
const rawPort = process.env.PULLFROG_MCP_PORT;
|
|
if (!rawPort) return null;
|
|
const parsed = Number.parseInt(rawPort, 10);
|
|
if (!Number.isInteger(parsed) || parsed <= 0 || parsed > 65535) {
|
|
throw new Error(`invalid PULLFROG_MCP_PORT: ${rawPort}`);
|
|
}
|
|
return parsed;
|
|
}
|
|
|
|
function isPortAvailable(port: number): Promise<boolean> {
|
|
return new Promise((resolve) => {
|
|
const server = createServer();
|
|
server.unref();
|
|
server.once("error", () => resolve(false));
|
|
server.once("listening", () => {
|
|
server.close(() => resolve(true));
|
|
});
|
|
server.listen(port, mcpHost);
|
|
});
|
|
}
|
|
|
|
function getErrorMessage(error: unknown): string {
|
|
if (error instanceof Error) return error.message;
|
|
return String(error);
|
|
}
|
|
|
|
function isAddressInUse(error: unknown): boolean {
|
|
const message = getErrorMessage(error).toLowerCase();
|
|
return message.includes("eaddrinuse") || message.includes("address already in use");
|
|
}
|
|
|
|
type JsonSchema = Record<string, unknown>;
|
|
|
|
function buildCommonTools(ctx: ToolContext, outputSchema?: JsonSchema): Tool<any, any>[] {
|
|
const tools: Tool<any, any>[] = [
|
|
StartDependencyInstallationTool(ctx),
|
|
AwaitDependencyInstallationTool(ctx),
|
|
CreateCommentTool(ctx),
|
|
EditCommentTool(ctx),
|
|
ReplyToReviewCommentTool(ctx),
|
|
IssueTool(ctx),
|
|
IssueInfoTool(ctx),
|
|
GetIssueCommentsTool(ctx),
|
|
GetIssueEventsTool(ctx),
|
|
CreatePullRequestReviewTool(ctx),
|
|
PullRequestInfoTool(ctx),
|
|
CommitInfoTool(ctx),
|
|
CheckoutPrTool(ctx),
|
|
GetReviewCommentsTool(ctx),
|
|
ListPullRequestReviewsTool(ctx),
|
|
ResolveReviewThreadTool(ctx),
|
|
GetCheckSuiteLogsTool(ctx),
|
|
AddLabelsTool(ctx),
|
|
GitTool(ctx),
|
|
GitFetchTool(ctx),
|
|
UploadFileTool(ctx),
|
|
];
|
|
|
|
const isStandalone = ctx.payload.event.trigger === "unknown";
|
|
if (isStandalone || outputSchema) {
|
|
tools.push(SetOutputTool(ctx, outputSchema));
|
|
}
|
|
|
|
// MCP shell with filtered env (no secrets leaked to child processes)
|
|
if (ctx.payload.shell === "restricted") {
|
|
tools.push(ShellTool(ctx));
|
|
tools.push(KillBackgroundTool(ctx));
|
|
}
|
|
|
|
return tools;
|
|
}
|
|
|
|
function buildOrchestratorTools(ctx: ToolContext, outputSchema?: JsonSchema): Tool<any, any>[] {
|
|
return [
|
|
...buildCommonTools(ctx, outputSchema),
|
|
ReportProgressTool(ctx),
|
|
SelectModeTool(ctx),
|
|
PushBranchTool(ctx),
|
|
PushTagsTool(ctx),
|
|
DeleteBranchTool(ctx),
|
|
CreatePullRequestTool(ctx),
|
|
UpdatePullRequestBodyTool(ctx),
|
|
UpdateLearningsTool(ctx),
|
|
];
|
|
}
|
|
|
|
type McpStartResult = {
|
|
server: FastMCP;
|
|
url: string;
|
|
port: number;
|
|
};
|
|
|
|
async function tryStartMcpServer(
|
|
ctx: ToolContext,
|
|
tools: Tool<any, any>[],
|
|
port: number
|
|
): Promise<McpStartResult | null> {
|
|
const server = new FastMCP({ name: pullfrogMcpName, version: "0.0.1" });
|
|
addTools(ctx, server, tools);
|
|
|
|
try {
|
|
await server.start({
|
|
transportType: "httpStream",
|
|
httpStream: {
|
|
port,
|
|
host: mcpHost,
|
|
endpoint: mcpEndpoint,
|
|
},
|
|
});
|
|
const url = `http://${mcpHost}:${port}${mcpEndpoint}`;
|
|
return { server, url, port };
|
|
} catch (error) {
|
|
if (!isAddressInUse(error)) {
|
|
throw error;
|
|
}
|
|
try {
|
|
await server.stop();
|
|
} catch {
|
|
// ignore cleanup errors on failed start
|
|
}
|
|
return null;
|
|
}
|
|
}
|
|
|
|
async function selectMcpPort(ctx: ToolContext, tools: Tool<any, any>[]): Promise<McpStartResult> {
|
|
let lastError: unknown = null;
|
|
|
|
const requestedPort = readEnvPort();
|
|
if (requestedPort !== null) {
|
|
if (await isPortAvailable(requestedPort)) {
|
|
const requestedResult = await tryStartMcpServer(ctx, tools, requestedPort);
|
|
if (requestedResult) {
|
|
return requestedResult;
|
|
}
|
|
}
|
|
}
|
|
|
|
// randomize start offset to reduce collision chance in parallel runs
|
|
const randomOffset = Math.floor(Math.random() * 50);
|
|
|
|
for (let offset = 0; offset < mcpPortAttempts; offset++) {
|
|
const port = mcpPortStart + randomOffset + offset;
|
|
try {
|
|
if (!(await isPortAvailable(port))) {
|
|
continue;
|
|
}
|
|
const result = await tryStartMcpServer(ctx, tools, port);
|
|
if (result) {
|
|
return result;
|
|
}
|
|
} catch (error) {
|
|
lastError = error;
|
|
if (!isAddressInUse(error)) {
|
|
throw error;
|
|
}
|
|
}
|
|
}
|
|
|
|
const message = getErrorMessage(lastError);
|
|
throw new Error(
|
|
`could not find available mcp port starting at ${mcpPortStart} (last error: ${message})`
|
|
);
|
|
}
|
|
|
|
async function killBackgroundProcesses(toolState: ToolState): Promise<void> {
|
|
const backgroundProcesses = toolState.backgroundProcesses;
|
|
if (backgroundProcesses.size === 0) return;
|
|
for (const proc of backgroundProcesses.values()) {
|
|
try {
|
|
process.kill(-proc.pid, "SIGTERM");
|
|
} catch {
|
|
// already dead
|
|
}
|
|
}
|
|
await sleep(200);
|
|
for (const proc of backgroundProcesses.values()) {
|
|
try {
|
|
process.kill(-proc.pid, "SIGKILL");
|
|
} catch {
|
|
// already dead
|
|
}
|
|
}
|
|
backgroundProcesses.clear();
|
|
}
|
|
|
|
type McpHttpServerOptions = {
|
|
outputSchema?: JsonSchema | undefined;
|
|
};
|
|
|
|
/**
|
|
* Start the MCP HTTP server.
|
|
*
|
|
* The returned disposer is idempotent — safe to call multiple times.
|
|
* Callers (e.g. the inner activity-timeout handler in main.ts) may need to
|
|
* stop the server before the `await using` block exits; a subsequent
|
|
* automatic dispose is then a no-op.
|
|
*/
|
|
export async function startMcpHttpServer(
|
|
ctx: ToolContext,
|
|
options?: McpHttpServerOptions
|
|
): Promise<{ url: string; [Symbol.asyncDispose]: () => Promise<void> }> {
|
|
const tools = buildOrchestratorTools(ctx, options?.outputSchema);
|
|
const startResult = await selectMcpPort(ctx, tools);
|
|
|
|
let disposed = false;
|
|
return {
|
|
url: startResult.url,
|
|
[Symbol.asyncDispose]: async () => {
|
|
if (disposed) return;
|
|
disposed = true;
|
|
closeBrowserDaemon(ctx.toolState);
|
|
await killBackgroundProcesses(ctx.toolState);
|
|
await startResult.server.stop();
|
|
},
|
|
};
|
|
}
|