Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,25 @@ agent "<identity>" {
}
```

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 "<identity>" {
host "<host>"
workspace "<workspace>"
lifecycle "adopt-only"
argv "codex" "<boot prompt>"
}
```

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.
Expand Down
2 changes: 2 additions & 0 deletions crates/agent-spec/src/kdl_format.rs
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@ fn agent_node_to_raw(node: &KdlNode) -> anyhow::Result<RawSpec> {
"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)?;
Expand Down Expand Up @@ -199,6 +200,7 @@ fn task_node_to_raw(node: &KdlNode) -> anyhow::Result<RawTask> {
"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() {
Expand Down
3 changes: 2 additions & 1 deletion crates/agent-spec/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};
42 changes: 41 additions & 1 deletion crates/agent-spec/src/spec.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
//!
Expand Down Expand Up @@ -142,6 +142,9 @@ pub struct Task {
pub env: BTreeMap<String, String>,
/// 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.
Expand All @@ -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 {
Expand Down Expand Up @@ -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<String>,
/// `pty "<name>" {}` / `[pty.<name>]` — interactive tasks.
#[serde(default)]
pub pty: BTreeMap<String, RawTask>,
Expand Down Expand Up @@ -307,6 +322,7 @@ pub(crate) struct RawTask {
pub env: BTreeMap<String, String>,
#[serde(default)]
pub keep: bool,
pub lifecycle: Option<String>,
}

#[derive(Debug, Default, Deserialize)]
Expand Down Expand Up @@ -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 {
Expand All @@ -620,6 +638,7 @@ impl RawSpec {
tags,
env: self.env.clone(),
keep: false,
lifecycle,
});
}
if self.ding {
Expand All @@ -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));
Expand Down Expand Up @@ -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,
Expand All @@ -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<TaskLifecycle> {
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>,
Expand Down
45 changes: 44 additions & 1 deletion crates/agent-spec/tests/discovery.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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!(
Expand Down Expand Up @@ -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();
Expand Down
7 changes: 7 additions & 0 deletions docs/vrs/spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
`<catalog>/pty` is the default; a catalog may declare another so that one host
can share a single registry across catalogs. Resolution is an exported
Expand Down
4 changes: 3 additions & 1 deletion src/eval_run.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)*) => {
Expand Down Expand Up @@ -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 {
Expand All @@ -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 {
Expand Down
3 changes: 2 additions & 1 deletion src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand Down
1 change: 1 addition & 0 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
49 changes: 31 additions & 18 deletions src/reconcile.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)]
Expand Down Expand Up @@ -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<String>,
/// Dead or absent `adopt-only` task ids held without reap or launch.
pub held: Vec<String>,
}

/// Resolve one exact local task selector (`host.agent.task` or explicit task id) without mutation.
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -255,7 +259,7 @@ pub fn reconcile<'a>(
continue;
}

let targets: Vec<TaskTarget> = spec
let targets: Vec<(TaskTarget, TaskLifecycle)> = spec
.tasks
.iter()
.filter_map(|t| {
Expand All @@ -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());
Expand All @@ -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,
Expand Down
Loading
Loading