fix(money-safety): 4 confirmed bugs from adversarial review + Animate guard
Adversarial review (21 agents, verify layer refuted the false positives) confirmed 4: CRITICAL — double-generation on requeue (content.js): submitEditor commits 15 credits, but if the visual stuck-signals lag under load the task requeue+reloads and re-submits (promptEchoed unreliable across reload) → up to 4×15=60 credits for one task. Fix: durable per-task 'submitted_<id>' marker persisted BEFORE the stuck-check, checked on entry, cleared only on terminal status (centralized in reportCompletion). A rare genuine submit-failure now times out and fails (retryable) instead of paying twice. HIGH — transient harvest miss permanently blacklisted the asset: every miss path did seen.add()+persist, so a hover/menu race wrote a never-downloaded tile to storage and the seen-guard skipped it forever. Fix: transient misses go to an in-memory missedThisPass set (retried next run); ONLY confirmed downloads are persisted. MEDIUM — Try-again re-ran succeeded gens: a spurious 'Something went wrong' banner co-occurring with a lagging-but-real asset auto-clicked Try-again (15cr each, ×3). Fix: settle + re-check assets AND new completion-message before clicking; cap 3→1. MEDIUM — seen-list slice(-3000) evicted oldest done URLs on big libraries → re-download. Fix: cap raised to 20000 (and only confirmed downloads accumulate now). + belt-and-suspenders: findTileMenuButton fallback can never land on Animate/Like/♥/redo (a stray Animate click = paid image→video gen). Proven by unit test. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
20941c007c
commit
4aa7e19825
70
content.js
70
content.js
@ -257,7 +257,7 @@ function findTryAgain() {
|
||||
// errors constantly under load, so instead of failing we AUTO-CLICK "Try again"
|
||||
// (what works by hand) up to maxRetries, with a cooldown so a lingering banner
|
||||
// doesn't burn all the retries at once. Returns 'asset' | 'error' | null(timeout).
|
||||
async function waitForResultOrError(baseCount, maxMs, errBase = 0, maxRetries = 3) {
|
||||
async function waitForResultOrError(baseCount, maxMs, errBase = 0, doneBase = 0, maxRetries = 1) {
|
||||
const t0 = Date.now();
|
||||
let retries = 0, cooldownUntil = 0;
|
||||
while (Date.now() - t0 < maxMs) {
|
||||
@ -269,10 +269,17 @@ async function waitForResultOrError(baseCount, maxMs, errBase = 0, maxRetries =
|
||||
const btn = findTryAgain();
|
||||
if (btn) {
|
||||
if (retries >= maxRetries) return 'error'; // gave it maxRetries shots
|
||||
// A "Something went wrong" banner can co-occur with a gen that ACTUALLY
|
||||
// succeeded (Flow errors constantly under load). Clicking Try-again re-runs
|
||||
// it for another 15 credits. So SETTLE and re-check: only retry if there is
|
||||
// still no asset AND no NEW completion message. Each retry is real money —
|
||||
// capped at maxRetries=1.
|
||||
await sleep(2500);
|
||||
if (snapshotAssets().size > baseCount || countDoneMsgs() > doneBase) return 'asset';
|
||||
retries++;
|
||||
log('info', `Flow hiccup — clicking "Try again" (${retries}/${maxRetries}).`);
|
||||
btn.click();
|
||||
cooldownUntil = now + 15000; // let it re-generate before re-checking
|
||||
cooldownUntil = Date.now() + 15000; // let it re-generate before re-checking
|
||||
await sleep(4000);
|
||||
continue;
|
||||
}
|
||||
@ -391,11 +398,16 @@ async function executeTask(task) {
|
||||
const doneBase = countDoneMsgs(); // old "finished" chatter lingers — only a NEW one counts
|
||||
const errBase = countErrMsgs(); // ditto for old error banners
|
||||
|
||||
// Soft-detect first: if the chat already echoes this prompt, the PREVIOUS attempt's
|
||||
// submit registered before its reload — re-typing would double-generate. Skip to waiting.
|
||||
const alreadySubmitted = promptEchoed(task.prompt);
|
||||
// Soft-detect first: a DURABLE marker (survives the requeue hard-reload) or a live
|
||||
// prompt-echo means Google ALREADY started the paid generation for this task —
|
||||
// re-typing would double-bill. Skip straight to waiting for the result.
|
||||
const submittedKey = 'submitted_' + task.id;
|
||||
const priorSubmit = await new Promise(r => chrome.storage.local.get({ [submittedKey]: false }, d => r(d[submittedKey])));
|
||||
const alreadySubmitted = priorSubmit || promptEchoed(task.prompt);
|
||||
if (alreadySubmitted) {
|
||||
log('info', 'Prompt already echoed in the session (earlier submit registered) — not re-typing.');
|
||||
log('info', priorSubmit
|
||||
? 'Task already submitted on a prior attempt (durable marker) — waiting for the result, NOT re-generating.'
|
||||
: 'Prompt already echoed in the session (earlier submit registered) — not re-typing.');
|
||||
} else {
|
||||
const landed = await typeIntoEditor(input, task.prompt);
|
||||
if (!landed) return await requeueWithReload(task, 'prompt did not register in the editor');
|
||||
@ -403,6 +415,11 @@ async function executeTask(task) {
|
||||
await sleep(600); // John's ritual: paste, wait a sec, THEN enter
|
||||
|
||||
await submitEditor(input); // trusted Enter via the DevTools Protocol — this submits
|
||||
// CREDITS COMMITTED. Persist the marker BEFORE the stuck-check: the visual "stuck"
|
||||
// signals lag under load, and a requeue+reload that trusts them to gate re-submit
|
||||
// is exactly what double-billed. Conservative by design — if the submit somehow
|
||||
// didn't take, the task times out and fails (retryable) rather than pays twice.
|
||||
await new Promise(r => chrome.storage.local.set({ [submittedKey]: true }, r));
|
||||
// "It stuck" = stop square appears, OR the box empties, OR the chat echoes the prompt.
|
||||
const stuck = () => findStopSquare() || editorText(input).length === 0 || promptEchoed(task.prompt);
|
||||
let took = await waitFor(stuck, 6000, 300);
|
||||
@ -445,7 +462,7 @@ async function executeTask(task) {
|
||||
}
|
||||
|
||||
// Images: the grid updates live — wait in-page for a new asset, error, or timeout.
|
||||
const outcome = await waitForResultOrError(before.size, timeoutMs, errBase);
|
||||
const outcome = await waitForResultOrError(before.size, timeoutMs, errBase, doneBase);
|
||||
if (outcome === 'ratelimit') { await handleRateLimit(task); return; } // long back-off, don't hammer
|
||||
if (outcome === 'error') return await requeueWithReload(task, '"Something went wrong" persisted through Try-again');
|
||||
if (outcome !== 'asset') {
|
||||
@ -536,8 +553,13 @@ function findTileMenuButton(tile) {
|
||||
let card = tile;
|
||||
for (let i = 0; i < 3 && card.parentElement; i++) card = card.parentElement;
|
||||
const btns = [...card.querySelectorAll('button, [role="button"]')].filter(visible);
|
||||
return btns.find(b => /more|option|menu/i.test((b.getAttribute('aria-label') || '') + (b.getAttribute('title') || '')))
|
||||
|| btns[btns.length - 1] || null;
|
||||
const lbl = b => ((b.getAttribute('aria-label') || '') + (b.getAttribute('title') || '') + (b.textContent || '')).toLowerCase();
|
||||
// SAFETY: never fall back onto ♥/↻/Animate — a stray click on Animate starts a PAID
|
||||
// image→video generation. Prefer the explicit more/options control; else the last
|
||||
// button that is NOT one of those.
|
||||
return btns.find(b => /more|option|menu/.test(lbl(b)))
|
||||
|| [...btns].reverse().find(b => !/animate|like|favou?rite|heart|redo|retry/.test(lbl(b)))
|
||||
|| null;
|
||||
}
|
||||
|
||||
function findMenuItem(re) {
|
||||
@ -592,13 +614,14 @@ async function executeHarvest(task) {
|
||||
try {
|
||||
await waitForAgentReady(30000);
|
||||
const stored = await new Promise(r => chrome.storage.local.get({ harvested_urls_v2: [] }, d => r(d.harvested_urls_v2)));
|
||||
const seen = new Set(stored);
|
||||
const seen = new Set(stored); // CONFIRMED downloads — persisted, never re-fetched
|
||||
const missedThisPass = new Set(); // transient misses — in-memory ONLY, retried next run
|
||||
await bg({ type: 'harvestRouting', on: true });
|
||||
window.scrollTo(0, 0); await sleep(800);
|
||||
let idle = 0;
|
||||
while (idle < 3 && !harvestAbort && got + missed < 500) {
|
||||
const tile = [...document.querySelectorAll('a[href*="/edit/"]')]
|
||||
.find(t => visible(t) && !seen.has(t.href.split('#')[0]));
|
||||
.find(t => { const k = t.href.split('#')[0]; return visible(t) && !seen.has(k) && !missedThisPass.has(k); });
|
||||
if (!tile) { // nothing new in view — page down (grid may virtualize)
|
||||
window.scrollBy(0, innerHeight * 0.8); await sleep(1000); idle++; continue;
|
||||
}
|
||||
@ -609,12 +632,12 @@ async function executeHarvest(task) {
|
||||
// Stage 1/4: hover the tile until the ♡ ↻ ⋮ pill renders
|
||||
await bg({ type: 'hoverAt', ...center(tile) });
|
||||
const dots = await waitFor(() => findTileMenuButton(tile), 3000, 300);
|
||||
if (!dots) { missed++; seen.add(href); log('info', `harvest: control pill never appeared — ${gridCensus()}`); continue; }
|
||||
if (!dots) { missed++; missedThisPass.add(href); log('info', `harvest: control pill never appeared — ${gridCensus()}`); continue; }
|
||||
|
||||
// Stage 2/4: click ⋮ ("More"), wait for the menu with Download in it
|
||||
await bg({ type: 'clickAt', ...center(dots) });
|
||||
const dl = await waitFor(() => findMenuItem(/^\s*download\b/i), 3000, 300);
|
||||
if (!dl) { missed++; seen.add(href); log('info', `harvest: ⋮ clicked but no Download item — ${gridCensus()}`); await bg({ type: 'pressEscape' }); await sleep(400); continue; }
|
||||
if (!dl) { missed++; missedThisPass.add(href); log('info', `harvest: ⋮ clicked but no Download item — ${gridCensus()}`); await bg({ type: 'pressEscape' }); await sleep(400); continue; }
|
||||
|
||||
// Stage 3/4: HOVER Download — the size flyout unfurls beside it (side depends on
|
||||
// where the tile sits; irrelevant, coords are re-queried fresh). Jiggle the
|
||||
@ -631,7 +654,7 @@ async function executeHarvest(task) {
|
||||
await bg({ type: 'clickAt', ...center(dl) });
|
||||
oneK = await waitFor(findSizeOption, 1600, 250);
|
||||
}
|
||||
if (!oneK) { missed++; seen.add(href); log('info', 'harvest: no "original size" option after hover + click-expand.'); await bg({ type: 'pressEscape' }); await sleep(400); continue; }
|
||||
if (!oneK) { missed++; missedThisPass.add(href); log('info', 'harvest: no "original size" option after hover + click-expand.'); await bg({ type: 'pressEscape' }); await sleep(400); continue; }
|
||||
|
||||
// Stage 4/4: walk the pointer INTO the flyout (keeps it open), then click 1K,
|
||||
// then VERIFY a download event actually fired before counting it.
|
||||
@ -643,10 +666,16 @@ async function executeHarvest(task) {
|
||||
await sleep(500);
|
||||
fired = (((await bg({ type: 'harvestStats' })) || {}).count || 0) > dlBefore;
|
||||
}
|
||||
if (fired) { got++; if (got % 10 === 0) log('info', `harvest: ${got} downloaded so far…`); }
|
||||
else { missed++; log('info', 'harvest: clicked 1K but no download event fired.'); }
|
||||
seen.add(href);
|
||||
chrome.storage.local.set({ harvested_urls_v2: [...seen].slice(-3000) });
|
||||
if (fired) {
|
||||
// ONLY a confirmed download is persisted (never re-fetched). A transient miss
|
||||
// stays in missedThisPass — skipped this pass, RETRIED on the next DL run, so a
|
||||
// hover/menu race never permanently blacklists a never-downloaded asset.
|
||||
got++; seen.add(href);
|
||||
chrome.storage.local.set({ harvested_urls_v2: [...seen].slice(-20000) });
|
||||
if (got % 10 === 0) log('info', `harvest: ${got} downloaded so far…`);
|
||||
} else {
|
||||
missed++; missedThisPass.add(href); log('info', 'harvest: clicked 1K but no download event fired.');
|
||||
}
|
||||
await bg({ type: 'pressEscape' }); await sleep(300); // ensure menus are closed before the next tile
|
||||
await sleep(1200 + Math.random() * 1200); // pace — don't machine-gun the account
|
||||
}
|
||||
@ -666,6 +695,11 @@ async function executeHarvest(task) {
|
||||
}
|
||||
|
||||
async function reportCompletion(id, status, error, assets) {
|
||||
// Terminal states clear the per-task markers; 'requeue'/'ratelimited' keep the task
|
||||
// pending AND keep the submitted-marker so the reload never re-generates it.
|
||||
if (status === 'success' || status === 'failed') {
|
||||
chrome.storage.local.remove(['submitted_' + id, 'requeue_' + id]);
|
||||
}
|
||||
try {
|
||||
await fetch(`${settings.serverUrl}/task-completed`, {
|
||||
method: 'POST',
|
||||
|
||||
Loading…
Reference in New Issue
Block a user