Skip to content

Commit 152cdc5

Browse files
Add adopt-only migration lifecycle (#99)
agent-session-id: a078daee-6f98-4916-91a8-d21291407789 agent-tool: Codex CLI agent-tool-version: 0.145.0 agent-model: unknown agent-runtime-profile: /nix/store/mnx8agbdq3wiyb6vz63lhgscgazkrn98-coding-agent-runtime-profile/share/coding-agents/profile.json agent-skills-manifest: /nix/store/5r69m9k2llmri3na81518zx0a7y0d3cn-agent-skills-corpus/share/agent-skills/manifest.json tooling-profile: dotfiles@0fb7e03
1 parent a35c44e commit 152cdc5

13 files changed

Lines changed: 243 additions & 34 deletions

File tree

README.md

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,25 @@ agent "<identity>" {
124124
}
125125
```
126126

127+
For a zero-interruption migration, add `lifecycle "adopt-only"` to the compact
128+
agent or to an explicit `pty`/`exec` task. st2 adopts that task when its current
129+
generation is alive. If the generation is dead or absent, st2 reports the task
130+
as `held` and does not remove or launch anything:
131+
132+
```kdl
133+
agent "<identity>" {
134+
host "<host>"
135+
workspace "<workspace>"
136+
lifecycle "adopt-only"
137+
argv "codex" "<boot prompt>"
138+
}
139+
```
140+
141+
This is a fence, not a restart policy. After inspecting or recovering the
142+
original generation, deliberately change the lifecycle back to `"service"` (or
143+
remove the field) to authorize ordinary absent launch and dead replacement.
144+
`retired #true` remains an explicit teardown instruction and takes precedence.
145+
127146
`resource` binds an agent-local semantic name to an exact RFC 3986 absolute URI. `_tag` selects a
128147
concrete resource contract understood by downstream readers; st2 preserves arbitrary non-empty tags
129148
and URI bytes without normalization. It neither owns their schemas nor resolves their targets.

crates/agent-spec/src/kdl_format.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,7 @@ fn agent_node_to_raw(node: &KdlNode) -> anyhow::Result<RawSpec> {
8383
"supervisor" => raw.supervisor = arg_string(child),
8484
"retired" => raw.retired = arg_bool(child),
8585
"keep" => raw.keep = arg_bool(child),
86+
"lifecycle" => raw.lifecycle = arg_string(child),
8687
"restart" => raw.restart = Some(restart_node_to_raw(child)),
8788
"resource" => {
8889
let (name, resource) = resource_node_to_raw(child)?;
@@ -199,6 +200,7 @@ fn task_node_to_raw(node: &KdlNode) -> anyhow::Result<RawTask> {
199200
"argv" => t.argv = Some(argv(child)?),
200201
"cwd" => t.cwd = arg_string(child),
201202
"keep" => t.keep = arg_bool(child),
203+
"lifecycle" => t.lifecycle = arg_string(child),
202204
// `tags role="agent" "st.network"="$CATALOG"` — properties on the node.
203205
"tags" => {
204206
for entry in child.entries() {

crates/agent-spec/src/lib.rs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,5 +28,6 @@ pub use discovery::{
2828
Declared, Discovered, SpecError, discover, is_catalog_path, parse_declared, path_defaults,
2929
};
3030
pub use spec::{
31-
AgentSpec, JobType, Resource, Restart, RestartMode, Task, TaskKind, parse_duration,
31+
AgentSpec, JobType, Resource, Restart, RestartMode, Task, TaskKind, TaskLifecycle,
32+
parse_duration,
3233
};

crates/agent-spec/src/spec.rs

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
//! allocates a terminal, an agent harness) and `exec{}` (a plain process — the ding, daemons, a
55
//! stage's script; must NOT allocate a terminal, R09). st2 reads only the runner-normative subset:
66
//! `identity`, `host`, `role` (metadata only), `type`, `workspace`, `retired`, `keep`, `supervisor`,
7-
//! `restart{}`, Resource bindings (declaration metadata), and the tasks. Everything render-only
7+
//! `restart{}`, task lifecycle, Resource bindings (declaration metadata), and the tasks. Everything render-only
88
//! (`harness`, `model`, `persona`, `permissions`, `transport`, `strategy`, `meta{}`) is baked into
99
//! the tasks/commands by the render layer and ignored here.
1010
//!
@@ -142,6 +142,9 @@ pub struct Task {
142142
pub env: BTreeMap<String, String>,
143143
/// Per-task GC pin.
144144
pub keep: bool,
145+
/// Reconciliation policy. `adopt-only` is a migration fence: st2 may adopt a live generation,
146+
/// but must not reap a dead generation or create a missing replacement.
147+
pub lifecycle: TaskLifecycle,
145148
}
146149

147150
/// Whether a task allocates a terminal.
@@ -153,6 +156,16 @@ pub enum TaskKind {
153156
Exec,
154157
}
155158

159+
/// How st2 reconciles a declared task.
160+
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
161+
pub enum TaskLifecycle {
162+
/// Ordinary service lifecycle: launch when absent and replace when dead.
163+
#[default]
164+
Service,
165+
/// Migration fence: adopt an already-live generation, otherwise hold without mutation.
166+
AdoptOnly,
167+
}
168+
156169
/// Restart policy (§4). Applies to long-running `service` tasks.
157170
#[derive(Debug, Clone, PartialEq, Eq)]
158171
pub struct Restart {
@@ -276,6 +289,8 @@ pub(crate) struct RawSpec {
276289
/// Compact catalog form: include the built-in `st2 ding` sidecar.
277290
#[serde(default)]
278291
pub ding: bool,
292+
/// Compact catalog form: reconciliation policy for the generated agent PTY.
293+
pub lifecycle: Option<String>,
279294
/// `pty "<name>" {}` / `[pty.<name>]` — interactive tasks.
280295
#[serde(default)]
281296
pub pty: BTreeMap<String, RawTask>,
@@ -307,6 +322,7 @@ pub(crate) struct RawTask {
307322
pub env: BTreeMap<String, String>,
308323
#[serde(default)]
309324
pub keep: bool,
325+
pub lifecycle: Option<String>,
310326
}
311327

312328
#[derive(Debug, Default, Deserialize)]
@@ -606,6 +622,8 @@ impl RawSpec {
606622
tasks.push(t.lower(&identity, TaskKind::Exec, name, &self.env)?);
607623
}
608624
if self.command.is_some() || self.argv.is_some() {
625+
let lifecycle =
626+
parse_task_lifecycle(&identity, "compact task", self.lifecycle.as_deref())?;
609627
let mut tags = BTreeMap::new();
610628
tags.insert("role".to_string(), "agent".to_string());
611629
tasks.push(Task {
@@ -620,6 +638,7 @@ impl RawSpec {
620638
tags,
621639
env: self.env.clone(),
622640
keep: false,
641+
lifecycle,
623642
});
624643
}
625644
if self.ding {
@@ -634,6 +653,7 @@ impl RawSpec {
634653
tags: BTreeMap::new(),
635654
env: self.env,
636655
keep: false,
656+
lifecycle: TaskLifecycle::Service,
637657
});
638658
}
639659
tasks.sort_by(|a, b| a.name.cmp(&b.name));
@@ -675,6 +695,11 @@ impl RawTask {
675695
)?;
676696
let mut env = inherited_env.clone();
677697
env.extend(self.env);
698+
let lifecycle = parse_task_lifecycle(
699+
identity,
700+
&format!("{kind:?} task '{name}'"),
701+
self.lifecycle.as_deref(),
702+
)?;
678703
Ok(Task {
679704
kind,
680705
derived: false,
@@ -686,10 +711,25 @@ impl RawTask {
686711
tags: self.tags,
687712
env,
688713
keep: self.keep,
714+
lifecycle,
689715
})
690716
}
691717
}
692718

719+
fn parse_task_lifecycle(
720+
identity: &str,
721+
location: &str,
722+
lifecycle: Option<&str>,
723+
) -> anyhow::Result<TaskLifecycle> {
724+
match lifecycle {
725+
None | Some("service") => Ok(TaskLifecycle::Service),
726+
Some("adopt-only") => Ok(TaskLifecycle::AdoptOnly),
727+
Some(other) => {
728+
anyhow::bail!("agent '{identity}' {location} has unknown lifecycle '{other}'")
729+
}
730+
}
731+
}
732+
693733
fn validate_launch(
694734
identity: &str,
695735
command: Option<&String>,

crates/agent-spec/tests/discovery.rs

Lines changed: 44 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ use std::fs;
88
use std::path::Path;
99
use std::time::Duration;
1010

11-
use agent_spec::spec::TaskKind;
11+
use agent_spec::spec::{TaskKind, TaskLifecycle};
1212
use agent_spec::{AgentSpec, JobType, Resource, Task, discover};
1313

1414
fn write(root: &Path, rel: &str, contents: &str) {
@@ -53,6 +53,7 @@ agent "fabric-claude" {
5353
5454
pty "agent" {
5555
id "silber.fabric-claude"
56+
lifecycle "adopt-only"
5657
command #"exec claude --permission-mode bypassPermissions 'boot'"#
5758
tags role="agent" env="prod"
5859
env {
@@ -107,6 +108,7 @@ fn parses_full_kdl_service_job() {
107108
let agent = s.tasks.iter().find(|t| t.name == "agent").unwrap();
108109
assert_eq!(agent.kind, TaskKind::Pty);
109110
assert_eq!(agent.id.as_deref(), Some("silber.fabric-claude"));
111+
assert_eq!(agent.lifecycle, TaskLifecycle::AdoptOnly);
110112
assert!(agent.command.as_deref().unwrap().starts_with("exec claude"));
111113
assert_eq!(agent.tags.get("role").map(String::as_str), Some("agent"));
112114
assert_eq!(
@@ -172,6 +174,47 @@ agent "cos" {
172174
);
173175
}
174176

177+
#[test]
178+
fn compact_adopt_only_lifecycle_lowers_to_the_generated_agent_task() {
179+
let tmp = tempfile::tempdir().unwrap();
180+
write(
181+
tmp.path(),
182+
"agents/h/migrant/agent.kdl",
183+
r#"
184+
agent "migrant" {
185+
host "h"
186+
lifecycle "adopt-only"
187+
command "codex"
188+
}
189+
"#,
190+
);
191+
192+
let found = discover(tmp.path());
193+
assert!(found.errors.is_empty(), "{:?}", found.errors);
194+
assert_eq!(found.specs[0].tasks[0].lifecycle, TaskLifecycle::AdoptOnly);
195+
}
196+
197+
#[test]
198+
fn unknown_task_lifecycle_is_rejected_instead_of_falling_back_to_service() {
199+
let tmp = tempfile::tempdir().unwrap();
200+
write(
201+
tmp.path(),
202+
"agents/h/unsafe/agent.kdl",
203+
r#"
204+
agent "unsafe" {
205+
host "h"
206+
lifecycle "replace-maybe"
207+
command "codex"
208+
}
209+
"#,
210+
);
211+
212+
let found = discover(tmp.path());
213+
assert!(found.specs.is_empty());
214+
assert_eq!(found.errors.len(), 1);
215+
assert!(found.errors[0].message.contains("unknown lifecycle"));
216+
}
217+
175218
#[test]
176219
fn direct_argv_lowers_for_compact_and_explicit_kdl_tasks() {
177220
let tmp = tempfile::tempdir().unwrap();

docs/vrs/spec.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,13 @@ validate ──► materialize ──► host-local st2 scheduler/reconciler
116116
binary, starts the control plane again, and proves adoption with the same
117117
agent PID/creation identity and no duplicate process.
118118

119+
- **Adopt-only migration fence:** A compact agent or explicit task may declare
120+
`lifecycle "adopt-only"`. Reconciliation adopts an already-live generation,
121+
but classifies a dead or absent generation as `held` without garbage
122+
collection or launch. Returning the declaration to the default `service`
123+
lifecycle is the explicit authority to resume ordinary replacement.
124+
`retired #true` remains the separate explicit teardown path.
125+
119126
- **Session registry:** A catalog owns the `pty` registry holding its tasks.
120127
`<catalog>/pty` is the default; a catalog may declare another so that one host
121128
can share a single registry across catalogs. Resolution is an exported

src/eval_run.rs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ use crate::expand::expand_catalog;
1616
use crate::flapping::FlappingCap;
1717
use crate::reconcile::reconcile;
1818
use crate::run::{Runner, SystemRunner, UpReport, detect_host, execute};
19-
use agent_spec::spec::{AgentSpec, JobType, Task, TaskKind};
19+
use agent_spec::spec::{AgentSpec, JobType, Task, TaskKind, TaskLifecycle};
2020

2121
macro_rules! eval_log {
2222
($($arg:tt)*) => {
@@ -71,6 +71,7 @@ pub fn spec_to_agent_specs(agents: &[SpecAgent], host: &str, root: &Path) -> Vec
7171
tags: ptags,
7272
env: a.env.clone(),
7373
keep: false,
74+
lifecycle: TaskLifecycle::Service,
7475
});
7576
for ex in &a.execs {
7677
tasks.push(Task {
@@ -84,6 +85,7 @@ pub fn spec_to_agent_specs(agents: &[SpecAgent], host: &str, root: &Path) -> Vec
8485
tags: BTreeMap::new(),
8586
env: ex.env.clone(),
8687
keep: false,
88+
lifecycle: TaskLifecycle::Service,
8789
});
8890
}
8991
AgentSpec {

src/lib.rs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,8 @@ pub use agent_spec::{discovery, spec};
3636

3737
pub use agent_spec::discovery::{Discovered, SpecError, discover};
3838
pub use agent_spec::spec::{
39-
AgentSpec, JobType, Resource, Restart, RestartMode, Task, TaskKind, parse_duration,
39+
AgentSpec, JobType, Resource, Restart, RestartMode, Task, TaskKind, TaskLifecycle,
40+
parse_duration,
4041
};
4142
pub use exec_backend::ExecBackend;
4243
pub use expand::{expand_env, expand_vars};

src/main.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1901,6 +1901,7 @@ fn print_report(report: &UpReport) {
19011901
report_line("launched", &report.launched);
19021902
report_line("torn down", &report.torn_down);
19031903
report_line("gc", &report.gc);
1904+
report_line("held", &report.held);
19041905
report_line("flapping", &report.flapping);
19051906
report_line("adopted", &report.adopted);
19061907
report_line("other-host", &report.other_host);

src/reconcile.rs

Lines changed: 31 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
use std::collections::BTreeMap;
1212
use std::collections::HashMap;
1313

14-
use agent_spec::spec::{AgentSpec, TaskKind};
14+
use agent_spec::spec::{AgentSpec, TaskKind, TaskLifecycle};
1515

1616
/// ACTUAL state: one running/known task as st2 observes it (unioned across backends).
1717
#[derive(Debug, Clone, PartialEq, Eq)]
@@ -87,6 +87,8 @@ pub struct ReconcilePlan<'a> {
8787
pub unrunnable: Vec<&'a AgentSpec>,
8888
/// Dead, non-`keep` sessions of declared tasks → reap (`rm`).
8989
pub gc: Vec<String>,
90+
/// Dead or absent `adopt-only` task ids held without reap or launch.
91+
pub held: Vec<String>,
9092
}
9193

9294
/// Resolve one exact local task selector (`host.agent.task` or explicit task id) without mutation.
@@ -172,7 +174,9 @@ pub fn reconcile_selected<'a>(
172174
keep: task.keep || owner.keep,
173175
};
174176
match actual {
175-
Some(s) if s.alive || target.keep => plan.adopt.push(owner),
177+
Some(s) if s.alive => plan.adopt.push(owner),
178+
_ if task.lifecycle == TaskLifecycle::AdoptOnly => plan.held.push(runtime),
179+
Some(_) if target.keep => plan.adopt.push(owner),
176180
Some(_) => {
177181
plan.gc.push(runtime);
178182
plan.launch.push(Launch {
@@ -255,7 +259,7 @@ pub fn reconcile<'a>(
255259
continue;
256260
}
257261

258-
let targets: Vec<TaskTarget> = spec
262+
let targets: Vec<(TaskTarget, TaskLifecycle)> = spec
259263
.tasks
260264
.iter()
261265
.filter_map(|t| {
@@ -276,27 +280,36 @@ pub fn reconcile<'a>(
276280
} else {
277281
env.remove("ST_SUPERVISOR");
278282
}
279-
Some(TaskTarget {
280-
kind: t.kind,
281-
pty_id: resolve_task_id(&bus_id, &t.name, t.id.as_deref()),
282-
bus_id: bus_id.clone(),
283-
name: t.name.clone(),
284-
launch,
285-
cwd: t.cwd.clone(),
286-
workspace: spec.workspace.clone(),
287-
tags: t.tags.clone(),
288-
env,
289-
keep: t.keep || spec.keep,
290-
})
283+
Some((
284+
TaskTarget {
285+
kind: t.kind,
286+
pty_id: resolve_task_id(&bus_id, &t.name, t.id.as_deref()),
287+
bus_id: bus_id.clone(),
288+
name: t.name.clone(),
289+
launch,
290+
cwd: t.cwd.clone(),
291+
workspace: spec.workspace.clone(),
292+
tags: t.tags.clone(),
293+
env,
294+
keep: t.keep || spec.keep,
295+
},
296+
t.lifecycle,
297+
))
291298
})
292299
.collect();
293300

294301
debug_assert!(!targets.is_empty());
295302

296303
let mut to_launch = Vec::new();
297-
for target in targets {
304+
let held_before = plan.held.len();
305+
for (target, lifecycle) in targets {
298306
match session_state(&by_id, &target.pty_id) {
299307
SessionState::Alive => {}
308+
SessionState::Dead | SessionState::Absent
309+
if lifecycle == TaskLifecycle::AdoptOnly =>
310+
{
311+
plan.held.push(target.pty_id.clone());
312+
}
300313
SessionState::Dead if target.keep => {}
301314
SessionState::Dead => {
302315
plan.gc.push(target.pty_id.clone());
@@ -306,9 +319,9 @@ pub fn reconcile<'a>(
306319
}
307320
}
308321

309-
if to_launch.is_empty() {
322+
if to_launch.is_empty() && plan.held.len() == held_before {
310323
plan.adopt.push(spec);
311-
} else {
324+
} else if !to_launch.is_empty() {
312325
plan.launch.push(Launch {
313326
spec,
314327
tasks: to_launch,

0 commit comments

Comments
 (0)