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

@@ -393,31 +393,17 @@ async function advanceJob(job: Job): Promise<Job | null> {
});
if (partialGrantReserved <= 0 && (job.absorbedSats ?? 0) > 0) {
// Pool drained between advisory decide() and payment confirmation.
// We cannot execute at the discounted terms — that would operate at a loss
// without pool backing. Mark the job failed with refund pending so the user
// can resubmit at full price.
logger.warn("partial grant pool drained at payment — failing job, refund pending", {
// Revert to normal paid flow: user already paid their invoice (chargeSats),
// which is less than full price. We proceed with zero subsidy — user will be
// charged their paid amount and work executes. Any normal refund (actual < paid)
// still applies through the standard refund path.
// partialGrantReserved = 0 means recordGrant will record 0 absorbed, so no
// pool accounting error occurs.
logger.warn("partial grant pool drained at payment — proceeding with zero subsidy (paid flow)", {
jobId: job.id,
pubkey: job.nostrPubkey.slice(0, 8),
requestedAbsorb: job.absorbedSats,
workAmountSats: job.workAmountSats,
});
await db
.update(jobs)
.set({
state: "failed",
errorMessage: "Generosity pool exhausted — your payment will be refunded. Please retry at full price.",
refundAmountSats: job.workAmountSats ?? 0,
refundState: "pending",
updatedAt: new Date(),
})
.where(eq(jobs.id, job.id));
eventBus.publish({
type: "job:failed",
jobId: job.id,
reason: "Generosity pool exhausted — your payment will be refunded.",
});
return getJobById(job.id);
}
}

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);