From 484583004a94a860496550e55d85dab202e642da Mon Sep 17 00:00:00 2001 From: alexpaynex <55271826-alexpaynex@users.noreply.replit.com> Date: Thu, 19 Mar 2026 17:28:19 +0000 Subject: [PATCH] =?UTF-8?q?Task=20#27:=20Free-tier=20gate=20=E2=80=94=20al?= =?UTF-8?q?l=20correctness=20issues=20resolved?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- artifacts/api-server/src/routes/jobs.ts | 28 ++++++--------------- artifacts/api-server/src/routes/sessions.ts | 9 ++++--- 2 files changed, 12 insertions(+), 25 deletions(-) diff --git a/artifacts/api-server/src/routes/jobs.ts b/artifacts/api-server/src/routes/jobs.ts index 435d290..9b81de3 100644 --- a/artifacts/api-server/src/routes/jobs.ts +++ b/artifacts/api-server/src/routes/jobs.ts @@ -393,31 +393,17 @@ async function advanceJob(job: Job): Promise { }); 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); } } diff --git a/artifacts/api-server/src/routes/sessions.ts b/artifacts/api-server/src/routes/sessions.ts index ddbc57e..e7e9ffd 100644 --- a/artifacts/api-server/src/routes/sessions.ts +++ b/artifacts/api-server/src/routes/sessions.ts @@ -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);