fix(podcast): the first real script parsed to zero turns, and retried forever
deploy / test (push) Successful in 5m28s
deploy / build (push) Successful in 5m53s

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 <[email protected]>
Claude-Session: https://claude.ai/code/session_01WZb5A2kfVfjpdwSochkuHz
This commit is contained in:
Omar Sobh
2026-09-22 11:01:21 -05:00
co-authored by Claude Opus 5
parent c1df958a13
commit ad6ce261d5
2 changed files with 92 additions and 10 deletions
+91 -10
View File
@@ -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<Turn> = 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;
}
+1
View File
@@ -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" ;;