fix(missions): close the three seams behind this run of failures
ci / gates (push) Failing after 6s
ci / rust (push) Skipped
ci / frontend (push) Skipped
ci / e2e (push) Skipped
ci / publish (push) Skipped

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:
Omar Sobh
2026-08-02 18:58:45 -07:00
co-authored by Claude Opus 5
parent bb34ef1b7e
commit ec85f6c8da
6 changed files with 415 additions and 19 deletions
+41 -13
View File
@@ -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.