diff --git a/artifacts/api-server/src/routes/jobs.ts b/artifacts/api-server/src/routes/jobs.ts index 6ee8a81..78f67ef 100644 --- a/artifacts/api-server/src/routes/jobs.ts +++ b/artifacts/api-server/src/routes/jobs.ts @@ -19,9 +19,112 @@ async function getInvoiceById(id: string) { return rows[0] ?? null; } +/** + * Runs the AI eval in a background task (fire-and-forget) so HTTP polls + * return immediately with "evaluating" state instead of blocking 5-8 seconds. + */ +async function runEvalInBackground(jobId: string, request: string): Promise { + try { + const evalResult = await agentService.evaluateRequest(request); + + if (evalResult.accepted) { + const inputEst = pricingService.estimateInputTokens(request); + const outputEst = pricingService.estimateOutputTokens(request); + const breakdown = await pricingService.calculateWorkFeeSats( + inputEst, + outputEst, + agentService.workModel, + ); + + const workInvoiceData = await lnbitsService.createInvoice( + breakdown.amountSats, + `Work fee for job ${jobId}`, + ); + const workInvoiceId = randomUUID(); + + await db.transaction(async (tx) => { + await tx.insert(invoices).values({ + id: workInvoiceId, + jobId, + paymentHash: workInvoiceData.paymentHash, + paymentRequest: workInvoiceData.paymentRequest, + amountSats: breakdown.amountSats, + type: "work", + paid: false, + }); + await tx + .update(jobs) + .set({ + state: "awaiting_work_payment", + workInvoiceId, + workAmountSats: breakdown.amountSats, + estimatedCostUsd: breakdown.estimatedCostUsd, + marginPct: breakdown.marginPct, + btcPriceUsd: breakdown.btcPriceUsd, + updatedAt: new Date(), + }) + .where(eq(jobs.id, jobId)); + }); + } else { + await db + .update(jobs) + .set({ state: "rejected", rejectionReason: evalResult.reason, updatedAt: new Date() }) + .where(eq(jobs.id, jobId)); + } + } catch (err) { + const message = err instanceof Error ? err.message : "Evaluation error"; + await db + .update(jobs) + .set({ state: "failed", errorMessage: message, updatedAt: new Date() }) + .where(eq(jobs.id, jobId)); + } +} + +/** + * Runs the AI work execution in a background task so HTTP polls return fast. + */ +async function runWorkInBackground(jobId: string, request: string, workAmountSats: number, btcPriceUsd: number | null): Promise { + try { + const workResult = await agentService.executeWork(request); + + const actualCostUsd = pricingService.calculateActualCostUsd( + workResult.inputTokens, + workResult.outputTokens, + agentService.workModel, + ); + + const lockedBtcPrice = btcPriceUsd ?? 100_000; + const actualAmountSats = pricingService.calculateActualChargeSats(actualCostUsd, lockedBtcPrice); + const refundAmountSats = pricingService.calculateRefundSats(workAmountSats, actualAmountSats); + const refundState = refundAmountSats > 0 ? "pending" : "not_applicable"; + + await db + .update(jobs) + .set({ + state: "complete", + result: workResult.result, + actualInputTokens: workResult.inputTokens, + actualOutputTokens: workResult.outputTokens, + actualCostUsd, + actualAmountSats, + refundAmountSats, + refundState, + updatedAt: new Date(), + }) + .where(eq(jobs.id, jobId)); + } catch (err) { + const message = err instanceof Error ? err.message : "Execution error"; + await db + .update(jobs) + .set({ state: "failed", errorMessage: message, updatedAt: new Date() }) + .where(eq(jobs.id, jobId)); + } +} + /** * Checks whether the active invoice for a job has been paid and, if so, - * advances the state machine. Returns the refreshed job after any transitions. + * advances the state machine. AI work runs in the background so this + * returns quickly. Returns the refreshed job after any DB transitions. */ async function advanceJob(job: Job): Promise { if (job.state === "awaiting_eval_payment" && job.evalInvoiceId) { @@ -46,60 +149,8 @@ async function advanceJob(job: Job): Promise { if (!advanced) return getJobById(job.id); - try { - const evalResult = await agentService.evaluateRequest(job.request); - - if (evalResult.accepted) { - const inputEst = pricingService.estimateInputTokens(job.request); - const outputEst = pricingService.estimateOutputTokens(job.request); - const breakdown = await pricingService.calculateWorkFeeSats( - inputEst, - outputEst, - agentService.workModel, - ); - - const workInvoiceData = await lnbitsService.createInvoice( - breakdown.amountSats, - `Work fee for job ${job.id}`, - ); - const workInvoiceId = randomUUID(); - - await db.transaction(async (tx) => { - await tx.insert(invoices).values({ - id: workInvoiceId, - jobId: job.id, - paymentHash: workInvoiceData.paymentHash, - paymentRequest: workInvoiceData.paymentRequest, - amountSats: breakdown.amountSats, - type: "work", - paid: false, - }); - await tx - .update(jobs) - .set({ - state: "awaiting_work_payment", - workInvoiceId, - workAmountSats: breakdown.amountSats, - estimatedCostUsd: breakdown.estimatedCostUsd, - marginPct: breakdown.marginPct, - btcPriceUsd: breakdown.btcPriceUsd, - updatedAt: new Date(), - }) - .where(eq(jobs.id, job.id)); - }); - } else { - await db - .update(jobs) - .set({ state: "rejected", rejectionReason: evalResult.reason, updatedAt: new Date() }) - .where(eq(jobs.id, job.id)); - } - } catch (err) { - const message = err instanceof Error ? err.message : "Evaluation error"; - await db - .update(jobs) - .set({ state: "failed", errorMessage: message, updatedAt: new Date() }) - .where(eq(jobs.id, job.id)); - } + // Fire AI eval in background — poll returns immediately with "evaluating" + setImmediate(() => { void runEvalInBackground(job.id, job.request); }); return getJobById(job.id); } @@ -126,51 +177,8 @@ async function advanceJob(job: Job): Promise { if (!advanced) return getJobById(job.id); - try { - const workResult = await agentService.executeWork(job.request); - - // ── Honest post-work accounting ─────────────────────────────────────── - const actualCostUsd = pricingService.calculateActualCostUsd( - workResult.inputTokens, - workResult.outputTokens, - agentService.workModel, - ); - - // Re-use the BTC price locked at invoice time so sats arithmetic is - // consistent. Fall back to the cached oracle price only if the column - // is somehow null (should not happen in normal flow). - const lockedBtcPrice = job.btcPriceUsd ?? 100_000; - const actualAmountSats = pricingService.calculateActualChargeSats( - actualCostUsd, - lockedBtcPrice, - ); - const refundAmountSats = pricingService.calculateRefundSats( - job.workAmountSats ?? 0, - actualAmountSats, - ); - const refundState = refundAmountSats > 0 ? "pending" : "not_applicable"; - - await db - .update(jobs) - .set({ - state: "complete", - result: workResult.result, - actualInputTokens: workResult.inputTokens, - actualOutputTokens: workResult.outputTokens, - actualCostUsd, - actualAmountSats, - refundAmountSats, - refundState, - updatedAt: new Date(), - }) - .where(eq(jobs.id, job.id)); - } catch (err) { - const message = err instanceof Error ? err.message : "Execution error"; - await db - .update(jobs) - .set({ state: "failed", errorMessage: message, updatedAt: new Date() }) - .where(eq(jobs.id, job.id)); - } + // Fire AI work in background — poll returns immediately with "executing" + setImmediate(() => { void runWorkInBackground(job.id, job.request, job.workAmountSats ?? 0, job.btcPriceUsd); }); return getJobById(job.id); } diff --git a/artifacts/api-server/src/routes/ui.ts b/artifacts/api-server/src/routes/ui.ts index 7bbbda0..2eafb1a 100644 --- a/artifacts/api-server/src/routes/ui.ts +++ b/artifacts/api-server/src/routes/ui.ts @@ -440,14 +440,28 @@ router.get("/ui", (_req, res) => { pollTimer = setInterval(async () => { try { const r = await fetch(BASE + '/api/jobs/' + jobId); + if (!r.ok) return; // keep polling on transient errors const data = await r.json(); - const s = data.status; + const s = data.state; + + // Failed is terminal regardless of phase + if (s === 'failed') { + clearInterval(pollTimer); + hide('card-eval'); + hide('card-work'); + $('rejected-reason').textContent = 'Error: ' + (data.errorMessage || 'Something went wrong. Try again.'); + show('card-rejected'); + setStep('rejected'); + return; + } if (phase === 'eval') { + // evaluating = AI running in background, keep waiting + if (s === 'evaluating') return; if (s === 'rejected') { clearInterval(pollTimer); hide('card-eval'); - $('rejected-reason').textContent = data.rejectionReason || 'Request was rejected.'; + $('rejected-reason').textContent = data.reason || 'Request was rejected.'; show('card-rejected'); setStep('rejected'); } else if (s === 'awaiting_work_payment') { @@ -461,20 +475,17 @@ router.get("/ui", (_req, res) => { setStep('awaiting_work_payment'); } } else if (phase === 'work') { + // executing = AI running in background, keep waiting + if (s === 'executing') return; if (s === 'complete') { clearInterval(pollTimer); hide('card-work'); $('result-text').textContent = data.result; show('card-result'); setStep('complete'); - } else if (s === 'failed') { - clearInterval(pollTimer); - hide('card-work'); - $('rejected-reason').textContent = 'Job failed: ' + (data.error || 'unknown error'); - show('card-rejected'); } } - } catch(e) { /* keep polling */ } + } catch(e) { /* keep polling through network errors */ } }, 1500); }