refactor: slim action/main.ts to an orchestrator + extract helpers (#755)

* refactor: extract helpers out of action/main.ts so non-orchestration churn stops touching the file

main.ts had grown to ~1240 lines holding ~500 lines of helpers that have
nothing to do with the resolver pipeline — billing-error UI/copy, proxy
minting, summary/learnings persistence, log formatters, end-of-run
cleanup waterfalls. any PR adding a new billing code branch or a new
log line was forced to edit main.ts, and since main.ts is in
ALWAYS_RUN_ALL the entire 52-job LLM CI matrix fired on what should
have been a 0-job change (e.g. #748).

extractions:
- action/utils/billingErrors.ts — BillingError, TransientError, the
  format*Summary renderers, billingConsoleUrl
- action/utils/proxy.ts — mintProxyKey, buildProxyTokenHeaders,
  resolveProxyModel, plus runProxyResolution wrapper that renders +
  rethrows BillingError/TransientError before the outer catch
- action/utils/prSummary.ts — fetchPreviousSnapshot, persistSummary
  co-located with the existing seed/read file helpers
- action/utils/learnings.ts — persistLearnings co-located with the
  existing seed/read file helpers
- action/utils/runStartupLog.ts — resolveOutputSchema + logRunStartup
  (the model/agent/push/shell/timeout block)
- action/utils/runErrorRenderer.ts — renderRunError classifies
  (BillingError reclassify / hang detect / API-key auth) and emits
  {summary, comment} markdown bodies
- action/utils/runLifecycle.ts — persistRunArtifacts, finalizeSuccessRun,
  writeRunErrorOutputs — the three end-of-run cleanup phases shared
  between the success path and the error catch path

main.ts is now ~570 lines — the irreducible orchestrator: disposables
(`await using` for tokenRef / gitAuthServer / mcpHttpServer), the
toolContext construction, the agent-timeout race, the catch/finally
shape, and the named phase calls. behavior is preserved verbatim
(verified: pnpm -r typecheck + pnpm test 695/695 pass, action/test
596/596 pass).

wiki/main.md gets a new "file layout" section describing the split.
AGENTS.md gets a single line pointing future edits at the helpers
instead of main.ts.

* anneal: address review findings

- restore MainResult.result?: string (accidental removal in initial commit;
  field was unused in current code but is part of the exported interface
  surface — keep the diff truly behavior-preserving)
- move resolveOutputSchema from runStartupLog.ts to payload.ts (it's an
  action-input resolver alongside resolvePromptInput / resolvePayload, not
  a log helper — was placed in runStartupLog.ts for matrix-churn pragmatism
  but the domain fit is in payload.ts)
- un-export resolveProxyModel (only used internally by runProxyResolution
  in proxy.ts; no external importer)
- fix runErrorRenderer.ts JSDoc "Three classifications" → four (Billing,
  hang, API-key, default)
- expand runLifecycle.ts module banner to note that finalizeSuccessRun
  calls persistRunArtifacts first, and to explain why the catch path
  splits writeRunErrorOutputs + persistRunArtifacts
- update billingErrors.ts header to point at proxy.ts and
  runErrorRenderer.ts as the actual origin sites (was stale "main.ts")
- expand proxy.ts header to spell out the runProxyResolution entrypoint
  contract (was stale "main.ts can render")
- update wiki/main.md resolver chain + dependency table to name
  runProxyResolution as the actual call site and document the early
  BillingError/TransientError rendering branch
- update wiki/main.md file-layout table to lead with runProxyResolution
  and describe mintProxyKey/buildProxyTokenHeaders/resolveProxyModel
  as internal helpers (was implying they were public surface)
This commit is contained in:
Colin McDonnell
2026-05-16 05:09:52 +00:00
committed by pullfrog[bot]
parent a78b1542da
commit 0a64659ee7
9 changed files with 915 additions and 712 deletions
+63
View File
@@ -1,5 +1,8 @@
import { mkdir, readFile, writeFile } from "node:fs/promises";
import { dirname, join } from "node:path";
import type { ToolContext } from "../mcp/server.ts";
import { apiFetch } from "./apiFetch.ts";
import { log } from "./cli.ts";
/**
* Repo-level learnings — operational facts about a repo (setup steps, test
@@ -88,3 +91,63 @@ export async function readLearningsFile(path: string): Promise<string | null> {
}
return truncateAtLineBoundary(raw.trim(), MAX_LEARNINGS_LENGTH);
}
/**
* Read the agent-edited repo-level learnings tmpfile and PATCH it to
* `Repo.learnings`.
*
* Best-effort: any failure is logged and does not affect the run's success
* status. Skips the PATCH when the file is byte-trim-identical to its seed —
* the agent didn't touch it, so writing the same content back would just
* burn a `LearningsRevision` row and an API round-trip.
*
* `ctx.toolState.model` is forwarded so `LearningsRevision.model` keeps
* populating; it powers the per-revision attribution badge in the UI
* history view.
*
* `learningsPersistAttempted` guards against double-execution between the
* normal end-of-run path and the SIGINT/SIGTERM handler.
*/
export async function persistLearnings(ctx: ToolContext): Promise<void> {
const filePath = ctx.toolState.learningsFilePath;
if (!filePath) return;
if (ctx.toolState.learningsPersistAttempted) return;
ctx.toolState.learningsPersistAttempted = true;
const current = await readLearningsFile(filePath);
if (current === null) {
log.debug(`learnings tmpfile missing or unreadable at ${filePath} — skipping persist`);
return;
}
const seed = ctx.toolState.learningsSeed?.trim() ?? "";
if (current === seed) {
log.debug("learnings tmpfile unchanged from seed — skipping persist");
return;
}
try {
const response = await apiFetch({
path: `/api/repo/${ctx.repo.owner}/${ctx.repo.name}/learnings`,
method: "PATCH",
headers: {
authorization: `Bearer ${ctx.apiToken}`,
"content-type": "application/json",
},
body: JSON.stringify({
learnings: current,
model: ctx.toolState.model,
}),
signal: AbortSignal.timeout(10_000),
});
if (!response.ok) {
const error = await response.text().catch(() => "(no body)");
// promoted from debug → warning: this path means the agent edited the
// file (we already short-circuited the unchanged-from-seed case above)
// but the PATCH dropped it on the floor. silently losing real work is
// worse than the noise of a CI warning.
log.warning(`learnings persist failed (${response.status}): ${error}`);
return;
}
log.info("» learnings updated");
} catch (err) {
log.warning(`learnings persist failed: ${err instanceof Error ? err.message : String(err)}`);
}
}