fix(missions): the planner was planning blind — show it the repository

The first real plan opened with "Identify the crate's hottest code path and run
its benchmark harness". This crate has no benchmark harness. The phase ran,
found nothing to baseline, delivered zero files, and the plan's second phase was
left with nothing to optimise against.

The planner saw the mission title, the description, and a boolean for whether a
repository was bound. It never saw the repository. A plan about a codebase
written without looking at the codebase is a guess that reads like a plan — and
the failure surfaces two phases and one VM boot later, as an agent reporting that
the thing it was told to run does not exist.

The prompt now carries the repository's root listing, read from the FORGE rather
than a checkout: at proposal time the mission is still a draft and
`ensure_checkout` has not run, so there is nothing on disk to list. It also says
outright that a phase needing something absent must CREATE it and say so in its
task — the failure was not only ignorance of the tree but the assumption that
missing tooling is someone else's problem.

A listing that cannot be fetched degrades to "(the repository listing could not
be read)" in the prompt rather than to an empty string. A model told the listing
is unavailable can hedge; a model told nothing assumes — which is the same
distinction as `Option<u32>` for the subagent probe, in a prompt instead of a
struct.

Found by running the thing end to end rather than by testing it: every unit test
here passes with a planner that has never seen a repository.

543 tests pass, clippy clean.

Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
Omar Sobh
2026-08-06 23:23:34 -07:00
co-authored by Claude Opus 5
parent a33dbdcdc3
commit 0aeae07db2
+68 -3
View File
@@ -21,6 +21,68 @@ use crate::{ApiError, AppState, Authed};
const PLANNER_MODEL: &str = "claude-opus-4-8"; const PLANNER_MODEL: &str = "claude-opus-4-8";
/// What the repository actually contains, for the planner's prompt.
///
/// The planner used to see only the mission's title, description and whether a
/// repo was bound — and it planned accordingly. Its first real plan opened with
/// "run its benchmark harness" against a crate that has none, so the phase found
/// nothing to baseline and delivered zero files. A plan about a repository
/// written without seeing the repository is a guess.
///
/// Read from the FORGE rather than a checkout: at proposal time the mission is
/// still a draft and `ensure_checkout` has not run, so there is nothing on disk
/// to list. One API call, and a failure degrades to a stated absence rather than
/// to silence — a model told "the listing could not be read" can hedge; a model
/// told nothing assumes.
async fn repo_listing(pool: &sqlx::PgPool, mission_id: uuid::Uuid) -> String {
let row: Option<(Option<String>, Option<String>, Option<String>)> = sqlx::query_as(
"SELECT r.owner, r.name, r.default_branch
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 Some((Some(owner), Some(name), branch)) = row else {
return "(this mission has no repository)".to_string();
};
let token = std::env::var("GITEA_TOKEN").unwrap_or_default();
let url = format!(
"https://git.redclaw.dev/api/v1/repos/{owner}/{name}/contents?ref={}",
branch.as_deref().unwrap_or("main")
);
let client = match reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(15))
.build()
{
Ok(c) => c,
Err(_) => return "(the repository listing could not be read)".to_string(),
};
let mut req = client.get(&url);
if !token.trim().is_empty() {
req = req.header("Authorization", format!("token {token}"));
}
let entries: Vec<serde_json::Value> = match req.send().await {
Ok(r) if r.status().is_success() => r.json().await.unwrap_or_default(),
_ => return "(the repository listing could not be read)".to_string(),
};
if entries.is_empty() {
return "(the repository root is empty)".to_string();
}
let mut names: Vec<String> = entries
.iter()
.filter_map(|e| {
let n = e.get("name")?.as_str()?;
let t = e.get("type").and_then(|v| v.as_str()).unwrap_or("file");
Some(if t == "dir" { format!("{n}/") } else { n.to_string() })
})
.collect();
names.sort();
names.join(", ")
}
const PLAN_SYSTEM: &str = "You decide what ONE software mission actually does — its phases, in order. \ const PLAN_SYSTEM: &str = "You decide what ONE software mission actually does — its phases, in order. \
Each phase is a full agent run against the same repository checkout: the next phase sees the tree the \ Each phase is a full agent run against the same repository checkout: the next phase sees the tree the \
previous one left. They run SEQUENTIALLY, so phases are expensive and a handoff loses context at every \ previous one left. They run SEQUENTIALLY, so phases are expensive and a handoff loses context at every \
@@ -63,11 +125,14 @@ pub async fn suggest(
.ok_or(ApiError::NotFound)?; .ok_or(ApiError::NotFound)?;
let prompt = format!( let prompt = format!(
"MISSION: {}\n\nDESCRIPTION:\n{}\n\nThis mission {} a repository.\n\nPHASE KINDS YOU MAY USE \ "MISSION: {}\n\nDESCRIPTION:\n{}\n\nREPOSITORY ROOT: {}\nPlan for the repository as it \
(nothing else runs): {}\nCEILING: {MAX_PHASES} phases.\n\nPropose the plan now (JSON only).", ACTUALLY IS. If the work needs something the repository does not have — a benchmark \
harness, a test suite, a config file — the phase that needs it must CREATE it, and its \
task must say so.\n\nPHASE KINDS YOU MAY USE (nothing else runs): {}\nCEILING: \
{MAX_PHASES} phases.\n\nPropose the plan now (JSON only).",
mission.title, mission.title,
mission.description.as_deref().unwrap_or("(none)"), mission.description.as_deref().unwrap_or("(none)"),
if mission.repo_id.is_some() { "HAS" } else { "has NO" }, repo_listing(&state.pool, id).await,
PLANNABLE_KINDS.join(", "), PLANNABLE_KINDS.join(", "),
); );