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:
co-authored by
Claude Opus 5
parent
e4bddeb1ba
commit
768e106614
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user