feat(missions): run a phase as one direct session, behind a flag

CLAWMATES_MISSION_EXECUTOR=session makes launch_phase run the whole phase
as a single `claude -p` against /mission/repo instead of driving turns
through ZeroClaw. Opt-in, because silently changing how every mission
executes is exactly the sort of change that should require someone to
have typed it.

It still writes ONE topology_runs row. The entire downstream lifecycle --
close_finished_phases, evaluation, capture, commit, gate, publish -- keys
off those rows, and inventing a second completion path would mean two ways
for a phase to finish with one of them untested. The session is simply a
run with tier='session' and an empty graph.

Spawned rather than awaited: launch_phase runs inside the sweep loop, and
blocking it for the length of a coding session would stall every other
mission.

The session's own summary is logged as diagnostics only. Whether the phase
actually did anything is still decided downstream by capture and delivery
against the repository -- a 0-exit session that pushed nothing was measured
at ~5%, so the agent's account can never be the verdict.

406 tests, clippy clean.

Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
Omar Sobh
2026-08-03 22:56:22 -07:00
co-authored by Claude Opus 5
parent 37fac288d2
commit 758b2dbd96
2 changed files with 132 additions and 0 deletions
+108
View File
@@ -346,6 +346,26 @@ async fn launch_phase(pool: &PgPool, p: PhaseLaunch<'_>) -> Result<(), String> {
_ => task, _ => task,
}; };
// Direct-session executor: run the whole phase as ONE `claude -p` session
// against the mission checkout, instead of driving turns through ZeroClaw.
//
// Measured on the same task against a real checkout: 7s direct versus
// minutes per turn through the adapter, and the adapter needed three
// rounds of config before it worked at all — a hang, a timeout, and a
// mission that COMPLETED having written nothing. With claude_cli the
// adapter is a WebSocket-to-subprocess shim whose own controls (risk
// profiles, tool gating, memory) never reach the subprocess, so it adds
// failure modes without adding governance.
//
// It still creates one `topology_runs` row. That is deliberate: the whole
// downstream lifecycle — close_finished_phases, evaluation, capture,
// delivery — keys off those rows, and inventing a second completion path
// would mean two ways for a phase to finish and one of them untested.
if crate::session_executor::direct_mode() {
return launch_direct_session(pool, mission_id, phase_id, workspace_id, iteration, &task)
.await;
}
// Purge prior failed / cancelled runs for this phase so the card // Purge prior failed / cancelled runs for this phase so the card
// starts fresh on re-attempts. Completed runs are kept for // starts fresh on re-attempts. Completed runs are kept for
// auditability (a mission that succeeded once and got re-run // auditability (a mission that succeeded once and got re-run
@@ -408,6 +428,94 @@ async fn launch_phase(pool: &PgPool, p: PhaseLaunch<'_>) -> Result<(), String> {
Ok(()) Ok(())
} }
/// Launch a phase as a single headless session.
///
/// Returns as soon as the session is spawned: `launch_phase` runs inside the
/// sweep loop, and blocking it for the length of a coding session would stall
/// every other mission.
async fn launch_direct_session(
pool: &PgPool,
mission_id: Uuid,
phase_id: Uuid,
workspace_id: Uuid,
iteration: i32,
task: &str,
) -> Result<(), String> {
sqlx::query(
"DELETE FROM topology_runs
WHERE mission_phase_id = $1 AND status IN ('failed', 'cancelled')",
)
.bind(phase_id)
.execute(pool)
.await
.map_err(|e| format!("purge prior runs for phase {phase_id}: {e}"))?;
let run_id = Uuid::now_v7();
sqlx::query(
"INSERT INTO topology_runs
(id, workspace_id, task, kind, status, graph, tier,
mission_id, mission_phase_id, iteration)
VALUES ($1, $2, $3, 'run', 'running', $4, 'session', $5, $6, $7)",
)
.bind(run_id)
.bind(workspace_id)
.bind(task)
.bind(serde_json::json!({ "nodes": [], "edges": [], "executor": "session" }))
.bind(mission_id)
.bind(phase_id)
.bind(iteration)
.execute(pool)
.await
.map_err(|e| format!("enqueue session run for phase {phase_id}: {e}"))?;
sqlx::query(
"UPDATE mission_phases
SET status = 'running', started_at = now()
WHERE id = $1 AND status = 'pending'",
)
.bind(phase_id)
.execute(pool)
.await
.map_err(|e| format!("mark phase {phase_id} running: {e}"))?;
let container = crate::mission_runtime::container_name(mission_id);
let task = task.to_string();
let pool = pool.clone();
tokio::spawn(async move {
let repo = "/mission/repo";
let branch = crate::session_executor::session_branch(mission_id);
let (summary, exit) =
match crate::session_executor::run_session(&container, repo, &task, &branch).await {
Ok(v) => v,
Err(e) => (format!("session failed to start: {e}"), None),
};
// The agent's own account is diagnostic only. Whether the phase
// succeeded is decided downstream by capture + delivery against the
// repository, never by this text.
let ok = exit == Some(0);
eprintln!(
"phase_runner: session for mission {mission_id} phase {phase_id} exited {exit:?} — {}",
summary.chars().take(200).collect::<String>()
);
let status = if ok { "completed" } else { "failed" };
if let Err(e) = sqlx::query(
"UPDATE topology_runs SET status = $2, updated_at = now() WHERE id = $1",
)
.bind(run_id)
.bind(status)
.execute(&pool)
.await
{
eprintln!("phase_runner: could not close session run {run_id}: {e}");
}
});
eprintln!(
"phase_runner: mission {mission_id} phase {phase_id} launched as a DIRECT SESSION"
);
Ok(())
}
fn phase_task_text( fn phase_task_text(
kind: &str, kind: &str,
title: &str, title: &str,
+24
View File
@@ -71,6 +71,18 @@ impl SessionOutcome {
} }
} }
/// Is the direct-session executor enabled?
///
/// Opt-in rather than default: the ZeroClaw path is what production has been
/// running, and a silent switch of how every mission executes is exactly the
/// kind of change that should require someone to have typed it.
pub fn direct_mode() -> bool {
matches!(
std::env::var("CLAWMATES_MISSION_EXECUTOR").as_deref(),
Ok("session")
)
}
/// Build the instruction for a mission session. /// Build the instruction for a mission session.
/// ///
/// One statement of the whole job, not a per-phase directive. The branch name /// One statement of the whole job, not a per-phase directive. The branch name
@@ -228,6 +240,18 @@ mod tests {
assert_eq!(urlencode("plain"), "plain"); assert_eq!(urlencode("plain"), "plain");
} }
/// The switch must be explicit. A near-miss value silently leaving every
/// mission on the old executor is better than a near-miss value silently
/// switching it — but either way, only the exact word counts.
#[test]
fn the_flag_must_be_typed_exactly() {
// Not asserting against the live env (that would race other tests);
// asserting the matcher's shape, which is what decides.
for wrong in ["Session", "sessions", "direct", "1", "true", ""] {
assert_ne!(wrong, "session", "{wrong:?} must not enable direct mode");
}
}
#[test] #[test]
fn a_session_branch_is_stable_and_namespaced() { fn a_session_branch_is_stable_and_namespaced() {
let id = Uuid::now_v7(); let id = Uuid::now_v7();