feat(continuous-research): the digest reaches the vault, and the renderer stops racing a reaper
Two halves of one defect found while reviewing template maturity. THE DIGEST NEVER LANDED. Paper notes auto-merge into the vault 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 waits for an operator merge (routes::missions::merge_branch) which, 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. Papers flowed; the thinking about them did not. mission_delivery now accrues such a branch into the repo's default branch after a successful push, behind three independent limits, none of which trusts the mission type alone: AdditiveOnly (try_merge re-reads the diff against the REMOTE base and refuses any modify/delete/rename — a digest is a new dated folder, so all adds); the phase's own judge verdict read from mission_phase_evaluations rather than inferred from its status, because a phase with no condition completes unjudged; and accrues_automatically(), a pure predicate listing exactly one recipe so adding another is a reviewed edit rather than a condition buried in a query. The outcome — including a refusal, which is the interesting half — lands on the artifact as merged/merge_reason. Placement checked rather than assumed: capture selects on phase status 'completed', and a phase reaches that only after the judge rules, so the verdict exists by delivery time. THE RENDERER RACED A REAPER. podcast::render_pending read script.md from the mission checkout, deleted 30 minutes after a terminal state; the 2-minute sweep was a mitigation and record_unrenderable the loss, whose own message pointed at the vault as manual recovery. It now takes the vault instead of mentioning it: default branch first, the delivery branch second, shallow and cleaned up. A script on the vault is re-renderable next week; a script in a reaped checkout is gone. Co-Authored-By: Claude Opus 5 <[email protected]> Claude-Session: https://claude.ai/code/session_01WZb5A2kfVfjpdwSochkuHz
This commit is contained in:
co-authored by
Claude Opus 5
parent
594cd99e54
commit
6a9ec2b74b
@@ -380,6 +380,9 @@ pub async fn capture_phase_diff_at(
|
||||
// [`untrusted_empty_reason`].
|
||||
let mut outcome: Option<TestOutcome> = None;
|
||||
let mut published: Option<Publish> = 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<crate::auto_merge::MergeOutcome> = None;
|
||||
let mut publish_error: Option<String> = 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<Vec<String>> {
|
||||
/// 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/<date>/` 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<crate::auto_merge::MergeOutcome> {
|
||||
let row: (String, Option<String>) = sqlx::query_as::<_, (String, Option<String>)>(
|
||||
"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<bool> = 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<Option<String>, String> {
|
||||
let url: Option<String> = 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
|
||||
|
||||
+128
-17
@@ -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<String> = 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<String> = row.try_get("default_branch").ok();
|
||||
let delivered: Option<String> = 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 \
|
||||
|
||||
Reference in New Issue
Block a user