6b18b6730b
* fix false "without reporting progress" error + live todo tracking clean up orphaned progress comments when review is skipped or only set_output is used, preventing the false positive in handleAgentResult. parse todowrite events from OpenCode's NDJSON stream and render a live markdown checklist in the PR progress comment (2s debounce). agent's explicit report_progress always takes priority. Made-with: Cursor * fix contradictory review/progress prompting align Review and IncrementalReview mode prompts with their guidance — mode prompts said "always submit" while guidance said "skip if clean." remove the empty-approval submission that was silently dropped by the tool. make progress comment lifecycle explicit: created on first call, updated in place, removed after review submission. Made-with: Cursor * centralize todo tracking into shared TodoTracker module extract inline todo tracking logic (~95 lines) from opentoad.ts into action/utils/todoTracking.ts. the tracker is created once in main.ts and passed to agents via AgentRunContext.todoTracker, making it agent-agnostic and reusable for future agent implementations. Made-with: Cursor * fix todoTracker optional type to match file convention add | undefined to todoTracker in AgentRunContext, matching every other optional property in the same interface. Made-with: Cursor * instruct agents to always maintain a task list for live progress system prompt now tells agents to create an internal task list at the start of every run. the tracker renders it to the progress comment automatically. report_progress is reserved for final results only — no more intermediate "Checking..." messages that cancel the tracker and leave stale text on the comment. Made-with: Cursor * require report_progress summary at end of every run agents must always call report_progress with a final summary — the completed task list should never be the end state of the progress comment. updated all review mode prompts to call report_progress after submitting (or not submitting) a review. Made-with: Cursor * keep progress comment after review with final summary stop deleting the progress comment after review submission — the agent now always calls report_progress with a summary at the end, and that summary should persist as a record of what was done. Made-with: Cursor * harden stranded progress comment cleanup - main.ts: detect when tracker was last writer (agent never called report_progress) and delete the stranded checklist instead of leaving it as the final comment state - postCleanup.ts: expand stuck-comment detection to also catch stranded todo checklists (regex match for checklist patterns) when the process is killed before normal cleanup runs - modes.ts + selectMode.ts: add report_progress step to Summarize and SummaryUpdate modes (only modes that were missing it) Made-with: Cursor * fix stale comments, typo, and build mode redundancy - comment.ts: update deleteProgressComment docstring and inline comment to reflect current usage (stranded-comment cleanup, not post-review) - modes.ts: merge duplicate report_progress steps (8 + 10) into single step 9, fix "optimizatfixons" typo - wiki/post-cleanup.md: document checklist detection regex Made-with: Cursor * collapsible completed todos in final progress, hide set_output outside standalone mode - add renderCollapsible() to TodoTracker, append completed task list as <details> section when agent calls report_progress - cancel tracker after agent's final report_progress so it doesn't overwrite with raw checklist - conditionally register SetOutputTool only in standalone mode or when output_schema is provided - remove unconditional set_output instruction from orchestrator task section - update Summarize/SummaryUpdate mode guidance to not reference set_output Made-with: Cursor * show completion count in collapsible task list summary Made-with: Cursor * only count completed (not cancelled) in collapsible task list summary Made-with: Cursor * reinforce concise summary prompting across system prompt, modes, and tool description Made-with: Cursor * address review feedback: wasUpdated bypass, tracker false-positive, race condition - remove wasUpdated=true from cleanup paths so handleAgentResult correctly detects genuinely silent runs - add hadProgressComment to ToolState as immutable snapshot for the safety check - use todoTracker.hasPublished instead of enabled for stranded-comment cleanup - serialize onUpdate calls via inflightPromise chain with post-cancel guard - add settled() to wait for in-flight updates before writing final summary Made-with: Cursor * address round-2 review: hasPublished after success, finalSummaryWritten flag - set hasPublished only after onUpdate resolves (not before) so failed writes are not counted as published - add finalSummaryWritten flag to ToolState, set after successful non-plan reportProgress; decouple cleanup detection from todoTracker.enabled so it survives API failures where cancel() ran but the write didn't succeed Made-with: Cursor
450 lines
16 KiB
TypeScript
450 lines
16 KiB
TypeScript
// changes to tool permissions should be reflected in wiki/granular-tools.md
|
|
|
|
import * as core from "@actions/core";
|
|
import { deleteProgressComment, reportProgress } from "./mcp/comment.ts";
|
|
import {
|
|
initToolState,
|
|
startMcpHttpServer,
|
|
type ToolContext,
|
|
type ToolState,
|
|
} from "./mcp/server.ts";
|
|
import { computeModes } from "./modes.ts";
|
|
import {
|
|
type ActivityTimeout,
|
|
createProcessOutputActivityTimeout,
|
|
DEFAULT_ACTIVITY_CHECK_INTERVAL_MS,
|
|
DEFAULT_ACTIVITY_TIMEOUT_MS,
|
|
} from "./utils/activity.ts";
|
|
import { resolveAgent } from "./utils/agent.ts";
|
|
import { apiFetch } from "./utils/apiFetch.ts";
|
|
import { validateAgentApiKey } from "./utils/apiKeys.ts";
|
|
import { resolveBody } from "./utils/body.ts";
|
|
import { formatUsageSummary, log, writeSummary } from "./utils/cli.ts";
|
|
import { reportErrorToComment } from "./utils/errorReport.ts";
|
|
import { onExitSignal } from "./utils/exitHandler.ts";
|
|
import { resolveGit, setGitAuthServer } from "./utils/gitAuth.ts";
|
|
import { startGitAuthServer } from "./utils/gitAuthServer.ts";
|
|
import { createOctokit, writeGitHubUsageSummaryToFile } from "./utils/github.ts";
|
|
import { resolveInstructions } from "./utils/instructions.ts";
|
|
import { executeLifecycleHook } from "./utils/lifecycle.ts";
|
|
import { normalizeEnv } from "./utils/normalizeEnv.ts";
|
|
import { resolvePayload, resolvePromptInput } from "./utils/payload.ts";
|
|
import { postReviewCleanup } from "./utils/reviewCleanup.ts";
|
|
import { handleAgentResult } from "./utils/run.ts";
|
|
import { resolveRunContextData } from "./utils/runContextData.ts";
|
|
import { createTempDirectory, setupGit } from "./utils/setup.ts";
|
|
import { killTrackedChildren } from "./utils/subprocess.ts";
|
|
import { parseTimeString, TIMEOUT_DISABLED } from "./utils/time.ts";
|
|
import { Timer } from "./utils/timer.ts";
|
|
import { createTodoTracker } from "./utils/todoTracking.ts";
|
|
import { getJobToken, resolveTokens } from "./utils/token.ts";
|
|
import { resolveRun } from "./utils/workflow.ts";
|
|
|
|
export { Inputs } from "./utils/payload.ts";
|
|
|
|
export interface MainResult {
|
|
success: boolean;
|
|
output?: string | undefined;
|
|
error?: string | undefined;
|
|
result?: string | undefined;
|
|
}
|
|
|
|
function resolveOutputSchema(): Record<string, unknown> | undefined {
|
|
const raw = core.getInput("output_schema");
|
|
if (!raw) return undefined;
|
|
|
|
let parsed: unknown;
|
|
try {
|
|
parsed = JSON.parse(raw);
|
|
} catch {
|
|
throw new Error(`invalid output_schema: not valid JSON`);
|
|
}
|
|
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
throw new Error(`invalid output_schema: must be a JSON object`);
|
|
}
|
|
log.info("» structured output schema provided — output will be required");
|
|
return parsed as Record<string, unknown>;
|
|
}
|
|
|
|
import type { ResolvedPayload } from "./utils/payload.ts";
|
|
|
|
interface OidcCredentials {
|
|
requestUrl: string;
|
|
requestToken: string;
|
|
}
|
|
|
|
async function mintProxyKey(ctx: { oidcCredentials: OidcCredentials }): Promise<string | null> {
|
|
try {
|
|
process.env.ACTIONS_ID_TOKEN_REQUEST_URL = ctx.oidcCredentials.requestUrl;
|
|
process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN = ctx.oidcCredentials.requestToken;
|
|
const oidcToken = await core.getIDToken("pullfrog-api");
|
|
delete process.env.ACTIONS_ID_TOKEN_REQUEST_URL;
|
|
delete process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN;
|
|
|
|
const response = await apiFetch({
|
|
path: "/api/proxy-token",
|
|
method: "POST",
|
|
headers: { Authorization: `Bearer ${oidcToken}` },
|
|
});
|
|
|
|
if (!response.ok) {
|
|
log.warning(`proxy key mint failed (${response.status})`);
|
|
return null;
|
|
}
|
|
|
|
const data = (await response.json()) as { key: string };
|
|
return data.key;
|
|
} catch (error) {
|
|
log.warning(`proxy key mint error: ${error instanceof Error ? error.message : String(error)}`);
|
|
return null;
|
|
} finally {
|
|
delete process.env.ACTIONS_ID_TOKEN_REQUEST_URL;
|
|
delete process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN;
|
|
}
|
|
}
|
|
|
|
async function resolveProxyModel(ctx: {
|
|
payload: ResolvedPayload;
|
|
oss: boolean;
|
|
proxyModel?: string | undefined;
|
|
oidcCredentials: OidcCredentials | null;
|
|
}): Promise<void> {
|
|
// env override = BYOK escape hatch, don't proxy
|
|
if (process.env.PULLFROG_MODEL?.trim()) return;
|
|
|
|
// OSS: server decided the model
|
|
if (ctx.oss && ctx.proxyModel) {
|
|
if (!ctx.oidcCredentials) {
|
|
log.warning("» oss repo but no OIDC credentials available — skipping proxy");
|
|
return;
|
|
}
|
|
const key = await mintProxyKey({ oidcCredentials: ctx.oidcCredentials });
|
|
if (!key) return;
|
|
|
|
process.env.OPENROUTER_API_KEY = key;
|
|
core.setSecret(key);
|
|
ctx.payload.proxyModel = ctx.proxyModel;
|
|
log.info(`» proxy: oss → ${ctx.proxyModel}`);
|
|
return;
|
|
}
|
|
|
|
// managed billing will add its path here later
|
|
}
|
|
|
|
async function writeJobSummary(toolState: ToolState): Promise<void> {
|
|
const usageSummary = formatUsageSummary(toolState.usageEntries);
|
|
const summaryParts = [toolState.lastProgressBody, usageSummary].filter(Boolean);
|
|
if (summaryParts.length > 0) {
|
|
await writeSummary(summaryParts.join("\n\n"));
|
|
}
|
|
}
|
|
|
|
export async function main(): Promise<MainResult> {
|
|
// normalize env var names to uppercase (handles case-insensitive workflow files)
|
|
normalizeEnv();
|
|
|
|
// write usage summary on SIGINT/SIGTERM so the worker can read it after sandbox.exec
|
|
const usageSummaryPath = process.env.PULLFROG_USAGE_SUMMARY_PATH;
|
|
if (usageSummaryPath) {
|
|
onExitSignal(() => writeGitHubUsageSummaryToFile(usageSummaryPath));
|
|
}
|
|
|
|
const timer = new Timer();
|
|
let activityTimeout: ActivityTimeout | null = null;
|
|
|
|
// parse prompt early to extract progressCommentId for toolState
|
|
const resolvedPromptInput = resolvePromptInput();
|
|
|
|
const toolState = initToolState({
|
|
progressCommentId:
|
|
typeof resolvedPromptInput !== "string" ? resolvedPromptInput.progressCommentId : undefined,
|
|
});
|
|
|
|
// resolve and fingerprint git binary before any agent code runs
|
|
resolveGit();
|
|
|
|
// get job token for initial API calls
|
|
const jobToken = getJobToken();
|
|
const initialOctokit = createOctokit(jobToken);
|
|
const runContext = await resolveRunContextData({ octokit: initialOctokit, token: jobToken });
|
|
timer.checkpoint("runContextData");
|
|
|
|
// resolve payload to determine shell permission
|
|
const payload = resolvePayload(resolvedPromptInput, runContext.repoSettings);
|
|
toolState.model = payload.model;
|
|
|
|
// resolve tokens first — acquireNewToken needs OIDC env vars for token exchange
|
|
await using tokenRef = await resolveTokens({ push: payload.push });
|
|
|
|
// stash OIDC credentials in memory before wiping from process.env
|
|
// the agent's shell commands can't access JS variables, so this is safe
|
|
const oidcCredentials: OidcCredentials | null =
|
|
process.env.ACTIONS_ID_TOKEN_REQUEST_URL && process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN
|
|
? {
|
|
requestUrl: process.env.ACTIONS_ID_TOKEN_REQUEST_URL,
|
|
requestToken: process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN,
|
|
}
|
|
: null;
|
|
|
|
// clear OIDC env vars in restricted mode to prevent agent from minting tokens
|
|
if (payload.shell !== "enabled") {
|
|
delete process.env.ACTIONS_ID_TOKEN_REQUEST_URL;
|
|
delete process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN;
|
|
}
|
|
|
|
// proxy decision: mint an OpenRouter key for OSS repos (or later, managed billing)
|
|
await resolveProxyModel({
|
|
payload,
|
|
oss: runContext.oss,
|
|
proxyModel: runContext.proxyModel,
|
|
oidcCredentials,
|
|
});
|
|
|
|
// create octokit with MCP token for GitHub API calls
|
|
const octokit = createOctokit(tokenRef.mcpToken);
|
|
|
|
const runInfo = await resolveRun({ octokit });
|
|
let toolContext: ToolContext | undefined;
|
|
let progressCallbackDisabled = false;
|
|
let todoTracker: ReturnType<typeof createTodoTracker> | undefined;
|
|
|
|
try {
|
|
if (payload.cwd && process.cwd() !== payload.cwd) {
|
|
process.chdir(payload.cwd);
|
|
}
|
|
|
|
// resolve body - fetches body_html and converts to markdown if images present
|
|
// this ensures agents receive markdown with working signed image URLs
|
|
const originalBody = payload.event.body;
|
|
const resolvedBody = await resolveBody({
|
|
event: payload.event,
|
|
octokit,
|
|
repo: runContext.repo,
|
|
});
|
|
if (resolvedBody !== originalBody) {
|
|
payload.event.body = resolvedBody;
|
|
// also update prompt if original body was included there
|
|
if (originalBody && payload.prompt.includes(originalBody)) {
|
|
payload.prompt = payload.prompt.replace(originalBody, resolvedBody ?? "");
|
|
}
|
|
}
|
|
|
|
const tmpdir = createTempDirectory();
|
|
|
|
await using gitAuthServer = await startGitAuthServer(tmpdir);
|
|
setGitAuthServer(gitAuthServer);
|
|
|
|
const agent = resolveAgent();
|
|
|
|
validateAgentApiKey({
|
|
agent,
|
|
model: payload.proxyModel ?? payload.model,
|
|
owner: runContext.repo.owner,
|
|
name: runContext.repo.name,
|
|
});
|
|
|
|
await setupGit({
|
|
gitToken: tokenRef.gitToken,
|
|
owner: runContext.repo.owner,
|
|
name: runContext.repo.name,
|
|
octokit,
|
|
toolState,
|
|
shell: payload.shell,
|
|
postCheckoutScript: runContext.repoSettings.postCheckoutScript,
|
|
});
|
|
timer.checkpoint("git");
|
|
|
|
// execute setup lifecycle hook (runs once at initialization)
|
|
await executeLifecycleHook({
|
|
event: "setup",
|
|
script: runContext.repoSettings.setupScript,
|
|
});
|
|
timer.checkpoint("lifecycleHooks::setup");
|
|
|
|
const modes = [...computeModes(), ...runContext.repoSettings.modes];
|
|
|
|
const outputSchema = resolveOutputSchema();
|
|
|
|
// mcpServerUrl and tmpdir are set after server starts
|
|
toolContext = {
|
|
repo: runContext.repo,
|
|
payload,
|
|
octokit,
|
|
githubInstallationToken: tokenRef.mcpToken,
|
|
gitToken: tokenRef.gitToken,
|
|
apiToken: runContext.apiToken,
|
|
modes,
|
|
postCheckoutScript: runContext.repoSettings.postCheckoutScript,
|
|
prApproveEnabled: runContext.repoSettings.prApproveEnabled,
|
|
modeInstructions: runContext.repoSettings.modeInstructions,
|
|
toolState,
|
|
runId: runInfo.runId,
|
|
jobId: runInfo.jobId,
|
|
mcpServerUrl: "",
|
|
tmpdir,
|
|
};
|
|
await using mcpHttpServer = await startMcpHttpServer(toolContext, { outputSchema });
|
|
toolContext.mcpServerUrl = mcpHttpServer.url;
|
|
log.info(`» MCP server started at ${mcpHttpServer.url}`);
|
|
timer.checkpoint("mcpServer");
|
|
|
|
const instructions = resolveInstructions({
|
|
payload,
|
|
repo: runContext.repo,
|
|
modes,
|
|
outputSchema,
|
|
learnings: runContext.repoSettings.learnings,
|
|
});
|
|
// log instructions as soon as they are fully resolved
|
|
const logParts = [
|
|
instructions.eventInstructions
|
|
? `EVENT-LEVEL INSTRUCTIONS:\n${instructions.eventInstructions}`
|
|
: null,
|
|
instructions.user ? `USER REQUEST:\n${instructions.user}` : null,
|
|
instructions.event,
|
|
].filter(Boolean);
|
|
log.box(logParts.join("\n\n---\n\n"), {
|
|
title: "Instructions",
|
|
});
|
|
|
|
// run agent, optionally with timeout enforcement
|
|
activityTimeout = createProcessOutputActivityTimeout({
|
|
timeoutMs: DEFAULT_ACTIVITY_TIMEOUT_MS,
|
|
checkIntervalMs: DEFAULT_ACTIVITY_CHECK_INTERVAL_MS,
|
|
});
|
|
activityTimeout.promise.catch(() => {}); // prevent unhandled rejection if agent wins race
|
|
todoTracker = createTodoTracker(async (body) => {
|
|
if (progressCallbackDisabled || !toolContext) return;
|
|
try {
|
|
await reportProgress(toolContext, { body });
|
|
} catch (err) {
|
|
log.debug(`progress update failed: ${err}`);
|
|
}
|
|
});
|
|
toolState.todoTracker = todoTracker;
|
|
|
|
const agentPromise = agent.run({
|
|
payload,
|
|
mcpServerUrl: mcpHttpServer.url,
|
|
tmpdir,
|
|
instructions,
|
|
todoTracker,
|
|
});
|
|
|
|
// timeout enforcement: default is 1 hour, but can be overridden via flags in the prompt:
|
|
// - --timeout=2h (or any duration like "--timeout=30m", "--timeout=1h30m") to set a custom timeout
|
|
// - --notimeout to disable timeout entirely
|
|
let result: Awaited<typeof agentPromise>;
|
|
if (payload.timeout === TIMEOUT_DISABLED) {
|
|
result = await Promise.race([agentPromise, activityTimeout.promise]);
|
|
} else {
|
|
const parsed = payload.timeout ? parseTimeString(payload.timeout) : null;
|
|
if (payload.timeout && parsed === null) {
|
|
log.warning(`invalid timeout format "${payload.timeout}", using default 1h`);
|
|
}
|
|
const timeoutMs = parsed ?? 3600000;
|
|
const actualTimeout = parsed !== null ? payload.timeout : "1h";
|
|
let timeoutId: NodeJS.Timeout | undefined;
|
|
const timeoutPromise = new Promise<never>((_, reject) => {
|
|
timeoutId = setTimeout(() => {
|
|
reject(new Error(`agent run timed out after ${actualTimeout}`));
|
|
}, timeoutMs);
|
|
});
|
|
timeoutPromise.catch(() => {}); // prevent unhandled rejection if agent wins race
|
|
try {
|
|
result = await Promise.race([agentPromise, timeoutPromise, activityTimeout.promise]);
|
|
} finally {
|
|
clearTimeout(timeoutId);
|
|
}
|
|
}
|
|
|
|
// accumulate top-level agent usage
|
|
if (result.usage) {
|
|
toolState.usageEntries.push(result.usage);
|
|
}
|
|
|
|
// validate this before writing job summary to avoid masking the error
|
|
if (outputSchema && !toolState.output) {
|
|
throw new Error(
|
|
"output_schema was provided but agent did not call set_output — structured output is required"
|
|
);
|
|
}
|
|
|
|
// post-agent review cleanup: reportReviewNodeId → follow-up re-review dispatch.
|
|
// runs after the agent exits so ordering is architecturally guaranteed (no LLM involvement).
|
|
// best-effort: cleanup failures must not turn a successful agent run into a failure.
|
|
if (toolContext) {
|
|
await postReviewCleanup(toolContext).catch((error) => {
|
|
log.debug(`post-review cleanup failed: ${error}`);
|
|
});
|
|
}
|
|
|
|
// clean up stranded progress comments. two cases:
|
|
// 1. wasUpdated=false: nothing wrote to the comment ("Leaping into action" orphan)
|
|
// 2. tracker published a checklist but the agent never wrote a final summary
|
|
// (hasPublished=true, finalSummaryWritten=false).
|
|
// in both cases, delete the comment so it doesn't linger with stale content.
|
|
// wasUpdated is intentionally NOT set here — cleanup is not a real progress update.
|
|
// uses finalSummaryWritten (not todoTracker.enabled) so cleanup survives API failures
|
|
// in report_progress where cancel() ran but the write didn't succeed.
|
|
const trackerWasLastWriter = todoTracker?.hasPublished && !toolState.finalSummaryWritten;
|
|
if (
|
|
toolContext &&
|
|
toolState.progressCommentId &&
|
|
(!toolState.wasUpdated || trackerWasLastWriter)
|
|
) {
|
|
await deleteProgressComment(toolContext).catch((error) => {
|
|
log.debug(`stranded progress comment cleanup failed: ${error}`);
|
|
});
|
|
}
|
|
|
|
await writeJobSummary(toolState);
|
|
|
|
// emit structured output marker for test validation
|
|
if (toolState.output) {
|
|
log.info(`::pullfrog-output::${Buffer.from(toolState.output).toString("base64")}`);
|
|
core.setOutput("result", toolState.output);
|
|
}
|
|
|
|
return await handleAgentResult({
|
|
result,
|
|
toolState,
|
|
silent: payload.event.silent ?? false,
|
|
});
|
|
} catch (error) {
|
|
const errorMessage = error instanceof Error ? error.message : "unknown error occurred";
|
|
progressCallbackDisabled = true;
|
|
todoTracker?.cancel();
|
|
killTrackedChildren();
|
|
log.error(errorMessage);
|
|
|
|
// best-effort summary — don't mask the original error
|
|
try {
|
|
await writeJobSummary(toolState);
|
|
} catch {}
|
|
|
|
try {
|
|
await reportErrorToComment({ toolState, error: errorMessage });
|
|
} catch {
|
|
// error reporting failed, but don't let it mask the original error
|
|
}
|
|
|
|
// best-effort review cleanup (e.g., agent timed out after submitting a review)
|
|
if (toolContext) {
|
|
await postReviewCleanup(toolContext).catch((error) => {
|
|
log.debug(`post-review cleanup failed: ${error}`);
|
|
});
|
|
}
|
|
|
|
return {
|
|
success: false,
|
|
error: errorMessage,
|
|
};
|
|
} finally {
|
|
activityTimeout?.stop();
|
|
if (usageSummaryPath) {
|
|
await writeGitHubUsageSummaryToFile(usageSummaryPath);
|
|
}
|
|
}
|
|
}
|