Parallel Agent Audits and Generation Guards for Async JS

Agent Architecture + Async Safety web

TL;DR: Partition parallel agents by file ownership — each agent owns exactly one file, zero merge conflicts. In async JS, capture a generation counter at call site, check it after every await. This kills stale callbacks from rapid user actions (seeks, tab switches, page navigations).

The Parallel Agent Audit Pattern

We ran a 10-dimension audit across 6 files in the life-dashboard reader pipeline. 10 agents, each examining a different concern: chapter index consistency, multi-format audio, Prologue sync, memory/perf, race conditions, error handling, audiomark pipeline, progress bar accuracy, book resolution cache, and cross-page feature parity.

The output: 43 issues (1 Critical, 14 High, 23 Medium, 12 Low).

The fix phase is where the architecture matters. Five fix agents, one per file. No agent touches another agent’s file. No merge conflicts, no coordination overhead, no rebasing. The 6th agent verifies.

Agent 1: transcribe.js     (shared engine)
Agent 2: book.html         (book page)
Agent 3: reading.html      (reading page)
Agent 4: plex.py + proxy   (backend streaming)
Agent 5: ingest.py         (ingestion pipeline)
Agent 6: verification      (read-only, runs last)

The key constraint: issues that span multiple files get assigned to the agent that owns the primary file. If a bug manifests in book.html but the fix is in plex.py, the plex agent owns it. Partition by where the code changes, not where the symptom appears.

Generation Guards: Killing Stale Async Callbacks

The most impactful pattern from the audit. Every async operation in the reader — fetching stream info, loading metadata, transcribing segments — can be invalidated by user action before it completes. User clicks play, then immediately seeks. User switches chapters. User closes the player.

Without guards, the stale callback writes to state that’s already moved on. Old metadata overwrites new metadata. A transcription result for chapter 3 renders into the chapter 5 waterfall.

The fix is a generation counter:

let _loopGen = 0;

async function seekTo(position) {
  _loopGen++;              // invalidate all in-flight work
  const myGen = _loopGen;  // capture at call site

  const info = await fetch(`/api/stream-info?chapter=${ch}`);
  if (myGen !== _loopGen) return;  // stale -- bail

  audio.src = info.url;
  await new Promise(r => audio.addEventListener('loadedmetadata', r, { once: true }));
  if (myGen !== _loopGen) return;  // stale again -- bail

  audio.currentTime = offset;
  startTranscription();
}

Check after every async boundary. Not just the first one. Each await is a point where the world can change.

loadedmetadata Listener Stacking

{ once: true } on addEventListener does not prevent multiple listeners from stacking. If seekTo is called 5 times rapidly, 5 loadedmetadata listeners queue up, and all 5 fire when the audio finally loads. Each one tries to set currentTime, start transcription, update UI.

The fix: track the current listener and remove it before adding a new one.

let _currentMetaListener = null;

function loadStream(url, onReady) {
  if (_currentMetaListener) {
    audio.removeEventListener('loadedmetadata', _currentMetaListener);
  }
  _currentMetaListener = onReady;
  audio.addEventListener('loadedmetadata', onReady, { once: true });
  audio.src = url;
}

{ once: true } means “remove after firing.” It does not mean “only one listener allowed.” Two different contracts.

Nullish Coalescing vs OR for Index Fields

Plex chapter indices are zero-based. Chapter 0 is valid. This breaks || fallbacks:

// BROKEN: chapter 0 is falsy, falls through to default
const ch = response.chapterIndex || 0;

// FIXED: only falls through on null/undefined
const ch = response.chapterIndex ?? 0;

Found 4 instances of this across book.html and reading.html. All were silent bugs — chapter 0 (typically the Prologue) would get skipped or miscounted.

The Documentation-Reality Gap

The most instructive find: CLAUDE.md documented audiomark rate limiting (15s cooldown) and dedup (same book within 30s). The code had neither. The documentation described intended behavior that was never implemented.

This is the strongest argument for automated audits over documentation reviews. Documentation tells you what someone planned. Code tells you what actually runs. When they disagree, the code wins — and the bug is in production.

The fix: time.monotonic() rate limiting (immune to clock drift, unlike time.time()) with module-level state. Simple, stateless between restarts, exactly sufficient.