feat(missions): commit captured work to a branch of its own
ci / gates (push) Failing after 5s
ci / rust (push) Skipped
ci / frontend (push) Skipped
ci / e2e (push) Skipped
ci / publish (push) Skipped

Second half of delivery, minus the push. After the patch is on disk and the
artifact registered, the phase's work is committed onto
`clawmates/mission-<mission8>-<phase8>`, with `-i<N>` for re-runs so a second
pass cannot collide with the first.

Three rules hold throughout:

- Never the default branch. The name is derived from the mission and phase, so
  a mission can only ever add a ref nobody else owns.
- Never force. A rejected update gets reported, not overwritten.
- The same exclusions as capture. What was too noisy for a patch is too noisy
  for someone's history — build output, vendored trees, and the workaround
  files agents write when infrastructure fights them. A test drops a 50 KB
  binary in `target/` and a `.gitconfig_temp` beside the real change and
  asserts neither is committed.

Ordering is deliberate: commit runs *after* capture, and a commit failure is
logged without failing the capture. The patch is the guarantee; the branch is
the convenience on top.

The branch is created even when there is nothing to stage, because agents
often commit their own work — `rust_sdlc` has a committer role — and that
commit is unreachable once the checkout is reaped unless a ref points at it.

One test changed meaning rather than breaking: it asserted capture left the
working tree untouched, which was correct while capture stood alone. Capture
now commits, so it asserts the new invariant — work on a namespaced branch, a
clean tree, and the created file present in the commit.

Push is still deliberately absent. Everything here is local, so a bug costs a
retry rather than reaching a remote.

Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
Omar Sobh
2026-08-02 12:37:31 -07:00
co-authored by Claude Opus 5
parent a0e6b16abc
commit ca1fd46e08
2 changed files with 217 additions and 5 deletions
+124
View File
@@ -79,6 +79,15 @@ pub struct Capture {
pub empty: bool,
pub truncated: bool,
pub patch_path: PathBuf,
/// Set once the work has been committed to a mission branch.
pub committed: Option<Commit>,
}
/// A commit made on the mission's own branch.
#[derive(Debug, Clone)]
pub struct Commit {
pub branch: String,
pub sha: String,
}
/// Where a mission's durable output lives. Sibling of the swept per-mission
@@ -99,12 +108,22 @@ pub async fn capture_phase_diff(
mission_id: Uuid,
phase_id: Uuid,
) -> Result<Option<Capture>, String> {
// The phase's pass number, so a re-run lands on its own branch instead of
// colliding with the previous attempt.
let iteration: i32 = sqlx::query_scalar("SELECT iteration FROM mission_phases WHERE id = $1")
.bind(phase_id)
.fetch_optional(pool)
.await
.ok()
.flatten()
.unwrap_or(0);
capture_phase_diff_at(
pool,
mission_id,
phase_id,
&mission_workspace::checkout_path(mission_id),
&outputs_root(mission_id),
iteration,
)
.await
}
@@ -121,6 +140,7 @@ pub async fn capture_phase_diff_at(
phase_id: Uuid,
repo: &Path,
outputs: &Path,
iteration: i32,
) -> Result<Option<Capture>, String> {
let repo = repo.to_path_buf();
if !repo.is_dir() {
@@ -198,9 +218,25 @@ pub async fn capture_phase_diff_at(
std::fs::write(dir.join("diffstat.txt"), &diffstat)
.map_err(|e| format!("write diffstat: {e}"))?;
// Commit only after the patch is safely on disk. If this fails, the work
// is still captured and the artifact still lands — the branch is the
// convenience, the patch is the guarantee.
let committed = match commit_phase_work(&repo, mission_id, phase_id, iteration).await {
Ok(c) => c,
Err(e) => {
eprintln!(
"mission_delivery: mission {mission_id} phase {phase_id} captured but not \
committed: {e}"
);
None
}
};
let meta = json!({
"base_sha": base_sha,
"base_recorded": base_is_recorded,
"branch": committed.as_ref().map(|c| c.branch.clone()),
"head_sha": committed.as_ref().map(|c| c.sha.clone()),
"files_changed": files_changed,
"insertions": insertions,
"deletions": deletions,
@@ -246,6 +282,7 @@ pub async fn capture_phase_diff_at(
);
Ok(Some(Capture {
committed,
base_sha,
files_changed,
insertions,
@@ -324,6 +361,93 @@ pub fn parse_diffstat(stat: &str) -> (usize, usize, usize) {
(files, ins, del)
}
/// Commit a phase's work onto a branch of its own.
///
/// Runs after capture, never before: the patch is already on disk and
/// registered, so a commit that goes wrong costs a branch and not the work.
///
/// Three rules, none of them negotiable:
///
/// - **Never the default branch.** The branch name is derived from the mission
/// and phase, so a mission can only ever add a ref nobody else owns.
/// - **Never force.** A rejected update is reported, not overwritten.
/// - **Same exclusions as capture.** Whatever was too noisy to put in a patch
/// is too noisy to put in someone's history — build output, vendored trees,
/// and the workaround files agents write when infrastructure fights them.
///
/// Returns `Ok(None)` when there is nothing to commit, which is a normal
/// outcome and not an error: the phase may have changed nothing, or the agents
/// may have committed their own work already.
pub async fn commit_phase_work(
repo: &Path,
mission_id: Uuid,
phase_id: Uuid,
iteration: i32,
) -> Result<Option<Commit>, String> {
let branch = branch_name(mission_id, phase_id, iteration);
// Work already committed by the agents still needs a branch pointing at
// it, or it is unreachable once the checkout is reaped. So the branch is
// created regardless, and only the staging step is conditional.
git(repo, &["checkout", "-B", &branch]).await?;
let mut add: Vec<&str> = vec!["add", "--", "."];
let excludes: Vec<String> = EXCLUDED_PATHS
.iter()
.map(|p| format!(":(exclude){p}"))
.collect();
add.extend(excludes.iter().map(String::as_str));
git(repo, &add).await?;
// `--cached` compares the index against HEAD: empty means the agents left
// nothing unstaged for us, which is the normal case when they committed
// themselves.
let staged = git(repo, &["diff", "--cached", "--stat"])
.await
.unwrap_or_default();
if !staged.trim().is_empty() {
let message = format!(
"clawmates: {} phase work\n\nMission: {mission_id}\nPhase: {phase_id}\n\n Committed by the ClawMates delivery pipeline from the agents' working tree.",
if iteration > 0 {
format!("pass {}", iteration + 1)
} else {
"phase".to_string()
}
);
git(repo, &["commit", "--no-verify", "-m", &message]).await?;
}
let sha = git(repo, &["rev-parse", "HEAD"])
.await
.map(|s| s.trim().to_string())
.unwrap_or_default();
if sha.is_empty() {
return Ok(None);
}
eprintln!(
"mission_delivery: mission {mission_id} phase {phase_id} → branch {branch} at {}",
&sha[..sha.len().min(8)]
);
Ok(Some(Commit { branch, sha }))
}
/// The branch a phase's work lands on.
///
/// Short ids keep it readable; the pair is unique per phase, and the iteration
/// suffix keeps a re-run from colliding with the pass before it. Deliberately
/// namespaced under `clawmates/` so it is obvious in a branch list who created
/// it and safe to delete in bulk.
pub fn branch_name(mission_id: Uuid, phase_id: Uuid, iteration: i32) -> String {
let m = mission_id.simple().to_string();
let p = phase_id.simple().to_string();
let base = format!("clawmates/mission-{}-{}", &m[..8], &p[..8]);
if iteration > 0 {
format!("{base}-i{}", iteration + 1)
} else {
base
}
}
/// 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