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`].
|
// [`untrusted_empty_reason`].
|
||||||
let mut outcome: Option<TestOutcome> = None;
|
let mut outcome: Option<TestOutcome> = None;
|
||||||
let mut published: Option<Publish> = 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());
|
let mut publish_error: Option<String> = untrusted_empty_reason(empty, diff_error.as_deref());
|
||||||
if let Some(c) = committed.as_ref() {
|
if let Some(c) = committed.as_ref() {
|
||||||
if !empty {
|
if !empty {
|
||||||
@@ -424,7 +427,15 @@ pub async fn capture_phase_diff_at(
|
|||||||
Ok(Some(url)) => {
|
Ok(Some(url)) => {
|
||||||
let verified = outcome.as_ref().and_then(TestOutcome::verified);
|
let verified = outcome.as_ref().and_then(TestOutcome::verified);
|
||||||
match publish_phase_branch(&repo, &url, &c.branch, gate, verified).await {
|
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
|
// `publish_phase_branch` only returns Err for a local
|
||||||
// git failure; a rejected push is Ok with an error
|
// git failure; a rejected push is Ok with an error
|
||||||
// inside. Both must reach the artifact.
|
// 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_status": outcome.as_ref().map(TestOutcome::status),
|
||||||
"tests_detail": outcome.as_ref().and_then(TestOutcome::detail),
|
"tests_detail": outcome.as_ref().and_then(TestOutcome::detail),
|
||||||
"pushed": published.as_ref().map(|p| p.pushed),
|
"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
|
"push_error": published
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.and_then(|p| p.error.clone())
|
.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
|
/// after clone because agents run as root in a container that mounts the
|
||||||
/// checkout. Building it here also means a rotated token takes effect
|
/// checkout. Building it here also means a rotated token takes effect
|
||||||
/// immediately instead of at the next clone.
|
/// 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> {
|
async fn push_url_for(pool: &sqlx::PgPool, mission_id: Uuid) -> Result<Option<String>, String> {
|
||||||
let url: Option<String> = sqlx::query_scalar(
|
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",
|
"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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
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::*;
|
use super::*;
|
||||||
|
|
||||||
/// Git says "your history diverged" several ways, and the one production
|
/// Git says "your history diverged" several ways, and the one production
|
||||||
|
|||||||
+121
-10
@@ -720,33 +720,54 @@ pub async fn render_pending(
|
|||||||
let date = crate::continuous_research::today();
|
let date = crate::continuous_research::today();
|
||||||
let checkout = crate::mission_workspace::checkout_path(mission_id);
|
let checkout = crate::mission_workspace::checkout_path(mission_id);
|
||||||
let mut path = checkout.join(script_path(&date));
|
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() {
|
if !path.is_file() {
|
||||||
// The mission may have run yesterday; take the newest script it has
|
// The mission may have run yesterday; take the newest script it has
|
||||||
// rather than assuming the render happens on the same UTC day.
|
// rather than assuming the render happens on the same UTC day.
|
||||||
match newest_script(&checkout) {
|
match newest_script(&checkout) {
|
||||||
Some(p) => path = p,
|
Some(p) => path = p,
|
||||||
None => {
|
None => {
|
||||||
// NEVER silent. The checkout is deleted 30 minutes after a
|
// The checkout is deleted 30 minutes after a mission
|
||||||
// mission reaches a terminal state (`mission_runtime`'s
|
// reaches a terminal state (`mission_runtime`'s sweeper
|
||||||
// sweeper tears down the container and the tree with it), so
|
// tears down the container and the tree with it), and
|
||||||
// a script that is not here is not late — it is gone, and
|
// this sweep used to lose that race permanently — the
|
||||||
// this mission will never produce an episode. Saying so is
|
// comment here said as much and pointed at the vault as
|
||||||
// the difference between a known gap and a feed that is
|
// the manual recovery. So take the vault instead of
|
||||||
// quietly missing a day.
|
// 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
|
// A script on the vault is also re-renderable next week;
|
||||||
// the phase's own vault branch by `mission_delivery`.
|
// 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;
|
record_unrenderable(pool, mission_id, &checkout).await;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let md = match std::fs::read_to_string(&path) {
|
}
|
||||||
|
}
|
||||||
|
let md = match vault_script {
|
||||||
|
Some(text) => text,
|
||||||
|
None => match std::fs::read_to_string(&path) {
|
||||||
Ok(s) => s,
|
Ok(s) => s,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
eprintln!("podcast: cannot read {}: {e}", path.display());
|
eprintln!("podcast: cannot read {}: {e}", path.display());
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
},
|
||||||
};
|
};
|
||||||
let script = parse_script(&md);
|
let script = parse_script(&md);
|
||||||
if script.turns.is_empty() {
|
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
|
/// 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
|
/// 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.
|
/// 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) {
|
async fn record_unrenderable(pool: &sqlx::PgPool, mission_id: uuid::Uuid, checkout: &std::path::Path) {
|
||||||
eprintln!(
|
eprintln!(
|
||||||
"podcast: mission {mission_id} has no script at {} — the checkout was reaped before the \
|
"podcast: mission {mission_id} has no script at {} — the checkout was reaped before the \
|
||||||
|
|||||||
Reference in New Issue
Block a user