diff --git a/crates/buzz-backend-kubernetes/src/main.rs b/crates/buzz-backend-kubernetes/src/main.rs index 374dc517db3..5e81eaa3bed 100644 --- a/crates/buzz-backend-kubernetes/src/main.rs +++ b/crates/buzz-backend-kubernetes/src/main.rs @@ -99,22 +99,49 @@ fn respond(input: &str) -> Response { match request { Request::Info => Response::info(), - Request::Deploy(deploy) => { - let runtime = match tokio::runtime::Builder::new_current_thread() - .enable_all() - .build() - { - Ok(runtime) => runtime, - Err(e) => return Response::error(format!("could not start the runtime: {e}")), - }; - match runtime.block_on(deploy_agent(&deploy)) { + Request::Deploy(deploy) => match runtime() { + Ok(runtime) => match runtime.block_on(deploy_agent(&deploy)) { Ok(agent_id) => Response::deployed(agent_id), Err(e) => Response::failed(e), - } - } + }, + Err(e) => Response::error(e), + }, + Request::Stop(stop) => match runtime() { + Ok(runtime) => match runtime.block_on(stop_session(&stop)) { + Ok((agent_id, state)) => Response::stopped(agent_id, state), + Err(e) => Response::failed(e), + }, + Err(e) => Response::error(e), + }, } } +fn runtime() -> Result { + tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .map_err(|e| format!("could not start the runtime: {e}")) +} + +/// End one Sandbox session. Refusals (identity, scope) precede any cluster +/// contact, exactly as for `deploy`. +async fn stop_session( + request: &wire::DeployRequest, +) -> Result<(String, sandbox::StopState), wire::ProviderError> { + let cfg = config::parse(&request.provider_config)?; + let scope = sandbox::Options::parse(&request.provider_config)? + .ok_or("stop requires provider_config.sandbox")?; + let identity = identity::check(request, true)?; + let owner = request + .agent + .launch + .as_ref() + .and_then(|launch| launch.owner_pubkey.as_deref()) + .ok_or("Sandbox requires a verified launch owner")?; + let client = client::connect(cfg.context.as_deref()).await?; + sandbox::stop(client, &identity, &cfg.namespace, &scope, owner).await +} + /// Refuse a shared-compute agent, reading the **raw wire value**. /// /// Trimmed before comparing: the desktop's own layers disagree about padding @@ -226,6 +253,27 @@ mod tests { assert_eq!(json["ok"], true); assert_eq!(json["protocol_version"], wire::PROTOCOL_VERSION); assert!(json["config_schema"]["properties"]["namespace"].is_object()); + assert!(json.get("ops").is_none()); + } + + /// `stop` is gated like a preflight: no approved identity policy, no + /// cluster contact. + #[test] + fn stop_requires_sandbox_scope_and_identity_policy_before_cluster_contact() { + use nostr::nips::nip19::ToBech32; + let agent = nostr::Keys::generate(); + let mut request = serde_json::json!({ + "op": "stop", + "agent": {"relay_url": "wss://example.invalid", + "private_key_nsec": agent.secret_key().to_bech32().unwrap()}, + "provider_config": {"namespace": "test", + "image": format!("example.invalid/pilot@sha256:{}", "a".repeat(64))} + }); + assert!(error_of(&respond(&request.to_string())).contains("provider_config.sandbox")); + request["provider_config"]["sandbox"] = serde_json::json!({ + "channel_id": uuid::Uuid::new_v4().to_string(), "thread_root": "a".repeat(64) + }); + assert!(error_of(&respond(&request.to_string())).contains("identity_policy")); } #[test] diff --git a/crates/buzz-backend-kubernetes/src/sandbox.rs b/crates/buzz-backend-kubernetes/src/sandbox.rs index b80d361c6fd..5460e7cdb86 100644 --- a/crates/buzz-backend-kubernetes/src/sandbox.rs +++ b/crates/buzz-backend-kubernetes/src/sandbox.rs @@ -12,16 +12,17 @@ use std::time::Duration; use buzz_backend_kubernetes::lifecycle::{ annotation, owned_pod, sandbox_resource, EndedReason, Lifecycle, OperatingMode, BIND_TIMEOUT_SECS, CHANNEL, CHECKPOINTING, CHECKPOINTING_DISABLED, CHECKPOINTING_ENABLED, - ENDED_REASON, GENERATION, INITIAL_POD, OWNER, RESTORE_CHECKPOINT, SANDBOX_BINDING_VERSION, - SESSION, THREAD, + ENDED_REASON, GENERATION, INITIAL_POD, LIFECYCLE, LIFECYCLE_ENDING, OWNER, RESTORE_CHECKPOINT, + SANDBOX_BINDING_VERSION, SESSION, THREAD, }; use chrono::{DateTime, Utc}; use k8s_openapi::api::core::v1::{EnvVar, EnvVarSource, ObjectFieldSelector, Pod, Secret}; use k8s_openapi::apimachinery::pkg::apis::meta::v1::OwnerReference; -use kube::api::{Api, ListParams, PostParams}; +use kube::api::{Api, ListParams, Patch, PatchParams, PostParams}; use kube::core::DynamicObject; use kube::{Client, ResourceExt}; -use serde::Deserialize; +use rand::RngExt; +use serde::{Deserialize, Serialize}; use serde_json::json; use sha2::{Digest, Sha256}; @@ -817,6 +818,86 @@ async fn wait_for_start( } } +/// What `stop` left behind; the `state` of its response. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "lowercase")] +pub enum StopState { + /// Suspended and draining; the reaper writes the tombstone. + Ending, + /// Already a tombstone. + Ended, + /// No Sandbox exists for this thread. + Absent, +} + +/// Read-and-patch attempts before `stop` gives up on a changing Sandbox. +const STOP_ATTEMPTS: u32 = 5; +/// Base pause after a conflicting `stop` patch; grows per attempt. +const STOP_BACKOFF_MS: u64 = 100; + +/// End a (possibly wedged) session: one CAS merge patch sets +/// `lifecycle=ending`, reason `stopped`, and `operatingMode: Suspended`. The +/// controller then deletes the Pod (SIGTERM, so the harness still gets its +/// checkpoint window). The normal stop remains `!shutdown`. +pub async fn stop( + client: Client, + identity: &naming::AgentIdentity, + namespace: &str, + scope: &Options, + owner: &str, +) -> Result<(String, StopState), ProviderError> { + let sandboxes = api(client, namespace); + let name = scope.name(identity); + for attempt in 1..=STOP_ATTEMPTS { + let Some(sandbox) = sandboxes + .get_opt(&name) + .await + .map_err(|e| format!("read Sandbox: {e}"))? + else { + return Ok((name, StopState::Absent)); + }; + verify_identity(&sandbox, identity, scope, owner)?; + match Lifecycle::of(&sandbox) { + Lifecycle::Ended => return Ok((name, StopState::Ended)), + Lifecycle::Ending => return Ok((name, StopState::Ending)), + // Never overwrite a record this version does not understand. + Lifecycle::Unrecognized => { + return Err(ProviderError::refused( + ErrorCode::LifecycleUnknown, + "Sandbox has a lifecycle this provider does not know; update Buzz", + )) + } + Lifecycle::Live => {} + } + let patch = json!({ + "metadata": { + "resourceVersion": sandbox.metadata.resource_version, + "annotations": { + (LIFECYCLE): LIFECYCLE_ENDING, + (ENDED_REASON): EndedReason::Stopped.as_str(), + }, + }, + "spec": {"operatingMode": "Suspended"}, + }); + match sandboxes + .patch(&name, &PatchParams::default(), &Patch::Merge(&patch)) + .await + { + Ok(_) => return Ok((name, StopState::Ending)), + Err(e) if reason_is(&e, "Conflict") => { + if attempt < STOP_ATTEMPTS { + // Jittered, so concurrent writers stop colliding. + let jitter = rand::rng().random_range(0..=STOP_BACKOFF_MS); + let pause = STOP_BACKOFF_MS * u64::from(attempt) + jitter; + tokio::time::sleep(Duration::from_millis(pause)).await; + } + } + Err(e) => return Err(format!("stop Sandbox: {e}").into()), + } + } + Err("Sandbox kept changing while stopping; retry".into()) +} + #[cfg(test)] #[path = "sandbox_tests.rs"] mod tests; diff --git a/crates/buzz-backend-kubernetes/src/sandbox_fake.rs b/crates/buzz-backend-kubernetes/src/sandbox_fake.rs index 7ee8afc0fa0..9faecf565ed 100644 --- a/crates/buzz-backend-kubernetes/src/sandbox_fake.rs +++ b/crates/buzz-backend-kubernetes/src/sandbox_fake.rs @@ -35,6 +35,10 @@ pub struct State { pub fail_secret_creates: usize, /// Store the Secret, then fail this many create responses with a 500. pub fail_secret_create_responses: usize, + /// Answer this many Sandbox patches with a 409 Conflict. + pub conflict_patches: usize, + /// Every merge patch body received. + pub patches: Vec, } pub type Shared = Arc>; @@ -55,6 +59,21 @@ fn ok(value: &Value) -> Response { .unwrap() } +fn merge(target: &mut Value, patch: &Value) { + match (target, patch) { + (Value::Object(target), Value::Object(patch)) => { + for (key, value) in patch { + if value.is_null() { + target.remove(key); + } else { + merge(target.entry(key.clone()).or_insert(Value::Null), value); + } + } + } + (target, patch) => *target = patch.clone(), + } +} + impl State { fn next_rv(&mut self) -> String { self.rv += 1; @@ -202,6 +221,27 @@ impl State { self.reconcile(name); ok(&sandbox) } + (Method::PATCH, Some(name)) => { + let patch: Value = serde_json::from_slice(body).unwrap(); + self.patches.push(patch.clone()); + if self.conflict_patches > 0 { + self.conflict_patches -= 1; + return status(409, "Conflict"); + } + let rv = self.next_rv(); + let Some(current) = self.sandboxes.get_mut(name) else { + return status(404, "NotFound"); + }; + let expected = &patch["metadata"]["resourceVersion"]; + if !expected.is_null() && *expected != current["metadata"]["resourceVersion"] { + return status(409, "Conflict"); + } + merge(current, &patch); + current["metadata"]["resourceVersion"] = rv.into(); + let updated = current.clone(); + self.reconcile(name); + ok(&updated) + } _ => status(405, "MethodNotAllowed"), } } diff --git a/crates/buzz-backend-kubernetes/src/sandbox_tests.rs b/crates/buzz-backend-kubernetes/src/sandbox_tests.rs index b6c530dfa49..a8faa006240 100644 --- a/crates/buzz-backend-kubernetes/src/sandbox_tests.rs +++ b/crates/buzz-backend-kubernetes/src/sandbox_tests.rs @@ -1,5 +1,6 @@ use super::*; use buzz_backend_kubernetes::lifecycle; +use buzz_backend_kubernetes::lifecycle::LIFECYCLE_ENDED; use nostr::nips::nip19::ToBech32; #[path = "sandbox_fake.rs"] @@ -363,18 +364,52 @@ async fn live_controller_reuses_scopes_and_refuses_lost_workspaces() { .await .unwrap_err(); assert_eq!(error.code, Some(ErrorCode::PodReplaced), "{error}"); - let sandboxes = api(client, &namespace) + let sandboxes = api(client.clone(), &namespace) .list(&ListParams::default()) .await .unwrap(); assert_eq!(sandboxes.items.len(), 2, "one Sandbox per canonical thread"); + + // Lifecycle: stop suspends the session; once tombstoned (the reaper's job, + // simulated here) an ordinary deploy is refused. + let (_, state) = stop( + client.clone(), + &identity, + &namespace, + &second_scope, + "owner", + ) + .await + .unwrap(); + assert_eq!(state, StopState::Ending); + let sandboxes = api(client.clone(), &namespace); + let old = sandboxes.get(&second).await.unwrap(); + assert_eq!(old.data["spec"]["operatingMode"], "Suspended"); + let tombstone = json!({"metadata": {"annotations": { + (LIFECYCLE): LIFECYCLE_ENDED, (lifecycle::CHECKPOINT): lifecycle::CHECKPOINT_MISSING, + (lifecycle::ENDED_AT): "2026-01-01T00:00:00Z"}}}); + sandboxes + .patch(&second, &PatchParams::default(), &Patch::Merge(&tombstone)) + .await + .unwrap(); + let ended = deploy( + client.clone(), + &identity, + &cfg, + &second_scope, + "owner", + fixture_env(), + ) + .await + .unwrap_err(); + assert_eq!(ended.code, Some(ErrorCode::SessionEnded), "{ended}"); namespaces .delete(&namespace, &DeleteParams::default()) .await .unwrap(); } -// ── Fake-apiserver tests: the real deploy paths over kube::Client ─────────── +// ── Fake-apiserver tests: the real deploy/stop paths over kube::Client ────── fn fake_identity() -> naming::AgentIdentity { naming::AgentIdentity::from_nsec(&nostr::Keys::generate().secret_key().to_bech32().unwrap()) @@ -872,3 +907,142 @@ async fn a_binding_too_close_to_the_reaper_timeout_is_not_resumed() { } } } + +#[tokio::test(start_paused = true)] +async fn stop_suspends_with_a_cas_patch_and_retries_conflicts() { + let state = fake::Shared::default(); + let (identity, cfg, scope) = (fake_identity(), fake_cfg(), fake_scope()); + assert_eq!( + stop(fake::client(&state), &identity, "agents", &scope, "owner") + .await + .unwrap() + .1, + StopState::Absent + ); + let name = deploy( + fake::client(&state), + &identity, + &cfg, + &scope, + "owner", + plain_env(), + ) + .await + .unwrap(); + assert!(stop( + fake::client(&state), + &identity, + "agents", + &scope, + "other-owner" + ) + .await + .is_err()); + state.lock().unwrap().conflict_patches = 1; + let (agent_id, stopped) = stop(fake::client(&state), &identity, "agents", &scope, "owner") + .await + .unwrap(); + assert_eq!( + (agent_id.as_str(), stopped), + (name.as_str(), StopState::Ending) + ); + { + let guard = state.lock().unwrap(); + let patch = guard.patches.last().unwrap(); + assert!(patch["metadata"]["resourceVersion"].is_string(), "{patch}"); + assert_eq!(patch["spec"]["operatingMode"], "Suspended"); + assert_eq!( + patch["metadata"]["annotations"][LIFECYCLE], + LIFECYCLE_ENDING + ); + assert_eq!(patch["metadata"]["annotations"][ENDED_REASON], "stopped"); + assert_eq!(guard.patches.len(), 2, "one conflict, one retry"); + assert!(!guard.pods.contains_key(&name), "Suspended removes the Pod"); + } + assert_eq!( + stop(fake::client(&state), &identity, "agents", &scope, "owner") + .await + .unwrap() + .1, + StopState::Ending + ); + let ending = deploy( + fake::client(&state), + &identity, + &cfg, + &scope, + "owner", + plain_env(), + ) + .await + .unwrap_err(); + assert_eq!(ending.code, Some(ErrorCode::SessionEnding), "{ending}"); +} + +/// `stop` never rewrites a tombstone, nor a lifecycle this version does not +/// know: either would erase the record the reaper and recovery rely on. +#[tokio::test(start_paused = true)] +async fn stop_leaves_ended_and_unknown_lifecycles_untouched() { + let (identity, cfg, scope) = (fake_identity(), fake_cfg(), fake_scope()); + let cases: [(&str, Result); 2] = [ + (LIFECYCLE_ENDED, Ok(StopState::Ended)), + ("paused", Err(ErrorCode::LifecycleUnknown)), + ]; + for (lifecycle, expected) in cases { + let state = fake::Shared::default(); + let name = deploy( + fake::client(&state), + &identity, + &cfg, + &scope, + "owner", + plain_env(), + ) + .await + .unwrap(); + let before = { + let mut guard = state.lock().unwrap(); + let sandbox = guard.sandboxes.get_mut(&name).unwrap(); + sandbox["metadata"]["annotations"][LIFECYCLE] = lifecycle.into(); + sandbox.clone() + }; + let result = stop(fake::client(&state), &identity, "agents", &scope, "owner").await; + assert_eq!( + result + .map(|(_, stopped)| stopped) + .map_err(|e| e.code.unwrap()), + expected, + "{lifecycle}" + ); + let guard = state.lock().unwrap(); + assert!(guard.patches.is_empty(), "{lifecycle}: patched"); + assert_eq!(guard.sandboxes[&name], before, "{lifecycle}"); + } +} + +/// A Sandbox that keeps changing makes `stop` back off between bounded +/// attempts, then give up. +#[tokio::test(start_paused = true)] +async fn stop_backs_off_between_conflicts_and_gives_up() { + let state = fake::Shared::default(); + let (identity, cfg, scope) = (fake_identity(), fake_cfg(), fake_scope()); + deploy( + fake::client(&state), + &identity, + &cfg, + &scope, + "owner", + plain_env(), + ) + .await + .unwrap(); + state.lock().unwrap().conflict_patches = usize::MAX; + let started = tokio::time::Instant::now(); + let error = stop(fake::client(&state), &identity, "agents", &scope, "owner") + .await + .unwrap_err(); + assert_eq!(error.code, None, "{error}"); + assert_eq!(state.lock().unwrap().patches.len(), STOP_ATTEMPTS as usize); + let minimum: u64 = (1..u64::from(STOP_ATTEMPTS)).sum::() * STOP_BACKOFF_MS; + assert!(started.elapsed() >= Duration::from_millis(minimum)); +} diff --git a/crates/buzz-backend-kubernetes/src/wire.rs b/crates/buzz-backend-kubernetes/src/wire.rs index 54160ec568d..36a7940325d 100644 --- a/crates/buzz-backend-kubernetes/src/wire.rs +++ b/crates/buzz-backend-kubernetes/src/wire.rs @@ -5,6 +5,8 @@ //! in `tests/fixtures/provider-wire/` are the arbiter shared with the desktop. use serde::{Deserialize, Serialize}; + +use crate::sandbox::StopState; use std::collections::BTreeMap; /// The wire-contract version this provider speaks (spec §Info). @@ -23,6 +25,9 @@ pub const PROTOCOL_VERSION: u32 = 1; pub enum Request { Info, Deploy(Box), + /// End an Agent Sandbox session. Same payload as `deploy`; requires an + /// approved `identity_policy` and `provider_config.sandbox`. + Stop(Box), } #[derive(Debug, Deserialize)] @@ -93,6 +98,7 @@ pub enum Response { Info(InfoResponse), Deploy(DeployResponse), IdentityPreflight(IdentityPreflightResponse), + Stop(StopResponse), Error(ErrorResponse), } @@ -122,6 +128,14 @@ pub struct IdentityPreflightResponse { pub preflight: &'static str, } +/// `stop` result: `state` is `ending`, `ended`, or `absent`. +#[derive(Debug, Serialize)] +pub struct StopResponse { + pub ok: bool, + pub agent_id: String, + pub state: StopState, +} + #[derive(Debug, Serialize)] pub struct ErrorResponse { pub ok: bool, @@ -245,6 +259,14 @@ impl Response { }) } + pub fn stopped(agent_id: impl Into, state: StopState) -> Self { + Response::Stop(StopResponse { + ok: true, + agent_id: agent_id.into(), + state, + }) + } + pub fn deployed(agent_id: impl Into) -> Self { Response::Deploy(DeployResponse { ok: true, @@ -280,6 +302,31 @@ mod tests { assert!(matches!(r, Request::Info)); } + #[test] + fn parses_stop_with_the_deploy_payload() { + let r: Request = serde_json::from_str( + r#"{"op":"stop","agent":{"relay_url":"wss://r","private_key_nsec":"nsec1x"}}"#, + ) + .unwrap(); + assert!(matches!(r, Request::Stop(_))); + } + + #[test] + fn stop_response_serializes_flat() { + let v = serde_json::to_value(Response::stopped("buzz-agent-abc-123", StopState::Ending)) + .unwrap(); + assert_eq!( + v, + serde_json::json!({"ok": true, "agent_id": "buzz-agent-abc-123", "state": "ending"}) + ); + } + + #[test] + fn info_remains_compatible_until_the_desktop_accepts_ops() { + let v = serde_json::to_value(Response::info()).unwrap(); + assert!(v.get("ops").is_none()); + } + #[test] fn rejects_unknown_op() { assert!(serde_json::from_str::(r#"{"op":"undeploy"}"#).is_err()); diff --git a/crates/buzz-backend-kubernetes/tests/fixtures/provider-wire/README.md b/crates/buzz-backend-kubernetes/tests/fixtures/provider-wire/README.md index 56a972f8942..12a3d28553a 100644 --- a/crates/buzz-backend-kubernetes/tests/fixtures/provider-wire/README.md +++ b/crates/buzz-backend-kubernetes/tests/fixtures/provider-wire/README.md @@ -37,3 +37,8 @@ Three rules keep these useful rather than decorative: `deploy-*` fixtures cover only responses reachable without a cluster — refusals and malformed input. A successful deploy needs an apiserver and is covered by the conformance suite, not by a static fixture. + +`stop-*` fixtures use the same payload as `deploy` with `op: "stop"`; the +desktop builds both through one path. `stop` requires an approved +`identity_policy`, so the fixture without one is refused before any cluster +contact. diff --git a/crates/buzz-backend-kubernetes/tests/fixtures/provider-wire/stop-no-identity-policy.request.json b/crates/buzz-backend-kubernetes/tests/fixtures/provider-wire/stop-no-identity-policy.request.json new file mode 100644 index 00000000000..2a644a2af4d --- /dev/null +++ b/crates/buzz-backend-kubernetes/tests/fixtures/provider-wire/stop-no-identity-policy.request.json @@ -0,0 +1,58 @@ +{ + "op": "stop", + "request_id": "req-7", + "agent": { + "agent_args": [], + "agent_command": "goose", + "auth_tag": "tag-1", + "env_vars": { + "USER_KEY": "user-value" + }, + "idle_timeout_seconds": null, + "launch": { + "args": [ + "acp" + ], + "command": "goose", + "env": { + "GOOSE_MODEL": "gpt-5", + "GOOSE_PROVIDER": "openai", + "USER_KEY": "user-value" + }, + "owner_pubkey": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "policy_env": { + "BUZZ_ACP_AGENTS": "10", + "BUZZ_ACP_DISPLAY_NAME": "worker", + "BUZZ_ACP_LAZY_POOL": "true", + "BUZZ_ACP_MODEL": "gpt-5", + "BUZZ_ACP_RELAY_OBSERVER": "true", + "BUZZ_ACP_SESSION_POLICY": "channel", + "BUZZ_ACP_SESSION_TITLE": "worker", + "GOOSE_MODE": "auto" + } + }, + "max_turn_duration_seconds": null, + "model": "gpt-5", + "name": "worker", + "parallelism": 10, + "private_key_nsec": "nsec1vl029mgpspedva04g90vltkh6fvh240zqtv9k0t9af8935ke9laqsnlfe5", + "provider": "openai", + "relay_url": "wss://relay.example", + "respond_to": "allowlist", + "respond_to_allowlist": [ + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + ], + "system_prompt": null, + "turn_timeout_seconds": 300 + }, + "provider_config": { + "namespace": "buzz-agents-test", + "image": "ghcr.io/block/buzz-sprig@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "inactivity_seconds": 3600, + "sandbox": { + "channel_id": "0b6c1f3e-5a7d-4c2b-9e8f-1a2b3c4d5e6f", + "thread_root": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc" + } + } +} diff --git a/crates/buzz-backend-kubernetes/tests/fixtures/provider-wire/stop-no-identity-policy.response.json b/crates/buzz-backend-kubernetes/tests/fixtures/provider-wire/stop-no-identity-policy.response.json new file mode 100644 index 00000000000..23a5f28ad02 --- /dev/null +++ b/crates/buzz-backend-kubernetes/tests/fixtures/provider-wire/stop-no-identity-policy.response.json @@ -0,0 +1 @@ +{"ok":false,"error":"identity preflight requires an approved identity_policy"} diff --git a/crates/buzz-backend-kubernetes/tests/wire_fixtures.rs b/crates/buzz-backend-kubernetes/tests/wire_fixtures.rs index c7f09aa84c2..a46460d5e3a 100644 --- a/crates/buzz-backend-kubernetes/tests/wire_fixtures.rs +++ b/crates/buzz-backend-kubernetes/tests/wire_fixtures.rs @@ -98,6 +98,7 @@ fn responses_match_their_fixtures() { "deploy-relay-mesh-padded", "deploy-tag-image", "deploy-no-owner", + "stop-no-identity-policy", ]; // The list must cover every response fixture on disk. A literal array is // never empty, so `!is_empty()` would assert nothing; what can actually go @@ -147,6 +148,9 @@ fn info_response_carries_the_contract_fields() { assert_eq!(info["ok"], true); assert_eq!(info["protocol_version"], 1); assert_eq!(info["name"], "kubernetes"); + // `ops` is not advertised yet: a desktop that predates it must keep + // accepting `info`. A later change adds it, additive within version 1. + assert!(info.get("ops").is_none()); let schema = &info["config_schema"]; assert_eq!( schema["required"], diff --git a/docs/remote-agents.md b/docs/remote-agents.md index 57520ba64fd..6268259eb18 100644 --- a/docs/remote-agents.md +++ b/docs/remote-agents.md @@ -401,6 +401,13 @@ response: {"ok": true, "name": str, "version": str, timeout: 10s ``` +**Planned (added in a later change):** an `ops: [str]` field that lists the +operations the provider answers. It will be additive within protocol +version 1: a provider that omits it answers only `info` and `deploy`. The +desktop will show Sandbox session controls (§Sandbox lifecycle) only when +`ops` contains `stop`. The provider does not advertise `ops` yet, so that +desktops that predate the field keep accepting `info`. + `version` is the provider's *software* version — useful in error reports, useless for compatibility. `protocol_version` (this document: `1`) is the wire-contract version, following the pattern Docker's CLI plugins @@ -469,8 +476,27 @@ collide with or reconstruct a reserved key. returns the pod name). `D` stores it as `backend_agent_id`; its presence is the `deployed` axis of I3. -**There is no `undeploy` op in v1.** Deletion of a remote agent from `D` -orphans the substrate objects; the UI therefore requires an explicit +### `stop` + +``` +request: {"op": "stop", "request_id": "", + "agent": , "provider_config": {…}} +response: {"ok": true, "agent_id": str, "state": "ending" | "ended" | "absent"} +timeout: 600s +``` + +`stop` ends one Agent Sandbox session (§Sandbox lifecycle). The request is +the `deploy` request with a different `op`. `provider_config.sandbox` and an +approved `identity_policy` are required, and the provider checks them before +it contacts the cluster. `stop` refuses a `recovery` field. `ending` means +the provider suspended the session and the reaper will record it as ended; +`ended` and `absent` mean that there was nothing to stop. `stop` is for a +wedged session: the normal stop is still `!shutdown` (§Stop and Delete), +because only the harness can drain turns and save a checkpoint first. + +**There is no `undeploy` op in v1.** `stop` ends a Sandbox session but +keeps its record; it does not delete an agent. Deletion of a remote agent +from `D` orphans the substrate objects; the UI therefore requires an explicit `force_remote_delete` confirmation, and the binding's GC + I5 bound the orphan's cost (the agent self-stops; the pod residue is reaped on the next deploy of the same key, or manually). @@ -894,7 +920,7 @@ can yield two live instances in one scope. ### Stop and Delete -- **Stop** is not a provider operation. `D` publishes `!shutdown` mentioning +- **Normal stop** is not a provider operation. `D` publishes `!shutdown` mentioning the agent on `R`; the harness verifies the sender is the owner and exits through its graceful path: agent-pool shutdown, drain of in-flight turns, publish presence `offline`, close relay connection. **The spec does not @@ -929,6 +955,12 @@ can yield two live instances in one scope. budget (§K8s Grace: 300s); anyone re-deriving "~37s" from the segment constants is reading numbers without their variables. The desktop's local stop command rejects remote agents. +- **Agent Sandbox sessions** stop the same way: `!shutdown` lets the harness + drain and save its workspace checkpoint. A session that does not respond + can be ended with the provider `stop` op, which suspends the Sandbox. The + controller then deletes the Pod with `SIGTERM`, so the harness still gets + its checkpoint window. Neither path restarts the session (§Sandbox + lifecycle). - **Delete** with a live `backend_agent_id` requires `force_remote_delete: true` from the UI's orphan-warning confirmation — a buggy IPC caller cannot silently orphan substrate objects. @@ -1572,7 +1604,9 @@ or reused, and a new refusal gets a new code. They are defined in The `session_ended` text names the ended reason only when it is one of the spellings in §Sandbox lifecycle; any other annotation value is not repeated. Transient and configuration failures (apiserver errors, timeouts) carry no -`code`. +`code`. A `stop` refusal uses the same codes: `identity_mismatch` for a +Sandbox of a different agent, owner, or thread, and `lifecycle_unknown` for +a lifecycle value that `stop` must not overwrite. ### Distribution