feat(podcast): render finished missions into episodes, and serve them as a feed
deploy / test (push) Successful in 4m29s
deploy / build (push) Successful in 5m22s

The renderer existed but nothing called it. This wires it to the missions and
puts the result somewhere a phone can reach.

**A sweep, not a phase step.** Rendering is not the agents' work and must not be
able to fail a phase that succeeded; a transient API error simply retries next
tick, and a mission already rendered is skipped because its episode row exists.
`podcast_episodes` is that record — without it the sweep would re-render on
every pass and re-bill for it, the same lesson `corpus_items` taught for papers.

**It is racing a reaper.** script.md lives in the mission checkout, and
`mission_runtime`'s sweeper deletes that tree 30 minutes after the mission
reaches a terminal state. So the sweep runs every 2 minutes, leaving ~15
attempts inside the window. When it does lose — as it did for three missions
that had completed hours before this shipped — it now SAYS so and records a
marker rather than skipping in silence, which is how a feed ends up quietly
missing a day. The feed filters those markers out: a zero-byte enclosure shows
a broken episode in a podcast app, where showing nothing is honest.

**Duration is read from the audio, not estimated from the script.** The feed
advertises a length and that length should be the real one — and it is the check
that catches a 6 MB file playing for six seconds.

**The feed authenticates by query-string token**, because no podcast app can set
headers. That is a real trade: the token lands in the app's database and any
proxy log. It reuses `AuthService::authenticate`, so revoking the session
revokes the feed with it rather than creating a second secret to forget to
rotate. Titles are XML-escaped — one raw ampersand makes a client reject the
WHOLE feed, not one episode.

363 tests pass.

Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
Omar Sobh
2026-08-18 10:25:24 -07:00
co-authored by Claude Opus 5
parent 55b16f25c8
commit 1f39f642a3
6 changed files with 496 additions and 0 deletions
+13
View File
@@ -387,6 +387,19 @@ async fn run() -> Result<(), String> {
// 60s matches the finest cron granularity; the sweep claims atomically and
// records each occurrence in `mission_fires`, so replicas and restarts
// cannot double-launch a container.
// Render finished Continuous Research missions into episodes. A sweep, not
// a phase step: rendering is not the agents' work and must not be able to
// fail a phase that succeeded, and a transient API error simply retries on
// the next tick.
// Every 2 minutes, NOT 5. The mission checkout that holds script.md is
// deleted 30 minutes after the mission reaches a terminal state, so this
// sweep is racing a reaper. Two minutes leaves ~15 attempts inside that
// window; a slower sweep loses the episode permanently.
cm_api::podcast::spawn(
pool.clone(),
Some(blob.clone()),
std::time::Duration::from_secs(2 * 60),
);
cm_api::mission_schedule::spawn(
pool.clone(),
Some(node_hub.clone()),