feat(missions): publish the mission branch, gated by commit_policy
Completes delivery. A phase's work is now captured, committed, gated and pushed — in that order, so every failure costs strictly less than the one before it. Publishing is last for a reason. By the time it runs the patch is on disk, the artifact is registered and the work is on a local branch, so a rejected ref, a rotated token or an unreachable forge costs a push and nothing else. A test pushes at a path that does not exist and asserts the commit is still there afterwards. The gate decides the branch name, never whether the work survives: - green, or policy `always` → `clawmates/mission-<m8>-<p8>` - red / unrunnable / no suite → `…-wip` - `on_reviewer_approval` → `…-review` Both land on the forge. A human can inspect, fix and re-push a branch; nobody can recover work discarded for failing a test. Deleting a red branch reproduces the old behaviour on purpose rather than by accident. `verify_tests` runs the project's own suite through the runtime container and returns `Option<bool>` — `None` for "could not establish", which the gate treats as unproven. An unreadable exit status is not a pass. That is the same fail-closed stance as the phase evaluator, and it is here because this tranche has now found four separate things reporting success while doing nothing. Never force-push. A rejected update is reported and left alone: the remote ref belongs to whoever set it, and overwriting it to make delivery look tidy is how a mission eats someone else's commit. The push URL is built fresh from the repo row and the ambient token, not read from `.git/config` — which no longer carries credentials, since agents run as root in a container that mounts the checkout. Tests push to a real `git init --bare` remote and assert the ref and its content actually arrived. A mock would have accepted anything. Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 5
parent
3ea288dbb5
commit
e2871c4361
@@ -67,6 +67,10 @@ const EXCLUDED_PATHS: &[&str] = &[
|
||||
/// operator needs to see to work out what happened.
|
||||
const MAX_PATCH_BYTES: usize = 4 * 1024 * 1024;
|
||||
|
||||
/// Ceiling on the gate's test run. Long enough for a real suite, short enough
|
||||
/// that a hung test does not hold a phase open indefinitely.
|
||||
const TEST_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(900);
|
||||
|
||||
/// What a phase produced.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Capture {
|
||||
@@ -117,6 +121,15 @@ pub async fn capture_phase_diff(
|
||||
.ok()
|
||||
.flatten()
|
||||
.unwrap_or(0);
|
||||
// `commit_policy` lives in the phase config, merged there from the
|
||||
// workflow recipe by `phases_for_create`.
|
||||
let policy: Option<String> =
|
||||
sqlx::query_scalar("SELECT config->>'commit_policy' FROM mission_phases WHERE id = $1")
|
||||
.bind(phase_id)
|
||||
.fetch_optional(pool)
|
||||
.await
|
||||
.ok()
|
||||
.flatten();
|
||||
capture_phase_diff_at(
|
||||
pool,
|
||||
mission_id,
|
||||
@@ -124,6 +137,7 @@ pub async fn capture_phase_diff(
|
||||
&mission_workspace::checkout_path(mission_id),
|
||||
&outputs_root(mission_id),
|
||||
iteration,
|
||||
Gate::parse(policy.as_deref()),
|
||||
)
|
||||
.await
|
||||
}
|
||||
@@ -141,6 +155,7 @@ pub async fn capture_phase_diff_at(
|
||||
repo: &Path,
|
||||
outputs: &Path,
|
||||
iteration: i32,
|
||||
gate: Gate,
|
||||
) -> Result<Option<Capture>, String> {
|
||||
let repo = repo.to_path_buf();
|
||||
if !repo.is_dir() {
|
||||
@@ -232,11 +247,49 @@ pub async fn capture_phase_diff_at(
|
||||
}
|
||||
};
|
||||
|
||||
// Gate, then publish. Both are best-effort on top of an artifact that has
|
||||
// already landed: a phase whose tests fail, or whose push is rejected,
|
||||
// still has its patch on disk and its work on a local branch.
|
||||
let mut verified: Option<bool> = None;
|
||||
let mut published: Option<Publish> = None;
|
||||
if let Some(c) = committed.as_ref() {
|
||||
if !empty {
|
||||
if gate == Gate::OnGreenTests {
|
||||
let container = std::env::var("CLAWMATES_RUNTIME_CONTAINER")
|
||||
.unwrap_or_else(|_| "clawmates-runtime".to_string());
|
||||
verified = verify_tests(&repo, &container).await;
|
||||
}
|
||||
match push_url_for(pool, mission_id).await {
|
||||
Some(url) => {
|
||||
published = publish_phase_branch(&repo, &url, &c.branch, gate, verified)
|
||||
.await
|
||||
.ok();
|
||||
}
|
||||
None => eprintln!(
|
||||
"mission_delivery: mission {mission_id} has no push URL — work is \
|
||||
committed locally on {} but not published",
|
||||
c.branch
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let meta = json!({
|
||||
"base_sha": base_sha,
|
||||
"base_recorded": base_is_recorded,
|
||||
"branch": committed.as_ref().map(|c| c.branch.clone()),
|
||||
"branch": published
|
||||
.as_ref()
|
||||
.map(|p| p.branch.clone())
|
||||
.or_else(|| committed.as_ref().map(|c| c.branch.clone())),
|
||||
"head_sha": committed.as_ref().map(|c| c.sha.clone()),
|
||||
"commit_policy": match gate {
|
||||
Gate::Always => "always",
|
||||
Gate::OnGreenTests => "on_green_tests",
|
||||
Gate::OnReviewerApproval => "on_reviewer_approval",
|
||||
},
|
||||
"tests_verified": verified,
|
||||
"pushed": published.as_ref().map(|p| p.pushed),
|
||||
"push_error": published.as_ref().and_then(|p| p.error.clone()),
|
||||
"files_changed": files_changed,
|
||||
"insertions": insertions,
|
||||
"deletions": deletions,
|
||||
@@ -529,6 +582,109 @@ pub fn discover_test_command(repo: &Path) -> Option<Vec<String>> {
|
||||
None
|
||||
}
|
||||
|
||||
/// The authenticated URL to push this mission's work to.
|
||||
///
|
||||
/// Built fresh from the repo row and the ambient token rather than read from
|
||||
/// `.git/config`, which no longer carries credentials — the token is scrubbed
|
||||
/// 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> {
|
||||
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()
|
||||
.flatten();
|
||||
url.map(|u| mission_workspace::with_ambient_auth(&u))
|
||||
}
|
||||
|
||||
/// Run the gate, then push the branch if the gate allows it.
|
||||
///
|
||||
/// Publishing is last on purpose. By the time this runs the patch is on disk,
|
||||
/// the artifact is registered and the work is committed to a local branch — so
|
||||
/// every failure mode here costs a ref that did not reach the forge, and
|
||||
/// nothing that was already captured.
|
||||
///
|
||||
/// The branch name carries the verdict. A gate that fails redirects to
|
||||
/// `<branch>-wip` or `<branch>-review` and pushes it anyway: a human can
|
||||
/// inspect, fix and re-push a branch, but cannot recover work that was thrown
|
||||
/// away for failing a test. Deleting a red branch reproduces the old
|
||||
/// behaviour — work destroyed — deliberately rather than by accident.
|
||||
pub async fn publish_phase_branch(
|
||||
repo: &Path,
|
||||
push_url: &str,
|
||||
branch: &str,
|
||||
gate: Gate,
|
||||
verified: Option<bool>,
|
||||
) -> Result<Publish, String> {
|
||||
let suffix = gate.branch_suffix(verified);
|
||||
let target = format!("{branch}{suffix}");
|
||||
if !suffix.is_empty() {
|
||||
// Move the local ref too, so the checkout and the forge agree about
|
||||
// where this work lives.
|
||||
git(repo, &["branch", "-f", &target, "HEAD"]).await?;
|
||||
}
|
||||
|
||||
// Never force. A rejected update is reported and left alone: the remote
|
||||
// ref belongs to whoever set it, and overwriting it to make delivery look
|
||||
// tidy is how a mission eats someone else's commit.
|
||||
let refspec = format!("HEAD:refs/heads/{target}");
|
||||
match git(repo, &["push", push_url, &refspec]).await {
|
||||
Ok(_) => {
|
||||
eprintln!("mission_delivery: pushed {target}");
|
||||
Ok(Publish {
|
||||
branch: target,
|
||||
pushed: true,
|
||||
error: None,
|
||||
})
|
||||
}
|
||||
Err(e) => {
|
||||
// Redacted by `git`'s error path already; the patch and the local
|
||||
// branch both survive, so this is a degraded success.
|
||||
eprintln!("mission_delivery: push of {target} failed: {e}");
|
||||
Ok(Publish {
|
||||
branch: target,
|
||||
pushed: false,
|
||||
error: Some(e),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Where a phase's work ended up, and whether the forge has it.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Publish {
|
||||
pub branch: String,
|
||||
pub pushed: bool,
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
/// Run the project's own tests to decide whether a green-tests gate is met.
|
||||
///
|
||||
/// `None` means "could not establish", which the gate treats as unproven. That
|
||||
/// is the same fail-closed stance the phase evaluator takes, and for the same
|
||||
/// reason: this codebase has repeatedly found things reporting success while
|
||||
/// doing nothing, and a test suite that never ran must not license a push to a
|
||||
/// mission branch.
|
||||
pub async fn verify_tests(repo: &Path, container: &str) -> Option<bool> {
|
||||
let argv = discover_test_command(repo)?;
|
||||
let workdir = repo.display().to_string();
|
||||
let docker = crate::container_exec::connect().ok()?;
|
||||
let out = crate::container_exec::exec(&docker, container, Some(&workdir), &argv, TEST_TIMEOUT)
|
||||
.await
|
||||
.ok()?;
|
||||
eprintln!(
|
||||
"mission_delivery: {} → exit {:?}",
|
||||
argv.join(" "),
|
||||
out.exit_code
|
||||
);
|
||||
// An unreadable status is not a pass.
|
||||
Some(out.success())
|
||||
}
|
||||
|
||||
/// Mark a phase as impossible to capture, so it stops being selected.
|
||||
///
|
||||
/// A phase whose checkout has already been reaped can never be captured. It
|
||||
|
||||
Reference in New Issue
Block a user