feat(missions): commit as the operator, overridable per deployment
ci / gates (push) Failing after 6s
ci / rust (push) Skipped
ci / frontend (push) Skipped
ci / e2e (push) Skipped
ci / publish (push) Skipped

Delivery commits now carry "Omar Sobh <[email protected]>" by default, so
pushed branches associate with the operator's forge account the way their
own commits do. CLAWMATES_COMMIT_NAME / CLAWMATES_COMMIT_EMAIL override
it — a shared instance wants a bot identity, not a person's.

This is attribution, not the fix. What made 019fc450's phase fail was the
*absence* of any identity: the server container has none of its own, so
git commit exits 128 regardless of which name would have been used. That
was fixed in 25d9805; this only changes the value. The push credential is
GITEA_TOKEN throughout and is untouched by any of it.

Since the author line now names a person, the commit body says plainly
that agents authored the work — otherwise autonomous commits would be
indistinguishable from hand-written ones in git log. Also fixes 13 stray
spaces that a string continuation had baked into every message body.

Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
Omar Sobh
2026-08-02 16:32:02 -07:00
co-authored by Claude Opus 5
parent 1a979f500f
commit ddab8e35f5
2 changed files with 46 additions and 13 deletions
+43 -10
View File
@@ -67,11 +67,33 @@ const EXCLUDED_PATHS: &[&str] = &[
/// operator needs to see to work out what happened. /// operator needs to see to work out what happened.
const MAX_PATCH_BYTES: usize = 4 * 1024 * 1024; const MAX_PATCH_BYTES: usize = 4 * 1024 * 1024;
/// Who delivery commits as. Not a real mailbox — it identifies the pipeline in /// Who delivery commits as.
/// `git log` and keeps commits attributable to the platform rather than to ///
/// whichever agent last touched the checkout's git config. /// The operator's identity by default, so pushed commits associate with their
const COMMIT_IDENTITY_NAME: &str = "ClawMates delivery"; /// forge account the way their own commits do. Overridable per deployment via
const COMMIT_IDENTITY_EMAIL: &str = "[email protected]"; /// `CLAWMATES_COMMIT_NAME` / `CLAWMATES_COMMIT_EMAIL` — a shared instance
/// wants a bot identity here, not a person's.
///
/// What matters for correctness is only that *some* identity is always set:
/// the server container has none of its own, so `git commit` fails outright
/// without this. The particular value is attribution, not function.
///
/// Attribution alone, to be clear — the push credential is `GITEA_TOKEN` and
/// is unaffected by any of this.
const DEFAULT_COMMIT_NAME: &str = "Omar Sobh";
const DEFAULT_COMMIT_EMAIL: &str = "[email protected]";
fn commit_identity() -> (String, String) {
let name = std::env::var("CLAWMATES_COMMIT_NAME")
.ok()
.filter(|v| !v.trim().is_empty())
.unwrap_or_else(|| DEFAULT_COMMIT_NAME.to_string());
let email = std::env::var("CLAWMATES_COMMIT_EMAIL")
.ok()
.filter(|v| !v.trim().is_empty())
.unwrap_or_else(|| DEFAULT_COMMIT_EMAIL.to_string());
(name, email)
}
/// Ceiling on the gate's test run. Long enough for a real suite, short enough /// 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. /// that a hung test does not hold a phase open indefinitely.
@@ -386,6 +408,7 @@ async fn git(repo: &Path, args: &[&str]) -> Result<String, String> {
Box::leak(format!("safe.directory={repo_s}").into_boxed_str()), Box::leak(format!("safe.directory={repo_s}").into_boxed_str()),
]; ];
full.extend_from_slice(args); full.extend_from_slice(args);
let (name, email) = commit_identity();
let out = tokio::process::Command::new("git") let out = tokio::process::Command::new("git")
.args(&full) .args(&full)
// The server container has no git identity — `git config --global // The server container has no git identity — `git config --global
@@ -404,10 +427,10 @@ async fn git(repo: &Path, args: &[&str]) -> Result<String, String> {
// needing a leaked string per invocation, and because they name the // needing a leaked string per invocation, and because they name the
// committer as the pipeline — which is the truth. The agents' own // committer as the pipeline — which is the truth. The agents' own
// commits keep whatever identity they set. // commits keep whatever identity they set.
.env("GIT_AUTHOR_NAME", COMMIT_IDENTITY_NAME) .env("GIT_AUTHOR_NAME", &name)
.env("GIT_AUTHOR_EMAIL", COMMIT_IDENTITY_EMAIL) .env("GIT_AUTHOR_EMAIL", &email)
.env("GIT_COMMITTER_NAME", COMMIT_IDENTITY_NAME) .env("GIT_COMMITTER_NAME", &name)
.env("GIT_COMMITTER_EMAIL", COMMIT_IDENTITY_EMAIL) .env("GIT_COMMITTER_EMAIL", &email)
.output() .output()
.await .await
.map_err(|e| format!("spawn git: {e}"))?; .map_err(|e| format!("spawn git: {e}"))?;
@@ -502,7 +525,17 @@ pub async fn commit_phase_work(
.unwrap_or_default(); .unwrap_or_default();
if !staged.trim().is_empty() { if !staged.trim().is_empty() {
let message = format!( 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.", // The trailer is the provenance record. It matters more now that
// the author line carries a person's name: without it, autonomous
// work would be indistinguishable from hand-written commits in
// `git log`. Keep it on any change to this message.
"clawmates: {} phase work\n\
\n\
Mission: {mission_id}\n\
Phase: {phase_id}\n\
\n\
Committed by the ClawMates delivery pipeline from the agents' \
working tree. Authored by agents, not by the named committer.",
if iteration > 0 { if iteration > 0 {
format!("pass {}", iteration + 1) format!("pass {}", iteration + 1)
} else { } else {
+3 -3
View File
@@ -775,9 +775,9 @@ async fn delivery_commits_under_its_own_identity() {
.unwrap(); .unwrap();
let author = String::from_utf8_lossy(&author.stdout).trim().to_string(); let author = String::from_utf8_lossy(&author.stdout).trim().to_string();
assert_eq!( assert_eq!(
author, "ClawMates delivery <[email protected]>", author, "Omar Sobh <[email protected]>",
"delivery must supply its own identity, not inherit whatever the \ "delivery must supply a configured identity, not inherit whatever \
checkout happens to have configured" the checkout happens to have configured"
); );
let base_author = Command::new("git") let base_author = Command::new("git")