diff --git a/artifacts/api-server/src/routes/jobs.ts b/artifacts/api-server/src/routes/jobs.ts index 125d74c..ade713b 100644 --- a/artifacts/api-server/src/routes/jobs.ts +++ b/artifacts/api-server/src/routes/jobs.ts @@ -381,16 +381,31 @@ async function advanceJob(job: Job): Promise { }); if (partialGrantReserved <= 0 && (job.absorbedSats ?? 0) > 0) { // Pool drained between advisory decide() and payment confirmation. - // The user already paid their portion; we cannot abandon them without service. - // Proceed with work. Timmy absorbs a bounded loss (at most job.absorbedSats sats) - // for this edge case. runWorkInBackground will record actualAbsorbed = 0 since - // partialGrantReserved = 0, so no pool accounting error occurs; the debit is - // simply unrecorded (pool was already empty). - logger.warn("partial grant pool drained after payment — work proceeds, no pool debit", { + // 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", { 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); } }