import { config } from '../config'; import { logger } from '../utils/logger'; import { sleep } from '../utils/retry'; import { createContext } from './browser-pool'; import { getAccountPassword } from '../crypto/password'; import { supabase } from '../db/client'; // DB imports (will be created by subagent) import { claimPendingJob, completeJobIfActive, updateJobStatus, updateJobFields, incrementJobAttempt, getJobById, TurnitinJob } from '../db/jobs'; import { claimAvailableAccount, claimSpecificAccountForResume, countAvailableAccounts, getAccountPoolState, getTurnitinAccountById, releaseAccount, updateAccountQuota, type TurnitinAccount, } from '../db/accounts'; import { insertJobEvent } from '../db/events'; import { downloadInputFile, uploadReceiptPdf, uploadReportPdf, downloadStorageState, uploadStorageState } from '../db/storage'; import { cancelJob, refundFailedJob } from '../db/tickets'; import { MODERN_ONE_POOL_KEY, runTurnitinJob, RunTurnitinJobInput, RunTurnitinJobResult } from '../engine/turnitin'; import * as path from 'path'; import * as fs from 'fs'; import * as os from 'os'; let activeWorkers = 0; let running = false; const ACCOUNT_WAIT_POLL_MS = Number(process.env.ACCOUNT_WAIT_POLL_MS || 15000); const ACCOUNT_WAIT_MAX_MS = Number(process.env.ACCOUNT_WAIT_MAX_MS || 30 * 60 * 1000); const RESUME_PROTECTED_STEPS = ['submitted', 'similarity', 'viewer', 'filters', 'download', 'receipt']; const RESUME_ACCOUNT_RETRY_DELAY_MS = Number(process.env.RESUME_ACCOUNT_RETRY_DELAY_MS || 30000); const DEFAULT_ACCOUNT_POOL_KEY = 'modern_lti'; const LEGACY_ACCOUNT_POOL_KEY = 'legacy_carta'; const LEGACY_ACCOUNT_QUOTA_LIMIT = 4; function accountQuotaLimitForPool(poolKey: string, account: TurnitinAccount): number { if (poolKey === MODERN_ONE_POOL_KEY) return 1; if (poolKey === LEGACY_ACCOUNT_POOL_KEY) return LEGACY_ACCOUNT_QUOTA_LIMIT; return account.turnitin_quota_limit || 3; } // --------------------------------------------------------------------------- // In-memory sets to prevent double processing and double refund. // These are per-process so each HF Space worker keeps its own bookkeeping. // --------------------------------------------------------------------------- /** Jobs currently being processed by this worker process. */ const processingJobs = new Set(); /** * Jobs that have already been refunded by this worker process. * NEW-BUG-4 FIX: Entries are auto-deleted after 1 hour to prevent unbounded growth. */ const refundedJobs = new Map(); function markAsRefunded(jobId: string): void { // Clear existing timer if re-refunding (shouldn't happen, but be safe) const existing = refundedJobs.get(jobId); if (existing) clearTimeout(existing); // Auto-remove after 1 hour const timer = setTimeout(() => refundedJobs.delete(jobId), 60 * 60 * 1000); // Prevent timer from keeping the process alive during shutdown if (timer.unref) timer.unref(); refundedJobs.set(jobId, timer); } function wasRefunded(jobId: string): boolean { return refundedJobs.has(jobId); } async function refundFailedTicketOnce( jobId: string, reason: string, jobLog: ReturnType, ): Promise { if (wasRefunded(jobId)) { jobLog.info('Refund already attempted by this worker process', { jobId }); return false; } try { const refunded = await refundFailedJob(jobId, reason); markAsRefunded(jobId); jobLog.info('Failed job refund checked', { jobId, refunded }); return refunded; } catch (error) { jobLog.warn('Failed job refund RPC failed', { jobId, error: error instanceof Error ? compactWorkerMessage(error.message) : String(error), }); return false; } } type AssignmentTargetConfig = RunTurnitinJobInput['assignmentTarget']; function normalizeUiVariant(value: unknown): AssignmentTargetConfig['uiVariant'] { return value === 'legacy_carta' ? 'legacy_carta' : 'modern_lti'; } function defaultAssignmentTarget(): AssignmentTargetConfig { return { targetUrl: config.turnitinTargetUrl, classTitle: config.turnitinClassTitle, assignmentTitle: config.turnitinAssignmentTitle as string | null, assignmentLaunchUrl: null, uiVariant: 'modern_lti', accountPoolKey: DEFAULT_ACCOUNT_POOL_KEY, }; } async function loadAssignmentTarget( assignmentTargetId: string, jobLog: ReturnType, ): Promise { const assignmentTarget = defaultAssignmentTarget(); if (!assignmentTargetId) return assignmentTarget; try { const { data, error } = await supabase .from('turnitin_assignment_targets') .select('*') .eq('id', assignmentTargetId) .single(); if (error) throw error; if (!data) return assignmentTarget; const uiVariant = normalizeUiVariant(data.ui_variant); assignmentTarget.targetUrl = data.target_url || assignmentTarget.targetUrl; assignmentTarget.classTitle = data.class_title || assignmentTarget.classTitle; assignmentTarget.assignmentTitle = data.assignment_title || null; assignmentTarget.assignmentLaunchUrl = data.assignment_launch_url || null; assignmentTarget.uiVariant = uiVariant; assignmentTarget.accountPoolKey = data.account_pool_key || (uiVariant === 'legacy_carta' ? 'legacy_carta' : DEFAULT_ACCOUNT_POOL_KEY); return assignmentTarget; } catch (error) { jobLog.warn('Failed to fetch assignment target, using defaults', { assignmentTargetId, error: error instanceof Error ? error.message : String(error), }); return assignmentTarget; } } // --------------------------------------------------------------------------- // Job queue with backpressure — prevents thundering herd // --------------------------------------------------------------------------- /** * Pending account claim resolvers, FIFO. * When a job needs an account and none is free, it pushes a resolve callback * here. When any job finishes and releases an account, we pop the first waiter * to signal it to retry its claim. */ const accountWaiters: Array<() => void> = []; /** Signal one waiting job that an account may now be available. */ function notifyNextWaiter(): void { const next = accountWaiters.shift(); if (next) next(); } /** Wait for a notification that an account was released. */ function waitForAccountRelease(timeoutMs: number): Promise<'notified' | 'timeout'> { return new Promise((resolve) => { const timer = setTimeout(() => { // Remove ourselves from the queue on timeout const idx = accountWaiters.indexOf(onNotify); if (idx >= 0) accountWaiters.splice(idx, 1); resolve('timeout'); }, timeoutMs); if (timer.unref) timer.unref(); const onNotify = () => { clearTimeout(timer); resolve('notified'); }; accountWaiters.push(onNotify); }); } /** * Start the worker polling loop. * Claims jobs from Supabase and processes them with Playwright. */ export async function startWorkerManager(): Promise { running = true; logger.info('Worker manager started', { workerId: config.workerId, maxWorkers: config.maxWorkers, }); // BUG-2 FIX: Stale job recovery is now handled EXCLUSIVELY by stale-recovery.ts // cron job, which runs every 5 minutes. Removing the duplicate inline loop here // prevents two Space workers from simultaneously resetting a job that the other // worker is still actively processing. while (running) { try { if (activeWorkers >= config.maxWorkers) { await sleep(2000); continue; } // Try to claim a pending job const job = await claimPendingJob(config.workerId); if (!job) { // No jobs available, wait before polling again const jitter = config.pollIntervalMs + Math.random() * 2000; await sleep(jitter); continue; } // ── Guard: skip job if already being processed by this worker ── if (processingJobs.has(job.id)) { logger.warn('Skipping job already in progress on this worker', { jobId: job.id, }); // Release the claim — set it back to pending so a healthy worker can take it. await updateJobStatus(job.id, 'pending').catch(() => {}); await sleep(1000); continue; } logger.info('Claimed job', { jobId: job.id, mode: job.mode, userId: job.user_id }); // ── BUG-3 FIX: Defensive guard — verify the claimed job actually belongs ── // to this worker. If the Supabase RPC `claim_turnitin_job` accidentally // returns a job owned by another worker (e.g. one stuck in 'waiting_account'), // processing it here would cause double-submit. Re-read the job from DB to // ensure worker_id matches before proceeding. const freshJob = await getJobById(job.id); if (freshJob && freshJob.worker_id && freshJob.worker_id !== config.workerId) { logger.warn('Claimed job belongs to a different worker; releasing', { jobId: job.id, ownWorker: config.workerId, actualWorker: freshJob.worker_id, }); await sleep(1000); continue; } // Mark as in-progress processingJobs.add(job.id); // Spawn async task for this job (non-blocking) activeWorkers++; processJob(job) .catch((err) => { logger.error('Unhandled error in job processing', { jobId: job.id, error: err instanceof Error ? err.message : String(err), }); }) .finally(() => { activeWorkers--; processingJobs.delete(job.id); }); } catch (err) { logger.error('Worker manager loop error', { error: err instanceof Error ? err.message : String(err), }); await sleep(5000); } } logger.info('Worker manager stopped'); } /** * Stop the worker polling loop gracefully. */ export function stopWorkerManager(): void { running = false; logger.info('Worker manager stop requested'); } /** * Get current active worker count. */ export function getActiveWorkerCount(): number { return activeWorkers; } /** * Process a single job end-to-end. */ async function processJob(job: TurnitinJob): Promise { const jobId = job.id; const jobLog = logger.child({ job: jobId }); let identityId: string | null = null; let claimedAccount: TurnitinAccount | null = null; let initialLastCompletedStep: string | undefined; let currentAttemptCount = (job.attempt_count as number) || 0; const maxAttempts = (job.max_attempts as number) || 3; // NEW-BUG-3 FIX: Declare tmpDir outside try so the finally block can always clean up. const tmpDir = path.join(os.tmpdir(), `turnitin-job-${jobId}`); const assignmentTargetId = job.assignment_target_id as string; let assignmentTarget = defaultAssignmentTarget(); let accountPoolKey = DEFAULT_ACCOUNT_POOL_KEY; try { // ── Re-check job status before processing ── // Another worker may have already picked this job up or cancelled it. const freshJob = await getJobById(jobId); if (!freshJob || !['claiming_account', 'pending'].includes(freshJob.status)) { jobLog.warn('Job no longer claimable; skipping', { currentStatus: freshJob?.status ?? 'not_found', }); return; } // Increment attempt count currentAttemptCount = await incrementJobAttempt(jobId); // If a previous attempt already submitted the file, keep retrying with // the same Turnitin account. A different account generally cannot access // the existing report viewer URL and must not submit the file again. initialLastCompletedStep = freshJob.last_completed_step || undefined; const resumeNeedsSameAccount = Boolean( initialLastCompletedStep && RESUME_PROTECTED_STEPS.includes(initialLastCompletedStep) && freshJob.identity_id, ); assignmentTarget = await loadAssignmentTarget(assignmentTargetId, jobLog); accountPoolKey = assignmentTarget.accountPoolKey || (assignmentTarget.uiVariant === 'legacy_carta' ? 'legacy_carta' : DEFAULT_ACCOUNT_POOL_KEY); // Claim an available Turnitin account await updateJobStatus(jobId, 'claiming_account'); await emitEvent(jobId, null, 'info', 'claiming_account', 'Looking for available Turnitin account', { accountPoolKey, uiVariant: assignmentTarget.uiVariant, }); // Before submission, every retry may safely rotate to another account. // Keeping a pre-submit job attached to an account that has since become // quota_limited makes it wait forever even when the pool has free accounts. // Post-submit checkpoints remain pinned to the original account to avoid // uploading the same file again. const account = resumeNeedsSameAccount ? await claimSpecificAccountForResume(freshJob.identity_id as string, config.workerId) : await claimAccountForJob(job, identityId, jobLog, accountPoolKey); if (!account) { if (resumeNeedsSameAccount) { const previousAccount = await getTurnitinAccountById( freshJob.identity_id as string, ).catch(() => null); if ( !previousAccount || ['disabled', 'login_failed'].includes(previousAccount.turnitin_status) ) { throw new Error( 'The Turnitin account that owns the submitted file is no longer available for report recovery.', ); } const message = 'Previous submission is still locked by its Turnitin account; retrying shortly.'; await updateJobStatus(jobId, 'waiting_account', { error_message: message, attempt_count: Math.max(0, currentAttemptCount - 1), next_retry_at: new Date(Date.now() + RESUME_ACCOUNT_RETRY_DELAY_MS).toISOString(), }); await emitEvent(jobId, freshJob.identity_id, 'warning', 'waiting_account', message); } return; } claimedAccount = account; identityId = account.id; jobLog.info('Account claimed', { accountId: identityId, email: account.email }); if ( accountPoolKey === MODERN_ONE_POOL_KEY && ( account.turnitin_quota_limit !== 1 || typeof account.turnitin_quota_remaining !== 'number' || account.turnitin_quota_remaining > 1 ) ) { await updateAccountQuota(identityId, { turnitin_quota_limit: 1, turnitin_quota_remaining: typeof account.turnitin_quota_remaining === 'number' ? Math.min(1, account.turnitin_quota_remaining) : 1, }).catch(() => {}); account.turnitin_quota_limit = 1; account.turnitin_quota_remaining = typeof account.turnitin_quota_remaining === 'number' ? Math.min(1, account.turnitin_quota_remaining) : 1; } if ( accountPoolKey === LEGACY_ACCOUNT_POOL_KEY && ( account.turnitin_quota_limit !== LEGACY_ACCOUNT_QUOTA_LIMIT || typeof account.turnitin_quota_remaining !== 'number' || account.turnitin_quota_remaining > LEGACY_ACCOUNT_QUOTA_LIMIT ) ) { await updateAccountQuota(identityId, { turnitin_quota_limit: LEGACY_ACCOUNT_QUOTA_LIMIT, turnitin_quota_remaining: typeof account.turnitin_quota_remaining === 'number' ? Math.min(LEGACY_ACCOUNT_QUOTA_LIMIT, account.turnitin_quota_remaining) : LEGACY_ACCOUNT_QUOTA_LIMIT, }).catch(() => {}); account.turnitin_quota_limit = LEGACY_ACCOUNT_QUOTA_LIMIT; account.turnitin_quota_remaining = typeof account.turnitin_quota_remaining === 'number' ? Math.min(LEGACY_ACCOUNT_QUOTA_LIMIT, account.turnitin_quota_remaining) : LEGACY_ACCOUNT_QUOTA_LIMIT; } // Update job with account reference await updateJobStatus(jobId, 'running', { identity_id: identityId, started_at: new Date().toISOString(), }); await emitEvent(jobId, identityId, 'info', 'running', `Starting job with account ${account.email}`); // Create temp directory for this job fs.mkdirSync(tmpDir, { recursive: true }); // Download input file from Supabase Storage, except for quota_check jobs. const localInputPath = job.mode === 'quota_check' ? '' : path.join(tmpDir, job.input_file_name as string); if (job.mode !== 'quota_check') { await downloadInputFile(job.input_file_path as string, localInputPath); await emitEvent(jobId, identityId, 'info', 'file_downloaded', 'Input file downloaded'); } // Get storage state for session reuse let storageStatePath: string | undefined; if (account.turnitin_session_storage_path) { const storageState = await downloadStorageState(account.turnitin_session_storage_path); if (storageState) { storageStatePath = path.join(tmpDir, 'storageState.json'); fs.writeFileSync(storageStatePath, storageState, 'utf-8'); } } // Get password const password = getAccountPassword(); // Parse filters from job const filters = (job.filters as Record) || {}; // ── Determine resume point ── // If a previous attempt already uploaded the file, we must NOT upload // again. The engine's `resumeAfterStep` tells it to skip earlier steps. const lastCompletedStep = initialLastCompletedStep; const effectiveMode = lastCompletedStep && ['submitted', 'similarity', 'viewer', 'filters', 'download', 'receipt'].includes(lastCompletedStep) ? 'resubmit' as const // force resubmit because file is already there : (job.mode as 'upload' | 'resubmit' | 'quota_check'); if (lastCompletedStep) { jobLog.info('Resuming job from previous step', { lastCompletedStep, effectiveMode, }); } // Prepare job input const jobInput: RunTurnitinJobInput = { account: { id: identityId, email: account.email, password, quotaLimit: accountQuotaLimitForPool(accountPoolKey, account), quotaRemaining: typeof account.turnitin_quota_remaining === 'number' ? account.turnitin_quota_remaining : null, }, assignmentTarget, inputFilePath: localInputPath, inputFileName: job.input_file_name as string, inputFileSize: job.input_file_size ?? undefined, outputDir: tmpDir, mode: effectiveMode, filters: { excludeBibliography: filters.excludeBibliography as boolean | undefined, excludeQuotes: filters.excludeQuotes as boolean | undefined, excludeCitations: filters.excludeCitations as boolean | undefined, excludeSmallMatches: filters.excludeSmallMatches as boolean | undefined, smallMatchMode: filters.smallMatchMode as 'words' | 'percent' | 'off' | null | undefined, smallMatchThreshold: filters.smallMatchThreshold as number | null | undefined, }, storageStatePath, resumeAfterStep: lastCompletedStep, resumeViewerUrl: freshJob.viewer_url, attemptCount: currentAttemptCount, onEvent: async (event) => { await emitEvent(jobId, identityId, event.level, event.step, event.message, event.metadata); await persistJobProgressFromEvent(jobId, event); }, }; // Run the Playwright job const result = await runTurnitinJob(jobInput); // Handle quota limit if (result.quotaLimit) { jobLog.warn('Account quota limited', { limit: result.quotaLimit }); await updateAccountQuota(identityId, { turnitin_status: 'quota_limited', turnitin_quota_remaining: 0, turnitin_quota_message: result.quotaLimit.message, turnitin_quota_detected_at: new Date().toISOString(), turnitin_next_retry_at: accountPoolKey === MODERN_ONE_POOL_KEY ? null : new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(), }); await releaseAccount(identityId, 'quota_limited', result.quotaLimit.message); const availableAccounts = await countAvailableAccounts(accountPoolKey); // Retry with a different account only when one is immediately available. if (currentAttemptCount < maxAttempts && availableAccounts > 0) { await updateJobStatus(jobId, 'pending'); await safeUpdateLastCompletedStep(jobId, result.lastCompletedStep); await emitEvent(jobId, identityId, 'warning', 'quota_limited', 'Account quota limited, retrying with different account'); } else { await updateJobStatus(jobId, 'failed', { error_message: result.quotaLimit.message, finished_at: new Date().toISOString(), }); const refunded = await refundFailedTicketOnce( jobId, result.quotaLimit.message, jobLog, ); await safeUpdateLastCompletedStep(jobId, result.lastCompletedStep); await emitEvent(jobId, identityId, 'error', 'quota_limited', result.quotaLimit.message, { refunded, }); } return; } // Upload PDF report if available let outputPdfPath: string | undefined; let outputPdfExpiresAt: string | undefined; if (result.outputPdfPath && fs.existsSync(result.outputPdfPath)) { const uploadResult = await uploadReportPdf( job.user_id as string, jobId, result.outputPdfPath ); outputPdfPath = uploadResult.storagePath; outputPdfExpiresAt = uploadResult.expiresAt; await emitEvent(jobId, identityId, 'info', 'pdf_uploaded', 'PDF report uploaded to storage'); } let receiptPdfPath: string | undefined; let receiptPdfExpiresAt: string | undefined; if (result.receiptPdfPath && fs.existsSync(result.receiptPdfPath)) { const uploadResult = await uploadReceiptPdf( job.user_id as string, jobId, result.receiptPdfPath, ); receiptPdfPath = uploadResult.storagePath; receiptPdfExpiresAt = uploadResult.expiresAt; await emitEvent( jobId, identityId, 'info', 'receipt_uploaded', 'Digital Receipt uploaded to storage', ); } // Save storage state for session reuse try { // The engine should have saved the storage state; we read and upload it const stateFile = path.join(tmpDir, 'storageState.json'); if (fs.existsSync(stateFile)) { const stateJson = fs.readFileSync(stateFile, 'utf-8'); const storagePath = await uploadStorageState(identityId, stateJson); await updateAccountQuota(identityId, { turnitin_session_storage_path: storagePath, }); } } catch { jobLog.warn('Failed to save storage state'); } // An administrator may fail/cancel a job while Playwright is already in // the viewer. Persist completion only if the job is still active so an // in-flight callback cannot revive a terminal job. const completionPersisted = await completeJobIfActive(jobId, { viewer_url: result.viewerUrl, similarity_percent: result.similarityPercent, output_pdf_path: outputPdfPath, output_pdf_expires_at: outputPdfExpiresAt, receipt_pdf_path: receiptPdfPath, receipt_pdf_expires_at: receiptPdfExpiresAt, error_message: null, finished_at: new Date().toISOString(), }); if (!completionPersisted) { const terminalArtifacts: Partial = {}; if (outputPdfPath) terminalArtifacts.output_pdf_path = outputPdfPath; if (outputPdfExpiresAt) terminalArtifacts.output_pdf_expires_at = outputPdfExpiresAt; if (receiptPdfPath) terminalArtifacts.receipt_pdf_path = receiptPdfPath; if (receiptPdfExpiresAt) { terminalArtifacts.receipt_pdf_expires_at = receiptPdfExpiresAt; } if (Object.keys(terminalArtifacts).length > 0) { await safeUpdateJobFields(jobId, terminalArtifacts); } } if (result.submissionDetails) { await safeUpdateJobFields(jobId, { submission_details: result.submissionDetails as Record, }); } await safeUpdateLastCompletedStep(jobId, result.lastCompletedStep || 'download'); // Insert submission record try { const { supabase } = await import('../db/client'); await supabase.from('turnitin_submissions').insert({ job_id: jobId, identity_id: identityId, assignment_target_id: assignmentTargetId, input_file_name: job.input_file_name as string, input_file_size: job.input_file_size, input_file_sha256: job.input_file_sha256, viewer_url: result.viewerUrl, similarity_percent: result.similarityPercent, submission_details: result.submissionDetails as Record | undefined, filters_applied: job.filters, pdf_path: outputPdfPath, pdf_expires_at: outputPdfExpiresAt, receipt_pdf_path: receiptPdfPath, receipt_pdf_expires_at: receiptPdfExpiresAt, submitted_at: result.submittedAt, }); } catch (err) { jobLog.error('Failed to insert submission record', { error: err instanceof Error ? err.message : String(err), }); } // Release account. A final-submission warning means this successful submit // likely consumed the last available submission, so keep the account out of // rotation until the cooldown window passes. if (result.permanentLimit) { await updateAccountQuota(identityId, { turnitin_status: 'quota_limited', turnitin_quota_limit: accountQuotaLimitForPool(accountPoolKey, account), turnitin_quota_remaining: 0, turnitin_quota_message: result.permanentLimit.message, turnitin_quota_detected_at: new Date().toISOString(), turnitin_next_retry_at: null, turnitin_last_success_at: new Date().toISOString(), }); await releaseAccount(identityId, 'quota_limited', result.permanentLimit.message); } else if (result.quotaCooldown) { await updateAccountQuota(identityId, { turnitin_status: 'cooling_down', turnitin_quota_remaining: null, turnitin_quota_message: result.quotaCooldown.message, turnitin_quota_detected_at: new Date().toISOString(), turnitin_next_retry_at: result.quotaCooldown.nextRetryAt, turnitin_last_success_at: new Date().toISOString(), }); await releaseAccount(identityId, 'cooling_down', result.quotaCooldown.message); } else if (result.quotaWarning) { await updateAccountQuota(identityId, { turnitin_status: 'quota_limited', turnitin_quota_remaining: 0, turnitin_quota_message: result.quotaWarning, turnitin_quota_detected_at: new Date().toISOString(), turnitin_next_retry_at: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(), }); await releaseAccount(identityId, 'quota_limited', result.quotaWarning); } else { const remainingFromSubmissionCount = typeof result.submissionCount === 'number' ? Math.max(0, accountQuotaLimitForPool(accountPoolKey, account) - result.submissionCount) : null; const currentRemaining = typeof account.turnitin_quota_remaining === 'number' ? account.turnitin_quota_remaining : accountQuotaLimitForPool(accountPoolKey, account); const nextRemaining = remainingFromSubmissionCount ?? Math.max(0, currentRemaining - 1); if (assignmentTarget.uiVariant === LEGACY_ACCOUNT_POOL_KEY && nextRemaining <= 0) { const cooldownMessage = 'Legacy Turnitin account reached 4 submissions. Account is permanently limited because the class should be dropped.'; await updateAccountQuota(identityId, { turnitin_status: 'quota_limited', turnitin_quota_remaining: 0, turnitin_quota_message: cooldownMessage, turnitin_quota_detected_at: new Date().toISOString(), turnitin_next_retry_at: null, turnitin_last_success_at: new Date().toISOString(), }); await releaseAccount(identityId, 'quota_limited', cooldownMessage); } else if (accountPoolKey === MODERN_ONE_POOL_KEY && nextRemaining <= 0) { const oneUseMessage = 'Modern one-use account consumed its single allowed submission. Account is permanently limited.'; await updateAccountQuota(identityId, { turnitin_status: 'quota_limited', turnitin_quota_limit: 1, turnitin_quota_remaining: 0, turnitin_quota_message: oneUseMessage, turnitin_quota_detected_at: new Date().toISOString(), turnitin_next_retry_at: null, turnitin_last_success_at: new Date().toISOString(), }); await releaseAccount(identityId, 'quota_limited', oneUseMessage); } else { await updateAccountQuota(identityId, { turnitin_status: 'available', turnitin_quota_remaining: nextRemaining, turnitin_quota_message: null, turnitin_next_retry_at: null, turnitin_last_success_at: new Date().toISOString(), }); await releaseAccount(identityId, 'available'); } } if (completionPersisted) { await emitEvent(jobId, identityId, 'info', 'completed', `Job completed. Similarity: ${result.similarityPercent ?? 'N/A'}%`); jobLog.info('Job completed successfully', { similarity: result.similarityPercent, viewerUrl: result.viewerUrl, }); } else { await emitEvent( jobId, identityId, 'warning', 'terminal_status_preserved', 'Worker cleanup finished after the job was stopped; terminal status preserved.', ); jobLog.warn('Worker finished after job entered a terminal state; completion was not persisted'); } // Cleanup temp directory (moved to finally, see below) } catch (err) { const errorMessage = err instanceof Error ? err.message : String(err); const publicErrorMessage = compactWorkerMessage(errorMessage); logger.error('Job failed', { jobId, error: publicErrorMessage }); // Try to extract lastCompletedStep from the engine result (it's set // on the result object even before throwing because the steps record // progress incrementally). We also check the error object itself in // case the engine attached the step there. const failedResultStep: string | undefined = (err as any)?.lastCompletedStep || undefined; const failedViewerUrl: string | undefined = (err as any)?.viewerUrl || undefined; const failedSimilarityPercent: number | undefined = typeof (err as any)?.similarityPercent === 'number' ? (err as any).similarityPercent : undefined; const isQuotaLimit = err instanceof Error && (err.name === 'SubmissionQuotaLimitError' || /reached your limit|submission quota limit/i.test(errorMessage)); const isTargetUnavailable = /waiting for locator\('td\.class_name a|waiting for locator\('tr\.assignment-row|Class title|class.*not found|assignment.*not found|Summer Reading 2026/i.test( errorMessage, ); // Determine whether the file was already uploaded on THIS attempt. // If so, subsequent retries MUST NOT re-upload. // We detect this from the job status transitions that went through // the emitEvent calls above (e.g. the 'submitted' event). let stepToSave: string | null = failedResultStep || null; if (!stepToSave) { // Fallback: if the error happened after similarity/viewer/download // the job status will have been updated through updateJobStatus. const latestJob = await getJobById(jobId).catch(() => null); stepToSave = latestJob?.last_completed_step || null; } const failureProgress: Partial = {}; if (stepToSave) failureProgress.last_completed_step = stepToSave; if (failedViewerUrl) failureProgress.viewer_url = failedViewerUrl; if (typeof failedSimilarityPercent === 'number') { failureProgress.similarity_percent = failedSimilarityPercent; } if (Object.keys(failureProgress).length > 0) { await safeUpdateJobFields(jobId, failureProgress); } const isPostSubmitResume = Boolean( stepToSave && RESUME_PROTECTED_STEPS.includes(stepToSave), ); const availableAccounts = isQuotaLimit || isTargetUnavailable ? await countAvailableAccounts(accountPoolKey).catch(() => 0) : 1; const shouldRetry = currentAttemptCount < maxAttempts && (!(isQuotaLimit || isTargetUnavailable) || availableAccounts > 0); const submissionConsumedThisAttempt = Boolean( isPostSubmitResume && !initialLastCompletedStep, ); const knownRemaining = typeof claimedAccount?.turnitin_quota_remaining === 'number' ? claimedAccount.turnitin_quota_remaining : null; const remainingAfterConsumedSubmit = knownRemaining === null ? null : Math.max(0, knownRemaining - 1); const remainingAfterJob = submissionConsumedThisAttempt ? remainingAfterConsumedSubmit : knownRemaining; const legacyNeedsClassCleanup = Boolean( accountPoolKey === LEGACY_ACCOUNT_POOL_KEY && isPostSubmitResume && !shouldRetry && remainingAfterJob === 0, ); // Release account if claimed if (identityId) { const isLoginError = errorMessage.toLowerCase().includes('login'); try { if (isQuotaLimit) { await updateAccountQuota(identityId, { turnitin_status: 'quota_limited', turnitin_quota_remaining: 0, turnitin_quota_message: errorMessage, turnitin_quota_detected_at: new Date().toISOString(), turnitin_next_retry_at: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(), turnitin_last_error: errorMessage, }); await releaseAccount(identityId, 'quota_limited', errorMessage); } else if (isTargetUnavailable) { await updateAccountQuota(identityId, { turnitin_status: 'quota_limited', turnitin_quota_remaining: 0, turnitin_quota_message: 'Target class or assignment is not available. This account is treated as limit because the class may have been dropped.', turnitin_quota_detected_at: new Date().toISOString(), turnitin_next_retry_at: null, turnitin_last_error: errorMessage, }); await releaseAccount(identityId, 'quota_limited', errorMessage); } else if (isLoginError) { const loginLimitMessage = 'Turnitin login failed. Account removed from rotation because the credential is invalid or the account no longer exists.'; await updateAccountQuota(identityId, { turnitin_status: 'quota_limited', turnitin_quota_remaining: 0, turnitin_quota_message: loginLimitMessage, turnitin_quota_detected_at: new Date().toISOString(), turnitin_next_retry_at: null, turnitin_last_error: errorMessage, }).catch(() => {}); await releaseAccount(identityId, 'quota_limited', errorMessage); } else { let nextStatus = 'available'; if (isPostSubmitResume) { if (shouldRetry) { nextStatus = 'cooling_down'; } else if (legacyNeedsClassCleanup) { nextStatus = 'cooling_down'; } else if (remainingAfterJob === 0) { nextStatus = 'quota_limited'; } else if (remainingAfterJob === null) { nextStatus = 'cooling_down'; } } if (submissionConsumedThisAttempt) { await updateAccountQuota(identityId, { turnitin_status: nextStatus, turnitin_quota_remaining: remainingAfterConsumedSubmit, turnitin_quota_message: legacyNeedsClassCleanup ? 'Legacy final submission was consumed, but report processing failed. Class cleanup is pending.' : remainingAfterConsumedSubmit === null ? 'Quota needs refresh after a failed post-submit attempt.' : null, turnitin_next_retry_at: legacyNeedsClassCleanup ? new Date().toISOString() : nextStatus === 'cooling_down' || nextStatus === 'quota_limited' ? new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString() : null, turnitin_last_error: errorMessage, }).catch(() => {}); } else if (nextStatus === 'cooling_down') { await updateAccountQuota(identityId, { turnitin_status: 'cooling_down', turnitin_next_retry_at: legacyNeedsClassCleanup ? new Date().toISOString() : claimedAccount?.turnitin_next_retry_at, turnitin_last_error: errorMessage, turnitin_quota_message: legacyNeedsClassCleanup ? 'Legacy final submission was consumed, but report processing failed. Class cleanup is pending.' : 'Reserved for retry after submitted file reached report viewer.', }).catch(() => {}); } else if (nextStatus === 'quota_limited') { await updateAccountQuota(identityId, { turnitin_status: 'quota_limited', turnitin_quota_remaining: 0, turnitin_quota_message: 'Submitted file consumed the remaining quota before report processing failed.', turnitin_next_retry_at: accountPoolKey === MODERN_ONE_POOL_KEY ? null : new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(), turnitin_last_error: errorMessage, }).catch(() => {}); } await releaseAccount(identityId, nextStatus, errorMessage); } } catch { // Ignore release errors } } // A manual failure/cancellation can happen while Playwright is still // unwinding. Preserve that terminal decision after account cleanup. const latestJob = await getJobById(jobId).catch(() => null); if (latestJob && ['failed', 'cancelled'].includes(latestJob.status)) { jobLog.info('Job already terminal after worker failure; retry status not changed', { status: latestJob.status, }); return; } // Check if we should retry if (shouldRetry) { await updateJobStatus(jobId, 'pending', { error_message: publicErrorMessage, }); await safeUpdateLastCompletedStep(jobId, stepToSave); await emitEvent(jobId, identityId, 'error', 'failed_retry', `Attempt failed, will retry: ${publicErrorMessage}`); } else { await updateJobStatus(jobId, 'failed', { error_message: publicErrorMessage, finished_at: new Date().toISOString(), }); const refunded = await refundFailedTicketOnce(jobId, publicErrorMessage, jobLog); await safeUpdateLastCompletedStep(jobId, stepToSave); await emitEvent( jobId, identityId, 'error', 'failed_final', refunded ? `Job failed after ${currentAttemptCount} attempt(s): ${publicErrorMessage}. Ticket refunded.` : `Job failed after ${currentAttemptCount} attempt(s): ${publicErrorMessage}`, { refunded }, ); } } finally { // NEW-BUG-3 FIX: Always clean up temp dir regardless of success or error try { if (fs.existsSync(tmpDir)) { fs.rmSync(tmpDir, { recursive: true, force: true }); } } catch { // Ignore cleanup errors } // Notify the next waiting job that an account may have been released. // This is part of the backpressure system — instead of all waiters // polling simultaneously (thundering herd), they wait in a FIFO queue. notifyNextWaiter(); } } // --------------------------------------------------------------------------- // Account claiming with bounded wait & single-refund guarantee // --------------------------------------------------------------------------- async function claimAccountForJob( job: TurnitinJob, identityId: string | null, jobLog: ReturnType, accountPoolKey: string, ): Promise { const startedAt = Date.now(); let lastWaitingEventAt = 0; while (true) { // ── Re-check that the job is still ours ── // CRITICAL: also check worker_id to catch the case where another Space // worker already picked this job while we were sleeping. const freshJob = await getJobById(job.id).catch(() => null); if (!freshJob) { jobLog.warn('Job disappeared while waiting for account', { jobId: job.id }); return null; } if (['cancelled', 'completed', 'failed'].includes(freshJob.status)) { jobLog.info('Job moved to terminal state while waiting for account; stopping', { jobId: job.id, status: freshJob.status, }); return null; } // If another worker took ownership of this job, stop immediately. if (freshJob.worker_id && freshJob.worker_id !== config.workerId) { jobLog.warn('Job was taken by another worker while waiting for account; stopping', { jobId: job.id, ownedBy: freshJob.worker_id, thisWorker: config.workerId, }); return null; } const account = await claimAvailableAccount(config.workerId, accountPoolKey); if (account) { // Double-check ownership one final time to prevent double-processing // that can happen when both workers exit the wait loop simultaneously. const jobAfterClaim = await getJobById(job.id).catch(() => null); if ( !jobAfterClaim || ['cancelled', 'completed', 'failed'].includes(jobAfterClaim.status) || (jobAfterClaim.worker_id && jobAfterClaim.worker_id !== config.workerId) ) { jobLog.warn('Job ownership lost right after claiming account; releasing account and stopping', { jobId: job.id, status: jobAfterClaim?.status, ownedBy: jobAfterClaim?.worker_id, }); // Release the just-claimed account back to the pool await releaseAccount(account.id, 'available').catch(() => {}); return null; } return account; } const poolState = await getAccountPoolState(accountPoolKey); // ── Wait only if there are running accounts AND we haven't timed out ── if (poolState.running > 0 && Date.now() - startedAt < ACCOUNT_WAIT_MAX_MS) { const message = `No free account right now. Waiting for ${poolState.running} running account(s) to finish.`; // Keep status as waiting_account — do NOT reset to claiming_account here. // Resetting to claiming_account would allow another worker's claim_turnitin_job // RPC to pick up this job, causing double processing. await updateJobStatus(job.id, 'waiting_account', { error_message: message, }); if (Date.now() - lastWaitingEventAt > 60000) { lastWaitingEventAt = Date.now(); await emitEvent(job.id, identityId, 'warning', 'waiting_account', message, { poolState, }); } // BACKPRESSURE: Instead of fixed-interval polling (sleep 15s), wait // in the FIFO queue for a notification from a finishing job. Fall back // to the old sleep interval if no notification arrives. await waitForAccountRelease(ACCOUNT_WAIT_POLL_MS); // Do NOT reset to claiming_account — keep as waiting_account continue; } // ── No accounts available and nothing running (or timed out) ── // Cancel the job and refund the ticket once. The database RPC is // idempotent, while the local marker only prevents duplicate attempts // after a successful cancellation/refund. if (wasRefunded(job.id)) { jobLog.warn('Refund already issued for this job; skipping duplicate refund', { jobId: job.id, }); // Set job to failed terminal state so neither worker picks it up again. await updateJobStatus(job.id, 'failed', { error_message: 'All Turnitin accounts are out of quota or cooling down. Ticket already refunded.', finished_at: new Date().toISOString(), }); return null; } const cancelMessage = poolState.total === 0 ? 'No Turnitin accounts are configured. Ticket refunded.' : 'All Turnitin accounts are out of quota or cooling down. Ticket refunded.'; jobLog.warn('No claimable Turnitin accounts; cancelling and refunding ticket', { poolState, }); try { const cancelled = await cancelJob(job.user_id as string, job.id); if (cancelled) { markAsRefunded(job.id); } await emitEvent(job.id, identityId, 'error', 'no_account', cancelMessage, { poolState, refunded: cancelled, }); } catch (error) { const message = error instanceof Error ? compactWorkerMessage(error.message) : String(error); // If the cancel RPC failed because the job is in a wrong state (e.g. // already cancelled by the other worker), just mark it failed. jobLog.error('Failed to cancel job', { userId: job.user_id, jobId: job.id, error: message, }); await updateJobStatus(job.id, 'failed', { error_message: `${cancelMessage} Refund note: ${message}`, finished_at: new Date().toISOString(), }).catch(() => {}); const refunded = await refundFailedTicketOnce( job.id, `${cancelMessage} Fallback after cancel error: ${message}`, jobLog, ); await emitEvent( job.id, identityId, 'error', 'no_account', refunded ? `${cancelMessage} Refund recovered after cancel error.` : `${cancelMessage} Refund note: ${message}`, { poolState, refunded }, ); } return null; } } function compactWorkerMessage(message: string): string { const firstLine = message.split('\n').map((line) => line.trim()).find(Boolean) || message; if (/locator\.waitFor: Timeout/i.test(firstLine)) { const selector = firstLine.match(/locator\('([^']+)'/i)?.[1]; return selector ? `Turnitin page element did not appear in time: ${selector}` : 'Turnitin page element did not appear in time.'; } if (/Upload file was selected, but no Upload\/Confirm\/Submit button was found/i.test(firstLine)) { return 'File was selected, but Turnitin did not show a usable upload confirmation button.'; } return firstLine.length > 260 ? `${firstLine.slice(0, 257)}...` : firstLine; } /** * Helper to emit a job event. */ async function emitEvent( jobId: string, identityId: string | null, level: 'info' | 'warning' | 'error', step: string, message: string, metadata?: Record ): Promise { try { await insertJobEvent({ job_id: jobId, identity_id: identityId, level, step, message, metadata: metadata || {}, }); } catch (err) { logger.error('Failed to insert job event', { jobId, step, error: err instanceof Error ? err.message : String(err), }); } } type EngineEvent = { level: 'info' | 'warning' | 'error'; step: string; message: string; metadata?: Record; }; async function persistJobProgressFromEvent( jobId: string, event: EngineEvent, ): Promise { const fields: Partial = {}; if (event.step === 'submitted' && event.level === 'info') { fields.last_completed_step = 'submitted'; } else if ( event.step === 'submission_details' && event.level === 'info' && event.metadata ) { fields.submission_details = event.metadata; } else if ( event.step === 'similarity' && event.level === 'info' && /^Similarity:/i.test(event.message) ) { fields.last_completed_step = 'similarity'; if (typeof event.metadata?.similarityPercent === 'number') { fields.similarity_percent = event.metadata.similarityPercent; } } else if ( event.step === 'similarity' && event.level === 'info' && typeof event.metadata?.similarityPercent === 'number' ) { fields.similarity_percent = event.metadata.similarityPercent; } else if ( event.step === 'viewer' && event.level === 'info' && typeof event.metadata?.viewerUrl === 'string' ) { fields.last_completed_step = 'viewer'; fields.viewer_url = event.metadata.viewerUrl; } else if ( event.step === 'filters' && event.level === 'info' && /Filter validation passed/i.test(event.message) ) { fields.last_completed_step = 'filters'; } else if ( event.step === 'download' && event.level === 'info' && /PDF downloaded successfully/i.test(event.message) ) { fields.last_completed_step = 'download'; } else if ( event.step === 'receipt' && event.level === 'info' && /Digital Receipt downloaded successfully/i.test(event.message) ) { fields.last_completed_step = 'receipt'; } if (Object.keys(fields).length > 0) { await safeUpdateJobFields(jobId, fields); } } async function safeUpdateJobFields( jobId: string, fields: Partial, ): Promise { try { await updateJobFields(jobId, fields); } catch (err) { logger.warn('safeUpdateJobFields: could not persist job progress', { jobId, fields: Object.keys(fields), error: err instanceof Error ? err.message : String(err), }); } } /** * Safely persist the last completed step to the job row. * Logs a warning on failure (e.g. column not yet migrated or DB unreachable) * but never throws — step tracking must not crash the main job flow. */ async function safeUpdateLastCompletedStep( jobId: string, step: string | null | undefined, ): Promise { if (!step) return; try { const { supabase } = await import('../db/client'); const { error } = await supabase .from('turnitin_jobs') .update({ last_completed_step: step, updated_at: new Date().toISOString() }) .eq('id', jobId); if (error) { logger.warn('safeUpdateLastCompletedStep: could not persist step (column may not exist yet)', { jobId, step, error: error.message, }); } } catch (err) { logger.warn('safeUpdateLastCompletedStep: unexpected error', { jobId, step, error: err instanceof Error ? err.message : String(err), }); } }