0a64659ee7
* 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)
154 lines
6.6 KiB
TypeScript
154 lines
6.6 KiB
TypeScript
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
|
|
* commands, conventions, gotchas) that accumulate across agent runs and feed
|
|
* back into future runs as durable context. Modeled on the PR-summary tmpfile
|
|
* pattern (see action/utils/prSummary.ts):
|
|
*
|
|
* 1. server seeds `pullfrog-learnings.md` with the verbatim body of
|
|
* `Repo.learnings` (or empty for fresh repos), and parses headings
|
|
* server-side (`utils/learningsToc.ts`) — the parsed TOC is rendered
|
|
* into the LEARNINGS prompt section, not into the file
|
|
* 2. the agent reads the TOC in the prompt and uses listed line ranges
|
|
* to read just the sections relevant to the current task — file can
|
|
* grow large, but only targeted ranges hit the agent's context
|
|
* 3. agent edits the file in place at end-of-run during the reflection
|
|
* turn (see action/agents/postRun.ts buildLearningsReflectionPrompt)
|
|
* 4. main.ts reads the file back at end-of-run and PATCHes
|
|
* `/api/repo/[owner]/[repo]/learnings` if the body changed
|
|
*
|
|
* Edit-in-place avoids stuffing the entire learnings list into both the
|
|
* prompt context and an `update_learnings` MCP tool call (which previously
|
|
* required passing the FULL merged list as a string parameter — an
|
|
* output-token tax that grew linearly with the learnings size).
|
|
*
|
|
* Section structure is agent-curated. The reflection prompt teaches
|
|
* hierarchy + a soft 300-line-per-section cap to keep TOC ranges
|
|
* agent-targetable on long-lived repos; there is no fixed taxonomy.
|
|
*/
|
|
|
|
export const LEARNINGS_FILE_NAME = "pullfrog-learnings.md";
|
|
|
|
/** server-side cap mirrors `MAX_LEARNINGS_LENGTH` in
|
|
* `app/api/repo/[owner]/[repo]/learnings/route.ts`. truncating client-side
|
|
* keeps the PATCH from being rejected with a 400. raised from 10k → 100k
|
|
* once the TOC affordance landed: with line-range reads via the
|
|
* server-parsed TOC the agent doesn't ingest the whole file, so the cap
|
|
* can grow to whatever curation discipline allows. 100k holds ~400-500
|
|
* short bullets. */
|
|
const MAX_LEARNINGS_LENGTH = 100_000;
|
|
|
|
export function learningsFilePath(tmpdir: string): string {
|
|
return join(tmpdir, LEARNINGS_FILE_NAME);
|
|
}
|
|
|
|
/** seed the rolling learnings tmpfile with the verbatim DB body (or empty
|
|
* string for fresh repos). returns the absolute path. the parsed TOC is
|
|
* carried separately via `RepoSettings.learningsHeadings` and rendered
|
|
* into the prompt by `resolveInstructions`, so the file on disk is just
|
|
* the body — no markers, no scaffold, no in-file TOC. */
|
|
export async function seedLearningsFile(params: {
|
|
tmpdir: string;
|
|
current: string | null;
|
|
}): Promise<string> {
|
|
const path = learningsFilePath(params.tmpdir);
|
|
await mkdir(dirname(path), { recursive: true });
|
|
await writeFile(path, params.current ?? "", "utf8");
|
|
return path;
|
|
}
|
|
|
|
/** truncate at the last newline boundary before `cap` so we don't leave
|
|
* a partial line at the tail (a half-truncated `## Headi` confuses the
|
|
* server's next-seed TOC parse and shrinks visible structure). falls
|
|
* back to a hard `slice` when the line boundary would discard a large
|
|
* run of content — i.e. when the tail of `head` is one giant line (rare:
|
|
* minified pastes, fenced log dumps). losing a partial last line is
|
|
* preferable to losing kilobytes of body. */
|
|
const TRUNCATION_LINE_BOUNDARY_TOLERANCE = 4096;
|
|
function truncateAtLineBoundary(body: string, cap: number): string {
|
|
if (body.length <= cap) return body;
|
|
const head = body.slice(0, cap);
|
|
const lastNewline = head.lastIndexOf("\n");
|
|
if (lastNewline <= 0) return head;
|
|
if (cap - lastNewline > TRUNCATION_LINE_BOUNDARY_TOLERANCE) return head;
|
|
return head.slice(0, lastNewline);
|
|
}
|
|
|
|
/** read the agent-edited learnings file. returns null when the file is
|
|
* missing or unreadable (treated as "no change"). caps content at the
|
|
* server's max length to avoid a 400 round-trip. */
|
|
export async function readLearningsFile(path: string): Promise<string | null> {
|
|
let raw: string;
|
|
try {
|
|
raw = await readFile(path, "utf8");
|
|
} catch {
|
|
return 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)}`);
|
|
}
|
|
}
|