diff --git a/artifacts/api-server/src/lib/free-tier.ts b/artifacts/api-server/src/lib/free-tier.ts index e4b87fe..368ad06 100644 --- a/artifacts/api-server/src/lib/free-tier.ts +++ b/artifacts/api-server/src/lib/free-tier.ts @@ -172,12 +172,28 @@ export class FreeTierService { logger.warn("free-tier: pool drained between check and debit", { pubkey: pubkey.slice(0, 8) }); return gate; } - logger.info("free-tier: reserved free (pool debited)", { + if (debited >= estimatedSats) { + // Pool covered the full cost — fully free. + logger.info("free-tier: reserved free (pool debited)", { + pubkey: pubkey.slice(0, 8), + tier: identity.tier, + absorbSats: debited, + }); + return { serve: "free", absorbSats: debited, chargeSats: 0 }; + } + // Pool covered only part of the cost — downgrade to partial. + // Pool has already been debited for `debited` sats; return it (no advisory hold; + // partial path will re-reserve at payment time). Release and fall through. + void this.releaseReservation(debited, "free downgraded to partial due to pool race"); + // fall through to return partial advisory below + const chargeSats = estimatedSats - debited; // advisory, not actually charged yet + logger.info("free-tier: downgraded free→partial (pool shrank under race)", { pubkey: pubkey.slice(0, 8), tier: identity.tier, - absorbSats: debited, + debited, + chargeSats, }); - return { serve: "free", absorbSats: debited, chargeSats: 0 }; + return { serve: "partial", absorbSats: debited, chargeSats }; } // Partial: advisory only — pool debit deferred until payment confirmed. diff --git a/artifacts/api-server/src/routes/jobs.ts b/artifacts/api-server/src/routes/jobs.ts index ade713b..435d290 100644 --- a/artifacts/api-server/src/routes/jobs.ts +++ b/artifacts/api-server/src/routes/jobs.ts @@ -63,32 +63,44 @@ async function runEvalInBackground( const ftDecision = await freeTierService.decide(nostrPubkey, breakdown.amountSats); if (ftDecision.serve === "free") { - // Skip work invoice — execute immediately at Timmy's expense - await db - .update(jobs) - .set({ - state: "executing", - workAmountSats: 0, - estimatedCostUsd: breakdown.estimatedCostUsd, - marginPct: breakdown.marginPct, - btcPriceUsd: breakdown.btcPriceUsd, - freeTier: true, - absorbedSats: breakdown.amountSats, - updatedAt: new Date(), - }) - .where(eq(jobs.id, jobId)); + // Pool was atomically debited for ftDecision.absorbSats by decide(). + // Store ONLY the actual debited amount (not the estimate) so reconciliation + // in recordGrant() can return over-reservation accurately. + const reservedAbsorbed = ftDecision.absorbSats; // actual pool debit + try { + await db + .update(jobs) + .set({ + state: "executing", + workAmountSats: 0, + estimatedCostUsd: breakdown.estimatedCostUsd, + marginPct: breakdown.marginPct, + btcPriceUsd: breakdown.btcPriceUsd, + freeTier: true, + absorbedSats: reservedAbsorbed, + updatedAt: new Date(), + }) + .where(eq(jobs.id, jobId)); - eventBus.publish({ type: "job:state", jobId, state: "executing" }); + eventBus.publish({ type: "job:state", jobId, state: "executing" }); - // Grant is recorded AFTER work completes (in runWorkInBackground) so we use - // actual cost rather than estimated sats for the audit log. - streamRegistry.register(jobId); - setImmediate(() => { - void runWorkInBackground( - jobId, request, 0, breakdown.btcPriceUsd, true, nostrPubkey, - breakdown.amountSats, // pass estimated as cap; actual cost may be lower + // Grant is recorded AFTER work completes (in runWorkInBackground) so we use + // actual cost rather than estimated sats for the audit log. + streamRegistry.register(jobId); + setImmediate(() => { + void runWorkInBackground( + jobId, request, 0, breakdown.btcPriceUsd, true, nostrPubkey, + reservedAbsorbed, // actual debited; runWorkInBackground will reconcile with actual cost + ); + }); + } catch (setupErr) { + // If DB transition or setup fails after pool was already debited, return sats. + void freeTierService.releaseReservation( + reservedAbsorbed, + `free job setup failed: ${setupErr instanceof Error ? setupErr.message : String(setupErr)}`, ); - }); + throw setupErr; // re-throw so outer catch handles job state + } return; }