From d9288498b795c0a611ccfaf815b3000d781bf4f5 Mon Sep 17 00:00:00 2001 From: dhgoal <153369624+dhgoal@users.noreply.github.com> Date: Fri, 10 Jul 2026 02:24:07 +0900 Subject: [PATCH] feat(miner-hands): add pure per-attempt cost/turn metering to gittensory-engine MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New packages/gittensory-engine/src/miner/attempt-metering.ts: pure accumulation of a coding-agent attempt's usage (tokens/turns/wall-clock/cost) and a pure evaluation of the running totals against a per-axis budget. Numbers only — no IO, no Date.now, no randomness, no enforcement (a ceiling reached is reported, never acted on). Mirrors the governor/rate-limit.ts discipline. Breach semantics: an axis breaches at or above its ceiling (>=); an omitted ceiling never breaches. Enforcement (graceful-stop vs SIGKILL) and attempt-log persistence (#4294) are separate concerns. Closes #4311 --- packages/gittensory-engine/src/index.ts | 1 + .../src/miner/attempt-metering.ts | 82 ++++++++++++++++++ .../test/attempt-metering.test.ts | 84 +++++++++++++++++++ 3 files changed, 167 insertions(+) create mode 100644 packages/gittensory-engine/src/miner/attempt-metering.ts create mode 100644 packages/gittensory-engine/test/attempt-metering.test.ts diff --git a/packages/gittensory-engine/src/index.ts b/packages/gittensory-engine/src/index.ts index c60b954291..80411d0fe1 100644 --- a/packages/gittensory-engine/src/index.ts +++ b/packages/gittensory-engine/src/index.ts @@ -176,6 +176,7 @@ export { type CreateCodingAgentDriverOptions, type RunCodingAgentAttemptOptions, } from "./miner/driver-factory.js"; +export * from "./miner/attempt-metering.js"; export * from "./plan-export.js"; export { countPlanStepsByStatus } from "./plan-step-stats.js"; export { countPlanSteps } from "./plan-step-count.js"; diff --git a/packages/gittensory-engine/src/miner/attempt-metering.ts b/packages/gittensory-engine/src/miner/attempt-metering.ts new file mode 100644 index 0000000000..0b4ab27326 --- /dev/null +++ b/packages/gittensory-engine/src/miner/attempt-metering.ts @@ -0,0 +1,82 @@ +// Per-attempt cost/turn metering (#4311): pure accumulation of a coding-agent attempt's usage +// (tokens / turns / wall-clock / cost) plus a pure evaluation of the running totals against a configured +// budget. Numbers only — no IO, no Date.now(), no randomness, no enforcement. This module reports whether a +// ceiling has been reached; it never stops, kills, or gates a driver. That enforcement wiring (graceful-stop +// vs. hard SIGKILL) and the attempt-log persistence (#4294) are separate, maintainer-owned concerns. +// +// Mirrors the governor/rate-limit.ts discipline ("computes numbers only... does NOT store state, schedule, +// or gate any write action"). Drivers report usage in different native shapes (CLI-subprocess vs. Agent-SDK); +// the caller normalizes each increment to the {tokens, turns, wallClockMs, costUsd} unit defined here. + +/** One usage increment normalized to the metered unit. `costUsd` is 0 when a driver can't report spend. */ +export type AttemptUsage = { + /** Model tokens consumed (prompt + completion) in this increment. */ + tokens: number; + /** Agent turns (one full agent iteration) in this increment. */ + turns: number; + /** Wall-clock milliseconds elapsed in this increment. */ + wallClockMs: number; + /** Monetary cost (USD) of this increment; 0 when the driver does not report spend. */ + costUsd: number; +}; + +/** Accumulated attempt totals — same shape as a single increment. */ +export type AttemptMeterTotals = AttemptUsage; + +/** Per-axis ceilings. An omitted axis means "no limit on that axis". */ +export type AttemptBudget = { + maxTokens?: number; + maxTurns?: number; + maxWallClockMs?: number; + maxCostUsd?: number; +}; + +/** Which metered axes have reached or exceeded their ceiling. */ +export type AttemptBudgetAxis = "tokens" | "turns" | "wallClockMs" | "costUsd"; + +export type AttemptMeterVerdict = { + totals: AttemptMeterTotals; + /** True when no axis has reached its ceiling. */ + withinBudget: boolean; + /** The axes at/over ceiling (empty when within budget). */ + breaches: AttemptBudgetAxis[]; +}; + +const ZERO_USAGE: AttemptUsage = { tokens: 0, turns: 0, wallClockMs: 0, costUsd: 0 }; + +/** Fold one usage increment into a running total. Pure — returns a new total, mutates nothing. */ +export function accumulateAttemptUsage( + total: AttemptMeterTotals, + next: AttemptUsage, +): AttemptMeterTotals { + return { + tokens: total.tokens + next.tokens, + turns: total.turns + next.turns, + wallClockMs: total.wallClockMs + next.wallClockMs, + costUsd: total.costUsd + next.costUsd, + }; +} + +/** Sum a sequence of usage increments from zero. Pure. */ +export function meterAttemptUsage(increments: readonly AttemptUsage[]): AttemptMeterTotals { + return increments.reduce(accumulateAttemptUsage, { ...ZERO_USAGE }); +} + +/** + * Evaluate accumulated totals against a budget. An axis is breached when its total is **at or above** its + * ceiling (`>=`), so a total exactly equal to the ceiling counts as a breach — the boundary the caller must + * stop on. An omitted ceiling never breaches. Pure and deterministic. + */ +export function evaluateAttemptBudget( + totals: AttemptMeterTotals, + budget: AttemptBudget, +): AttemptMeterVerdict { + const breaches: AttemptBudgetAxis[] = []; + if (budget.maxTokens !== undefined && totals.tokens >= budget.maxTokens) breaches.push("tokens"); + if (budget.maxTurns !== undefined && totals.turns >= budget.maxTurns) breaches.push("turns"); + if (budget.maxWallClockMs !== undefined && totals.wallClockMs >= budget.maxWallClockMs) { + breaches.push("wallClockMs"); + } + if (budget.maxCostUsd !== undefined && totals.costUsd >= budget.maxCostUsd) breaches.push("costUsd"); + return { totals, withinBudget: breaches.length === 0, breaches }; +} diff --git a/packages/gittensory-engine/test/attempt-metering.test.ts b/packages/gittensory-engine/test/attempt-metering.test.ts new file mode 100644 index 0000000000..c30817b138 --- /dev/null +++ b/packages/gittensory-engine/test/attempt-metering.test.ts @@ -0,0 +1,84 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { + accumulateAttemptUsage, + meterAttemptUsage, + evaluateAttemptBudget, + type AttemptUsage, +} from "../dist/index.js"; + +const u = (tokens: number, turns: number, wallClockMs: number, costUsd: number): AttemptUsage => ({ + tokens, + turns, + wallClockMs, + costUsd, +}); + +test("accumulateAttemptUsage folds one increment into a running total without mutating inputs", () => { + const total = u(100, 1, 500, 0.5); + const result = accumulateAttemptUsage(total, u(50, 1, 250, 0.25)); + assert.deepEqual(result, u(150, 2, 750, 0.75)); // binary-exact cost values + assert.deepEqual(total, u(100, 1, 500, 0.5)); // input unchanged +}); + +test("meterAttemptUsage sums a sequence of increments from zero", () => { + const totals = meterAttemptUsage([u(100, 1, 500, 0.5), u(50, 1, 250, 0.25), u(25, 1, 100, 0.125)]); + assert.deepEqual(totals, u(175, 3, 850, 0.875)); +}); + +test("meterAttemptUsage of an empty sequence is all-zero", () => { + assert.deepEqual(meterAttemptUsage([]), u(0, 0, 0, 0)); +}); + +test("evaluateAttemptBudget: totals under every ceiling are within budget", () => { + const v = evaluateAttemptBudget(u(100, 2, 500, 0.05), { + maxTokens: 1000, + maxTurns: 10, + maxWallClockMs: 60000, + maxCostUsd: 1, + }); + assert.equal(v.withinBudget, true); + assert.deepEqual(v.breaches, []); +}); + +test("evaluateAttemptBudget: a total exactly at a ceiling is a breach (>= boundary)", () => { + const v = evaluateAttemptBudget(u(1000, 0, 0, 0), { maxTokens: 1000 }); + assert.equal(v.withinBudget, false); + assert.deepEqual(v.breaches, ["tokens"]); +}); + +test("evaluateAttemptBudget: a total just under the ceiling is within budget", () => { + const v = evaluateAttemptBudget(u(999, 0, 0, 0), { maxTokens: 1000 }); + assert.equal(v.withinBudget, true); + assert.deepEqual(v.breaches, []); +}); + +test("evaluateAttemptBudget: each axis breaches independently", () => { + assert.deepEqual(evaluateAttemptBudget(u(0, 5, 0, 0), { maxTurns: 5 }).breaches, ["turns"]); + assert.deepEqual(evaluateAttemptBudget(u(0, 0, 60000, 0), { maxWallClockMs: 60000 }).breaches, ["wallClockMs"]); + assert.deepEqual(evaluateAttemptBudget(u(0, 0, 0, 2), { maxCostUsd: 1.5 }).breaches, ["costUsd"]); +}); + +test("evaluateAttemptBudget: multiple axes over ceiling all surface, in order", () => { + const v = evaluateAttemptBudget(u(2000, 20, 0, 0), { maxTokens: 1000, maxTurns: 10 }); + assert.equal(v.withinBudget, false); + assert.deepEqual(v.breaches, ["tokens", "turns"]); +}); + +test("evaluateAttemptBudget: an omitted ceiling never breaches, even at huge totals", () => { + const v = evaluateAttemptBudget(u(1e9, 1e6, 1e9, 1e6), {}); + assert.equal(v.withinBudget, true); + assert.deepEqual(v.breaches, []); + assert.equal(v.totals.tokens, 1e9); // verdict echoes the totals +}); + +test("evaluateAttemptBudget: a breach mid-attempt is detectable from accumulated totals", () => { + const budget = { maxTurns: 3 }; + const steps = [u(10, 1, 100, 0), u(10, 1, 100, 0), u(10, 1, 100, 0)]; + let total = meterAttemptUsage([]); + const withinAfterEachStep = steps.map((s) => { + total = accumulateAttemptUsage(total, s); + return evaluateAttemptBudget(total, budget).withinBudget; + }); + assert.deepEqual(withinAfterEachStep, [true, true, false]); // breach at the 3rd turn +});