diff --git a/README.md b/README.md index d3f7abe..bdafc39 100644 --- a/README.md +++ b/README.md @@ -124,6 +124,25 @@ agent "" { } ``` +For a zero-interruption migration, add `lifecycle "adopt-only"` to the compact +agent or to an explicit `pty`/`exec` task. st2 adopts that task when its current +generation is alive. If the generation is dead or absent, st2 reports the task +as `held` and does not remove or launch anything: + +```kdl +agent "" { + host "" + workspace "" + lifecycle "adopt-only" + argv "codex" "" +} +``` + +This is a fence, not a restart policy. After inspecting or recovering the +original generation, deliberately change the lifecycle back to `"service"` (or +remove the field) to authorize ordinary absent launch and dead replacement. +`retired #true` remains an explicit teardown instruction and takes precedence. + `resource` binds an agent-local semantic name to an exact RFC 3986 absolute URI. `_tag` selects a concrete resource contract understood by downstream readers; st2 preserves arbitrary non-empty tags and URI bytes without normalization. It neither owns their schemas nor resolves their targets. diff --git a/crates/agent-spec/src/kdl_format.rs b/crates/agent-spec/src/kdl_format.rs index 70bbfbd..a14a397 100644 --- a/crates/agent-spec/src/kdl_format.rs +++ b/crates/agent-spec/src/kdl_format.rs @@ -83,6 +83,7 @@ fn agent_node_to_raw(node: &KdlNode) -> anyhow::Result { "supervisor" => raw.supervisor = arg_string(child), "retired" => raw.retired = arg_bool(child), "keep" => raw.keep = arg_bool(child), + "lifecycle" => raw.lifecycle = arg_string(child), "restart" => raw.restart = Some(restart_node_to_raw(child)), "resource" => { let (name, resource) = resource_node_to_raw(child)?; @@ -199,6 +200,7 @@ fn task_node_to_raw(node: &KdlNode) -> anyhow::Result { "argv" => t.argv = Some(argv(child)?), "cwd" => t.cwd = arg_string(child), "keep" => t.keep = arg_bool(child), + "lifecycle" => t.lifecycle = arg_string(child), // `tags role="agent" "st.network"="$CATALOG"` — properties on the node. "tags" => { for entry in child.entries() { diff --git a/crates/agent-spec/src/lib.rs b/crates/agent-spec/src/lib.rs index 0377dcc..e3f5fff 100644 --- a/crates/agent-spec/src/lib.rs +++ b/crates/agent-spec/src/lib.rs @@ -28,5 +28,6 @@ pub use discovery::{ Declared, Discovered, SpecError, discover, is_catalog_path, parse_declared, path_defaults, }; pub use spec::{ - AgentSpec, JobType, Resource, Restart, RestartMode, Task, TaskKind, parse_duration, + AgentSpec, JobType, Resource, Restart, RestartMode, Task, TaskKind, TaskLifecycle, + parse_duration, }; diff --git a/crates/agent-spec/src/spec.rs b/crates/agent-spec/src/spec.rs index e856c7e..f47d2d1 100644 --- a/crates/agent-spec/src/spec.rs +++ b/crates/agent-spec/src/spec.rs @@ -4,7 +4,7 @@ //! allocates a terminal, an agent harness) and `exec{}` (a plain process — the ding, daemons, a //! stage's script; must NOT allocate a terminal, R09). st2 reads only the runner-normative subset: //! `identity`, `host`, `role` (metadata only), `type`, `workspace`, `retired`, `keep`, `supervisor`, -//! `restart{}`, Resource bindings (declaration metadata), and the tasks. Everything render-only +//! `restart{}`, task lifecycle, Resource bindings (declaration metadata), and the tasks. Everything render-only //! (`harness`, `model`, `persona`, `permissions`, `transport`, `strategy`, `meta{}`) is baked into //! the tasks/commands by the render layer and ignored here. //! @@ -142,6 +142,9 @@ pub struct Task { pub env: BTreeMap, /// Per-task GC pin. pub keep: bool, + /// Reconciliation policy. `adopt-only` is a migration fence: st2 may adopt a live generation, + /// but must not reap a dead generation or create a missing replacement. + pub lifecycle: TaskLifecycle, } /// Whether a task allocates a terminal. @@ -153,6 +156,16 @@ pub enum TaskKind { Exec, } +/// How st2 reconciles a declared task. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum TaskLifecycle { + /// Ordinary service lifecycle: launch when absent and replace when dead. + #[default] + Service, + /// Migration fence: adopt an already-live generation, otherwise hold without mutation. + AdoptOnly, +} + /// Restart policy (§4). Applies to long-running `service` tasks. #[derive(Debug, Clone, PartialEq, Eq)] pub struct Restart { @@ -276,6 +289,8 @@ pub(crate) struct RawSpec { /// Compact catalog form: include the built-in `st2 ding` sidecar. #[serde(default)] pub ding: bool, + /// Compact catalog form: reconciliation policy for the generated agent PTY. + pub lifecycle: Option, /// `pty "" {}` / `[pty.]` — interactive tasks. #[serde(default)] pub pty: BTreeMap, @@ -307,6 +322,7 @@ pub(crate) struct RawTask { pub env: BTreeMap, #[serde(default)] pub keep: bool, + pub lifecycle: Option, } #[derive(Debug, Default, Deserialize)] @@ -606,6 +622,8 @@ impl RawSpec { tasks.push(t.lower(&identity, TaskKind::Exec, name, &self.env)?); } if self.command.is_some() || self.argv.is_some() { + let lifecycle = + parse_task_lifecycle(&identity, "compact task", self.lifecycle.as_deref())?; let mut tags = BTreeMap::new(); tags.insert("role".to_string(), "agent".to_string()); tasks.push(Task { @@ -620,6 +638,7 @@ impl RawSpec { tags, env: self.env.clone(), keep: false, + lifecycle, }); } if self.ding { @@ -634,6 +653,7 @@ impl RawSpec { tags: BTreeMap::new(), env: self.env, keep: false, + lifecycle: TaskLifecycle::Service, }); } tasks.sort_by(|a, b| a.name.cmp(&b.name)); @@ -675,6 +695,11 @@ impl RawTask { )?; let mut env = inherited_env.clone(); env.extend(self.env); + let lifecycle = parse_task_lifecycle( + identity, + &format!("{kind:?} task '{name}'"), + self.lifecycle.as_deref(), + )?; Ok(Task { kind, derived: false, @@ -686,10 +711,25 @@ impl RawTask { tags: self.tags, env, keep: self.keep, + lifecycle, }) } } +fn parse_task_lifecycle( + identity: &str, + location: &str, + lifecycle: Option<&str>, +) -> anyhow::Result { + match lifecycle { + None | Some("service") => Ok(TaskLifecycle::Service), + Some("adopt-only") => Ok(TaskLifecycle::AdoptOnly), + Some(other) => { + anyhow::bail!("agent '{identity}' {location} has unknown lifecycle '{other}'") + } + } +} + fn validate_launch( identity: &str, command: Option<&String>, diff --git a/crates/agent-spec/tests/discovery.rs b/crates/agent-spec/tests/discovery.rs index c340b69..e7113ab 100644 --- a/crates/agent-spec/tests/discovery.rs +++ b/crates/agent-spec/tests/discovery.rs @@ -8,7 +8,7 @@ use std::fs; use std::path::Path; use std::time::Duration; -use agent_spec::spec::TaskKind; +use agent_spec::spec::{TaskKind, TaskLifecycle}; use agent_spec::{AgentSpec, JobType, Resource, Task, discover}; fn write(root: &Path, rel: &str, contents: &str) { @@ -53,6 +53,7 @@ agent "fabric-claude" { pty "agent" { id "silber.fabric-claude" + lifecycle "adopt-only" command #"exec claude --permission-mode bypassPermissions 'boot'"# tags role="agent" env="prod" env { @@ -107,6 +108,7 @@ fn parses_full_kdl_service_job() { let agent = s.tasks.iter().find(|t| t.name == "agent").unwrap(); assert_eq!(agent.kind, TaskKind::Pty); assert_eq!(agent.id.as_deref(), Some("silber.fabric-claude")); + assert_eq!(agent.lifecycle, TaskLifecycle::AdoptOnly); assert!(agent.command.as_deref().unwrap().starts_with("exec claude")); assert_eq!(agent.tags.get("role").map(String::as_str), Some("agent")); assert_eq!( @@ -172,6 +174,47 @@ agent "cos" { ); } +#[test] +fn compact_adopt_only_lifecycle_lowers_to_the_generated_agent_task() { + let tmp = tempfile::tempdir().unwrap(); + write( + tmp.path(), + "agents/h/migrant/agent.kdl", + r#" +agent "migrant" { + host "h" + lifecycle "adopt-only" + command "codex" +} +"#, + ); + + let found = discover(tmp.path()); + assert!(found.errors.is_empty(), "{:?}", found.errors); + assert_eq!(found.specs[0].tasks[0].lifecycle, TaskLifecycle::AdoptOnly); +} + +#[test] +fn unknown_task_lifecycle_is_rejected_instead_of_falling_back_to_service() { + let tmp = tempfile::tempdir().unwrap(); + write( + tmp.path(), + "agents/h/unsafe/agent.kdl", + r#" +agent "unsafe" { + host "h" + lifecycle "replace-maybe" + command "codex" +} +"#, + ); + + let found = discover(tmp.path()); + assert!(found.specs.is_empty()); + assert_eq!(found.errors.len(), 1); + assert!(found.errors[0].message.contains("unknown lifecycle")); +} + #[test] fn direct_argv_lowers_for_compact_and_explicit_kdl_tasks() { let tmp = tempfile::tempdir().unwrap(); diff --git a/docs/vrs/spec.md b/docs/vrs/spec.md index 4309fe2..88d8ed5 100644 --- a/docs/vrs/spec.md +++ b/docs/vrs/spec.md @@ -110,6 +110,13 @@ validate ──► materialize ──► host-local st2 scheduler/reconciler binary, starts the control plane again, and proves adoption with the same agent PID/creation identity and no duplicate process. +- **Adopt-only migration fence:** A compact agent or explicit task may declare + `lifecycle "adopt-only"`. Reconciliation adopts an already-live generation, + but classifies a dead or absent generation as `held` without garbage + collection or launch. Returning the declaration to the default `service` + lifecycle is the explicit authority to resume ordinary replacement. + `retired #true` remains the separate explicit teardown path. + - **Session registry:** A catalog owns the `pty` registry holding its tasks. `/pty` is the default; a catalog may declare another so that one host can share a single registry across catalogs. Resolution is an exported diff --git a/src/eval_run.rs b/src/eval_run.rs index c85fc1e..87f5eba 100644 --- a/src/eval_run.rs +++ b/src/eval_run.rs @@ -16,7 +16,7 @@ use crate::expand::expand_catalog; use crate::flapping::FlappingCap; use crate::reconcile::reconcile; use crate::run::{Runner, SystemRunner, UpReport, detect_host, execute}; -use agent_spec::spec::{AgentSpec, JobType, Task, TaskKind}; +use agent_spec::spec::{AgentSpec, JobType, Task, TaskKind, TaskLifecycle}; macro_rules! eval_log { ($($arg:tt)*) => { @@ -71,6 +71,7 @@ pub fn spec_to_agent_specs(agents: &[SpecAgent], host: &str, root: &Path) -> Vec tags: ptags, env: a.env.clone(), keep: false, + lifecycle: TaskLifecycle::Service, }); for ex in &a.execs { tasks.push(Task { @@ -84,6 +85,7 @@ pub fn spec_to_agent_specs(agents: &[SpecAgent], host: &str, root: &Path) -> Vec tags: BTreeMap::new(), env: ex.env.clone(), keep: false, + lifecycle: TaskLifecycle::Service, }); } AgentSpec { diff --git a/src/lib.rs b/src/lib.rs index 6e8ad32..ce2f7c5 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -36,7 +36,8 @@ pub use agent_spec::{discovery, spec}; pub use agent_spec::discovery::{Discovered, SpecError, discover}; pub use agent_spec::spec::{ - AgentSpec, JobType, Resource, Restart, RestartMode, Task, TaskKind, parse_duration, + AgentSpec, JobType, Resource, Restart, RestartMode, Task, TaskKind, TaskLifecycle, + parse_duration, }; pub use exec_backend::ExecBackend; pub use expand::{expand_env, expand_vars}; diff --git a/src/main.rs b/src/main.rs index 35d887f..8fc41ff 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1890,6 +1890,7 @@ fn print_report(report: &UpReport) { report_line("launched", &report.launched); report_line("torn down", &report.torn_down); report_line("gc", &report.gc); + report_line("held", &report.held); report_line("flapping", &report.flapping); report_line("adopted", &report.adopted); report_line("other-host", &report.other_host); diff --git a/src/reconcile.rs b/src/reconcile.rs index baf822f..f69c395 100644 --- a/src/reconcile.rs +++ b/src/reconcile.rs @@ -11,7 +11,7 @@ use std::collections::BTreeMap; use std::collections::HashMap; -use agent_spec::spec::{AgentSpec, TaskKind}; +use agent_spec::spec::{AgentSpec, TaskKind, TaskLifecycle}; /// ACTUAL state: one running/known task as st2 observes it (unioned across backends). #[derive(Debug, Clone, PartialEq, Eq)] @@ -87,6 +87,8 @@ pub struct ReconcilePlan<'a> { pub unrunnable: Vec<&'a AgentSpec>, /// Dead, non-`keep` sessions of declared tasks → reap (`rm`). pub gc: Vec, + /// Dead or absent `adopt-only` task ids held without reap or launch. + pub held: Vec, } /// Resolve one exact local task selector (`host.agent.task` or explicit task id) without mutation. @@ -172,7 +174,9 @@ pub fn reconcile_selected<'a>( keep: task.keep || owner.keep, }; match actual { - Some(s) if s.alive || target.keep => plan.adopt.push(owner), + Some(s) if s.alive => plan.adopt.push(owner), + _ if task.lifecycle == TaskLifecycle::AdoptOnly => plan.held.push(runtime), + Some(_) if target.keep => plan.adopt.push(owner), Some(_) => { plan.gc.push(runtime); plan.launch.push(Launch { @@ -255,7 +259,7 @@ pub fn reconcile<'a>( continue; } - let targets: Vec = spec + let targets: Vec<(TaskTarget, TaskLifecycle)> = spec .tasks .iter() .filter_map(|t| { @@ -276,27 +280,36 @@ pub fn reconcile<'a>( } else { env.remove("ST_SUPERVISOR"); } - Some(TaskTarget { - kind: t.kind, - pty_id: resolve_task_id(&bus_id, &t.name, t.id.as_deref()), - bus_id: bus_id.clone(), - name: t.name.clone(), - launch, - cwd: t.cwd.clone(), - workspace: spec.workspace.clone(), - tags: t.tags.clone(), - env, - keep: t.keep || spec.keep, - }) + Some(( + TaskTarget { + kind: t.kind, + pty_id: resolve_task_id(&bus_id, &t.name, t.id.as_deref()), + bus_id: bus_id.clone(), + name: t.name.clone(), + launch, + cwd: t.cwd.clone(), + workspace: spec.workspace.clone(), + tags: t.tags.clone(), + env, + keep: t.keep || spec.keep, + }, + t.lifecycle, + )) }) .collect(); debug_assert!(!targets.is_empty()); let mut to_launch = Vec::new(); - for target in targets { + let held_before = plan.held.len(); + for (target, lifecycle) in targets { match session_state(&by_id, &target.pty_id) { SessionState::Alive => {} + SessionState::Dead | SessionState::Absent + if lifecycle == TaskLifecycle::AdoptOnly => + { + plan.held.push(target.pty_id.clone()); + } SessionState::Dead if target.keep => {} SessionState::Dead => { plan.gc.push(target.pty_id.clone()); @@ -306,9 +319,9 @@ pub fn reconcile<'a>( } } - if to_launch.is_empty() { + if to_launch.is_empty() && plan.held.len() == held_before { plan.adopt.push(spec); - } else { + } else if !to_launch.is_empty() { plan.launch.push(Launch { spec, tasks: to_launch, diff --git a/src/run.rs b/src/run.rs index 10f9a3b..9d24e3c 100644 --- a/src/run.rs +++ b/src/run.rs @@ -548,6 +548,8 @@ pub struct UpReport { /// not-alive but was alive within the grace window, i.e. a transient `pty list` flicker under load, /// left alone rather than destructively reaped (R21c). Not "noteworthy" (it's a no-op by design). pub deferred: Vec, + /// dead or absent adopt-only task ids held without reap or launch. + pub held: Vec, /// pty ids the flapping-cap refused to (re)launch this pass (parked / crash-looping). pub flapping: Vec, /// Rich crash-loop records (a superset of `flapping`) — the source for supervisor surfacing. @@ -571,6 +573,7 @@ impl UpReport { self.torn_down.append(&mut other.torn_down); self.gc.append(&mut other.gc); self.deferred.append(&mut other.deferred); + self.held.append(&mut other.held); self.flapping.append(&mut other.flapping); self.crash_loops.append(&mut other.crash_loops); self.adopted.append(&mut other.adopted); @@ -679,6 +682,7 @@ pub fn execute( report .adopted .extend(plan.adopt.iter().map(|s| s.identity.clone())); + report.held.extend(plan.held.iter().cloned()); report .other_host .extend(plan.other_host.iter().map(|s| s.identity.clone())); @@ -1327,7 +1331,7 @@ pub fn detect_host() -> String { #[cfg(test)] mod tests { use super::*; - use agent_spec::spec::{AgentSpec, JobType, Task, TaskKind}; + use agent_spec::spec::{AgentSpec, JobType, Task, TaskKind, TaskLifecycle}; use std::cell::Cell; use std::collections::BTreeMap; use std::ffi::OsStr; @@ -1394,6 +1398,7 @@ mod tests { tags: BTreeMap::new(), env: BTreeMap::new(), keep: false, + lifecycle: TaskLifecycle::Service, }], path: "/tmp/spec.kdl".into(), }; diff --git a/tests/reconcile.rs b/tests/reconcile.rs index 2000813..d425375 100644 --- a/tests/reconcile.rs +++ b/tests/reconcile.rs @@ -5,7 +5,7 @@ use std::path::PathBuf; use st2::reconcile::reconcile_selected; use st2::reconcile::resolve_task; -use st2::spec::{AgentSpec, JobType, Resource, Task, TaskKind}; +use st2::spec::{AgentSpec, JobType, Resource, Task, TaskKind, TaskLifecycle}; use st2::{Session, reconcile}; #[test] @@ -183,6 +183,28 @@ fn selected_reconcile_freezes_dead_keep_and_retired_task_keep() { assert!(p.teardown.is_empty() && p.gc.is_empty()); } +#[test] +fn selected_reconcile_holds_adopt_only_dead_or_absent_without_mutation() { + let mut t = task(TaskKind::Pty, "agent", None, Some("codex")); + t.lifecycle = TaskLifecycle::AdoptOnly; + let specs = [svc("a", None, vec![t])]; + + for sessions in [ + vec![Session { + pty_id: "host.a.agent".into(), + alive: false, + exit_code: Some(1), + }], + vec![], + ] { + let plan = reconcile_selected(&specs, &sessions, "host", "host.a.agent").unwrap(); + assert!(plan.launch.is_empty()); + assert!(plan.gc.is_empty()); + assert!(plan.adopt.is_empty()); + assert_eq!(plan.held, ["host.a.agent"]); + } +} + #[test] fn selected_reconcile_action_ids_are_exact_and_refusals_immutable() { let specs = vec![ @@ -199,13 +221,7 @@ fn selected_reconcile_action_ids_are_exact_and_refusals_immutable() { let sessions = vec![live("host.a.y"), live("host.b.z")]; let before = (specs.clone(), sessions.clone()); let p = reconcile_selected(&specs, &sessions, "host", "host.a.x").unwrap(); - assert_eq!( - p.launch - .iter() - .flat_map(|l| l.tasks.iter().map(|t| t.pty_id.as_str())) - .collect::>(), - vec!["host.a.x"] - ); + assert_eq!(p.launch.iter().flat_map(|l| l.tasks.iter().map(|t| t.pty_id.as_str())).collect::>(), vec!["host.a.x"]); assert!(p.gc.is_empty() && p.teardown.is_empty()); assert!(reconcile_selected(&specs, &sessions, "host", "host.a.missing").is_err()); assert_eq!((specs, sessions), before); @@ -248,7 +264,13 @@ fn selected_dead_non_keep_gc_and_relaunch_only_selected() { ) .unwrap(); assert_eq!(p.gc, vec!["host.a.x"]); - assert_eq!(p.launch.iter().flat_map(|l| l.tasks.iter().map(|t| t.pty_id.as_str())).collect::>(), vec!["host.a.x"]); + assert_eq!( + p.launch + .iter() + .flat_map(|l| l.tasks.iter().map(|t| t.pty_id.as_str())) + .collect::>(), + vec!["host.a.x"] + ); assert!(p.teardown.is_empty()); } #[test] @@ -333,6 +355,7 @@ fn task(kind: TaskKind, name: &str, id: Option<&str>, command: Option<&str>) -> tags: BTreeMap::new(), env: BTreeMap::new(), keep: false, + lifecycle: TaskLifecycle::Service, } } @@ -479,6 +502,33 @@ fn exited_session_is_reaped_and_relaunched() { assert_eq!(plan.gc, vec!["hetz.a"]); // reap the corpse, then respawn } +#[test] +fn adopt_only_task_holds_dead_or_absent_generation_without_replacement() { + let mut t = task(TaskKind::Pty, "agent", Some("hetz.a"), Some("x")); + t.lifecycle = TaskLifecycle::AdoptOnly; + let specs = vec![svc("a", Some(HOST), vec![t])]; + + for sessions in [vec![dead("hetz.a")], vec![]] { + let plan = reconcile(&specs, &sessions, HOST); + assert!(plan.launch.is_empty()); + assert!(plan.gc.is_empty()); + assert_eq!(plan.held, vec!["hetz.a"]); + } +} + +#[test] +fn leaving_adopt_only_explicitly_restores_replacement_lifecycle() { + let specs = vec![svc( + "a", + Some(HOST), + vec![task(TaskKind::Pty, "agent", Some("hetz.a"), Some("x"))], + )]; + let plan = reconcile(&specs, &[dead("hetz.a")], HOST); + assert_eq!(plan.gc, vec!["hetz.a"]); + assert_eq!(plan.launch[0].tasks[0].pty_id, "hetz.a"); + assert!(plan.held.is_empty()); +} + #[test] fn dead_keep_task_is_frozen_not_reaped() { let mut t = task(TaskKind::Pty, "agent", Some("hetz.a"), Some("x")); diff --git a/tests/run.rs b/tests/run.rs index 60f5bd3..43603a6 100644 --- a/tests/run.rs +++ b/tests/run.rs @@ -9,7 +9,7 @@ use st2::message; use st2::reconcile::{Session, TaskTarget}; use st2::run::Runner; use st2::run::{CrashLoop, surface_crash_loop, up_once_selected, up_once_selected_specs}; -use st2::spec::{AgentSpec, JobType, Task, TaskKind}; +use st2::spec::{AgentSpec, JobType, Task, TaskKind, TaskLifecycle}; fn selected_catalog_agent(identity: &str, workspace: &Path, render: &str) -> String { format!( @@ -237,6 +237,7 @@ fn task_spec(identity: &str, host: Option<&str>, id: &str) -> AgentSpec { tags: BTreeMap::new(), env: BTreeMap::new(), keep: false, + lifecycle: TaskLifecycle::Service, }], path: "/tmp/spec.kdl".into(), } @@ -263,6 +264,7 @@ fn two_task_spec(identity: &str, first: &str, second: &str) -> AgentSpec { tags: BTreeMap::new(), env: BTreeMap::new(), keep: false, + lifecycle: TaskLifecycle::Service, }); spec } @@ -293,6 +295,29 @@ fn selected_one_shot_missing_spawns_only_selected_task() { assert_eq!(report.launched, ["host.agent.work"]); } +#[test] +fn selected_adopt_only_absent_task_is_reported_held_without_runner_mutation() { + let mut spec = task_spec("agent", None, "host.agent.work"); + spec.tasks[0].lifecycle = TaskLifecycle::AdoptOnly; + let runner = FakeRunner::default(); + + let report = up_once_selected_specs( + Path::new("/tmp"), + &[spec], + "host.agent.work", + "host", + &runner, + ) + .unwrap(); + + assert_eq!(report.held, ["host.agent.work"]); + assert!(report.launched.is_empty()); + assert!(report.gc.is_empty()); + assert!(runner.spawned.borrow().is_empty()); + assert!(runner.reaped.borrow().is_empty()); + assert!(runner.removed.borrow().is_empty()); +} + #[test] fn selected_one_shot_live_adopts_without_actions() { let runner = FakeRunner {