Make job evaluation and execution run in the background

Refactors `runEvalInBackground` and `runWorkInBackground` to execute AI tasks asynchronously. Updates `pollJob` in `ui.ts` to handle 'evaluating', 'executing', and 'failed' states, and corrects `data.status` to `data.state` and `data.rejectionReason` to `data.reason`.

Replit-Commit-Author: Agent
Replit-Commit-Session-Id: 418bf6f8-212b-4bb0-a7a5-8231a061da4e
Replit-Commit-Checkpoint-Type: full_checkpoint
Replit-Commit-Event-Id: ecf857ee-fa4d-47db-b4c1-b374ffb3815d
Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/9f85e954-647c-46a5-90a7-396e495a805a/418bf6f8-212b-4bb0-a7a5-8231a061da4e/Q83Uqvu
Replit-Helium-Checkpoint-Created: true
This commit is contained in:
alexpaynex
2026-03-18 21:00:43 +00:00
parent 1b5c7045da
commit b02efc9057
2 changed files with 127 additions and 108 deletions

View File

@@ -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<void> {
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<void> {
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<Job | null> {
if (job.state === "awaiting_eval_payment" && job.evalInvoiceId) {
@@ -46,60 +149,8 @@ async function advanceJob(job: Job): Promise<Job | null> {
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<Job | null> {
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);
}

View File

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