fix(microvm): a mission with no repository can run in a VM, and its work comes back

Two halves, and the first was worse than the plan assumed. `run_phase_in_vm`
packed `<missions_root>/<mission>/repo` unconditionally — a directory a
repo-less mission does not have — and then required `/mission/repo/.git` inside
the guest before spending a turn. So a repo-less microVM phase did not merely
go uncaptured: it failed before the agent ran.

A repo-less mission now gets an EMPTY workspace at the same guest path, created
host-side so the collect unpacks back over it with no special case, and the
readiness probe asks for what was actually sent — the directory rather than a
`.git` that was never going to be there.

`mission_outputs` then drops its `runtime_kind <> 'microvm'` exclusion, whose
stated reason ("a microVM mission always has a checkout") is exactly what
stopped being true. Where the files come from now depends on the runtime, and
the difference is not cosmetic: a container mission's output is still inside a
running container, while a VM's has already been unpacked onto the host by the
end-of-turn collect. Asking docker for a VM mission's files would query a
container that never existed.

The recursive copy skips symlinks rather than following them — a link out of
the tree would publish whatever it points at.

`research-vm` is the proof, added to the suite as well as the dispatch: the same
assertions as `research-only` with `runtime_kind: microvm`. A scenario nobody
runs is a scenario that does not exist.

Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
Omar Sobh
2026-08-09 19:04:00 -07:00
co-authored by Claude Opus 5
parent e4bddeb1ba
commit 768e106614
6 changed files with 141 additions and 20 deletions
+55 -9
View File
@@ -66,14 +66,17 @@ const EMPTY_MARKER: &str = "NO-OUTPUT.md";
/// Capture the outputs of finished phases on missions that have no repo.
pub async fn capture_repo_less_phases(pool: &PgPool) -> Result<(), String> {
let rows = sqlx::query(
"SELECT mp.id, mp.mission_id, mp.kind, mp.config
"SELECT mp.id, mp.mission_id, mp.kind, mp.config, m.runtime_kind
FROM mission_phases mp
JOIN missions m ON m.id = mp.mission_id
WHERE mp.status IN ('completed', 'failed')
AND m.repo_id IS NULL
-- A microVM mission always has a checkout (`run_phase_in_vm` refuses
-- to boot without one), so this path is container-only.
AND m.runtime_kind <> 'microvm'
-- microVM used to be excluded here because `run_phase_in_vm`
-- refused to boot without a checkout. It no longer does: a
-- repo-less mission gets an empty workspace at the same guest path,
-- and the collect unpacks it back onto the host — so those files are
-- already on disk and `collect_into` reads them instead of asking a
-- container that never existed.
AND NOT EXISTS (
SELECT 1 FROM mission_artifacts a
WHERE a.mission_id = mp.mission_id
@@ -94,9 +97,10 @@ pub async fn capture_repo_less_phases(pool: &PgPool) -> Result<(), String> {
let mission_id: Uuid = row.get("mission_id");
let kind: String = row.get("kind");
let config: serde_json::Value = row.get("config");
let runtime_kind: String = row.get("runtime_kind");
let dest = outputs_dir(mission_id, phase_id);
let captured = match collect_into(mission_id, &dest).await {
let captured = match collect_into(mission_id, &dest, &runtime_kind).await {
Ok(files) => files,
Err(e) => {
// Loud and retryable, never silently "captured nothing": the
@@ -239,18 +243,60 @@ async fn register_empty_marker(
.map_err(|e| format!("register empty marker: {e}"))
}
/// Copy `/mission/repo` out of the mission's container and return the files kept.
async fn collect_into(mission_id: Uuid, dest: &Path) -> Result<Vec<PathBuf>, String> {
let container = crate::mission_runtime::container_name(mission_id);
let docker = crate::container_exec::connect()?;
/// Gather the mission's produced files and return the ones worth keeping.
///
/// Where they come from depends on the runtime, and the difference is not
/// cosmetic: a container mission's files are still INSIDE a running container,
/// while a microVM's have already been unpacked onto the host by the collect at
/// the end of the turn (`microvm_executor` writes them over
/// `mission_workspace::checkout_path`). Asking docker for a VM mission's files
/// would query a container that never existed.
async fn collect_into(mission_id: Uuid, dest: &Path, runtime_kind: &str) -> Result<Vec<PathBuf>, String> {
// A stale copy from an earlier attempt would be registered as this pass's
// output — the same "captured a tree nobody wrote" shape capture avoids.
let _ = std::fs::remove_dir_all(dest);
std::fs::create_dir_all(dest).map_err(|e| format!("create {}: {e}", dest.display()))?;
if runtime_kind == "microvm" {
let src = crate::mission_workspace::checkout_path(mission_id);
if !src.is_dir() {
return Err(format!(
"{} is absent — the VM's collect did not land",
src.display()
));
}
copy_tree(&src, &dest.join("repo"))?;
return Ok(keep_files(&dest.join("repo")));
}
let container = crate::mission_runtime::container_name(mission_id);
let docker = crate::container_exec::connect()?;
crate::mission_fs::copy_out(&docker, &container, "/mission/repo", dest).await?;
Ok(keep_files(&dest.join("repo")))
}
/// Recursive file copy. Small on purpose — the alternative is a dependency or a
/// shell-out, and this runs as the server's own uid against its own directory.
fn copy_tree(src: &Path, dest: &Path) -> Result<(), String> {
std::fs::create_dir_all(dest).map_err(|e| format!("create {}: {e}", dest.display()))?;
let entries =
std::fs::read_dir(src).map_err(|e| format!("read {}: {e}", src.display()))?;
for entry in entries.flatten() {
let from = entry.path();
let to = dest.join(entry.file_name());
match entry.file_type() {
Ok(t) if t.is_dir() => copy_tree(&from, &to)?,
Ok(t) if t.is_file() => {
std::fs::copy(&from, &to).map_err(|e| format!("copy {}: {e}", from.display()))?;
}
// Symlinks and specials are skipped rather than followed: a link out
// of the tree would publish whatever it points at.
_ => {}
}
}
Ok(())
}
/// Every regular file worth keeping, recursively.
fn keep_files(root: &Path) -> Vec<PathBuf> {
let mut out = Vec::new();