/** * provider-human-check.ts — turn a provider's human check into a lane-wide pause. * * A Cloudflare Turnstile in front of a submit is anti-bot RATE LIMITING keyed on * the account's cadence (three back-to-back submits tripped it on 2026-08-07), * not a verdict on the prompt. Every other driver on the same `route:account` * lane — on every computer — is about to hit the same wall and burn a draw on * it. So the driver that sees it reports a cooldown on its still-held ticket, * and the coordinator promotes nobody until it elapses. * * What it does NOT do: clear the challenged driver's own fingerprint (a cooldown * cannot; a challenged headless fingerprint stays challenged), and it does not * fire on Google Flow's reCAPTCHA 403 — `flow-captcha.ts` has the API solve that * one with `captchaRetry`, and `flow-r2v` cools down and retries on its own. A * bare "captcha" match here would pause the whole veo-useapi lane for fifteen * minutes on a condition the auto-solver handles, so the trigger is Turnstile * and the explicit "human verification" wording only. * * The report is best-effort: the wall is the event, the pause is a courtesy to * the others. A coordinator without the endpoint (deployed before this existed) * or a local queue that refuses the ticket yields a warning, never a throw. */ import type { LaneCoordinatorPort } from './lane-coordinator.js'; import { MAX_COOLDOWN_SECONDS, MIN_COOLDOWN_SECONDS, type LaneCooldown } from './lane-queue.js'; export const DEFAULT_PROVIDER_HUMAN_CHECK_COOLDOWN_SECONDS = 900; const HUMAN_CHECK_PATTERN = /turnstile_required|cloudflare turnstile|human verification/i; /** True when a provider error is a human check (see the module docblock for what is deliberately excluded). */ export function isProviderHumanCheck(message: string): boolean { return HUMAN_CHECK_PATTERN.test(message); } /** The pause length: `VCLAW_PROVIDER_HUMAN_CHECK_COOLDOWN_SECONDS`, clamped to the coordinator's window; default 900. */ export function providerHumanCheckCooldownSeconds(env: NodeJS.ProcessEnv): number { const raw = env.VCLAW_PROVIDER_HUMAN_CHECK_COOLDOWN_SECONDS?.trim(); const configured = raw ? Number(raw) : DEFAULT_PROVIDER_HUMAN_CHECK_COOLDOWN_SECONDS; if (!Number.isInteger(configured)) return DEFAULT_PROVIDER_HUMAN_CHECK_COOLDOWN_SECONDS; return Math.min(MAX_COOLDOWN_SECONDS, Math.max(MIN_COOLDOWN_SECONDS, configured)); } export type ProviderHumanCheckReport = | { status: 'not-a-human-check' } | { status: 'unsupported'; warning: string } | { status: 'recorded'; cooldown: LaneCooldown } | { status: 'failed'; warning: string }; /** * Report a provider human check on the ticket the driver STILL HOLDS. Call it * before releasing the ticket: a queued or done ticket is refused by both * coordinators, because a ticket that never spoke to the provider has no wall * to report. Never throws. */ export function recordProviderHumanCheckCooldown(input: { /** Only `cooldown` is used, so a narrower port (the cinema queue's) fits too. */ coordinator: Pick; ticketId: string; lane: string; message: string; env: NodeJS.ProcessEnv; }): ProviderHumanCheckReport { if (!isProviderHumanCheck(input.message)) return { status: 'not-a-human-check' }; if (!input.coordinator.cooldown) { return { status: 'unsupported', warning: `lane ${input.lane}: the provider asked for a human check but this coordinator cannot record a shared cooldown` }; } try { const cooldown = input.coordinator.cooldown(input.ticketId, input.lane, 'provider-human-check', providerHumanCheckCooldownSeconds(input.env)); return { status: 'recorded', cooldown }; } catch (error) { return { status: 'failed', warning: `lane ${input.lane}: the provider asked for a human check but the shared cooldown could not be recorded: ${error instanceof Error ? error.message : String(error)}`, }; } } /** * The same report, from a POLL's issue list. This is the shape the free Seedance * engine uses: its submit always mints a job id and the Turnstile arrives later * in `issues[]`, so nothing throws at submit and the wall is first visible to * `execute-status` — which still holds the ticket at the moment it goes * terminal. Call it there BEFORE the release. Also says what happened, once, on * stderr, so the machine that paused the queue knows it did. Never throws. */ export function recordProviderHumanCheckFromIssues(input: { coordinator: Pick; ticketId: string; lane: string; issues: readonly string[]; env: NodeJS.ProcessEnv; log?: (line: string) => void; }): ProviderHumanCheckReport { const message = input.issues.find((issue) => isProviderHumanCheck(issue)); if (!message) return { status: 'not-a-human-check' }; const report = recordProviderHumanCheckCooldown({ ...input, message }); announceProviderHumanCheck(input.lane, report, input.log); return report; } /** One stderr line for a report that did something (or failed to). */ export function announceProviderHumanCheck( lane: string, report: ProviderHumanCheckReport, log: (line: string) => void = (line) => { process.stderr.write(`${line}\n`); }, ): void { if (report.status === 'recorded') { log(`render queue ${lane}: the provider asked for a human check — submissions are paused on every computer for ~${Math.ceil(report.cooldown.remainingSeconds / 60)} min`); } else if (report.status === 'unsupported' || report.status === 'failed') { log(report.warning); } }