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