fix(missions): close the three seams behind this run of failures
Seam 1 — delivery inferred checkout state from the tree, so whether work survived depended on what the agent happened to do. 019fc444 committed and left a clean tree; 019fc476 had its base advanced to match HEAD; 019fc450 survived only because a phase FAILED to commit and left the tree dirty. Same code, opposite outcomes, decided by the agent. mark_phase_started records the fact at phase launch, before the agent acts, so every one of those states answers identically. The tree checks remain as a second line of defence for pre-existing checkouts. Seam 2 — phase config was accepted, stored and read by nobody. That was `task`: every phase of every mission got identical instructions. The new phase_config registry names the reader for each live key and lists the eight that are declared-but-unimplemented, reporting both at mission creation so an author sees what will not happen. Its CI test found one I had missed: security_hardening.toml sets phase-level mcp_bundles asking for gitea_forge + security_scan, but bundles come from the TEAM template and the phase gets neither. Seam 4 — push_url_for collapsed a failed query, an unbound repo and a missing clone_url into one None, so a database fault was recorded as "nothing to push to" and metadata read `pushed: null, push_error: null` — the same ambiguity commit_error already fixed. Each case now carries its reason into the artifact, and a local git failure during publish is recorded rather than dropped by .ok(). Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 5
parent
bb34ef1b7e
commit
ec85f6c8da
@@ -295,6 +295,7 @@ pub async fn capture_phase_diff_at(
|
||||
// still has its patch on disk and its work on a local branch.
|
||||
let mut outcome: Option<TestOutcome> = None;
|
||||
let mut published: Option<Publish> = None;
|
||||
let mut publish_error: Option<String> = None;
|
||||
if let Some(c) = committed.as_ref() {
|
||||
if !empty {
|
||||
if gate == Gate::OnGreenTests {
|
||||
@@ -313,17 +314,38 @@ pub async fn capture_phase_diff_at(
|
||||
outcome = Some(o);
|
||||
}
|
||||
match push_url_for(pool, mission_id).await {
|
||||
Some(url) => {
|
||||
Ok(Some(url)) => {
|
||||
let verified = outcome.as_ref().and_then(TestOutcome::verified);
|
||||
published = publish_phase_branch(&repo, &url, &c.branch, gate, verified)
|
||||
.await
|
||||
.ok();
|
||||
match publish_phase_branch(&repo, &url, &c.branch, gate, verified).await {
|
||||
Ok(p) => published = Some(p),
|
||||
// `publish_phase_branch` only returns Err for a local
|
||||
// git failure; a rejected push is Ok with an error
|
||||
// inside. Both must reach the artifact.
|
||||
Err(e) => {
|
||||
eprintln!(
|
||||
"mission_delivery: mission {mission_id} phase {phase_id} \
|
||||
could not publish {}: {e}",
|
||||
c.branch
|
||||
);
|
||||
publish_error = Some(e.chars().take(500).collect());
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(None) => {
|
||||
// Legitimate: a mission with no repo bound has nowhere to
|
||||
// push. Still recorded, because "not pushed" with no reason
|
||||
// is the ambiguity this whole pass exists to remove.
|
||||
publish_error =
|
||||
Some("mission has no repo bound; work is committed locally only".into());
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!(
|
||||
"mission_delivery: mission {mission_id} could not resolve a push \
|
||||
URL ({e}) — work is committed locally on {} but not published",
|
||||
c.branch
|
||||
);
|
||||
publish_error = Some(format!("could not resolve push URL: {e}"));
|
||||
}
|
||||
None => eprintln!(
|
||||
"mission_delivery: mission {mission_id} has no push URL — work is \
|
||||
committed locally on {} but not published",
|
||||
c.branch
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -348,7 +370,10 @@ pub async fn capture_phase_diff_at(
|
||||
"tests_status": outcome.as_ref().map(TestOutcome::status),
|
||||
"tests_detail": outcome.as_ref().and_then(TestOutcome::detail),
|
||||
"pushed": published.as_ref().map(|p| p.pushed),
|
||||
"push_error": published.as_ref().and_then(|p| p.error.clone()),
|
||||
"push_error": published
|
||||
.as_ref()
|
||||
.and_then(|p| p.error.clone())
|
||||
.or(publish_error),
|
||||
"commit_error": commit_error,
|
||||
"files_changed": files_changed,
|
||||
"insertions": insertions,
|
||||
@@ -680,16 +705,19 @@ pub fn discover_test_command(repo: &Path) -> Option<Vec<String>> {
|
||||
/// after clone because agents run as root in a container that mounts the
|
||||
/// checkout. Building it here also means a rotated token takes effect
|
||||
/// immediately instead of at the next clone.
|
||||
async fn push_url_for(pool: &sqlx::PgPool, mission_id: Uuid) -> Option<String> {
|
||||
async fn push_url_for(pool: &sqlx::PgPool, mission_id: Uuid) -> Result<Option<String>, String> {
|
||||
let url: Option<String> = sqlx::query_scalar(
|
||||
"SELECT r.clone_url FROM missions m JOIN repos r ON r.id = m.repo_id WHERE m.id = $1",
|
||||
)
|
||||
.bind(mission_id)
|
||||
.fetch_optional(pool)
|
||||
.await
|
||||
.ok()
|
||||
// `.ok().flatten()` used to collapse a failed query into the same `None`
|
||||
// as a mission with no repo bound, so a database fault was recorded as
|
||||
// "nothing to push to" — the shape that made `commit_error` necessary.
|
||||
.map_err(|e| format!("query push URL: {e}"))?
|
||||
.flatten();
|
||||
url.map(|u| mission_workspace::with_ambient_auth(&u))
|
||||
Ok(url.map(|u| mission_workspace::with_ambient_auth(&u)))
|
||||
}
|
||||
|
||||
/// Run the gate, then push the branch if the gate allows it.
|
||||
|
||||
Reference in New Issue
Block a user