feat(memory): missions remember their verdicts, per repository

Until now missions wrote no memory. The chat path records every turn into
the claw's .brain, but a mission's crew is minted per mission, so a brain
keyed by agent would be written once and never read. What persists across
missions is the repository: mission_memory keeps one .brain per repo_id,
writes each judge verdict into it (reason when met, sanitized guidance when
not — the operator reason may quote the acceptance text), and recalls
against the next phase's task text into the brief, under a heading all
three executors carry because it rides on the task.

Recall is BM25 over the keyword index, no embedder; the harness asserts the
brief carries the section once the repo has one judged mission behind it,
and says 'first mission' rather than failing before that. OpenClaw's
flush-before-compaction was the other half of this item and is moot here:
the chat loop has no compaction and already remembers both halves of
every turn.

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-20 22:11:55 -05:00
co-authored by Claude Opus 5
parent 76ac3714f1
commit 13f7fb3aff
6 changed files with 311 additions and 4 deletions
+1
View File
@@ -29,6 +29,7 @@ pub mod mission_delivery;
pub mod podcast;
pub mod mission_events;
pub mod mission_fs;
pub mod mission_memory;
pub mod mission_gc;
pub mod mission_orchestrator;
pub mod mission_schedule;
+240
View File
@@ -0,0 +1,240 @@
//! Project memory: what past missions on a repository learned.
//!
//! Until 2026-09-20 missions wrote no memory at all. The chat path records
//! every turn into the claw's `.brain`, but a mission's crew is minted per
//! mission (`per-mission-crews`: reuse is OFF by operator decision), so a
//! brain keyed by agent would be written once and never read. What persists
//! across missions is the repository. So the memory is keyed by `repo_id`:
//! one `.brain` per repo, holding the judge's verdicts, recalled by the next
//! mission's task text and placed in its brief.
//!
//! What is remembered is the verdict, not the work: for a met phase the
//! judge's `reason` (what it found), for an unmet one its `guidance` — the
//! agent-facing half, already stripped of acceptance literals by
//! `evaluator::sanitize_guidance`, because a verdict quoted verbatim into
//! the next brief is how the 2026-08-01 Goodhart incident happened.
//!
//! Recall is BM25 over the keyword index (`cm_brain::ClawBrain::recall`);
//! there is no embedder. Measured before anything richer is built: the test
//! is a second mission on the same repo recalling the first's verdict.
use std::path::{Path, PathBuf};
use cm_brain::ClawBrain;
use uuid::Uuid;
/// How many past verdicts a brief carries. Three is enough to say "this was
/// tried" without becoming the prompt.
pub const RECALL_K: usize = 3;
/// The heading the recalled lines go under. Named here because the scorer and
/// the prompt-order tests read it back.
pub const SECTION_HEADING: &str = "# What past missions on this repository learned";
fn brain_path(dir: &Path, repo_id: Uuid) -> PathBuf {
dir.join(format!("repo_{repo_id}.h5"))
}
/// One line of memory from a verdict. Pure, so the shape is testable without
/// a brain file.
pub fn verdict_line(
mission_id: Uuid,
phase_kind: &str,
condition: &str,
verdict: &crate::evaluator::Verdict,
) -> Option<String> {
// A judge that could not be reached has not judged; there is no lesson.
if verdict.error.is_some() {
return None;
}
let outcome = if verdict.met { "MET" } else { "UNMET" };
let finding = if verdict.met {
verdict.reason.trim()
} else {
verdict.guidance.trim()
};
if finding.is_empty() {
return None;
}
let short = mission_id.simple().to_string();
Some(format!(
"{outcome}{phase_kind} phase of mission {} — condition: {} — judge: {}",
&short[..8],
head(condition, 200),
head(finding, 400),
))
}
/// Record a verdict in the repo's brain. Best-effort and loud on failure:
/// memory must never fail a phase, and a brain that silently stopped
/// recording is the kind of thing that stays broken for a month.
pub fn remember_verdict(
repo_id: Uuid,
mission_id: Uuid,
phase_kind: &str,
condition: &str,
verdict: &crate::evaluator::Verdict,
) {
remember_in(
&cm_runtime::brain::brain_dir(),
repo_id,
mission_id,
phase_kind,
condition,
verdict,
)
}
fn remember_in(
dir: &Path,
repo_id: Uuid,
mission_id: Uuid,
phase_kind: &str,
condition: &str,
verdict: &crate::evaluator::Verdict,
) {
let Some(line) = verdict_line(mission_id, phase_kind, condition, verdict) else {
return;
};
let path = brain_path(dir, repo_id);
if let Some(dir) = path.parent() {
let _ = std::fs::create_dir_all(dir);
}
match ClawBrain::open_or_create(&path, &format!("repo_{repo_id}")) {
Ok(mut brain) => {
if let Err(e) = brain.remember("judge", &line, &mission_id.to_string()) {
eprintln!("mission_memory: could not record verdict for repo {repo_id}: {e}");
}
}
Err(e) => eprintln!("mission_memory: could not open brain for repo {repo_id}: {e}"),
}
}
/// The past verdicts most relevant to `query` (the phase's task text).
/// Empty when the repo has no brain yet, which is every repo's first mission.
pub fn recall(repo_id: Uuid, query: &str) -> Vec<String> {
recall_in(&cm_runtime::brain::brain_dir(), repo_id, query)
}
fn recall_in(dir: &Path, repo_id: Uuid, query: &str) -> Vec<String> {
let path = brain_path(dir, repo_id);
if !path.exists() {
return Vec::new();
}
match ClawBrain::open_or_create(&path, &format!("repo_{repo_id}")) {
Ok(brain) => brain
.recall(query, RECALL_K)
.into_iter()
// `remember` stores "role: text"; the role is ours and not a lesson.
.map(|m| m.strip_prefix("judge: ").map(str::to_string).unwrap_or(m))
.collect(),
Err(e) => {
eprintln!("mission_memory: could not open brain for repo {repo_id}: {e}");
Vec::new()
}
}
}
/// The section a brief carries, or nothing when there is nothing to say —
/// an empty heading tells the agent there is history and then shows none.
pub fn section(recalled: &[String]) -> Option<String> {
if recalled.is_empty() {
return None;
}
let mut out = String::from(SECTION_HEADING);
out.push_str(
"\n\nJudge verdicts from earlier missions here, most relevant first. \
They say what was checked and what was found; they are not the task.\n",
);
for line in recalled {
out.push_str("- ");
out.push_str(line);
out.push('\n');
}
Some(out)
}
fn head(s: &str, n: usize) -> String {
match s.char_indices().nth(n) {
Some((i, _)) => format!("{}", &s[..i]),
None => s.to_string(),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::evaluator::{Usage, Verdict};
fn verdict(met: bool, reason: &str, guidance: &str, error: Option<&str>) -> Verdict {
Verdict {
met,
reason: reason.into(),
guidance: guidance.into(),
model: "m".into(),
error: error.map(str::to_string),
checks: Vec::new(),
independent: true,
usage: Usage::default(),
expectation: None,
}
}
/// Unmet carries the sanitized guidance, never the operator reason —
/// the reason may quote the acceptance text the next mission must earn.
#[test]
fn unmet_remembers_guidance_not_reason() {
let v = verdict(false, "token ZZQX-9 is absent", "the required marker is absent", None);
let line = verdict_line(Uuid::nil(), "coding", "cond", &v).unwrap();
assert!(line.starts_with("UNMET — coding phase"));
assert!(line.contains("the required marker is absent"));
assert!(!line.contains("ZZQX-9"));
}
#[test]
fn met_remembers_what_the_judge_found() {
let v = verdict(true, "MICROVM.md holds both lines", "", None);
let line = verdict_line(Uuid::nil(), "coding", "cond", &v).unwrap();
assert!(line.starts_with("MET — "));
assert!(line.contains("MICROVM.md holds both lines"));
}
/// No judgement, no lesson.
#[test]
fn an_unreachable_judge_leaves_no_memory() {
let v = verdict(false, "could not evaluate", "could not evaluate", Some("429"));
assert!(verdict_line(Uuid::nil(), "coding", "cond", &v).is_none());
}
#[test]
fn section_is_absent_when_nothing_was_recalled() {
assert!(section(&[]).is_none());
let s = section(&["MET — x".into()]).unwrap();
assert!(s.starts_with(SECTION_HEADING));
assert!(s.contains("- MET — x\n"));
}
/// Round trip through a real brain file: what one mission's verdict
/// wrote, a query shaped like the next mission's task recalls.
#[test]
fn a_second_mission_recalls_the_first_verdict() {
let dir = std::env::temp_dir().join(format!("cm-mission-memory-{}", Uuid::now_v7()));
let repo = Uuid::now_v7();
let v = verdict(
true,
"BASELINE.md records 0.689 ns/iter from benches/add_bench.rs",
"",
None,
);
remember_in(&dir, repo, Uuid::now_v7(), "benchmark", "a baseline is recorded", &v);
let got = recall_in(&dir, repo, "record a performance baseline for the hot path");
assert_eq!(got.len(), 1, "{got:?}");
assert!(got[0].starts_with("MET — benchmark phase"), "{}", got[0]);
assert!(!got[0].starts_with("judge: "));
// A repo with no history recalls nothing and creates no file.
let other = Uuid::now_v7();
assert!(recall_in(&dir, other, "anything").is_empty());
assert!(!brain_path(&dir, other).exists());
let _ = std::fs::remove_dir_all(&dir);
}
}
+35 -2
View File
@@ -858,7 +858,9 @@ async fn start_pending_phases(
m.runtime_kind, m.backend, m.target_node_id, m.team_engine,
-- Whether a checkout exists at all. A repo-less mission's
-- /mission/repo is scratch space, and the task text must say so.
(m.repo_id IS NOT NULL) AS has_repo
(m.repo_id IS NOT NULL) AS has_repo,
-- The key of the project memory (`mission_memory`).
m.repo_id
FROM mission_phases mp
JOIN missions m ON m.id = mp.mission_id
WHERE mp.status = 'pending'
@@ -890,6 +892,7 @@ async fn start_pending_phases(
let target_node_id: Option<Uuid> = row.get("target_node_id");
let team_engine: Option<String> = row.get("team_engine");
let has_repo: bool = row.get("has_repo");
let repo_id: Option<Uuid> = row.get("repo_id");
if let Err(e) = launch_phase(
pool,
@@ -909,6 +912,7 @@ async fn start_pending_phases(
target_node_id,
team_engine: team_engine.as_deref(),
has_repo,
repo_id,
},
)
.await
@@ -955,6 +959,8 @@ struct PhaseLaunch<'a> {
/// `/mission/repo` is a git checkout or a scratch workspace whose contents
/// are captured as artifacts.
has_repo: bool,
/// `missions.repo_id`, the key of the project memory a brief recalls from.
repo_id: Option<Uuid>,
}
/// Which team purposes execute a phase of this kind.
@@ -995,6 +1001,7 @@ async fn launch_phase(
backend: _,
target_node_id: _,
team_engine: _,
repo_id: _,
} = p;
// Which team purposes should execute this phase.
let purposes: &[&str] = purposes_for(kind);
@@ -1220,6 +1227,26 @@ async fn launch_phase(
_ => task,
};
// What earlier missions on this repository learned, by the judge's own
// account, recalled against this phase's task. Every mission's crew is
// new, so this is the only memory a mission has of the ones before it.
// Appended to the task rather than the identity so all three executors
// carry it: the task text is the one thing they share.
let task = match p.repo_id {
Some(repo) => match crate::mission_memory::section(&crate::mission_memory::recall(
repo, &task,
)) {
Some(memory) => {
eprintln!(
"phase_runner: phase {phase_id} brief carries project memory for repo {repo}"
);
format!("{task}\n\n{memory}")
}
None => task,
},
None => task,
};
// The container tier is deliberately NOT given this: it injects per-turn in
// `topology_exec`, with the running node's own role, and appending here too
// would put every crew member's skills in every turn twice.
@@ -2537,7 +2564,7 @@ async fn evaluate_finished_phases(
) -> Result<(), String> {
let rows = sqlx::query(
"SELECT mp.id, mp.mission_id, mp.kind, mp.done_when, mp.max_iterations, mp.iteration,
m.runtime_kind
m.runtime_kind, m.repo_id
FROM mission_phases mp
JOIN missions m ON m.id = mp.mission_id
WHERE mp.status = 'evaluating' AND m.status = 'running'
@@ -2558,6 +2585,7 @@ async fn evaluate_finished_phases(
let max_iterations: i32 = row.get("max_iterations");
let iteration: i32 = row.get("iteration");
let runtime_kind: String = row.get("runtime_kind");
let repo_id: Option<Uuid> = row.get("repo_id");
// Pull the agent's work onto the host BEFORE judging it.
//
@@ -2603,6 +2631,11 @@ async fn evaluate_finished_phases(
{
eprintln!("phase_runner: recording evaluation for {phase_id} failed: {e}");
}
// The project remembers the verdict. Only a repo-backed mission has a
// project to remember into; a repo-less one leaves no trace here.
if let Some(repo) = repo_id {
crate::mission_memory::remember_verdict(repo, mission_id, &kind, &condition, &verdict);
}
// A judge that could not be REACHED has not judged. `Verdict.error` is
// set only when the evaluator itself failed — "could not judge" as