Ctrl K
ComunidadGuías…

main.ts

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
// changes to tool permissions should be reflected in wiki/granular-tools.md

import { existsSync, readdirSync } from "node:fs";
import { readFile } from "node:fs/promises";
import { join } from "node:path";
import { detect } from "package-manager-detector";
import { agents } from "./agents/index.ts";
import { opencodeGeneration } from "./agents/opencodeGeneration.ts";
import { hasDeliveredReview } from "./agents/postRun.ts";
import { subagentDeniedToolNames } from "./agents/subagentToolGates.ts";
import { findDanglingPromptToolRefs } from "./external.ts";
import { resolveAskModels } from "./mcp/askModel.ts";
import { reportProgress } from "./mcp/comment.ts";
import { startInstallation } from "./mcp/dependencies.ts";
import { adoptPostedReview } from "./mcp/review.ts";
import { startMcpHttpServer, type ToolContext } from "./mcp/server.ts";
import { computeModes } from "./modes.ts";
import { initToolState, primaryRepoState } from "./toolState.ts";
import {
  type ActivityTimeout,
  AGENT_ACTIVITY_TIMEOUT_MS,
  createProcessOutputActivityTimeout,
  DEFAULT_ACTIVITY_CHECK_INTERVAL_MS,
} from "./utils/activity.ts";
import { activateOpenAICompatibleSet, resolveAgent, resolveModel } from "./utils/agent.ts";
import {
  buildRejectedCredentialError,
  buildTrialSpentError,
  NoUsableCredentialError,
  validateAgentApiKey,
} from "./utils/apiKeys.ts";
import { formatCommercialGateSummary } from "./utils/billingErrors.ts";
import { resolveBody } from "./utils/body.ts";
import { log } from "./utils/cli.ts";
import { installCodenav, logCodenavCalls } from "./utils/codenav.ts";
import { installCodexAuth, installXaiAuth, PULLFROG_DATA_DIR } from "./utils/codexHome.ts";
import { checkConfiguredCredentials } from "./utils/credentialFallback.ts";
import {
  initializeCredentialPool,
  resolvePoolModel,
  selectConfiguredCredential,
} from "./utils/credentialPool.ts";
import { recordDiffReadFromToolUse } from "./utils/diffCoverage.ts";
import { onExitSignal } from "./utils/exitHandler.ts";
import { resolveGit, setGitAuthServer } from "./utils/gitAuth.ts";
import { startGitAuthServer } from "./utils/gitAuthServer.ts";
import {
  createOctokit,
  type OidcCredentials,
  writeGitHubUsageSummaryToFile,
} from "./utils/github.ts";
import { resolveInstructions } from "./utils/instructions.ts";
import { setBotLogin } from "./utils/isPullfrog.ts";
import { startKeylessAuth } from "./utils/keylessAuth.ts";
import {
  persistLearnings,
  persistXrepoLearnings,
  seedLearningsFile,
  seedXrepoLearningsFile,
} from "./utils/learnings.ts";
import { describeSetupFailure, executeLifecycleHook } from "./utils/lifecycle.ts";
import { buildModelAccessError, decideModelAccess } from "./utils/modelAccess.ts";
import { normalizeEnv, sanitizeSecret } from "./utils/normalizeEnv.ts";
import {
  captureAuthorizedModels,
  captureBaselineModels,
  getAuthorizedModels,
  getModelsCatalogFailure,
  seedModelsCatalog,
} from "./utils/openCodeModels.ts";
import { applyOverrides } from "./utils/overrides.ts";
import {
  type ProvisionablePackageManager,
  packageManagerBinDir,
  provisionPackageManager,
  resolvePackageManagerSpec,
} from "./utils/packageManager.ts";
import { aggregateUsage, patchWorkflowRunFields } from "./utils/patchWorkflowRunFields.ts";
import {
  isOutsideAuthor,
  resolveOutputSchema,
  resolvePayload,
  resolvePromptInput,
} from "./utils/payload.ts";
import { resolveTrialFallback, runProxyResolution } from "./utils/proxy.ts";
import { fetchPreviousSnapshot, persistSummary, seedSummaryFile } from "./utils/prSummary.ts";
import { handleAgentResult } from "./utils/run.ts";
import { getDefaultBranch, resolveRunContextData } from "./utils/runContextData.ts";
import { ossEffortFloor } from "./utils/runEffort.ts";
import { renderRunError } from "./utils/runErrorRenderer.ts";
import {
  finalizeSuccessRun,
  persistRunArtifacts,
  writeRunErrorOutputs,
} from "./utils/runLifecycle.ts";
import { logRunStartup } from "./utils/runStartupLog.ts";
import { getSandboxMethod, narrowProcessPath, recordGitDir } from "./utils/sandbox.ts";
import { setEnvAllowlist } from "./utils/secrets.ts";
import { createTempDirectory, setupGit, wipeRunnerLeakSurface } from "./utils/setup.ts";
import { reportStatusChecks } from "./utils/statusChecks.ts";
import { killTrackedChildren } from "./utils/subprocess.ts";
import { resolveRunTimeout } from "./utils/time.ts";
import { Timer } from "./utils/timer.ts";
import { createTodoTracker } from "./utils/todoTracking.ts";
import {
  getGitHubInstallationToken,
  getJobToken,
  getMcpTokenRefresh,
  resolveTokens,
} from "./utils/token.ts";
import {
  cleanupVertexCredentials,
  materializeVertexCredentials,
  type VertexCredentials,
} from "./utils/vertex.ts";
import { resolveRun } from "./utils/workflow.ts";
import { dirtyTrackedPaths, restoreDirtiedSince } from "./utils/worktree.ts";

export { Inputs } from "./utils/payload.ts";

export interface MainResult {
  success: boolean;
  output?: string | undefined;
  error?: string | undefined;
  result?: string | undefined;
}

export async function main(): Promise<MainResult> {
  // normalize env var names to uppercase (handles case-insensitive workflow files)
  normalizeEnv();

  // apply caller-supplied env overrides — JSON object forwarded as the
  // UNSAFE_OVERRIDES env var (NOT a `with:` input). gated by `actions:write`
  // on the repo and refuses integrity-critical names; see utils/overrides.ts
  // for the deny-list and wiki/e2e-testing.md for usage + threat model.
  // the `unsafe` prefix is intentional: GH echoes the env-block value in the
  // step-header log, so the raw JSON is visible to anyone with `actions:read`.
  const overridesRaw = process.env.UNSAFE_OVERRIDES ?? "";
  if (overridesRaw.trim()) {
    const result = applyOverrides({ raw: overridesRaw, env: process.env });
    if (result.applied.length > 0) {
      log.info(`» applied ${result.applied.length} env override(s): ${result.applied.join(", ")}`);
    }
    if (result.denied.length > 0) {
      log.warning(
        `» refused to override ${result.denied.length} protected env var(s): ${result.denied.join(", ")}`
      );
    }
  }

  // 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;
  let safetyNetTimer: NodeJS.Timeout | undefined;

  // parse prompt early to extract progressComment for toolState
  const resolvedPromptInput = resolvePromptInput();

  // resolve and fingerprint git binary before any agent code runs
  resolveGit();

  // the job token is only run-context's bearer; GitHub reads use the installation token (#1584)
  const jobToken = getJobToken();
  const runContext = await resolveRunContextData({
    token: jobToken,
    runType: typeof resolvedPromptInput === "string" ? undefined : resolvedPromptInput.type,
    plainPrompt: typeof resolvedPromptInput === "string",
    routedTier:
      typeof resolvedPromptInput === "string" ? undefined : resolvedPromptInput.routing?.tier,
  });
  timer.checkpoint("runContextData");
  setBotLogin(runContext.repoSettings.botLogin);

  const payload = resolvePayload(resolvedPromptInput, runContext.repoSettings);

  // seed toolState with the primary repo (keyed in `repos`). dir is the
  // run-entry cwd; configureRepoGit refreshes it after any payload.cwd chdir.
  const toolState = initToolState({
    progressComment:
      typeof resolvedPromptInput !== "string" ? resolvedPromptInput.progressComment : undefined,
    owner: runContext.repo.owner,
    name: runContext.repo.name,
    dir: process.cwd(),
  });
  toolState.model = payload.model;
  toolState.oss = runContext.oss;
  // seed the comment target before every terminal branch. `reportErrorToComment`
  // reads only toolState, so silent triggers otherwise have nowhere to post.
  if (payload.event.issue_number !== undefined) {
    primaryRepoState(toolState).issueNumber = payload.event.issue_number;
  }
  if (payload.event.trigger === "pull_request_synchronize") {
    primaryRepoState(toolState).beforeSha = payload.event.before_sha;
  }

  // stash OIDC credentials before any early return. refused runs need a scoped
  // comment token; normal runs reuse the snapshot after the restricted env wipe.
  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;

  if (runContext.commercialRefused) {
    // gate-refusal path: posts one comment and exits, so no agent and no gh token
    await using _commentTokenRef = await resolveTokens({
      push: "disabled",
      shell: "disabled",
      authorPermission: undefined,
      oidc: oidcCredentials,
    });
    const errorMessage = {
      commercial: "Pro trial expired for this organization",
      subscription_ended: "Pro subscription ended for this organization",
      subscription_unpaid: "Pro renewal failed for this organization",
    }[runContext.commercialRefused];
    log.error(errorMessage);
    const body = formatCommercialGateSummary({
      reason: runContext.commercialRefused,
      ownerLogin: runContext.repo.owner,
    });
    await writeRunErrorOutputs({ rendered: { summary: body, comment: body }, toolState });
    return { success: false, error: errorMessage };
  }

  // tmpdir hoisted out of the try block: `installFromNpmTarball` reads
  // PULLFROG_TEMP_DIR (set as a side effect of createTempDirectory) when
  // the opencode CLI install runs below for BYOK introspection. agent +
  // mcp server setup further down also consume the same tmpdir.
  //
  // the return value is reused below rather than calling again: every call
  // `mkdtempSync`s a NEW directory and overwrites PULLFROG_TEMP_DIR, and the
  // installers key their fs cache off that variable *at call time* — so a
  // second call silently invalidated the cache and re-downloaded plus
  // re-extracted the whole opencode tarball on every run.
  //
  // `using` removes it only when main() returns, after both run-end paths
  // below have persisted the PR summary and learnings they read from here.
  using tempDir = createTempDirectory();
  const tmpdir = tempDir.path;

  // install OpenCode + capture the BASELINE model set BEFORE dbSecrets and
  // Codex auth.json are in scope. this is the set of models OpenCode can
  // route from the runner's pre-existing environment alone (workflow
  // `env:` block + GH Actions secrets). install is fs-cached, so the
  // duplicate call inside the opencode agent's run() is a no-op.
  //
  // one restore window straddling both `opencode models` captures (#1151):
  // nothing between them writes to the worktree, and both finish before any
  // agent tool exists, so the #1146 "don't discard the agent's own work" hazard
  // cannot apply.
  //
  // keyed on `payload.cwd`, NOT `process.cwd()`. that is the whole lesson of
  // 0.1.54: this code runs BEFORE the chdir below, and `runCli.ts` starts the
  // CLI outside the checkout (a bootstrap tmpdir, or an action checkout with no
  // `.git`), so reading `process.cwd()` here made git exit 129 and throw — every
  // customer run died before the agent started. with no repo dir resolved there
  // is nothing to protect, so the window simply does not open.
  const repoDir = payload.cwd;
  const preIntrospectionDirty = repoDir ? dirtyTrackedPaths({ cwd: repoDir }) : null;
  // decided from the checkout, before anything installs or runs opencode; every later read reuses it.
  opencodeGeneration({ cwd: repoDir ?? process.cwd(), rollouts: payload.rollouts ?? [] });
  const opencodeCliPath = await agents.opencode.install();
  // not alongside the install: its `spawnSync` tar extraction blocks the event loop, which spent the
  // download's timeout on every first attempt on a hosted runner
  await seedModelsCatalog(tmpdir);
  captureBaselineModels({ cliPath: opencodeCliPath, tmpdir });

  // DRAGON: nothing above this block may resolve a model or read a provider env var — console-stored
  // config (`OPENAI_COMPATIBLE_MODEL`, `BEDROCK_MODEL_ID`, …) is not in process.env yet. 0.1.85 did, and broke every such run (#1433).
  // inject account-level secrets into process.env (YAML secrets take precedence).
  // sanitizeSecret trims + masks so accidental trailing whitespace doesn't leak
  // through GitHub Actions' line-based log masking. whitespace-only values
  // return null and skip injection so the user sees a clear missing-key error.
  const injected: string[] = [];
  if (runContext.dbSecrets) {
    for (const [key, value] of Object.entries(runContext.dbSecrets)) {
      if (!process.env[key]) {
        const sanitized = sanitizeSecret(key, value);
        if (sanitized === null) continue;
        process.env[key] = sanitized;
        injected.push(key);
      }
    }
    const count = Object.keys(runContext.dbSecrets).length;
    if (count > 0) log.info(`» ${count} db secret(s) loaded`);
  }

  // a named OpenAI-compatible set only exists from here on when it is stored in Pullfrog.
  const openAICompatibleSet = activateOpenAICompatibleSet({ slug: payload.model });

  // keyless auth is the first credential a provider tries, so it starts before the pool loads,
  // and before model discovery so opencode reads the provider as authorized.
  await using keyless = await startKeylessAuth({ oidc: oidcCredentials });

  // load one stored subscription per provider for the discovery below. `injected` keeps a
  // workflow credential distinct from a stored one now that both sit in process.env.
  await initializeCredentialPool({
    ctx: runContext,
    slug: payload.model,
    injected,
    exclude: keyless.suppressed,
  });

  // materialize the subscription auth.json entries (idempotent — the opencode
  // agent re-calls both inside run() and writes the same file; the writer
  // merges per provider so neither clobbers the other). this has to land
  // BEFORE captureAuthorizedModels so OpenCode's model introspection sees the
  // OAuth-routed models: measured, `opencode models` lists 0 `xai/*` entries
  // without the Grok credential on disk and 12 with it, so skipping this
  // would read a subscription-only account as unable to run its own models
  // and fall the run back to the free tier.
  installCodexAuth();
  installXaiAuth();

  // capture the AUTHORIZED model set after dbSecrets + Codex auth.json are
  // applied. this is the authoritative source for the BYOK fallback
  // decision and the opencode-agent path of validateAgentApiKey — strictly
  // more accurate than the static envVars/managedCredentials catalog,
  // which can miss new auth shapes.
  captureAuthorizedModels({ cliPath: opencodeCliPath, tmpdir });

  // close the window opened before the baseline capture.
  if (preIntrospectionDirty && repoDir) {
    restoreDirtiedSince({
      before: preIntrospectionDirty,
      actor: "model introspection",
      cwd: repoDir,
    });
  }

  // configure env allowlist for subprocess filtering
  if (runContext.repoSettings.envAllowlist) {
    setEnvAllowlist(runContext.repoSettings.envAllowlist);
  }

  // surface a narrowed cross-repo request in the run output (not just the
  // server-side dispatch log) so the triggerer sees what wasn't granted.
  const xrepoUnavailable = payload.xrepo?.unavailable ?? [];
  if (xrepoUnavailable.length > 0) {
    log.warning(
      `» --xrepo: requested but not granted: ${xrepoUnavailable.join(", ")} (unknown repo, different owner, or you lack access)`
    );
  }

  // probe the sandbox ONCE, here, rather than lazily inside the first
  // `spawnShell`. on an unprivileged Kubernetes/ARC runner every PID-namespace
  // method fails, so every `pullfrog_shell` call throws — but detection used to
  // happen minutes in, the tool stayed registered, and the prompt kept telling
  // the agent it was the only sanctioned way to run anything. 192 runs / 757
  // dead tool calls in 24h, all green, with agents submitting reviews whose
  // tests they had decided to run and could not (#1093). resolving it to
  // `disabled` up front makes the tool set and the prompt agree with reality,
  // and gives the operator one line they can act on. it runs before the mint so
  // a run left without a shell is not handed a `gh` token it cannot use (#1517).
  if (payload.shell !== "disabled" && process.env.CI === "true" && getSandboxMethod() === "none") {
    payload.shell = "disabled";
    payload.shellUnsandboxable = true;
    payload.codenav = false;
    log.warning(
      "» shell commands and the `gh` tool are disabled for this run: this runner provides no PID-namespace isolation " +
        "(unprivileged unshare, sudo unshare and userns-nested unshare all failed). " +
        "on a self-hosted Kubernetes/ARC runner, allow user-namespace creation in the pod's seccomp " +
        "profile — see https://docs.pullfrog.com/security#self-hosted-runners"
    );
  }
  // `enabled` gives the agent the environment this protects, and needs the customer's PATH natively
  if (payload.shell !== "enabled") narrowProcessPath(tmpdir);

  // resolve tokens first — acquireNewToken needs OIDC env vars for token exchange
  await using tokenRef = await resolveTokens({
    push: payload.push,
    shell: payload.shell,
    authorPermission: payload.event.authorPermission,
    xrepo: payload.xrepo,
    xrepoGrant: payload.xrepoGrant,
    oidc: oidcCredentials,
  });

  // wipe the GHA runner's known credential leak surface inside $RUNNER_TEMP
  // before the agent spawns. our installation token is already in memory
  // (tokenRef above), and setupGit's includeIf strip handles the matching
  // dangling references in the user's .git/config. see wipeRunnerLeakSurface
  // for the leak inventory and threat model.
  wipeRunnerLeakSurface();

  // clear the OIDC request env so the agent can't mint installation tokens itself.
  // mid-run token refresh uses the `oidcCredentials` snapshot above, not process.env,
  // so this is safe in every shell tier — including `enabled`, which passes the full
  // env to shell commands and so would otherwise hand that mint primitive to any
  // injected command (a full-permission token for the repo, with no body).
  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 managed billing
  // accounts. BillingError (402) and TransientError (503) get rendered inside
  // `runProxyResolution` before being rethrown — handled here (not in the
  // outer catch) because the outer catch needs `toolContext` (not yet built)
  // for its general-purpose error path.
  await runProxyResolution({
    payload,
    oss: runContext.oss,
    proxyModel: runContext.proxyModel,
    oidcCredentials,
    repo: runContext.repo,
    toolState,
  });

  // create octokit with MCP token for GitHub API calls.
  // the refresh handles mid-run token invalidation (#891)
  const octokit = createOctokit(tokenRef.mcpToken, getMcpTokenRefresh());

  const runInfo = await resolveRun({ octokit });
  let toolContext: ToolContext | undefined;
  // this start's own result, reported on the end-of-run PATCH in `finally`
  let succeeded = false;
  let progressCallbackDisabled = false;
  let todoTracker: ReturnType<typeof createTodoTracker> | undefined;
  let vertexCredentials: VertexCredentials | 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,
      tmpdir,
      githubToken: tokenRef.mcpToken,
    });
    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 ?? "");
      }
    }

    await using gitAuthServer = await startGitAuthServer(tmpdir);
    setGitAuthServer(gitAuthServer);

    let resolvedModel = resolveModel({ slug: payload.model });
    if (!resolvedModel && !payload.proxyModel && runContext.credentialAccess?.candidates.length) {
      resolvedModel = resolvePoolModel({
        codexAgent: runContext.repoSettings.codexAgent || payload.codexArm === true,
      });
    }
    let poolSelected =
      !payload.proxyModel &&
      (await selectConfiguredCredential({
        ctx: runContext,
        model: resolvedModel,
      }));

    // model-access gate: an explicitly-requested per-run model (`--opus`,
    // `--model=<slug>`) that this run can't serve hard-fails here, before the
    // agent starts. standing defaults keep `modelExplicit = false` and fall
    // through to `validateAgentApiKey`'s missing-key error below (#938).
    // proxy / byok decisions mutate `payload.proxyModel` so the resolution
    // beneath sees the corrected routing.
    const access = decideModelAccess({
      modelExplicit: payload.modelExplicit ?? false,
      model: payload.model,
      oss: runContext.oss,
      proxyActive: !!payload.proxyModel,
      subsidyTarget: runContext.proxyModel,
      resolvedModel,
      authorized: getAuthorizedModels(),
    });
    if (access.kind === "error") {
      throw new Error(
        buildModelAccessError({
          reason: access.reason,
          model: payload.model ?? "(unknown)",
          owner: runContext.repo.owner,
          name: runContext.repo.name,
        })
      );
    }
    if (access.kind === "proxy") payload.proxyModel = access.target;
    if (access.kind === "byok") {
      payload.proxyModel = undefined;
      if (!poolSelected)
        poolSelected = await selectConfiguredCredential({
          ctx: runContext,
          model: resolvedModel,
        });
    }

    // a subsidised run is Pullfrog's spend, so it is pinned to `high` on
    // whatever ladder the funded model publishes — matching what every OSS run
    // got before this setting existed. see `ossEffortFloor` for why this asks
    // for the rung by name rather than taking position 0.
    if (runContext.oss && payload.proxyModel) {
      payload.effort = ossEffortFloor({ payload });
    }

    if (payload.proxyModel) resolvedModel = undefined;

    // ask the providers whether the configured model's credentials still work
    // before committing to it. a rejected credential becomes an accurate error naming
    // the credential — never the 401-three-seconds-in that used to send users
    // to a GitHub Actions secrets page they had never put a key in. skipped
    // for proxy runs for the same reason validateAgentApiKey is: the server
    // minted the key and is the authority on it.
    const credentials =
      payload.proxyModel || poolSelected
        ? { kind: "ok" as const }
        : await checkConfiguredCredentials({ model: resolvedModel });
    if (credentials.kind === "dead") {
      throw new Error(
        buildRejectedCredentialError({
          credential: credentials.credential,
          reason: credentials.reason,
          owner: runContext.repo.owner,
          name: runContext.repo.name,
          inPullfrogStore: credentials.credential in (runContext.dbSecrets ?? {}),
        })
      );
    }
    vertexCredentials = materializeVertexCredentials({ model: resolvedModel });

    let agent = resolveAgent({
      model: resolvedModel,
      proxyModel: payload.proxyModel,
      // the account opt-in and the canary arm are both admissions to codex, so
      // they OR: an account that opted in explicitly always gets it, and the
      // canary widens the pool without ever demoting a run that already had it.
      codexAgent: runContext.repoSettings.codexAgent || payload.codexArm === true,
    });

    // record the harness that actually ran. `agent` is what the run DID;
    // `WorkflowRun.codexArm` is what it was ASSIGNED — comparing arms on the
    // assignment is what keeps runs that die before this PATCH in the analysis.
    toolState.agent = agent.name;

    // agent-agnostic best-effort for the model that ran: proxy spec for
    // router/oss runs, else the resolved model, else the slug.
    // payload.model is just the stored slug (often undefined for router/oss
    // runs that derive the target from proxyModel). matching priority with
    // resolveModelForLog so the "Using `…`" badge reflects what actually ran.
    // the opencode agent refines this from `rawModel` once it auto-selects (a
    // pick main.ts can't know — see opencode.ts), so auto-select runs persist
    // their real model rather than this placeholder.
    const effectiveModel = payload.proxyModel ?? resolvedModel ?? payload.model;
    // surface it in comment/review footers and persist it on the end-of-run PATCH.
    toolState.model = effectiveModel;

    // fail fast when the configured model needs a key the runner doesn't
    // have. the thrown markdown is mirrored by `renderRunError` →
    // `writeRunErrorOutputs` to both the PR/issue comment (when the run has
    // issue context) and the GHA job summary, with model-specific fix
    // instructions. Without this, the agent launches with no key, the LLM
    // provider 401s, and the run dies in seconds with a synthetic "Invalid
    // API key" — the silent-churn pattern that took out 15 accounts
    // pre-launch.
    //
    // skipped when proxyModel is set: `runProxyResolution` already minted
    // OPENROUTER_API_KEY and the server-side gate (`run-context/route.ts`)
    // is the authority on "can this run use the router". the `authorized`
    // set was captured BEFORE the proxy mint, so it doesn't see the
    // openrouter slug — validating would spuriously throw.
    if (!payload.proxyModel) {
      try {
        validateAgentApiKey({
          agent,
          model: effectiveModel,
          authorized: getAuthorizedModels(),
          owner: runContext.repo.owner,
          name: runContext.repo.name,
          secretsUnavailable: runContext.secretsUnavailable,
          routerUnfunded: runContext.routerUnfunded,
          openAICompatibleSet,
        });
      } catch (missingKey) {
        // the trial fallback fires HERE and nowhere earlier: this is the first
        // moment anything knows that no credential could pay for the run.
        // `validateAgentApiKey` has just searched everything the server cannot
        // see — workflow `env:`, GitHub Actions secrets, the harness-specific
        // shapes — so an earlier mint would have downgraded accounts that were
        // fine. the server only granted permission; the verdict is here.
        //
        // and only THAT verdict: the same function also throws when opencode
        // could not start, when a Bedrock/Vertex/Azure/OpenAI-compatible setup
        // is half-wired, and when we could not read our own stored secrets.
        // every one of those means the user HAS brought a credential, so
        // falling back would swap their model for a cheap one and swallow the
        // message that says what to fix.
        const funded =
          runContext.trialFallback && missingKey instanceof NoUsableCredentialError
            ? await resolveTrialFallback({
                payload,
                configuredModel: effectiveModel,
                creditsLeftUsd: runContext.trialCreditsLeftUsd,
                oidcCredentials,
                repo: runContext.repo,
                toolState,
              })
            : false;
        // a trial whose credits are spent would otherwise read "add an API key" with no
        // word that the free credits it had been running on are gone.
        const trialSpent =
          runContext.trialCreditsLeftUsd !== undefined && runContext.trialCreditsLeftUsd <= 0;
        if (!funded && trialSpent && missingKey instanceof NoUsableCredentialError) {
          throw new Error(buildTrialSpentError({ owner: runContext.repo.owner }));
        }
        if (!funded) throw missingKey;
        // the run is now Router-served, and only opencode speaks that provider —
        // the harness picked above was chosen for a credential that does not
        // exist. re-resolving is not optional bookkeeping: leaving `agent` alone
        // sends a proxy run to claude-code or codex, which then dies on the same
        // missing key this branch just worked around.
        agent = resolveAgent({
          model: resolvedModel,
          proxyModel: payload.proxyModel,
          codexAgent: runContext.repoSettings.codexAgent || payload.codexArm === true,
        });
        toolState.agent = agent.name;
      }
    }

    await setupGit({
      gitToken: tokenRef.gitToken,
      owner: runContext.repo.owner,
      name: runContext.repo.name,
      octokit,
      toolState,
      shell: payload.shell,
      postCheckoutScript: runContext.repoSettings.postCheckoutScript,
      botLogin: runContext.repoSettings.botLogin,
      botUserId: runContext.repoSettings.botUserId,
    });
    if (payload.shell !== "enabled") recordGitDir();
    timer.checkpoint("git");

    // pin the project's package manager via corepack BEFORE the setup hook
    // runs. without this, a customer setup script like `npm i -g pnpm &&
    // pnpm install` installs whatever pnpm is "latest" today and writes
    // unrelated lockfile drift (e.g. the `packageManagerDependencies`
    // block pnpm 11.3 added) into our commit — see #844. resolution
    // honors pnpm 11+ precedence (`devEngines.packageManager` over
    // `packageManager`); failure is non-fatal — we fall back to whatever
    // is on PATH with a warning. the shim lands in a private bin dir
    // (prepended to PATH), not the node bin dir, so a setup `npm i -g pnpm`
    // can't collide with it.
    const pmSpec = await resolvePackageManagerSpec(process.cwd());
    // resolve the manager the same way the prep phase will (declared spec first,
    // then lockfile) and provision it by whatever route it needs. asking corepack
    // alone was the bug: it manages pnpm and yarn and ignores bun and deno, so a
    // `setup` hook running `bun install` died with `bun: command not found` 0.8s
    // before Pullfrog installed bun itself. see #1121.
    const pmDetected = await detect({ cwd: process.cwd(), strategies: ["lockfile"] });
    const pmName = pmSpec?.name ?? (pmDetected?.name as ProvisionablePackageManager) ?? "npm";
    // provisioning executes code, so it is gated on shell exactly like the prep phase.
    if (payload.shell !== "disabled") {
      const pmError = await provisionPackageManager({
        name: pmName,
        declared: pmSpec,
        binDir: packageManagerBinDir(tmpdir),
      });
      if (pmError) log.warning(`» could not provision ${pmName}: ${pmError}`);
    }
    timer.checkpoint("packageManager");

    // execute the setup lifecycle hook (runs once at initialization). best-effort:
    // a failure no longer aborts the run — we warn the operator and surface it to
    // the agent via the SETUP HOOK FAILED banner so it can verify the env and adapt.
    const setupHook = await executeLifecycleHook({
      event: "setup",
      script: runContext.repoSettings.setupScript,
      shell: payload.shell,
      normalizeWorkingTreeAfter: true,
    });
    if (setupHook.warning) {
      log.warning(setupHook.warning);
    }
    timer.checkpoint("lifecycleHooks::setup");

    const agentId = agent.name;
    const modes = [
      ...computeModes({
        agentId,
        signedCommits: runContext.repoSettings.signedCommits,
        botLogin: runContext.repoSettings.botLogin,
      }),
      ...runContext.repoSettings.modes,
    ];

    const outputSchema = resolveOutputSchema();

    const repo = {
      ...runContext.repo,
      defaultBranch: await getDefaultBranch({ octokit, repo: runContext.repo }),
    };

    // mcpServerUrl and tmpdir are set after server starts
    toolContext = {
      agentId,
      repo,
      payload,
      octokit,
      // live getter so raw-token consumers (asset fetches, plan/summary-comment
      // GETs) see the refreshed MCP token after a mid-run re-acquisition (#891)
      get githubInstallationToken() {
        return getGitHubInstallationToken();
      },
      // live getter, same reason as #891 above — reads the current git token
      // (canonical rationale on TokenRef.gitToken). see #964.
      get gitToken() {
        return tokenRef.gitToken;
      },
      refreshGitToken: tokenRef.refreshGitToken,
      readToken: tokenRef.readToken,
      ghToken: tokenRef.ghToken,
      shellToken: tokenRef.shellToken,
      xrepo: payload.xrepo,
      apiToken: runContext.apiToken,
      modes,
      postCheckoutScript: runContext.repoSettings.postCheckoutScript,
      botLogin: runContext.repoSettings.botLogin,
      botUserId: runContext.repoSettings.botUserId,
      prepushScript: runContext.repoSettings.prepushScript,
      prApproveEnabled: runContext.repoSettings.prApproveEnabled,
      reviewDrafts: runContext.repoSettings.reviewDrafts,
      autoMergeEnabled: runContext.repoSettings.autoMergeEnabled,
      signedCommits: runContext.repoSettings.signedCommits,
      repoIntelligence: runContext.repoSettings.repoIntelligence,
      modeInstructions: runContext.repoSettings.modeInstructions,
      toolState,
      runId: runInfo.runId,
      jobId: runInfo.jobId,
      mcpServerUrl: "",
      tmpdir,
      oss: runContext.oss,
      plan: runContext.plan,
      resolvedModel,
      askModels: resolveAskModels({ runContext, proxied: !!payload.proxyModel }),
    };
    if (payload.codenav) installCodenav(tmpdir);
    await using mcpHttpServer = await startMcpHttpServer(toolContext, { outputSchema });
    toolContext.mcpServerUrl = mcpHttpServer.url;
    log.info(`» MCP server started at ${mcpHttpServer.url}`);
    timer.checkpoint("mcpServer");

    // derive the subagent deny list from the same tool set the server just
    // registered, so the gate can never drift from the registered tools.
    const subagentDeniedTools = subagentDeniedToolNames(toolContext, outputSchema);

    // seed the rolling repo-level learnings tmpfile for every run. the
    // agent reads the file at startup (path is surfaced in the LEARNINGS
    // section of the prompt) and may edit it during the post-run
    // reflection turn. persistLearnings reads it back at end-of-run and
    // PATCHes any changes to Repo.learnings, byte-trim equality against
    // the seed gates the API call. always-seed (vs gated): learnings are
    // universal — any run can produce them, and gating just hides the
    // affordance.
    //
    // wrapped in best-effort try/catch: this block runs unconditionally,
    // and an unwrapped filesystem failure (ENOSPC, EACCES, hostile sandbox)
    // would unwind into the outer main() catch and flip an otherwise-
    // successful run to "❌ Pullfrog failed" before the agent even starts.
    // on failure toolState.learningsFilePath stays unset, and downstream
    // consumers (`persistLearnings`, agent harnesses, `resolveInstructions`)
    // all treat undefined as "no learnings affordance this run".
    toolState.learningsLocked = isOutsideAuthor(payload.event);
    try {
      const learningsPath = await seedLearningsFile({
        tmpdir,
        current: runContext.repoSettings.learnings,
      });
      toolState.learningsFilePath = learningsPath;
      // file on disk is the verbatim DB body, so the seed used for
      // change-detection is just `current ?? ""` (trimmed). persistLearnings
      // byte-compares against the trimmed read-back to skip no-op PATCHes.
      toolState.learningsSeed = (runContext.repoSettings.learnings ?? "").trim();
      log.info(
        `» learnings seeded at ${learningsPath} (existing=${runContext.repoSettings.learnings ? "yes" : "no"})`
      );
      const ctxForExit = toolContext;
      onExitSignal(() => persistLearnings(ctxForExit));
    } catch (err) {
      log.warning(
        `» learnings seed failed: ${err instanceof Error ? err.message : String(err)} — continuing without learnings file`
      );
    }

    // on --xrepo runs, seed the org-level cross-repo learnings tmpfile too.
    // same lifecycle as repo learnings (read at startup, agent-editable,
    // persisted at end), but org-scoped and only present cross-repo.
    if (payload.xrepo) {
      try {
        const xrepoPath = await seedXrepoLearningsFile({
          tmpdir,
          current: runContext.repoSettings.xrepoLearnings,
        });
        toolState.xrepoLearningsFilePath = xrepoPath;
        toolState.xrepoLearningsSeed = (runContext.repoSettings.xrepoLearnings ?? "").trim();
        log.info(
          `» xrepo learnings seeded at ${xrepoPath} (existing=${runContext.repoSettings.xrepoLearnings ? "yes" : "no"})`
        );
        const ctxForExit = toolContext;
        onExitSignal(() => persistXrepoLearnings(ctxForExit));
      } catch (err) {
        log.warning(
          `» xrepo learnings seed failed: ${err instanceof Error ? err.message : String(err)} — continuing without xrepo learnings file`
        );
      }
    }

    // seed the rolling PR summary tmpfile when the dispatcher requested it.
    // gated on event being a PR — issue/workflow_dispatch runs have no
    // summarySnapshot to maintain. file path is exposed to the agent via
    // the select_mode response addendum (action/mcp/selectMode.ts).
    if (payload.generateSummary && payload.event.is_pr && payload.event.issue_number) {
      const previousSnapshot = await fetchPreviousSnapshot(toolContext, payload.event.issue_number);
      const filePath = await seedSummaryFile({ tmpdir, previousSnapshot });
      toolState.summaryFilePath = filePath;
      // capture the exact bytes the agent will see at startup. used by
      // the post-run retry loop to detect the agent forgetting to edit
      // the file (byte-identical to seed → nudge once via resume turn)
      // and by persistSummary to skip the DB write when nothing changed.
      try {
        toolState.summarySeed = await readFile(filePath, "utf8");
      } catch {
        // intentionally empty — summarySeed stays undefined
      }
      log.info(
        `» summary snapshot seeded at ${filePath} (previous=${previousSnapshot ? "yes" : "no"})`
      );
      // on SIGINT/SIGTERM we still want to persist whatever the agent has
      // written so far. handler is best-effort: any failure inside is
      // swallowed by Promise.allSettled in exitHandler.ts, and the
      // summaryPersistAttempted guard prevents double-execution if the
      // signal arrives after the normal path already persisted. capture a
      // narrowed reference so the closure doesn't depend on the outer
      // `toolContext` variable being defined later.
      const ctxForExit = toolContext;
      onExitSignal(() => persistSummary(ctxForExit));
    }

    startInstallation(toolContext);

    logRunStartup({ payload, resolvedModel, agentName: agent.name });

    const instructions = resolveInstructions({
      payload,
      repo,
      modes,
      agentId,
      outputSchema,
      signedCommits: runContext.repoSettings.signedCommits,
      repoIntelligence: runContext.repoSettings.repoIntelligence,
      learningsFilePath: toolState.learningsFilePath ?? null,
      learningsHeadings: runContext.repoSettings.learningsHeadings,
      setupHookFailure: describeSetupFailure(setupHook.failure),
      xrepoBrief: runContext.repoSettings.xrepoBrief,
      xrepoLearningsFilePath: toolState.xrepoLearningsFilePath ?? null,
      xrepoLearningsHeadings: runContext.repoSettings.xrepoLearningsHeadings,
    });
    // the prompt and the tool registry are decided in different files off
    // overlapping conditions; this is what stops them disagreeing silently.
    // WARNS, never throws: the prompt embeds customer-authored text (repo
    // instructions, an issue body), so a customer who merely writes a prefixed
    // token controls this predicate — throwing let them fail every run on their
    // own repo (`arcainc/arca`, 0.1.56).
    const danglingToolRefs = findDanglingPromptToolRefs({
      agentId,
      prompt: instructions.full,
      toolNames: mcpHttpServer.toolNames,
    });
    if (danglingToolRefs.length > 0) {
      log.warning(
        `» prompt advertises ${danglingToolRefs.length} tool(s) the MCP server did not register: ${danglingToolRefs.join(", ")}`
      );
    }
    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",
    });
    log.group("View full prompt", () => {
      log.info(instructions.full);
    });

    // OpenCode loads .opencode/plugin/ files at startup. if the repo has any,
    // eagerly await dependency installation so plugin imports can resolve.
    if (agentId === "opencode") {
      const pluginDir = join(process.cwd(), ".opencode", "plugin");
      const hasPlugins =
        existsSync(pluginDir) && readdirSync(pluginDir).some((f) => /\.[jt]sx?$/.test(f));
      if (hasPlugins && toolState.dependencyInstallation?.promise) {
        log.info(
          "» .opencode/plugin/ detected — awaiting dependency installation before agent start"
        );
        await toolState.dependencyInstallation.promise.catch(() => {});
        timer.checkpoint("awaitDepsForPlugins");
      }
    }

    // run agent, optionally with timeout enforcement
    activityTimeout = createProcessOutputActivityTimeout({
      timeoutMs: AGENT_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 {
        // liveProgress: this is the auto-rendered checklist, not a deliberate
        // final answer — must not flip wasUpdated / lastProgressBody (see #868).
        await reportProgress(toolContext, { body, liveProgress: true });
      } catch (err) {
        log.debug(`progress update failed: ${err}`);
      }
    });
    toolState.todoTracker = todoTracker;

    // on cancellation, stop scheduling new tracker writes immediately. without this, a
    // debounced write queued just before SIGTERM could land at GitHub *after* the
    // workflow_run.completed webhook has already replaced the comment with the
    // "This run was cancelled" body, clobbering it back to the task list. we can't
    // await in-flight writes (the process is exiting), but cancelling the timer
    // shrinks the race window.
    onExitSignal(() => {
      todoTracker?.cancel();
    });

    // when the agent is killed for inner activity timeout, start a short
    // safety-net timer — if the agent promise hasn't resolved within 5min after
    // the inner kill, stop the MCP HTTP server (so mcp-proxy's SSE reconnect
    // attempts don't keep the outer activity timer alive) and force-reject the
    // outer timer so the run can exit.
    //
    // both were previously done the instant the watchdog fired, which is what
    // made the salvage in `opencode.ts` impossible: the re-prompt landed on a
    // dead MCP server, so the agent could not call `create_pull_request_review`
    // and the "recovery" produced a confidently toolless turn. deferring costs
    // nothing — `forceReject` ends the run directly, so it never depended on
    // the teardown, and `onTurnRecovered` cancels the whole thing the moment a
    // turn actually comes back. see #1085.
    let innerTimeoutFired = false;
    const onInnerActivityTimeout = () => {
      if (innerTimeoutFired) return;
      innerTimeoutFired = true;
      log.info("» inner activity timeout fired — starting 5min safety-net timer");
      safetyNetTimer = setTimeout(
        () => {
          // fire and forget — the server's dispose is idempotent so the
          // `await using` cleanup at block exit is still safe.
          mcpHttpServer[Symbol.asyncDispose]().catch((err) => {
            log.debug(
              `mcp server stop after inner kill failed: ${err instanceof Error ? err.message : String(err)}`
            );
          });
          activityTimeout?.forceReject(
            "agent still pending 5min after inner activity kill — forcing exit"
          );
        },
        5 * 60 * 1000
      );
      safetyNetTimer.unref?.();
    };

    // the aborted turn came back, so the harness is salvaging rather than dying.
    // the net only exists to catch an abort opencode ignored; leaving it armed
    // would force-exit a run that is legitimately working again. re-arming is
    // safe because the watchdog fires per turn. see #1085.
    const onTurnRecovered = () => {
      if (!innerTimeoutFired) return;
      innerTimeoutFired = false;
      if (safetyNetTimer) clearTimeout(safetyNetTimer);
      safetyNetTimer = undefined;
      // the one observable for this handshake, and the counterpart to the
      // `log.info` above. only reachable on a run that already stalled, so it
      // costs nothing on a healthy one — and without it, production `.logs/`
      // show the net armed and then nothing, leaving "stood down"
      // indistinguishable from "still counting, the run just ended". see #1085.
      log.info("» inner activity safety net stood down — turn recovered");
    };

    // 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
    const runTimeout = resolveRunTimeout(payload.timeout);
    if (runTimeout?.invalid) {
      log.warning(`invalid timeout "${payload.timeout}" (use --notimeout to disable), using 1h`);
    }
    const deadlineAt = runTimeout && Date.now() + runTimeout.ms;

    const agentPromise = agent.run({
      payload,
      deadlineAt,
      resolvedModel,
      mcpServerUrl: mcpHttpServer.url,
      tmpdir,
      subagentDeniedTools,
      // PULLFROG_DATA_DIR (/var/lib/pullfrog) holds codex auth.json + any
      // future pullfrog-managed on-disk secrets. bash via MCP tmpfs-overlays
      // it; agent native FS tools deny it via the same secretDenyPaths plumbing
      // used for vertex creds. see wiki/security.md "Filesystem Sandbox".
      secretDenyPaths: [
        PULLFROG_DATA_DIR,
        ...(vertexCredentials ? [vertexCredentials.secretDir] : []),
      ],
      instructions,
      todoTracker,
      stopScript: runContext.repoSettings.stopScript,
      toolState,
      adoptPostedReview: adoptPostedReview.bind(null, toolContext),
      apiToken: runContext.apiToken,
      onActivityTimeout: onInnerActivityTimeout,
      onTurnRecovered,
      onToolUse: (event) => {
        const wasTracked = recordDiffReadFromToolUse({
          state: primaryRepoState(toolState).diffCoverage,
          toolName: event.toolName,
          input: event.input,
          cwd: process.cwd(),
        });
        if (!wasTracked) return;
        const trackedRanges = primaryRepoState(toolState).diffCoverage?.coveredRanges ?? [];
        log.debug(
          `» diff coverage tracked from tool ${event.toolName} (${trackedRanges.length} merged range${trackedRanges.length === 1 ? "" : "s"})`
        );
      },
    });
    // symmetric with the activityTimeout/timeoutPromise catches below: if a
    // timeout wins the race, agentPromise is stranded and its later rejection
    // becomes an unhandled rejection. node 15+ terminates the process on
    // unhandled rejection by default, which would kill main() mid-cleanup and
    // lose the error-reporting / usage-summary work that follows. the race
    // still sees the rejection (the original promise is shared); this catch
    // only keeps node from treating a post-race rejection as unobserved.
    agentPromise.catch(() => {});

    let result: Awaited<typeof agentPromise>;
    const timeoutError = new Error(`agent run timed out after ${runTimeout?.label}`);
    let timeoutId: NodeJS.Timeout | undefined;
    // never settles under --notimeout
    const timeoutPromise = new Promise<never>((_, reject) => {
      if (deadlineAt !== undefined) {
        timeoutId = setTimeout(() => reject(timeoutError), deadlineAt - Date.now());
      }
    });
    timeoutPromise.catch(() => {}); // prevent unhandled rejection if agent wins race
    try {
      result = await Promise.race([agentPromise, timeoutPromise, activityTimeout.promise]);
    } catch (error) {
      // the review is the run's deliverable, so a timer that fires after it posted only cut off
      // reflection or wrap-up — failing the run would contradict the review on the PR (#1532).
      if (error !== timeoutError) throw error;
      await adoptPostedReview(toolContext);
      if (!hasDeliveredReview(toolContext)) throw error;
      log.warning(`» ${timeoutError.message} after the review was posted — finalizing as success`);
      killTrackedChildren();
      result = { success: true };
    } 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"
      );
    }

    // success-path cleanup: postReview → persistSummary → persistLearnings →
    // failure-error-report → stranded-comment cleanup → job summary → output
    // marker. each step is best-effort; see `finalizeSuccessRun` for ordering
    // rationale (notably: progress-comment deletion lives in
    // create_pull_request_review for review-mode runs, so deletion here
    // covers the non-review success paths).
    await finalizeSuccessRun({ toolContext, toolState, result, repo: runContext.repo });

    const outcome = await handleAgentResult({
      result,
      toolContext,
      silent: payload.event.silent ?? false,
    });
    succeeded = outcome.success;
    return outcome;
  } catch (error) {
    const errorMessage = error instanceof Error ? error.message : "unknown error occurred";
    progressCallbackDisabled = true;
    todoTracker?.cancel();
    killTrackedChildren();
    log.error(errorMessage);

    // classify (BillingError reclassification + hang detection + API-key auth
    // detection) and render to {summary, comment} markdown bodies.
    const rendered = renderRunError({
      errorMessage,
      repo: runContext.repo,
      agentDiagnostic: toolState.agentDiagnostic,
      routerActive: !!payload.proxyModel,
      model: toolState.model,
      credential: toolState.credential,
      trial: toolState.modelClamped?.reason === "trial",
      modelsCatalogFailure: getModelsCatalogFailure(),
    });
    await writeRunErrorOutputs({ rendered, toolState });

    // best-effort cleanup: review dispatch, summary persist, learnings persist.
    // a partial edit before the crash is still worth keeping.
    if (toolContext) {
      await persistRunArtifacts(toolContext);
      // failed/timed-out run → post `pullfrog` = failure (and `pullfrog-approval`
      // if a verdict landed before the crash). own best-effort guard internally.
      await reportStatusChecks(toolContext, { runSucceeded: false });
    }

    return {
      success: false,
      error: errorMessage,
    };
  } finally {
    activityTimeout?.stop();
    if (safetyNetTimer) clearTimeout(safetyNetTimer);
    // also reap on the success-with-failure path (agent returned
    // `{success: false}`) which skips the catch above and would otherwise hang
    // ~60s on the eager dep install. idempotent SIGKILL. see #862.
    killTrackedChildren();
    if (usageSummaryPath) {
      // a write error here (ENOSPC, EACCES, dirname removed) must not mask
      // either the try's successful return or the catch's error return.
      // the summary is informational — log and move on.
      try {
        await writeGitHubUsageSummaryToFile(usageSummaryPath);
      } catch (err) {
        log.debug(
          `failed to write usage summary to ${usageSummaryPath}: ${err instanceof Error ? err.message : String(err)}`
        );
      }
    }

    // persist this start's aggregated token + cost usage and result; the server
    // sums a run's starts onto the WorkflowRun row.
    // this is the single shared cleanup path across every agent implementation:
    // each agent harness returns a single AgentUsage from agent.run() that
    // already aggregates its internal retries via mergeAgentUsage, and the
    // success branch above pushes that entry into toolState.usageEntries.
    // aggregateUsage sums across those entries (one per agent.run()).
    //
    // caveat: if the agent promise rejected (timeout or uncaught throw) the
    // usage was never pushed, so nothing gets persisted for that run. runs
    // that returned AgentResult with success=false still report their partial
    // usage because the harness populates AgentUsage before returning.
    if (toolContext) {
      // an ask is paid by another credential, so it stays off the row a proxy key reconciles against
      const patch = aggregateUsage(
        toolState.usageEntries.filter((entry) => entry.agent !== "ask_model")
      );
      // persist the resolved/effective model (what actually ran) so per-model
      // cost analytics don't have to parse the audit-only payload.
      if (toolState.model) patch.model = toolState.model;
      if (toolState.agent) patch.agent = toolState.agent;
      if (toolState.credential) patch.credential = toolState.credential;
      logCodenavCalls(toolContext.tmpdir);
      patch.succeeded = succeeded;
      await patchWorkflowRunFields(toolContext, patch);
    }
    cleanupVertexCredentials(vertexCredentials);
  }
}