← back to Wallco Ai
haiku weekend test: yolo-queue runner (max-plan path) + SQL fixes
875105cabf057fe69bfa6f8ca3ae4587bc6cc893 · 2026-05-29 08:40:22 -0700 · Steve Abrams
new: scripts/queue-haiku-yolo-tasks.js — generates yolo-queue tasks
for the weekend recall test running under Steve's Max plan (Claude
Code via yolo loop, not developer API). Same sampling, same rubric,
JSONL output aligned with haiku-weekend-summary.js. Batches 15 designs
per task to stay under MAX_TURNS=30 + 25min wallclock; defaults to
3-batch smoke before committing to full sweep.
fix: SELECT DISTINCT + ORDER BY random() is illegal in PG ('ORDER BY
expressions must appear in select list'), but psql exits 0 silently
on SQL errors by default — bulk-tag queries were returning empty
strings, fetchBads returned just the 13 gold rows. Two-part fix:
(1) psql helper now passes -v ON_ERROR_STOP=1 so syntax errors fail
loudly, (2) bulk-tag query wraps DISTINCT in an inner subquery + sorts
random in the outer.
fix: live-queue query was hitting needs_fixing_at on spoon_all_designs
view (which doesn't expose that column — view is a strict subset of
all_designs). Switched FROM to all_designs directly + added user_removed
guard for the proper 'undecided' filter.
verified: dry-run now returns 500 bads + 226 goods + 200 live = 838
designs across 56 batches (vs previous broken 13+226+0=239).
Files touched
A scripts/queue-haiku-yolo-tasks.jsM scripts/test-haiku-vision-weekend.js
Diff
commit 875105cabf057fe69bfa6f8ca3ae4587bc6cc893
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Fri May 29 08:40:22 2026 -0700
haiku weekend test: yolo-queue runner (max-plan path) + SQL fixes
new: scripts/queue-haiku-yolo-tasks.js — generates yolo-queue tasks
for the weekend recall test running under Steve's Max plan (Claude
Code via yolo loop, not developer API). Same sampling, same rubric,
JSONL output aligned with haiku-weekend-summary.js. Batches 15 designs
per task to stay under MAX_TURNS=30 + 25min wallclock; defaults to
3-batch smoke before committing to full sweep.
fix: SELECT DISTINCT + ORDER BY random() is illegal in PG ('ORDER BY
expressions must appear in select list'), but psql exits 0 silently
on SQL errors by default — bulk-tag queries were returning empty
strings, fetchBads returned just the 13 gold rows. Two-part fix:
(1) psql helper now passes -v ON_ERROR_STOP=1 so syntax errors fail
loudly, (2) bulk-tag query wraps DISTINCT in an inner subquery + sorts
random in the outer.
fix: live-queue query was hitting needs_fixing_at on spoon_all_designs
view (which doesn't expose that column — view is a strict subset of
all_designs). Switched FROM to all_designs directly + added user_removed
guard for the proper 'undecided' filter.
verified: dry-run now returns 500 bads + 226 goods + 200 live = 838
designs across 56 batches (vs previous broken 13+226+0=239).
---
scripts/queue-haiku-yolo-tasks.js | 287 +++++++++++++++++++++++++++++++++++
scripts/test-haiku-vision-weekend.js | 25 ++-
2 files changed, 304 insertions(+), 8 deletions(-)
diff --git a/scripts/queue-haiku-yolo-tasks.js b/scripts/queue-haiku-yolo-tasks.js
new file mode 100644
index 0000000..85bc372
--- /dev/null
+++ b/scripts/queue-haiku-yolo-tasks.js
@@ -0,0 +1,287 @@
+#!/usr/bin/env node
+/**
+ * queue-haiku-yolo-tasks — fan the weekend defect-scan into ~/.claude/yolo-queue
+ * tasks so the loop processes them under Steve's Max plan (not the developer API).
+ *
+ * Why a separate runner from test-haiku-vision-weekend.js: the API runner uses
+ * `x-api-key` against /v1/messages. The Max plan can only be tapped via Claude
+ * Code (this loop). Same sampling logic, same rubric, same JSONL output — just
+ * a different execution substrate.
+ *
+ * Each generated task:
+ * - Lists 15 designs (id, category, kind, abs path to PNG)
+ * - Tells the spawned Claude Code worker to Read each PNG and classify it
+ * - Writes one JSONL row per design appended to the shared JSONL
+ *
+ * Sample sizing — Max plan is rate-limited per 5-hour window, so each task
+ * burns ~100-200K context tokens (mostly image vision). At 15 designs/task,
+ * 1,200 designs = 80 tasks. The loop processes ~one task every 10-15 minutes,
+ * so the full sweep takes ~12-20 hours of loop time spread across a week.
+ *
+ * --smoke (default ON) — only queues 3 tasks (~45 designs) for the initial
+ * verification pass. After eyeballing those results, pass --full to commit
+ * to the rest.
+ *
+ * Usage:
+ * node scripts/queue-haiku-yolo-tasks.js # dry-run preview, smoke scope
+ * node scripts/queue-haiku-yolo-tasks.js --commit # actually queue 3 smoke tasks
+ * node scripts/queue-haiku-yolo-tasks.js --commit --full # queue all ~80 tasks
+ * node scripts/queue-haiku-yolo-tasks.js --commit --batch-size 10 --bads 200 --goods 200 --live 50
+ */
+const fs = require('fs');
+const path = require('path');
+const { spawnSync } = require('child_process');
+
+// ── args ──────────────────────────────────────────────────────────────
+const argv = process.argv.slice(2);
+const ARG = (k, d) => { const i = argv.indexOf('--' + k); return i >= 0 ? argv[i + 1] : d; };
+const HAS = k => argv.includes('--' + k);
+
+const opt = {
+ commit: HAS('commit'),
+ full: HAS('full'),
+ batchSize: parseInt(ARG('batch-size', '15'), 10),
+ bads: parseInt(ARG('bads', '500'), 10),
+ goods: parseInt(ARG('goods', '500'), 10),
+ live: parseInt(ARG('live', '200'), 10),
+ priority: parseInt(ARG('priority', '60'), 10), // task-filename prefix; lower runs first
+ smokeBatches: parseInt(ARG('smoke-batches', '3'), 10),
+ jsonl: ARG('jsonl', `data/haiku-weekend-${new Date().toISOString().slice(0,10)}.jsonl`),
+ goodsSince: ARG('goods-since', '2026-05-27'),
+ queueDir: ARG('queue-dir', path.join(process.env.HOME, '.claude', 'yolo-queue', 'tasks')),
+};
+
+// ── PG ────────────────────────────────────────────────────────────────
+// ON_ERROR_STOP=1 — psql exits 0 by default even on SQL errors (silent corruption);
+// without this, a syntax error returns 0 rows and the caller sees an "empty" result.
+function psql(sql) {
+ const r = spawnSync('psql', ['dw_unified', '-At', '-q', '-v', 'ON_ERROR_STOP=1'], { input: sql, encoding: 'utf8' });
+ if (r.status !== 0) throw new Error(`psql failed (exit ${r.status}): ${r.stderr}`);
+ return r.stdout.split('\n').filter(Boolean).map(line => line.split('|'));
+}
+
+// Same sampling logic as test-haiku-vision-weekend.js — keep them in sync.
+function fetchBads(target) {
+ const goldTags = ['repeat_break_lr', 'repeat_break_tb', 'auto_seam_break', 'manual_bad_region'];
+ const bulkTags = ['ghost', 'bleed', 'edges_fail'];
+
+ const goldSql = `
+ SELECT DISTINCT b.design_id, b.category, array_to_string(b.defect_tags,','), d.kind, b.local_path
+ FROM bad_examples b
+ JOIN spoon_all_designs d ON d.id = b.design_id
+ WHERE b.local_path IS NOT NULL
+ AND b.defect_tags && ARRAY['${goldTags.join("','")}']::text[]`;
+ const gold = psql(goldSql);
+ const goldIds = new Set(gold.map(r => r[0]));
+ const remaining = Math.max(0, target - gold.length);
+ const perTag = Math.ceil(remaining / bulkTags.length);
+
+ let bulk = [];
+ for (const tag of bulkTags) {
+ // SELECT DISTINCT + ORDER BY random() is illegal in PG ("ORDER BY expressions
+ // must appear in select list"), so DISTINCT in inner subquery, random sort outer.
+ const sql = `
+ SELECT * FROM (
+ SELECT DISTINCT b.design_id, b.category, array_to_string(b.defect_tags,','), d.kind, b.local_path
+ FROM bad_examples b
+ JOIN spoon_all_designs d ON d.id = b.design_id
+ WHERE b.local_path IS NOT NULL
+ AND '${tag}' = ANY(b.defect_tags)
+ ) dedup
+ ORDER BY random()
+ LIMIT ${perTag * 2}`;
+ const rows = psql(sql).filter(r => !goldIds.has(r[0]));
+ bulk = bulk.concat(rows.slice(0, perTag));
+ rows.slice(0, perTag).forEach(r => goldIds.add(r[0]));
+ }
+
+ return [...gold, ...bulk.slice(0, remaining)].map(r => ({
+ id: parseInt(r[0], 10), category: r[1] || null, defect_tags: r[2] ? r[2].split(',') : [],
+ kind: r[3] || null, local_path: r[4], bucket: 'bad',
+ }));
+}
+
+function fetchGoods(target) {
+ const sql = `
+ SELECT id, category, kind, local_path
+ FROM spoon_all_designs
+ WHERE is_published = TRUE AND local_path IS NOT NULL
+ AND created_at >= '${opt.goodsSince}'::date
+ AND id NOT IN (SELECT DISTINCT design_id FROM bad_examples)
+ ORDER BY random() LIMIT ${target}`;
+ return psql(sql).map(r => ({
+ id: parseInt(r[0], 10), category: r[1] || null, kind: r[2] || null,
+ local_path: r[3], defect_tags: [], bucket: 'good',
+ }));
+}
+
+function fetchLive(target) {
+ // spoon_all_designs is a view that doesn't expose needs_fixing_at — query the
+ // underlying table directly to get the "undecided" filter (not approved, not
+ // parked for fix, not user-removed).
+ const sql = `
+ SELECT id, category, kind, local_path
+ FROM all_designs
+ WHERE local_path IS NOT NULL
+ AND (is_published IS NULL OR is_published = FALSE)
+ AND needs_fixing_at IS NULL
+ AND (user_removed IS NULL OR user_removed = FALSE)
+ AND id NOT IN (SELECT DISTINCT design_id FROM bad_examples)
+ ORDER BY created_at DESC LIMIT ${target}`;
+ return psql(sql).map(r => ({
+ id: parseInt(r[0], 10), category: r[1] || null, kind: r[2] || null,
+ local_path: r[3], defect_tags: [], bucket: 'live',
+ }));
+}
+
+// ── existing JSONL (skip already-verdicted ids) ───────────────────────
+function loadDoneIds(jsonlPath) {
+ if (!fs.existsSync(jsonlPath)) return new Set();
+ const done = new Set();
+ for (const line of fs.readFileSync(jsonlPath, 'utf8').split('\n')) {
+ if (!line.trim()) continue;
+ try {
+ const r = JSON.parse(line);
+ if (r.id && r.verdict) done.add(r.id);
+ } catch {}
+ }
+ return done;
+}
+
+// ── task body ─────────────────────────────────────────────────────────
+const RUBRIC = `## Rubric (multi-label, one per design)
+
+- **OK** — clean, no production defects.
+- **GHOST** — translucent/partial-opacity layers, ghosted shapes behind main motif, multi-opacity print artifacts. Solid screen-print is the standard.
+- **FUZZY_SEAM** — a blurred/smeared horizontal/vertical band (~12 px) across the tile midline, or fuzzy heal artifacts at the edges. Distinct from a sharp clean seam.
+- **TONE_BREAK** — pattern broken at L/R or T/B edges; motifs sliced at the wrap and don't match across the seam. Subtle on tone-on-tone designs. (N/A for murals.)
+- **MURAL_MISCLASS** — image is actually a single-subject mural/scenic, NOT a repeating tile, despite being categorized as tile. (N/A if kind is already mural_panel/mural.)
+- **VENDOR_LEAK** — visible text containing a vendor brand name (Schumacher, Brunschwig, Lee Jofa, Thibaut, Kravet, Arte, Cole & Son, Wolf-Gordon, etc.).
+- **BLEED** — color bleeds outside motif boundary or smears at edges.
+- **OTHER** — some other quality issue; specify briefly in the reason.`;
+
+function buildTaskBody(batchIdx, totalBatches, items, jsonlAbsPath) {
+ const designLines = items.map((d, i) => {
+ const isMural = (d.kind === 'mural_panel' || d.kind === 'mural');
+ return `${i+1}. **#${d.id}** — bucket=${d.bucket}, category=\`${d.category || '(none)'}\`, kind=\`${d.kind || '(none)'}\` (${isMural ? 'MURAL — single image, not tileable' : 'TILE — should repeat seamlessly L↔R and T↔B'})${d.defect_tags?.length ? `, defect_tags=[${d.defect_tags.join(',')}]` : ''}\n path=\`${d.local_path}\``;
+ }).join('\n\n');
+
+ return `# Haiku-vision weekend recall test — batch ${batchIdx + 1} of ${totalBatches}
+
+You are a wallpaper-design defect classifier for the wallco-ai weekend recall test (Steve 2026-05-29). This is a classification audit — **DO NOT modify any design file, DO NOT touch \`is_published\`, DO NOT call /api/admin/*, DO NOT regenerate or fix anything**. Just look, classify, append.
+
+## What to do
+
+For EACH of the ${items.length} designs below:
+
+1. Use the **Read** tool on the design's PNG path to view the image.
+2. Apply the rubric to classify it as EXACTLY ONE label.
+3. Compose a single JSONL row (one line, no pretty-printing) capturing the verdict.
+
+After classifying ALL ${items.length} designs, **append all rows** to:
+
+\`${jsonlAbsPath}\`
+
+via a single bash heredoc (\`cat <<'EOF' >> ${jsonlAbsPath} ... EOF\`). Use \`>>\` not \`>\` so you don't clobber prior batches. **Do this in one bash call** to keep the append atomic per task.
+
+## JSONL row schema (one line each)
+
+\`\`\`json
+{"id": <int>, "bucket": "<bad|good|live>", "category": "<str|null>", "kind": "<str|null>", "defect_tags": [<strings>], "verdict": "<LABEL>", "reason": "<one sentence, <=25 words>", "ts": "<ISO8601>", "via": "max-plan-yolo", "batch": ${batchIdx + 1}}
+\`\`\`
+
+${RUBRIC}
+
+## Designs to classify (${items.length})
+
+${designLines}
+
+## When done
+
+Output a single concluding **## What Landed** block:
+- count of verdicts written
+- verdict histogram (e.g. \`OK: 9, FUZZY_SEAM: 3, GHOST: 2, OTHER: 1\`)
+- the JSONL path
+- any designs you skipped and why (e.g. file missing on disk)
+
+That's the whole task. Do not invoke other skills, do not call /dtd, do not branch — just read, classify, append, summarize.
+`;
+}
+
+// ── main ──────────────────────────────────────────────────────────────
+function main() {
+ console.log(`\n══ queue-haiku-yolo-tasks ══`);
+ console.log(` Mode: ${opt.commit ? '🔴 COMMIT' : '🟡 DRY-RUN'} ${opt.full ? '(FULL sweep)' : '(SMOKE only — ' + opt.smokeBatches + ' batches)'}`);
+ console.log(` Batch size: ${opt.batchSize} designs/task`);
+ console.log(` Queue dir: ${opt.queueDir}`);
+ console.log(` JSONL: ${opt.jsonl}\n`);
+
+ if (!fs.existsSync(opt.queueDir)) throw new Error(`queue dir not found: ${opt.queueDir} — is the yolo-loop installed?`);
+ const stopMarker = path.join(opt.queueDir, '..', 'STOP');
+ if (fs.existsSync(stopMarker)) console.log(`⚠ STOP marker exists at ${stopMarker} — loop will exit after current task. Tasks will pile up.`);
+
+ console.log(`Sampling…`);
+ const bads = fetchBads(opt.bads);
+ const goods = fetchGoods(opt.goods);
+ const live = fetchLive(opt.live);
+ console.log(` bad_examples: ${bads.length}`);
+ console.log(` known-goods: ${goods.length} (post-${opt.goodsSince})`);
+ console.log(` live-queue: ${live.length}`);
+
+ // Skip already-verdicted ids
+ const jsonlAbsPath = path.isAbsolute(opt.jsonl) ? opt.jsonl : path.join(__dirname, '..', opt.jsonl);
+ fs.mkdirSync(path.dirname(jsonlAbsPath), { recursive: true });
+ const done = loadDoneIds(jsonlAbsPath);
+ if (done.size) console.log(` resumable: ${done.size} ids already verdicted in ${opt.jsonl}\n`);
+
+ const all = [...bads, ...goods, ...live]
+ .filter(d => !done.has(d.id))
+ .filter(d => fs.existsSync(d.local_path)); // orphan filter at queue time
+
+ console.log(` total to queue (after skip+orphan-filter): ${all.length}`);
+
+ // Batch
+ const batches = [];
+ for (let i = 0; i < all.length; i += opt.batchSize) {
+ batches.push(all.slice(i, i + opt.batchSize));
+ }
+ const queueBatches = opt.full ? batches : batches.slice(0, opt.smokeBatches);
+ console.log(` total batches: ${batches.length} · queueing now: ${queueBatches.length}`);
+ console.log(` designs in queue: ${queueBatches.reduce((s,b) => s + b.length, 0)} (of ${all.length})\n`);
+
+ if (!opt.commit) {
+ console.log(`🟡 DRY-RUN. Sample of first batch:`);
+ queueBatches.slice(0, 1)[0]?.slice(0, 3).forEach((d,i) => console.log(` ${i+1}. #${d.id} ${d.bucket}/${d.kind || '?'}/${d.category || '?'} → ${d.local_path}`));
+ console.log(`\nPass --commit to actually write task files.`);
+ if (!opt.full) console.log(`Pass --commit --full to queue the entire sweep (${batches.length} batches).`);
+ return;
+ }
+
+ // Write tasks
+ const stamp = new Date().toISOString().replace(/[-:.]/g, '').slice(0, 13); // YYYYMMDDTHHMM
+ let written = 0;
+ for (let i = 0; i < queueBatches.length; i++) {
+ const taskNum = String(i + 1).padStart(3, '0');
+ const fname = `${opt.priority}-haiku-vision-${stamp}-${taskNum}.md`;
+ const fpath = path.join(opt.queueDir, fname);
+ const body = buildTaskBody(i, queueBatches.length, queueBatches[i], jsonlAbsPath);
+ fs.writeFileSync(fpath, body);
+ written++;
+ if (i < 3 || i === queueBatches.length - 1) {
+ console.log(` ✓ ${fname} (${queueBatches[i].length} designs)`);
+ } else if (i === 3) {
+ console.log(` … (${queueBatches.length - 4} more)`);
+ }
+ }
+ console.log(`\n══ ${written} task files written to ${opt.queueDir} ══`);
+ console.log(`\nThe yolo-loop will pick them up at ~one task every 10-15 minutes`);
+ console.log(`(currently polling every 60s, queue was empty).\n`);
+ console.log(`Monitor:`);
+ console.log(` tail -f ~/.claude/yolo-queue/loop.log`);
+ console.log(` wc -l ${opt.jsonl} # rows written so far`);
+ console.log(` ls ~/.claude/yolo-queue/done/${opt.priority}-haiku-vision-* 2>/dev/null | wc -l # tasks completed`);
+ console.log(`\nWhen ${opt.full ? 'all batches complete' : 'smoke completes (~30-45 min)'}, run:`);
+ console.log(` node scripts/haiku-weekend-summary.js`);
+}
+
+try { main(); } catch (e) { console.error('fatal:', e.message); process.exit(1); }
diff --git a/scripts/test-haiku-vision-weekend.js b/scripts/test-haiku-vision-weekend.js
index 100dbd5..32e5acd 100644
--- a/scripts/test-haiku-vision-weekend.js
+++ b/scripts/test-haiku-vision-weekend.js
@@ -70,9 +70,11 @@ function loadApiKey() {
}
// ── PG helpers ────────────────────────────────────────────────────────
+// ON_ERROR_STOP=1 — psql exits 0 by default even on SQL errors (silent corruption);
+// without this, a syntax error returns 0 rows and the caller sees an "empty" result.
function psql(sql) {
- const r = spawnSync('psql', ['dw_unified', '-At', '-q'], { input: sql, encoding: 'utf8' });
- if (r.status !== 0) throw new Error(`psql failed: ${r.stderr}`);
+ const r = spawnSync('psql', ['dw_unified', '-At', '-q', '-v', 'ON_ERROR_STOP=1'], { input: sql, encoding: 'utf8' });
+ if (r.status !== 0) throw new Error(`psql failed (exit ${r.status}): ${r.stderr}`);
return r.stdout.split('\n').filter(Boolean).map(line => line.split('|'));
}
@@ -99,12 +101,16 @@ function fetchBads(target) {
const perTag = Math.ceil(remaining / bulkTags.length);
let bulk = [];
for (const tag of bulkTags) {
+ // SELECT DISTINCT + ORDER BY random() is illegal in PG ("ORDER BY expressions
+ // must appear in select list"), so DISTINCT in inner subquery, random sort outer.
const sql = `
- SELECT DISTINCT b.design_id, b.category, array_to_string(b.defect_tags,','), d.kind, b.local_path
- FROM bad_examples b
- JOIN spoon_all_designs d ON d.id = b.design_id
- WHERE b.local_path IS NOT NULL
- AND '${tag}' = ANY(b.defect_tags)
+ SELECT * FROM (
+ SELECT DISTINCT b.design_id, b.category, array_to_string(b.defect_tags,','), d.kind, b.local_path
+ FROM bad_examples b
+ JOIN spoon_all_designs d ON d.id = b.design_id
+ WHERE b.local_path IS NOT NULL
+ AND '${tag}' = ANY(b.defect_tags)
+ ) dedup
ORDER BY random()
LIMIT ${perTag * 2}`;
const rows = psql(sql).filter(r => !goldIds.has(r[0]));
@@ -136,12 +142,15 @@ function fetchGoods(target) {
function fetchLive(target) {
// Latest curator queue — neither flagged nor explicitly approved.
+ // spoon_all_designs is a view that doesn't expose needs_fixing_at — query the
+ // underlying table directly so the "undecided" filter actually works.
const sql = `
SELECT id, category, kind, local_path
- FROM spoon_all_designs
+ FROM all_designs
WHERE local_path IS NOT NULL
AND (is_published IS NULL OR is_published = FALSE)
AND needs_fixing_at IS NULL
+ AND (user_removed IS NULL OR user_removed = FALSE)
AND id NOT IN (SELECT DISTINCT design_id FROM bad_examples)
ORDER BY created_at DESC
LIMIT ${target}`;
← d335cbc Add rollback-photoreal-rooms.py: regenerate living_room mock
·
back to Wallco Ai
·
feat(designs-grid): Contact sheet button on admin bulk-selec 9b7f1eb →