Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0a647c0bfa | ||
|
|
06ae608d0c |
@@ -11,7 +11,13 @@
|
||||
//! against the world) is layered on top by Slices 5–8.
|
||||
//!
|
||||
//! Design notes:
|
||||
//! - Runtime provisioning is opt-in via `RuntimeProvisioner::from_env`.
|
||||
//! - Runtime provisioning is opt-in. Claws are provisioned against the
|
||||
//! mission's OWN daemon (`RuntimeProvisioner::for_gateway` with the
|
||||
//! per-mission endpoint), falling back to the global gateway only when
|
||||
//! there is no per-mission runtime. Provisioning into the global gateway
|
||||
//! while the run executes on a per-mission daemon leaves that daemon
|
||||
//! without the `claw_*` agents — it falls back to the default `scout`
|
||||
//! agent, which cannot see `/mission/repo`.
|
||||
//! Missing runtime = "insert DB rows only, no live claw" — the
|
||||
//! mission still boots; live claws land the moment the runtime
|
||||
//! env is configured + the mission re-launches.
|
||||
@@ -70,9 +76,13 @@ pub async fn on_launch(
|
||||
// running. Falls back silently when docker is unreachable so
|
||||
// dev-mode + tests still work — the topology_worker will use the
|
||||
// shared runtime endpoint in that case.
|
||||
// The mission's own runtime endpoint. Claws MUST be provisioned against
|
||||
// THIS gateway, not the global one — see RuntimeProvisioner::for_gateway.
|
||||
let mut mission_gateway: Option<String> = None;
|
||||
if let Some(prov) = crate::mission_runtime::MissionRuntimeProvisioner::from_env() {
|
||||
match prov.ensure_container(mission_id).await {
|
||||
Ok(ec) => {
|
||||
mission_gateway = Some(ec.endpoint.clone());
|
||||
let container_name = crate::mission_runtime::container_name(mission_id);
|
||||
if let Err(e) = cm_db::repo::missions::set_runtime_binding(
|
||||
pool,
|
||||
@@ -157,7 +167,13 @@ pub async fn on_launch(
|
||||
);
|
||||
}
|
||||
|
||||
let provisioner = RuntimeProvisioner::from_env();
|
||||
// Provision into the mission's own daemon when we have one (so the daemon
|
||||
// that actually runs the turns knows these claws); fall back to the global
|
||||
// gateway only for dev/no-docker setups where the run uses it too.
|
||||
let provisioner = match mission_gateway.clone() {
|
||||
Some(url) => RuntimeProvisioner::for_gateway(url),
|
||||
None => RuntimeProvisioner::from_env(),
|
||||
};
|
||||
let mut first_team_id: Option<Uuid> = None;
|
||||
let mut provisioned_claws: Vec<cm_domain::AgentId> = Vec::new();
|
||||
for (purpose, template_id) in &picks {
|
||||
@@ -213,15 +229,27 @@ pub async fn on_launch(
|
||||
// the freshly-provisioned claws for the run. Non-fatal: without the
|
||||
// pin, agents still write (to the sandbox) but the committer can't
|
||||
// find the changes in /mission/repo.
|
||||
if !provisioned_claws.is_empty() {
|
||||
if !provisioned_claws.is_empty() && mission_gateway.is_some() {
|
||||
if let Some(mp) = crate::mission_runtime::MissionRuntimeProvisioner::from_env() {
|
||||
if let Err(e) = mp
|
||||
match mp
|
||||
.pin_agent_workspaces(mission_id, &provisioned_claws, "/mission/repo")
|
||||
.await
|
||||
{
|
||||
eprintln!(
|
||||
Ok(()) => {
|
||||
// The daemon reads config ONCE at boot and never re-reads
|
||||
// the file, so the pin is invisible until it restarts. Its
|
||||
// agents were created through its own config API, so they
|
||||
// are already persisted to the file and survive the
|
||||
// restart; the pairing code is re-minted on every launch.
|
||||
if let Err(e) = mp.restart_container(mission_id).await {
|
||||
eprintln!(
|
||||
"mission_orchestrator: restart runtime for {mission_id} failed (continuing, workspace pin will not apply): {e}"
|
||||
);
|
||||
}
|
||||
}
|
||||
Err(e) => eprintln!(
|
||||
"mission_orchestrator: pin workspaces for mission {mission_id} failed (continuing): {e}"
|
||||
);
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -482,6 +482,46 @@ fn extract_pairing_code_from_json(body: &str) -> Option<String> {
|
||||
}
|
||||
|
||||
impl MissionRuntimeProvisioner {
|
||||
/// Restart the mission's runtime container and wait for its gateway to
|
||||
/// answer again. Needed after `pin_agent_workspaces`: the daemon reads
|
||||
/// config once at boot and never re-reads the file, so a file-only setting
|
||||
/// (`workspace.path`, which the config prop API cannot set) only takes
|
||||
/// effect across a restart. Agents provisioned through the daemon's own
|
||||
/// config API are already persisted to that file, so they survive.
|
||||
pub async fn restart_container(&self, mission_id: Uuid) -> Result<(), String> {
|
||||
let name = container_name(mission_id);
|
||||
self.docker
|
||||
.restart_container(&name, None::<bollard::query_parameters::RestartContainerOptions>)
|
||||
.await
|
||||
.map_err(|e| format!("restart mission runtime container: {e}"))?;
|
||||
// Wait for the gateway to serve again so the caller can launch a run
|
||||
// immediately after. ~20s ceiling; the daemon normally boots in ~2s.
|
||||
for _ in 0..40 {
|
||||
tokio::time::sleep(std::time::Duration::from_millis(500)).await;
|
||||
// NOTE: exec_capture only fails on docker errors — a curl that
|
||||
// can't connect still "succeeds" (it prints `curl: (7) …`), so we
|
||||
// must inspect the BODY. /health answers `{"paired":…}`.
|
||||
let body = self
|
||||
.exec_capture(
|
||||
&name,
|
||||
vec![
|
||||
"curl".into(),
|
||||
"-fsS".into(),
|
||||
"-m".into(),
|
||||
"2".into(),
|
||||
format!("http://127.0.0.1:{GATEWAY_PORT}/health"),
|
||||
],
|
||||
)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
if body.contains("\"paired\"") {
|
||||
eprintln!("mission_runtime: restarted {name}, gateway healthy");
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
Err(format!("{name} gateway did not come back after restart"))
|
||||
}
|
||||
|
||||
/// Force-remove the mission's runtime container AND its host workspace
|
||||
/// dir (the `/mission/repo` checkout bind source). Idempotent: a missing
|
||||
/// container or dir is not an error — this is called both by the terminal
|
||||
|
||||
@@ -66,12 +66,26 @@ impl RuntimeProvisioner {
|
||||
let gateway_url = std::env::var("ZEROCLAW_GATEWAY_URL")
|
||||
.ok()
|
||||
.filter(|u| !u.is_empty())?;
|
||||
Self::for_gateway(gateway_url)
|
||||
}
|
||||
|
||||
/// Build a provisioner aimed at a SPECIFIC gateway, reusing the durable
|
||||
/// `ZEROCLAW_TOKEN`. Mirrors `ZeroClawDriveExecutor::from_env_for_gateway`.
|
||||
///
|
||||
/// Missions MUST use this with their own per-mission runtime endpoint:
|
||||
/// each mission runs its turns against its own daemon, and that daemon
|
||||
/// loads config once at boot and never re-reads the file. Provisioning a
|
||||
/// mission's claws against the global gateway therefore leaves the
|
||||
/// per-mission daemon with no `claw_*` agents at all — it silently falls
|
||||
/// back to the default agent (`scout`), which is jailed to the global
|
||||
/// workspace and cannot see `/mission/repo`.
|
||||
pub fn for_gateway(gateway_url: String) -> Option<RuntimeProvisioner> {
|
||||
let token = std::env::var("ZEROCLAW_TOKEN")
|
||||
.ok()
|
||||
.filter(|t| !t.is_empty())?;
|
||||
Some(RuntimeProvisioner {
|
||||
http: reqwest::Client::new(),
|
||||
gateway_url,
|
||||
gateway_url: gateway_url.trim_end_matches('/').to_string(),
|
||||
token,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -60,6 +60,9 @@ if [ -z "${IMAGES_ONLY:-}" ]; then
|
||||
ssh "$BUILD_HOST" 'set -e; cd ~/clawmates
|
||||
export PATH=$HOME/.cargo/bin:$PATH CARGO_NET_GIT_FETCH_WITH_CLI=true SQLX_OFFLINE=true
|
||||
cargo build --release -p clawmates-node
|
||||
# rsync excludes frontend/public/dl/ (build artifacts), so the dir may not
|
||||
# exist on the build host — create it before staging the daemon binary.
|
||||
mkdir -p frontend/public/dl
|
||||
cp target/release/clawmates-node frontend/public/dl/clawmates-node-linux-amd64
|
||||
for svc in server frontend; do
|
||||
docker build -f images/$svc.Dockerfile -t '"$REGISTRY"'/clawmates/$svc:'"$TAG"' \
|
||||
|
||||
Reference in New Issue
Block a user