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
+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