[object Object]

← back to Ticket System

dtd-run: concurrency guard (refuse to clobber a live sweep) + --merge (overlay subset verdicts) + --cap/--chunk flags; fixes the board-button-vs-CLI race that corrupted dtd-verdicts.json

1f89978333a9c0b7179fdbc095f3863555cb86a7 · 2026-08-13 10:07:27 -0700 · Steve Abrams

Files touched

Diff

commit 1f89978333a9c0b7179fdbc095f3863555cb86a7
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Thu Aug 13 10:07:27 2026 -0700

    dtd-run: concurrency guard (refuse to clobber a live sweep) + --merge (overlay subset verdicts) + --cap/--chunk flags; fixes the board-button-vs-CLI race that corrupted dtd-verdicts.json
---
 dtd-run.js | 27 +++++++++++++++++++++++++++
 1 file changed, 27 insertions(+)

diff --git a/dtd-run.js b/dtd-run.js
index 739dcc8f..88fc3984 100755
--- a/dtd-run.js
+++ b/dtd-run.js
@@ -33,6 +33,7 @@ const flag = f => { const i = argv.indexOf(f); if (i === -1) return false; argv.
 const opt  = f => { const i = argv.indexOf(f); if (i === -1) return undefined; const v = argv[i + 1]; argv.splice(i, 2); return v; };
 const RECENT = flag('--recent');
 const ANNOTATE = flag('--annotate');
+const MERGE = flag('--merge');       // overlay verdicts onto the existing file (for re-scoring a subset)
 const DAYS = parseInt(opt('--days') || '14', 10);
 const CAP = parseInt(opt('--cap') || '40', 10);      // max tickets scored per sweep (bump to score all)
 const CHUNK = parseInt(opt('--chunk') || '33', 10);  // tickets per panel.sh call (keeps local-model JSON un-truncated)
@@ -132,6 +133,20 @@ ${digest}`;
   return { dir, verdicts };
 }
 
+// ── concurrency guard: never clobber a live run (fixed 2026-08-13 after a board-
+// triggered detached run raced a CLI run on the single unlocked verdicts file and
+// corrupted it to 1 ticket). If a RUNNING marker is FRESH (<8min; panel watchdog
+// is 5.5min so a real run finishes inside that), another dtd-run is active — abort
+// rather than overwrite. A stale marker (>8min = crashed run) is ignored. --force
+// overrides. NEVER `rm -f` the marker by hand; that's what defeated the guard. ──
+if (!flag('--force')) {
+  try {
+    const st = fs.statSync(RUNNING);
+    const ageMin = (Date.now() - st.mtimeMs) / 60000;
+    if (ageMin < 8) { console.error(`dtd-run: another sweep is active (marker ${ageMin.toFixed(1)}m old) — aborting to avoid clobbering. Wait or pass --force.`); process.exit(3); }
+  } catch { /* no marker → clear to run */ }
+}
+
 // ── run in panel-sized chunks so a large set doesn't truncate local-model JSON ──
 const chunks = [];
 for (let i = 0; i < chosen.length; i += CHUNK) chunks.push(chosen.slice(i, i + CHUNK));
@@ -154,6 +169,18 @@ try {
   console.error('dtd-run: panel.sh failed:', e.message); process.exit(1);
 }
 
+// --merge: overlay these (deeper) verdicts onto the existing file instead of
+// replacing it — so re-scoring a SUBSET (e.g. just the YES candidates at smaller
+// chunks) keeps every other ticket's prior verdict on the board.
+if (MERGE) {
+  try {
+    const prior = JSON.parse(fs.readFileSync(OUT, 'utf8'));
+    result.tickets = Object.assign({}, prior.tickets || {}, result.tickets);
+    result.count = Object.keys(result.tickets).length;
+    result.merged = (chosen || []).length;
+    if (Array.isArray(prior.dirs)) result.dirs = [...prior.dirs, ...result.dirs].slice(-12);
+  } catch (e) { /* no prior file — just write fresh */ }
+}
 fs.writeFileSync(OUT, JSON.stringify(result, null, 2));
 try { fs.unlinkSync(RUNNING); } catch {}
 

← a3720755 dtd-run: --cap flag + auto-chunk large sweeps (33/panel call  ·  back to Ticket System  ·  chore: session-close quality gate — refactor (hoist resolveL f8c7ca9f →