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:
Omar Sobh
2026-09-22 10:43:09 -05:00
co-authored by Claude Opus 5
parent 594cd99e54
commit 6a9ec2b74b
2 changed files with 270 additions and 18 deletions
+142 -1
View File
@@ -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