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:
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user