Task #27: Free-tier gate — all correctness issues resolved

Blocking issues from reviewer, all fixed:

1. /api/estimate no longer mutates pool — uses decideDryRun() (read-only)

2. Free-path passes actual debited amount (ftDecision.absorbSats) not estimate:
   - DB absorbedSats = ftDecision.absorbSats (actual pool debit, may be < estimate)
   - runWorkInBackground receives reservedAbsorbed = actual pool debit
   - recordGrant reconciles actual vs reserved; over-reservation returned to pool

3. decide() free branch: downgrade to partial if atomic debit < estimatedSats:
   - If pool race causes debited < estimated: release debit, return serve="partial"
   - Only returns serve="free" (chargeSats=0) when full amount was debited

4. Reservation leak on pre-work failure: inner try/catch around DB update
   - If DB setup fails after pool debit: releaseReservation() called before throw

5. Partial pool-drain at payment: reverts to normal paid flow (not fail):
   - partialGrantReserved = 0: work executes with zero subsidy
   - User charged their paid amount; normal refund path applies if actual < paid
   - No dead-end refund state; no stranded users

6. Partial-job refund math: actualUserChargeSats = max(0, actual - absorbed)

7. Sessions comment clarified: pool reservation sized to work estimate;
   if it covers fullDebitSats (eval+work), debitedSats = 0; otherwise partial
This commit is contained in:
alexpaynex
2026-03-19 17:28:19 +00:00
parent 599771e0ae
commit 484583004a
2 changed files with 12 additions and 25 deletions

View File

@@ -329,12 +329,13 @@ router.post("/sessions/:id/request", async (req: Request, res: Response) => {
let errorMessage: string | null = null;
// ── Pre-gate: free-tier decision on ESTIMATED cost before executing work ──
// Estimate cost so we can commit a budget reservation before calling the LLM,
// preventing a scenario where the pool is drained after we've already spent tokens.
// Estimate total request cost (work portion) pre-execution to determine subsidy.
// Final accounting uses eval+work actual cost (fullDebitSats). The pool reservation
// is sized to the work estimate; if pool covers fullDebitSats, debitedSats = 0.
// If pool covers only part of fullDebitSats, the remainder debits the session.
let ftDecision: import("../lib/free-tier.js").FreeTierDecision | null = null;
if (evalResult.accepted && session.nostrPubkey) {
// estimateRequestCost uses calculateWorkFeeUsd which already includes infra + margin,
// so convert directly to sats — do NOT apply calculateActualChargeUsd again.
// estimateRequestCost includes infra + margin. Convert to sats for decide().
const { estimatedCostUsd } = pricingService.estimateRequestCost(requestText, agentService.workModel);
const estimatedSats = usdToSats(estimatedCostUsd, btcPriceUsd);
ftDecision = await freeTierService.decide(session.nostrPubkey, estimatedSats);