From ad6ce261d5a149fc5d054ca8d3b4c72d35d87071 Mon Sep 17 00:00:00 2001 From: Omar Sobh Date: Tue, 22 Sep 2026 11:01:21 -0500 Subject: [PATCH] fix(podcast): the first real script parsed to zero turns, and retried forever MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The baseline run the plan called for found a different defect than the one it was written to find, which is the point of running it. The worker was healthy: it found mission 01a0c9c4, found script.md in the checkout — no reaper race — and parse_script returned ZERO turns, so it skipped. Every two minutes. Forever. No episode, no tombstone, and the same log line repeating, so nothing downstream could tell "not rendered yet" from "never will be". Cause: the skill asks the writer for `HOST:` and the writer, producing a markdown file, wrote `**HOST:**`. split_once(':') then yields `**HOST`, the `*` fails the all-uppercase test, every line falls to the continuation branch with no turn to attach to, and the entire episode parses to nothing. The skill is a prompt and models vary; the parser is deterministic, so the parser is the half that gives. strip_emphasis accepts `**HOST:**` and `_HOST_:` while leaving emphasis INSIDE a sentence alone — that belongs to what is said — and a bolded non-speaker line like `**Note:**` is still prose, not a turn. Second defect, same symptom: "no spoken turns" now records a tombstone (`unrenderable:no-turns`) instead of retrying a script that cannot change. Every tombstone value keeps the `unrenderable` prefix so existing readers still see one, and the suffix names which dead end it was — a missing script and an unparseable one are different bugs and were previously indistinguishable. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01WZb5A2kfVfjpdwSochkuHz --- crates/cm-api/src/podcast.rs | 101 ++++++++++++++++++++++++++--- scripts/verify-mission-delivery.sh | 1 + 2 files changed, 92 insertions(+), 10 deletions(-) diff --git a/crates/cm-api/src/podcast.rs b/crates/cm-api/src/podcast.rs index 29b475a..0822dcd 100644 --- a/crates/cm-api/src/podcast.rs +++ b/crates/cm-api/src/podcast.rs @@ -62,6 +62,15 @@ impl Script { /// /// A continuation line (no speaker prefix) belongs to the turn above it, so a /// wrapped paragraph stays one turn rather than becoming a new one. +/// +/// **Markdown emphasis around the label is accepted**, because a model asked +/// for `HOST:` in a markdown file writes `**HOST:**` — measured, on the first +/// script this pipeline ever rendered (mission `01a0c9c4`). `split_once(':')` +/// then yields `**HOST`, the `*` fails the uppercase test, every line falls +/// through to the continuation branch with no turn to attach to, and the +/// whole episode parses to nothing. The skill asks for the bare form; the +/// parser accepts the form a writer actually produces, because the parser is +/// the deterministic half of that pair. pub fn parse_script(md: &str) -> Script { let mut title = String::new(); let mut turns: Vec = Vec::new(); @@ -82,17 +91,22 @@ pub fn parse_script(md: &str) -> Script { continue; } match line.split_once(':') { - Some((who, said)) - if !who.is_empty() - && who.len() <= 12 - && who - .chars() - .all(|c| c.is_ascii_uppercase() || c.is_ascii_digit() || c == ' ') => + Some((who_raw, said_raw)) + if { + let who = strip_emphasis(who_raw); + !who.is_empty() + && who.len() <= 12 + && who + .chars() + .all(|c| c.is_ascii_uppercase() || c.is_ascii_digit() || c == ' ') + } => { - let text = said.trim(); + let who = strip_emphasis(who_raw); + let text = strip_emphasis(said_raw); + let text = text.as_str(); if !text.is_empty() { turns.push(Turn { - speaker: who.trim().to_string(), + speaker: who, text: text.to_string(), }); } @@ -110,6 +124,15 @@ pub fn parse_script(md: &str) -> Script { } +/// Trim markdown emphasis and surrounding whitespace from a fragment. +/// +/// `**HOST` -> `HOST`, `** Welcome back.` -> `Welcome back.`, `_GUEST_` -> +/// `GUEST`. Only the wrapper is removed; emphasis INSIDE a sentence is left +/// alone, because it is the writer's and belongs in what is said. +fn strip_emphasis(s: &str) -> String { + s.trim().trim_matches(|c| c == '*' || c == '_').trim().to_string() +} + /// MPEG1 Layer III bitrates (kbps) and sample rates, indexed as the frame /// header encodes them. const MP3_BITRATES: [u32; 16] = [ @@ -430,6 +453,43 @@ impl AudioBackend for ElevenLabs { #[cfg(test)] mod tests { + + /// The format the first real script was written in. Bold speaker labels + /// parsed to ZERO turns before `strip_emphasis`, so the episode was + /// silently never rendered — and retried every two minutes. + #[test] + fn markdown_bold_speakers_are_still_speakers() { + let md = "# Podcast Script — 2026-09-22\n\n---\n\n **HOST:** Welcome back. Today's batch is about agents.\n\n **GUEST:** I want to start with the one that surprised me.\n\n _HOST_: And underscores count too.\n"; + let s = parse_script(md); + assert_eq!(s.title, "Podcast Script — 2026-09-22"); + assert_eq!(s.turns.len(), 3, "{:?}", s.turns); + assert_eq!(s.turns[0].speaker, "HOST"); + assert_eq!(s.turns[0].text, "Welcome back. Today's batch is about agents."); + assert_eq!(s.turns[1].speaker, "GUEST"); + assert_eq!(s.turns[2].speaker, "HOST"); + } + + /// The bare form the skill asks for keeps working, and a bolded line + /// that is NOT a speaker is still prose rather than a new turn. + #[test] + fn plain_speakers_work_and_bold_prose_is_not_a_turn() { + let md = "HOST: One.\n**Note:** this is an aside, not a speaker.\nGUEST: Two.\n"; + let s = parse_script(md); + assert_eq!(s.turns.len(), 2, "{:?}", s.turns); + assert!( + s.turns[0].text.contains("aside"), + "a non-speaker line belongs to the turn above: {:?}", + s.turns[0] + ); + assert_eq!(s.turns[1].speaker, "GUEST"); + } + + /// Emphasis inside a sentence is the writer's and must survive. + #[test] + fn emphasis_inside_speech_is_left_alone() { + let s = parse_script("**HOST:** it is *really* about tool use\n"); + assert_eq!(s.turns[0].text, "it is *really* about tool use"); + } use super::*; #[test] @@ -771,7 +831,18 @@ pub async fn render_pending( }; let script = parse_script(&md); if script.turns.is_empty() { - eprintln!("podcast: {} has no spoken turns — skipping", path.display()); + // A script that is present and yields nothing is NOT a transient + // failure: it will read the same on the next sweep, and the one + // after, forever. Before this, that is exactly what happened — + // mission 01a0c9c4 logged this line every two minutes with no + // episode and no tombstone, so nothing downstream could tell + // "not rendered yet" from "never will be". + eprintln!( + "podcast: {} has no spoken turns — recording it as unrenderable rather \ + than retrying a script that cannot change", + path.display() + ); + record_unrenderable_because(pool, mission_id, "unrenderable:no-turns").await; continue; } @@ -935,16 +1006,26 @@ async fn record_unrenderable(pool: &sqlx::PgPool, mission_id: uuid::Uuid, checko vault branch if it is wanted.", checkout.display() ); + record_unrenderable_because(pool, mission_id, "unrenderable").await; +} + +/// Write the tombstone, naming WHY in `rendered_by`. +/// +/// Every value starts with `unrenderable` so a reader keying on the prefix +/// still sees a tombstone, and the suffix says which dead end it was — a +/// missing script and an unparseable one are different bugs. +async fn record_unrenderable_because(pool: &sqlx::PgPool, mission_id: uuid::Uuid, why: &str) { let _ = sqlx::query( "INSERT INTO podcast_episodes (id, workspace_id, mission_id, episode_date, title, blob_key, bytes, duration_secs, rendered_by, script_sha) - SELECT $1, m.workspace_id, m.id, '', m.title, '', 0, 0, 'unrenderable', '' + SELECT $1, m.workspace_id, m.id, '', m.title, '', 0, 0, $3, '' FROM missions m WHERE m.id = $2 ON CONFLICT (mission_id) DO NOTHING", ) .bind(uuid::Uuid::now_v7()) .bind(mission_id) + .bind(why) .execute(pool) .await; } diff --git a/scripts/verify-mission-delivery.sh b/scripts/verify-mission-delivery.sh index 8a3cc26..544ea76 100755 --- a/scripts/verify-mission-delivery.sh +++ b/scripts/verify-mission-delivery.sh @@ -1594,6 +1594,7 @@ print("ok")' >/dev/null 2>&1 \ from podcast_episodes where mission_id='$mission';\"" | head -1 | tr -d '\r') case "$ep" in '') fail "cr: no podcast_episodes row — the render sweep never reached this mission" ;; + unrenderable:no-turns*) fail "cr: the script parsed to zero spoken turns — the writer's format and parse_script disagree" ;; unrenderable*) fail "cr: the episode is a tombstone (unrenderable) — the script was not found in the checkout OR the vault" ;; *' 0') fail "cr: an episode was recorded with zero duration: $ep" ;; *) pass "cr: audio rendered by $ep" ;;