f662b1a0c8
* unify per-run token + cost accounting across agents every agent harness now logs the same 5-column (or 6 with cost) table and populates the same AgentUsage contract, regardless of agent or upstream provider. previously OpenCode and the Claude fallback path emitted a 3-col table whose "Input Tokens" was actually only the non-cached delta, silently dropping cache read/write — real runs were being reported at ~0.4% of their true input (e.g. one baseline showed Input=30 while step_finish events summed to cache_read=724,753). changes: - add logTokenTable helper in action/agents/shared.ts with stable columns: Input | Cache Read | Cache Write | Output | Total | Cost ($). cost column renders only when a value is known. - action/agents/opencode.ts: accumulate step_finish.part.tokens AND step_finish.part.cost (sourced from models.dev inside opencode — confirmed working across Anthropic, OpenAI, Google, xAI, DeepSeek, Moonshot, and OpenRouter). drop the event.stats.total_tokens fallback since that payload has no cache breakdown. - action/agents/claude.ts: success-path now treats input_tokens as the non-cached field (matching OpenCode semantics), carries cache_read_input_tokens / cache_creation_input_tokens separately, and captures total_cost_usd from the final result event. the per-message fallback accumulator now captures cache fields too so it's no longer lossy when the result event never fires. - formatUsageSummary gains a Cost ($) column that matches the stdout table row-for-row; missing values render as "—". - scripts/token-usage.ts parses all three historical formats (new 5-col, legacy 4-col Claude success, legacy 3-col lossy) and explicitly flags the lossy runs instead of averaging misleading values. validation (pnpm play --local, identical "say hello" prompt): agent+model Input CacheR CacheW Output Total Cost OpenCode + Anthropic Sonnet 4.6 4 41,177 20,735 129 62,045 $0.0921 Claude CLI + Anthropic Sonnet 4.6 9 80,133 11,611 389 92,142 $0.0766 OpenCode + OpenAI codex-mini 10,893 46,976 0 606 58,475 $0.0059 OpenCode + Google Gemini 3 Flash — — — — — $0.0114 OpenCode + xAI Grok 4 Fast — — — — — $0.0035 OpenCode + DeepSeek Chat 18,854 0 0 1 18,855 $0.0053 OpenCode + Moonshot Kimi K2.5 — — — — — $0.0106 OpenCode + OpenRouter→Anthropic — — — — — $0.0617 OpenCode + OpenRouter→OpenAI — — — — — $0.0038 * isolate play.ts from developer gitconfig play.ts is a CI-emulator but inherits the developer's user- and system-scope gitconfig. a common local convenience — url."git@github.com:".insteadOf "https://github.com/" to force SSH auth — gets applied at read time on every git call inside the temp repo, causing `git remote get-url --push origin` to return an SSH URL instead of the stored HTTPS one. pullfrog_push_branch's validatePushDestination (correctly) treats that as tampering and blocks the push. the agent then burns the full MAX_COMMIT_RETRIES budget trying workarounds that can't beat a user-scope insteadOf rule, turning a trivial "say hello" run into a 1.35M-token session. point GIT_CONFIG_GLOBAL and GIT_CONFIG_SYSTEM at /dev/null inside run() so the play process and its spawned agent see the same empty gitconfig that a real CI runner would. CI has no rewrites, so this is a no-op there; dev machines get CI-identical git state. SSH client config (~/.ssh/config and keys) is separate from gitconfig and is unaffected, so setupTestRepo's SSH clone still works locally. setupGit only writes --local scope, so nothing downstream depends on user-scope values. verification: with the scratch repo cleaned up and this isolation in place, OpenCode + Anthropic on the same "say hello" prompt goes from 1,349,654 tokens / $2.00+ to 62,045 tokens / $0.0921 — no retry loop, no push blocks. * persist aggregated token + cost usage to WorkflowRun AgentUsage has been memory-only — rendered into the GitHub step summary and then discarded when the runner tears down. that made questions like "avg cost per customer per day" require log-spelunking. persist it: - add Int? columns for inputTokens / outputTokens / cacheReadTokens / cacheWriteTokens and a Decimal? costUsd column on workflow_runs. Int4's 2.1B ceiling is ~200x larger than any realistic run so BigInt would be overkill. costUsd uses the same default Decimal precision as existing money columns (accounts.usageUsd, proxy_keys.hwmUsage). - extend PATCH /api/workflow-run/[runId] to accept the new numeric fields alongside the existing artifact strings. per-field type validation ensures the allowlist stays scalar-safe and rejects negative / non-finite values. - generalize patchWorkflowRunFields in the action so it accepts a mixed string/number payload, and add an aggregateUsage(entries) helper that sums per-agent AgentUsage records into a single patch. - call the reporter from main.ts's outer finally block, gated on toolContext. this is the shared cleanup path that every agent implementation flows through — claude.ts, opencode.ts, and any future harness all push their AgentUsage into toolState.usageEntries via the same line 468, so one finally-block call covers them all. running in finally also means partial usage gets persisted even when the agent errored out mid-run. * anneal token + cost accounting follow-up polish from a review pass: - aggregate usage across commit-retry iterations inside each agent harness. previously runClaude / runOpenCode returned only the final retry's usage, so any run that hit the dirty-tree retry loop under-counted tokens and cost in both the stdout table and the WorkflowRun row. added a shared mergeAgentUsage helper in agents/shared.ts; both harnesses now fold each iteration's usage into a running total and return the sum. - scripts/token-usage.ts now handles the unified format with or without the Cost ($) column. previously the int-only number regex rejected decimals and the 5-cell length check rejected 6-cell rows, so logs from post-cost-tracking runs fell through to "no token table". the parser now accepts both 5- and 6-cell unified rows, splits int vs decimal cells, and averages reported Cost alongside the tokens. - PATCH /api/workflow-run/[runId] now rejects INT field values above INT4_MAX (2_147_483_647) so a malformed payload gets a clean 400 instead of propagating a Prisma error. also defends against a compromised runner sending a deliberately huge value. - clarifying comments: opencode.ts documents that step_finish.part.cost is a per-step delta (empirically verified), main.ts explains that toolState.usageEntries already carries merged per-retry usage so aggregateUsage just sums entries (one per agent.run()). - tests for aggregateUsage and mergeAgentUsage — 12 new cases covering empty / partial / multi-agent inputs and the "keep undefined" semantic that prevents spurious zeros from being persisted. - drop `as number` cast in logTokenTable — narrow via const instead. * anneal: clamp INT overflow + guarantee mergeAgentUsage immutability second review pass surfaced two defensive gaps: - a single token field exceeding INT4_MAX would pass the client but be rejected by the server's per-field validator, writing a partial row with some NULLs where sums belonged. clamp in aggregateUsage so the wire payload is always self-consistent across all numeric columns, with a loud warning so the clamp doesn't silently swallow weirdness. - mergeAgentUsage's single-sided branches returned the input reference. callers treat AgentUsage as immutable but future callers might not; always return a fresh shallow copy instead. two new tests guarantee the no-mutation-leak property. no behavior change in the happy path — INT4_MAX is ~200x the largest realistic per-run token count. * anneal: resilient usage persistence + cross-platform null device third review pass surfaced three small issues: - main.ts finally block: writeGitHubUsageSummaryToFile throwing would skip the WorkflowRun usage PATCH. both are independent best-effort cleanup tasks — wrap the former in catch so a filesystem failure doesn't block DB persistence. - AgentUsage.inputTokens had no jsdoc explaining that it's the full billable input (cached + non-cached). the same word "Input" means "non-cached only" in the stdout/markdown tables (derived by subtraction). document the semantic so dashboards querying WorkflowRun.inputTokens don't misinterpret it. - play.ts gitconfig isolation was hard-coded to "/dev/null" which doesn't exist on Windows. use `os.devNull` for cross-platform parity (resolves to `\\.\nul` on win32). the project is Linux-only in CI so this only helps local Windows contributors, but it's a zero-cost swap. also updated the finally-block caveat comment: usage is only pushed to toolState.usageEntries when agent.run() returns an AgentResult, not when the timeout race rejects — so timed-out runs don't persist partial usage. documented instead of trying to thread state through Promise.race. * anneal: NaN-guard cost accumulators + clarify inputTokens docs final polish from review round 4: - guard both cost accumulators (opencode step_finish.part.cost and claude result.total_cost_usd) with Number.isFinite. `typeof x === "number"` accepts NaN, and one NaN `+=` would poison the running total for the whole session. - reword prisma schema comment on WorkflowRun usage fields to call out that cacheReadTokens / cacheWriteTokens are SUB-totals within inputTokens (not additional tokens on top). prevents future dashboards from double-counting by ~2x when summing "total tokens used".
151 lines
5.0 KiB
TypeScript
151 lines
5.0 KiB
TypeScript
import type { AgentUsage } from "../agents/shared.ts";
|
|
import type { ToolContext } from "../mcp/server.ts";
|
|
import { apiFetch } from "./apiFetch.ts";
|
|
import { log } from "./cli.ts";
|
|
import { retry } from "./retry.ts";
|
|
|
|
/**
|
|
* Artifact tracking fields — one-off PATCHes from MCP tools as GitHub entities
|
|
* are created during the run. Strings only (GraphQL node IDs).
|
|
* Keep in sync with `STRING_FIELDS` in `app/api/workflow-run/[runId]/route.ts`.
|
|
*/
|
|
export type WorkflowRunArtifactPatchKey =
|
|
| "prNodeId"
|
|
| "issueNodeId"
|
|
| "reviewNodeId"
|
|
| "planCommentNodeId"
|
|
| "summaryCommentNodeId";
|
|
|
|
/**
|
|
* Usage fields — aggregated across all agent calls and PATCHed once at
|
|
* end-of-run. Token counts are Int4 on the DB side (ample for any realistic
|
|
* run); `costUsd` is a Decimal populated by provider-reported dollar amounts.
|
|
* Keep in sync with `INT_FIELDS` + `DECIMAL_FIELDS` in the server route.
|
|
*/
|
|
export type WorkflowRunUsagePatchKey =
|
|
| "inputTokens"
|
|
| "outputTokens"
|
|
| "cacheReadTokens"
|
|
| "cacheWriteTokens"
|
|
| "costUsd";
|
|
|
|
export type WorkflowRunPatch = Partial<Record<WorkflowRunArtifactPatchKey, string>> &
|
|
Partial<Record<WorkflowRunUsagePatchKey, number>>;
|
|
|
|
const STRING_KEYS: WorkflowRunArtifactPatchKey[] = [
|
|
"prNodeId",
|
|
"issueNodeId",
|
|
"reviewNodeId",
|
|
"planCommentNodeId",
|
|
"summaryCommentNodeId",
|
|
];
|
|
|
|
const NUMBER_KEYS: WorkflowRunUsagePatchKey[] = [
|
|
"inputTokens",
|
|
"outputTokens",
|
|
"cacheReadTokens",
|
|
"cacheWriteTokens",
|
|
"costUsd",
|
|
];
|
|
|
|
/** PATCH workflow-run fields (Pullfrog JWT, not GitHub). */
|
|
export async function patchWorkflowRunFields(
|
|
ctx: ToolContext,
|
|
fields: WorkflowRunPatch
|
|
): Promise<void> {
|
|
if (ctx.runId === undefined || !ctx.apiToken) return;
|
|
const body: Record<string, string | number> = {};
|
|
for (const key of STRING_KEYS) {
|
|
const value = fields[key];
|
|
if (typeof value === "string" && value.length > 0) {
|
|
body[key] = value;
|
|
}
|
|
}
|
|
for (const key of NUMBER_KEYS) {
|
|
const value = fields[key];
|
|
if (typeof value === "number" && Number.isFinite(value) && value >= 0) {
|
|
body[key] = value;
|
|
}
|
|
}
|
|
if (Object.keys(body).length === 0) return;
|
|
try {
|
|
await retry(
|
|
async () => {
|
|
const response = await apiFetch({
|
|
path: `/api/workflow-run/${ctx.runId}`,
|
|
method: "PATCH",
|
|
headers: {
|
|
authorization: `Bearer ${ctx.apiToken}`,
|
|
"content-type": "application/json",
|
|
},
|
|
body: JSON.stringify(body),
|
|
signal: AbortSignal.timeout(10_000),
|
|
});
|
|
if (!response.ok) throw new Error(`PATCH workflow-run: ${response.status}`);
|
|
},
|
|
{
|
|
maxAttempts: 3,
|
|
delayMs: 2000,
|
|
label: "patchWorkflowRunFields",
|
|
}
|
|
);
|
|
} catch (error) {
|
|
log.warning(`patchWorkflowRunFields exhausted retries: ${error}`);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Postgres INTEGER / Prisma Int4 is signed 32-bit. Aggregated usage won't
|
|
* realistically hit this in a single run (2.1B tokens ≈ $6000+ of input on
|
|
* Claude Opus), but clamping here keeps the wire payload self-consistent:
|
|
* the server rejects out-of-range INT fields individually, so without a
|
|
* client-side clamp a single overflow would write a partial row where
|
|
* some columns land and others silently don't.
|
|
*/
|
|
const INT4_MAX = 2_147_483_647;
|
|
|
|
function clampInt(value: number, field: WorkflowRunUsagePatchKey): number {
|
|
if (value > INT4_MAX) {
|
|
log.warning(
|
|
`aggregateUsage: ${field}=${value} exceeds INT4_MAX (${INT4_MAX}) — clamping so the rest of the usage row still persists.`
|
|
);
|
|
return INT4_MAX;
|
|
}
|
|
return value;
|
|
}
|
|
|
|
/**
|
|
* Sum per-agent usage entries into a single WorkflowRunPatch payload.
|
|
* Returns an empty object when there's nothing to report, which causes
|
|
* `patchWorkflowRunFields` to no-op — safe to call unconditionally from
|
|
* end-of-run paths. Zero-valued fields are dropped so the DB only stores
|
|
* positive sums (and NULL means "not reported").
|
|
*
|
|
* Token sums are clamped to INT4_MAX to guarantee the payload the server
|
|
* sees is always self-consistent across all numeric columns.
|
|
*/
|
|
export function aggregateUsage(entries: AgentUsage[]): WorkflowRunPatch {
|
|
if (entries.length === 0) return {};
|
|
|
|
const sum = entries.reduce(
|
|
(acc, e) => ({
|
|
inputTokens: acc.inputTokens + e.inputTokens,
|
|
outputTokens: acc.outputTokens + e.outputTokens,
|
|
cacheReadTokens: acc.cacheReadTokens + (e.cacheReadTokens ?? 0),
|
|
cacheWriteTokens: acc.cacheWriteTokens + (e.cacheWriteTokens ?? 0),
|
|
costUsd: acc.costUsd + (e.costUsd ?? 0),
|
|
}),
|
|
{ inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0, costUsd: 0 }
|
|
);
|
|
|
|
const out: WorkflowRunPatch = {};
|
|
if (sum.inputTokens > 0) out.inputTokens = clampInt(sum.inputTokens, "inputTokens");
|
|
if (sum.outputTokens > 0) out.outputTokens = clampInt(sum.outputTokens, "outputTokens");
|
|
if (sum.cacheReadTokens > 0)
|
|
out.cacheReadTokens = clampInt(sum.cacheReadTokens, "cacheReadTokens");
|
|
if (sum.cacheWriteTokens > 0)
|
|
out.cacheWriteTokens = clampInt(sum.cacheWriteTokens, "cacheWriteTokens");
|
|
if (sum.costUsd > 0) out.costUsd = sum.costUsd;
|
|
return out;
|
|
}
|