diff --git a/crates/cm-api/src/mission_delivery.rs b/crates/cm-api/src/mission_delivery.rs index 22251fa..435a813 100644 --- a/crates/cm-api/src/mission_delivery.rs +++ b/crates/cm-api/src/mission_delivery.rs @@ -380,6 +380,9 @@ pub async fn capture_phase_diff_at( // [`untrusted_empty_reason`]. let mut outcome: Option = None; let mut published: Option = None; + // Set only for a recipe whose output accrues into its repository; `None` + // means "not that kind of mission", which is different from "refused". + let mut merged: Option = None; let mut publish_error: Option = untrusted_empty_reason(empty, diff_error.as_deref()); if let Some(c) = committed.as_ref() { if !empty { @@ -424,7 +427,15 @@ pub async fn capture_phase_diff_at( Ok(Some(url)) => { let verified = outcome.as_ref().and_then(TestOutcome::verified); match publish_phase_branch(&repo, &url, &c.branch, gate, verified).await { - Ok(p) => published = Some(p), + Ok(p) => { + if p.pushed { + merged = try_accrue_to_default_branch( + pool, mission_id, phase_id, &repo, &url, &p.branch, + ) + .await; + } + published = Some(p); + } // `publish_phase_branch` only returns Err for a local // git failure; a rejected push is Ok with an error // inside. Both must reach the artifact. @@ -484,6 +495,11 @@ pub async fn capture_phase_diff_at( "tests_status": outcome.as_ref().map(TestOutcome::status), "tests_detail": outcome.as_ref().and_then(TestOutcome::detail), "pushed": published.as_ref().map(|p| p.pushed), + // Whether the branch was accrued into the repo's default branch, and + // why not when it was not. Null for every recipe that is reviewed by + // a human, which is all of them but continuous_research. + "merged": merged.as_ref().map(|m| m.merged), + "merge_reason": merged.as_ref().map(|m| m.reason.clone()), "push_error": published .as_ref() .and_then(|p| p.error.clone()) @@ -867,6 +883,113 @@ pub fn discover_test_command(repo: &Path) -> Option> { /// after clone because agents run as root in a container that mounts the /// checkout. Building it here also means a rotated token takes effect /// immediately instead of at the next clone. +/// Merge a delivered branch into the repository's default branch, when the +/// mission is one whose output is meant to accrue rather than be reviewed. +/// +/// **Why this exists.** Paper notes reached the vault automatically from the +/// day the library shipped (`library.rs` calls `auto_merge::try_merge`); the +/// DIGEST that analyses them did not, because nothing on the mission path +/// ever called it. A mission branch waited for an operator merge +/// (`routes::missions::merge_branch`) that, measured on 2026-09-22, had not +/// happened since 2026-08-18: every `continuous_research` run in that month +/// produced `analysis.md`, `script.md` and `episode.json` onto a branch +/// nobody merged. The pipeline was half-continuous — papers flowed, the +/// thinking about them did not. +/// +/// **Why it is safe to do automatically here.** Three independent limits, +/// none of which trusts the mission type on its own: +/// * `MergePolicy::AdditiveOnly` — `try_merge` re-reads the diff against +/// the REMOTE base and refuses on any modify, delete or rename. A digest +/// writes a new `ContinuousResearch//` folder, so it is all adds; +/// the day that stops being true the merge stops, loudly. +/// * `verified` — the phase's own judge said `met`. Read from +/// `mission_phase_evaluations` rather than inferred from the phase's +/// status, the same way `library.rs` measures `healthy() && shelved` +/// instead of assuming a clean run. +/// * the recipe — only `continuous_research`, whose whole point is an +/// unattended loop into the operator's own vault. Every other recipe's +/// branch is left exactly as it was, for a human. +/// +/// Returns `None` when this mission is not one of those; `Some` otherwise, +/// including when the merge was refused, because a refusal is the interesting +/// half and belongs in the artifact beside the branch. +/// Which recipes deliver into a repository that ACCRUES rather than one a +/// human reviews. Pure, so the list is readable and testable without a +/// database — and so adding one is a deliberate edit here rather than a +/// condition buried in a query. +/// +/// Only `continuous_research`: its output is a dated folder in the +/// operator's own vault, produced on a schedule, and a human merge gate in +/// front of it means the digest is never read (measured: a month of them). +/// Every other recipe writes into a code repository where a review gate is +/// the point. +fn accrues_automatically(template_kind: &str) -> bool { + template_kind == crate::continuous_research::TEMPLATE_KIND +} + +async fn try_accrue_to_default_branch( + pool: &sqlx::PgPool, + mission_id: Uuid, + phase_id: Uuid, + repo: &std::path::Path, + push_url: &str, + branch: &str, +) -> Option { + let row: (String, Option) = sqlx::query_as::<_, (String, Option)>( + "SELECT m.template_kind, r.default_branch + FROM missions m LEFT JOIN repos r ON r.id = m.repo_id + WHERE m.id = $1", + ) + .bind(mission_id) + .fetch_optional(pool) + .await + .map_err(|e| eprintln!("mission_delivery: accrue lookup for {mission_id} failed: {e}")) + .ok() + .flatten()?; + let (template_kind, default_branch) = row; + if !accrues_automatically(&template_kind) { + return None; + } + + // The judge's own verdict for THIS phase, not the phase status. A phase + // with no completion condition completes without ever being judged, and + // merging that into a knowledge base on the strength of "it finished" + // is the kind of inference this codebase keeps paying for. + let met: Option = sqlx::query_scalar( + "SELECT met FROM mission_phase_evaluations + WHERE phase_id = $1 ORDER BY iteration DESC LIMIT 1", + ) + .bind(phase_id) + .fetch_optional(pool) + .await + .unwrap_or(None); + let verified = met == Some(true); + + let base = default_branch.unwrap_or_else(|| "main".to_string()); + let outcome = crate::auto_merge::try_merge( + repo, + push_url, + branch, + &base, + crate::auto_merge::MergePolicy::AdditiveOnly, + verified, + ) + .await + .unwrap_or_else(|e| crate::auto_merge::MergeOutcome { + merged: false, + reason: format!("merge attempt failed: {e}"), + }); + eprintln!( + "mission_delivery: {branch} -> {base} — {} ({})", + outcome.reason, + match verified { + true => "judge met", + false => "not judged met", + } + ); + Some(outcome) +} + async fn push_url_for(pool: &sqlx::PgPool, mission_id: Uuid) -> Result, String> { let url: Option = sqlx::query_scalar( "SELECT r.clone_url FROM missions m JOIN repos r ON r.id = m.repo_id WHERE m.id = $1", @@ -1314,6 +1437,24 @@ mod changed_path_capture_tests { #[cfg(test)] mod tests { + + /// Exactly one recipe accrues without a human. The others deliver into + /// code repositories where the review gate is the point, and a recipe + /// added to that list should be an edit somebody reviewed. + #[test] + fn only_continuous_research_accrues_automatically() { + assert!(accrues_automatically(crate::continuous_research::TEMPLATE_KIND)); + for kind in [ + "research_and_code", + "research_only", + "security_hardening", + "benchmark", + "refactor", + "", + ] { + assert!(!accrues_automatically(kind), "{kind} must not auto-merge"); + } + } use super::*; /// Git says "your history diverged" several ways, and the one production diff --git a/crates/cm-api/src/podcast.rs b/crates/cm-api/src/podcast.rs index 2c53c14..29b475a 100644 --- a/crates/cm-api/src/podcast.rs +++ b/crates/cm-api/src/podcast.rs @@ -720,33 +720,54 @@ pub async fn render_pending( let date = crate::continuous_research::today(); let checkout = crate::mission_workspace::checkout_path(mission_id); let mut path = checkout.join(script_path(&date)); + // Set when the checkout is gone and the vault supplied the script. + let mut vault_script: Option = None; if !path.is_file() { // The mission may have run yesterday; take the newest script it has // rather than assuming the render happens on the same UTC day. match newest_script(&checkout) { Some(p) => path = p, None => { - // NEVER silent. The checkout is deleted 30 minutes after a - // mission reaches a terminal state (`mission_runtime`'s - // sweeper tears down the container and the tree with it), so - // a script that is not here is not late — it is gone, and - // this mission will never produce an episode. Saying so is - // the difference between a known gap and a feed that is - // quietly missing a day. + // The checkout is deleted 30 minutes after a mission + // reaches a terminal state (`mission_runtime`'s sweeper + // tears down the container and the tree with it), and + // this sweep used to lose that race permanently — the + // comment here said as much and pointed at the vault as + // the manual recovery. So take the vault instead of + // saying it: `mission_delivery` pushed the script and, + // for this recipe, merged it into the default branch. // - // The audio is recoverable by hand: the script was pushed to - // the phase's own vault branch by `mission_delivery`. - record_unrenderable(pool, mission_id, &checkout).await; - continue; + // A script on the vault is also re-renderable next week; + // a script in a reaped checkout is gone. The 2-minute + // sweep was a mitigation for a source that should never + // have been temporary. + match script_from_vault(pool, mission_id, &date).await { + Some((p, text)) => { + eprintln!( + "podcast: mission {mission_id} — checkout is gone; \ + rendering from the vault ({p})" + ); + vault_script = Some(text); + } + None => { + // NEVER silent: not here and not in the vault + // means this day has no episode. + record_unrenderable(pool, mission_id, &checkout).await; + continue; + } + } } } } - let md = match std::fs::read_to_string(&path) { - Ok(s) => s, - Err(e) => { - eprintln!("podcast: cannot read {}: {e}", path.display()); - continue; - } + let md = match vault_script { + Some(text) => text, + None => match std::fs::read_to_string(&path) { + Ok(s) => s, + Err(e) => { + eprintln!("podcast: cannot read {}: {e}", path.display()); + continue; + } + }, }; let script = parse_script(&md); if script.turns.is_empty() { @@ -817,6 +838,96 @@ pub async fn render_pending( /// Once, not every tick: the sweep revisits the same missions forever, and a /// line per mission per five minutes would bury everything else in the log. The /// episode row is the marker, with a zero-length blob key that the feed skips. +/// Read a mission's script out of its repository when the checkout is gone. +/// +/// Tries the repository's default branch first — where `mission_delivery` +/// accrues a continuous-research digest — then the phase's own delivery +/// branch, which exists whether or not the merge was taken. A shallow clone +/// into a scratch directory, removed afterwards; the vault is markdown, so +/// this is cheap. +/// +/// Returns the path it found and the contents, or `None` when neither ref +/// has a script — which is the genuinely unrenderable case. +async fn script_from_vault( + pool: &sqlx::PgPool, + mission_id: uuid::Uuid, + date: &str, +) -> Option<(String, String)> { + use sqlx::Row; + let row = sqlx::query( + "SELECT r.clone_url, r.default_branch, + (SELECT a.metadata->>'branch' FROM mission_artifacts a + WHERE a.mission_id = m.id AND a.kind = 'code_diff' + AND a.metadata->>'branch' IS NOT NULL + ORDER BY a.created_at DESC LIMIT 1) AS delivered + FROM missions m JOIN repos r ON r.id = m.repo_id + WHERE m.id = $1", + ) + .bind(mission_id) + .fetch_optional(pool) + .await + .ok() + .flatten()?; + let clone_url: String = row.try_get("clone_url").ok()?; + let default_branch: Option = row.try_get("default_branch").ok(); + let delivered: Option = row.try_get("delivered").ok(); + + let auth = crate::mission_workspace::with_ambient_auth(&clone_url); + let workdir = crate::mission_workspace::missions_root() + .join("_episode") + .join(mission_id.to_string()); + let _ = tokio::fs::remove_dir_all(&workdir).await; + if let Some(parent) = workdir.parent() { + let _ = tokio::fs::create_dir_all(parent).await; + } + let base = default_branch.unwrap_or_else(|| "main".to_string()); + let clone = tokio::process::Command::new("git") + .args(["clone", "--quiet", "--no-single-branch", "--depth", "1", &auth.url]) + .arg(&workdir) + .env("GIT_TERMINAL_PROMPT", "0") + .output() + .await + .ok()?; + if !clone.status.success() { + eprintln!( + "podcast: could not clone the vault for {mission_id}: {}", + String::from_utf8_lossy(&clone.stderr).trim() + ); + let _ = tokio::fs::remove_dir_all(&workdir).await; + return None; + } + + let mut found = None; + for reference in [Some(base), delivered].into_iter().flatten() { + let co = tokio::process::Command::new("git") + .args(["-C"]) + .arg(&workdir) + .args(["fetch", "--quiet", "--depth", "1", "origin", &reference]) + .env("GIT_TERMINAL_PROMPT", "0") + .output() + .await; + if co.map(|o| o.status.success()).unwrap_or(false) { + let show = tokio::process::Command::new("git") + .args(["-C"]) + .arg(&workdir) + .args(["show", &format!("FETCH_HEAD:{}", script_path(date))]) + .output() + .await; + if let Ok(out) = show { + if out.status.success() { + let text = String::from_utf8_lossy(&out.stdout).to_string(); + if !text.trim().is_empty() { + found = Some((format!("{reference}:{}", script_path(date)), text)); + break; + } + } + } + } + } + let _ = tokio::fs::remove_dir_all(&workdir).await; + found +} + async fn record_unrenderable(pool: &sqlx::PgPool, mission_id: uuid::Uuid, checkout: &std::path::Path) { eprintln!( "podcast: mission {mission_id} has no script at {} — the checkout was reaped before the \