cfd38d82fc
* refactor delegation system and add PR summary comments Delegation system: - replace mode-based delegation with select_mode → delegate two-step flow - orchestrator crafts self-contained subagent prompts (clean context — no system/repo/event instructions leak) - add role-based tool filtering via FastMCP authenticate hook (?role=subagent hides orchestrator-only tools) - add select_mode tool for orchestrator guidance per mode - add ask_question tool for lightweight research subagents - extract shared subagent lifecycle into subagent.ts (create, complete, stdout, instructions) - route set_output to per-subagent state when activeSubagentId is set - track per-subagent state (SubagentState Map) replacing boolean delegationActive flag - capture and aggregate AgentUsage across all agents (claude, codex, gemini, opencode) - write usage summary table to GitHub job summary - block built-in subagent spawning (Task for Claude, Task(*) for Cursor) - increase activity timeout from 60s to 300s (subagent thinking phases) - fix gh CLI misguidance in system prompt — explicitly forbid usage PR summary comments: - add prSummaryComment trigger (DB schema + migrations + Zod + UI toggle) - dispatch mini-effort summary job alongside PR review on pr.created - add update_pull_request_body MCP tool - add defaultEffort option to webhook dispatch Hardening: - rewrite delegate/selectMode tests with simulated state management - add toolFiltering.test.ts for role extraction, canAccess, set_output routing - remove non-null assertions for PULLFROG_TEMP_DIR (proper error throws) - use fetchWithRetry for direct tarball downloads - DRY fix for rate limit check in test runner Co-authored-by: Cursor <cursoragent@cursor.com> * fix: add type keyword to Effort import in handleWebhook.ts Co-authored-by: Cursor <cursoragent@cursor.com> * clean up delegation system, improve code quality across the codebase - simplify delegate tool to instructions + effort params with subagent lifecycle in subagent.ts - add select_mode and ask_question orchestrator-only tools with canAccess filtering - replace delegate.test.ts/selectMode.test.ts with toolFiltering.test.ts (live MCP integration) - add set_output routing for subagent context and AgentUsage tracking across all agents - add PR summary comment trigger (schema, UI, webhook dispatch with silent flag) - add update_pull_request_body MCP tool - fix changed-agents.sh to always include claude canary for non-agent action changes - fix cursor pagination bug in getSelectedInstallationReposPage - remove destructuring patterns, inline type definitions, and unsafe type casts - replace non-null assertions with explicit checks in install.ts - convert multi-param functions to single param objects (postCleanup, runActionLocal, etc.) - use isHttpError helper in API routes instead of catch-any patterns - add adhoc test fixtures for delegation scenarios (context isolation, error handling, synthesis, etc.) Co-authored-by: Cursor <cursoragent@cursor.com> * no subagent mutation, one mcp per subagent * address review feedback: parallel-safe usage tracking, subagent isolation, minor improvements * fix subagent state isolation: replace Object.freeze with shallow copy Object.freeze throws TypeErrors when subagent tools (checkout_pr, report_progress) write scalar properties to toolState. A shallow copy achieves the same isolation for scalar fields while allowing tools to work normally. Shared references (subagents Map, usageEntries array) remain shared for coordination. --------- Co-authored-by: Cursor <cursoragent@cursor.com> Co-authored-by: pullfrog[bot] <226033991+pullfrog[bot]@users.noreply.github.com>
413 lines
12 KiB
TypeScript
413 lines
12 KiB
TypeScript
// this must be imported first
|
|
import "./arkConfig.ts";
|
|
import { createServer } from "node:net";
|
|
import { FastMCP, type Tool } from "fastmcp";
|
|
import type { Agent, AgentUsage } from "../agents/index.ts";
|
|
import { ghPullfrogMcpName } from "../external.ts";
|
|
import type { Mode } from "../modes.ts";
|
|
import type { PrepResult } from "../prep/index.ts";
|
|
import type { OctokitWithPlugins } from "../utils/github.ts";
|
|
import type { ResolvedPayload } from "../utils/payload.ts";
|
|
|
|
export type BackgroundProcess = {
|
|
pid: number;
|
|
outputPath: string;
|
|
pidPath: string;
|
|
};
|
|
|
|
export type StoredPushDest = {
|
|
remoteName: string;
|
|
remoteBranch: string;
|
|
localBranch: string;
|
|
};
|
|
|
|
export type SubagentStatus = "running" | "completed" | "failed";
|
|
|
|
export type SubagentState = {
|
|
id: string;
|
|
status: SubagentStatus;
|
|
mode: string;
|
|
stdoutFilePath: string;
|
|
output: string | undefined;
|
|
usage: AgentUsage | undefined;
|
|
startedAt: number;
|
|
keepAliveInterval: ReturnType<typeof setInterval> | undefined;
|
|
};
|
|
|
|
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;
|
|
selectedMode?: string;
|
|
// per-subagent lifecycle tracking (keyed by subagent uuid)
|
|
subagents: Map<string, SubagentState>;
|
|
// set while a subagent is running — routes set_output to the correct subagent and prevents nesting
|
|
activeSubagentId: string | undefined;
|
|
backgroundProcesses: Map<string, BackgroundProcess>;
|
|
review?: {
|
|
id: number;
|
|
nodeId: string;
|
|
};
|
|
dependencyInstallation?: {
|
|
status: "not_started" | "in_progress" | "completed" | "failed";
|
|
promise: Promise<PrepResult[]> | undefined;
|
|
results: PrepResult[] | undefined;
|
|
};
|
|
// undefined = no comment yet, number = active comment, null = deliberately deleted
|
|
progressCommentId: number | null | undefined;
|
|
lastProgressBody?: string;
|
|
wasUpdated?: boolean;
|
|
output?: string;
|
|
usageEntries: AgentUsage[];
|
|
}
|
|
|
|
interface InitToolStateParams {
|
|
progressCommentId: string | undefined;
|
|
}
|
|
|
|
export function initToolState(params: InitToolStateParams): ToolState {
|
|
const parsed = params.progressCommentId ? parseInt(params.progressCommentId, 10) : NaN;
|
|
const resolvedId = Number.isNaN(parsed) || parsed <= 0 ? undefined : parsed;
|
|
|
|
if (resolvedId) {
|
|
log.info(`» using pre-created progress comment: ${resolvedId}`);
|
|
}
|
|
|
|
return {
|
|
progressCommentId: resolvedId,
|
|
subagents: new Map(),
|
|
activeSubagentId: undefined,
|
|
backgroundProcesses: new Map(),
|
|
usageEntries: [],
|
|
};
|
|
}
|
|
|
|
export interface ToolContext {
|
|
repo: RunContextData["repo"];
|
|
payload: ResolvedPayload;
|
|
octokit: OctokitWithPlugins;
|
|
githubInstallationToken: string;
|
|
gitToken: string;
|
|
apiToken: string;
|
|
agent: Agent;
|
|
modes: Mode[];
|
|
postCheckoutScript: string | null;
|
|
toolState: ToolState;
|
|
runId: string;
|
|
jobId: string | undefined;
|
|
// set after MCP server starts — used by delegate tool to pass URL to subagents
|
|
mcpServerUrl: string;
|
|
tmpdir: string;
|
|
}
|
|
|
|
/**
|
|
* tool names that are only available to the orchestrator.
|
|
* subagent MCP servers are started with these tools excluded.
|
|
*
|
|
* - delegation tools: only the orchestrator can spawn/manage subagents
|
|
* - remote-mutating tools: subagents work locally; the orchestrator pushes and creates PRs
|
|
*/
|
|
export const ORCHESTRATOR_ONLY_TOOLS = [
|
|
"select_mode",
|
|
"delegate",
|
|
"ask_question",
|
|
"push_branch",
|
|
"push_tags",
|
|
"delete_branch",
|
|
"create_pull_request",
|
|
"update_pull_request_body",
|
|
] as const;
|
|
|
|
import { log } from "../utils/cli.ts";
|
|
import type { RunContextData } from "../utils/runContextData.ts";
|
|
import { AskQuestionTool } from "./askQuestion.ts";
|
|
import { BashTool, KillBackgroundTool } from "./bash.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 { DelegateTool } from "./delegate.ts";
|
|
import {
|
|
AwaitDependencyInstallationTool,
|
|
StartDependencyInstallationTool,
|
|
} from "./dependencies.ts";
|
|
import {
|
|
FileDeleteTool,
|
|
FileEditTool,
|
|
FileReadTool,
|
|
FileWriteTool,
|
|
ListDirectoryTool,
|
|
} from "./file.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 { SetOutputTool } from "./output.ts";
|
|
import { CreatePullRequestTool, UpdatePullRequestBodyTool } from "./pr.ts";
|
|
import { PullRequestInfoTool } from "./prInfo.ts";
|
|
import { CreatePullRequestReviewTool } from "./review.ts";
|
|
import {
|
|
GetReviewCommentsTool,
|
|
ListPullRequestReviewsTool,
|
|
ResolveReviewThreadTool,
|
|
} from "./reviewComments.ts";
|
|
import { SelectModeTool } from "./selectMode.ts";
|
|
import { addTools } from "./shared.ts";
|
|
import { UploadFileTool } from "./upload.ts";
|
|
|
|
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");
|
|
}
|
|
|
|
// tools shared by both orchestrator and subagent servers
|
|
function buildCommonTools(ctx: ToolContext): 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),
|
|
SetOutputTool(ctx),
|
|
FileReadTool(ctx),
|
|
FileWriteTool(ctx),
|
|
FileEditTool(ctx),
|
|
FileDeleteTool(ctx),
|
|
ListDirectoryTool(ctx),
|
|
ReportProgressTool(ctx),
|
|
];
|
|
|
|
// only add BashTool when bash is "restricted"
|
|
// - "enabled": native bash only (no MCP bash needed)
|
|
// - "restricted": MCP bash only (native blocked, env filtered)
|
|
// - "disabled": no bash at all
|
|
if (ctx.payload.bash === "restricted") {
|
|
tools.push(BashTool(ctx));
|
|
tools.push(KillBackgroundTool(ctx));
|
|
}
|
|
|
|
return tools;
|
|
}
|
|
|
|
// orchestrator gets common tools + delegation + remote-mutating tools
|
|
function buildOrchestratorTools(ctx: ToolContext): Tool<any, any>[] {
|
|
return [
|
|
...buildCommonTools(ctx),
|
|
SelectModeTool(ctx),
|
|
DelegateTool(ctx),
|
|
AskQuestionTool(ctx),
|
|
PushBranchTool(ctx),
|
|
PushTagsTool(ctx),
|
|
DeleteBranchTool(ctx),
|
|
CreatePullRequestTool(ctx),
|
|
UpdatePullRequestBodyTool(ctx),
|
|
];
|
|
}
|
|
|
|
// subagent gets only common tools (no delegation, no remote mutation)
|
|
function buildSubagentTools(ctx: ToolContext): Tool<any, any>[] {
|
|
return buildCommonTools(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: ghPullfrogMcpName, 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 new Promise((resolve) => setTimeout(resolve, 200));
|
|
for (const proc of backgroundProcesses.values()) {
|
|
try {
|
|
process.kill(-proc.pid, "SIGKILL");
|
|
} catch {
|
|
// already dead
|
|
}
|
|
}
|
|
backgroundProcesses.clear();
|
|
}
|
|
|
|
/**
|
|
* Start the orchestrator MCP HTTP server (has all tools including push/PR/delegation).
|
|
*/
|
|
export async function startMcpHttpServer(
|
|
ctx: ToolContext
|
|
): Promise<{ url: string; [Symbol.asyncDispose]: () => Promise<void> }> {
|
|
const tools = buildOrchestratorTools(ctx);
|
|
const startResult = await selectMcpPort(ctx, tools);
|
|
|
|
return {
|
|
url: startResult.url,
|
|
[Symbol.asyncDispose]: async () => {
|
|
await killBackgroundProcesses(ctx.toolState);
|
|
await startResult.server.stop();
|
|
},
|
|
};
|
|
}
|
|
|
|
export type ManagedMcpServer = {
|
|
url: string;
|
|
stop: () => Promise<void>;
|
|
};
|
|
|
|
/**
|
|
* Start a per-subagent MCP server (common tools only — no push/PR/delegation).
|
|
* Each subagent gets its own server; call stop() when the subagent completes.
|
|
*
|
|
* The subagent gets its own shallow copy of toolState so scalar writes
|
|
* (pushUrl, pushDest, selectedMode, etc.) don't mutate the orchestrator's state.
|
|
* Shared references (subagents Map, usageEntries array, dependencyInstallation)
|
|
* are intentionally shared for coordination (set_output routing, usage tracking).
|
|
*/
|
|
export async function startSubagentMcpServer(ctx: ToolContext): Promise<ManagedMcpServer> {
|
|
const subagentToolState: ToolState = {
|
|
...ctx.toolState,
|
|
backgroundProcesses: new Map(),
|
|
};
|
|
const subagentCtx: ToolContext = { ...ctx, toolState: subagentToolState };
|
|
const tools = buildSubagentTools(subagentCtx);
|
|
const startResult = await selectMcpPort(subagentCtx, tools);
|
|
return { url: startResult.url, stop: () => startResult.server.stop() };
|
|
}
|