Skip to content

Commit 41c7ee5

Browse files
committed
feat: complete targeted reconcile CLI
1 parent 5c77dcf commit 41c7ee5

9 files changed

Lines changed: 694 additions & 34 deletions

File tree

README.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -160,6 +160,15 @@ st2 up --catalog "$CATALOG" --host <host> --once
160160

161161
There is intentionally no resident macOS service path.
162162

163+
For a shortest-path change to one exact task, render only its owning agent and reconcile only that
164+
task in a bounded pass:
165+
166+
```sh
167+
st2 up --catalog "$CATALOG" --host <host> --once --task <host.agent.task>
168+
```
169+
170+
Unknown, ambiguous, and wrong-host task selectors refuse before workspace writes or PTY inspection.
171+
163172
`st2 doctor` accepts the absence of a live host lock as the normal manual/`--once` mode. For a
164173
resident `st2 up` deployment, use `st2 doctor --require-supervisor` to make a missing loop fail the
165174
health check. A stale lock left by a dead supervisor is always a failure. The underlying

docs/vrs/spec.md

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -117,14 +117,21 @@ positive declaration/template wakes, negative runtime/bus events, bounded
117117
discovery/materialization/PTY queries and writes, continuous-event starvation,
118118
and no-op desired-equals-actual behavior.
119119

120-
## Open design questions
120+
## Targeted reconciliation (R19)
121+
122+
`st2 up --materialize-only --task <host.agent.task>` resolves one exact local
123+
task before writing and renders only its owning agent. `st2 up --once --task
124+
<host.agent.task>` performs the same owner-only materialization, then inspects
125+
PTY/exec state and executes a plan containing only that task. Unknown,
126+
ambiguous, and wrong-host selectors refuse before writes or runner inspection;
127+
unrelated discovery diagnostics remain visible without preventing the selected
128+
owner/task path.
121129

122-
### Targeted materialization (R13)
130+
`st2 up --materialize-only --agent <id>` remains the agent-wide rendering
131+
selector. Targeted task reconciliation is intentionally bounded to `--once`;
132+
the resident supervisor continues to reconcile the complete local catalog.
123133

124-
`st2 up --materialize-only --agent <id>` filters discovery before rendering,
125-
so a declared agent/task change cannot be blocked by unrelated slow or
126-
unreadable workspaces. This selector is materialization-only; live
127-
reconciliation remains separately gated and host-local.
134+
## Open design questions
128135

129136
- **DQ1 Scheduled work:** The vision includes per-machine schedulers that form a
130137
distributed workflow engine, but the KDL shape, event inbox, deduplication

src/lib.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,6 @@ pub use host_lock::HostLock;
3939
pub use reconcile::{Launch, ReconcilePlan, Session, TaskTarget, Teardown, reconcile};
4040
pub use run::{
4141
PtyCli, Runner, SystemRunner, UpReport, detect_host, down, down_specs, exec_state_dir, execute,
42-
up_loop, up_loop_specs, up_once, up_once_specs,
42+
up_loop, up_loop_specs, up_once, up_once_selected, up_once_selected_specs, up_once_specs,
4343
};
4444
pub use spec::{AgentSpec, JobType, Restart, RestartMode, Task, TaskKind, parse_duration};

src/main.rs

Lines changed: 19 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -53,9 +53,11 @@ enum Command {
5353
/// Materialize every local agent's render block and exit without reconciling or spawning.
5454
#[arg(long, conflicts_with = "once")]
5555
materialize_only: bool,
56-
/// Limit materialization/reconciliation to one declared agent identity.
56+
/// Limit materialization to one declared agent identity.
5757
#[arg(long)]
5858
agent: Option<String>,
59+
/// Select one exact local task. Use with --materialize-only to render only its owner, or
60+
/// with --once to render its owner and reconcile only that task.
5961
#[arg(long, conflicts_with = "agent")]
6062
task: Option<String>,
6163
/// Seconds between timer-driven reconcile passes when looping (folder changes reconcile
@@ -534,11 +536,8 @@ fn main() -> Result<()> {
534536
task,
535537
} => {
536538
let root = catalog_arg(root)?;
537-
if task.is_some() && once {
538-
anyhow::bail!("--task --once is deferred until selected reconcile support");
539-
}
540-
if task.is_some() && !materialize_only {
541-
anyhow::bail!("--task requires --materialize-only in this stage");
539+
if task.is_some() && !materialize_only && !once {
540+
anyhow::bail!("--task requires --once or --materialize-only");
542541
}
543542
if agent.is_some() && !materialize_only {
544543
anyhow::bail!("--agent requires --materialize-only");
@@ -1751,6 +1750,9 @@ fn up(
17511750
// An st2-SPEC path (a `*.kdl` file, or a folder with one top-level spec `*.kdl`) supervises its
17521751
// top-level team directly — no catalog discovery. Otherwise, the classic catalog reconcile loop.
17531752
if let Some(spec_file) = st2::eval_run::resolve_spec_path(root) {
1753+
if task.is_some() {
1754+
anyhow::bail!("--task is for folder catalogs, not single-file specs");
1755+
}
17541756
if materialize_only {
17551757
anyhow::bail!(
17561758
"--materialize-only is for folder catalogs with agent render{{}} blocks, not single-file specs"
@@ -1811,7 +1813,7 @@ fn up(
18111813
return Ok(());
18121814
}
18131815

1814-
let runner = SystemRunner::new(catalog_root, exec_state_dir(&this_host));
1816+
let runner = SystemRunner::new(catalog_root.clone(), exec_state_dir(&this_host));
18151817

18161818
// One supervisor per (folder, host). A single `--once` pass must also refuse while a loop owns
18171819
// the lock (it would double-spawn) — but it does NOT take the lock itself (that would clobber the
@@ -1823,12 +1825,21 @@ fn up(
18231825
}
18241826

18251827
if once {
1826-
let report = up_once(root, &this_host, &runner)?;
1828+
let targeted = task.is_some();
1829+
let report = match task.as_deref() {
1830+
Some(selector) => {
1831+
st2::run::up_once_selected(&catalog_root, selector, &this_host, &runner)?
1832+
}
1833+
None => up_once(root, &this_host, &runner)?,
1834+
};
18271835
println!("reconcile pass on host '{this_host}':");
18281836
print_report(&report);
18291837
if report.skipped {
18301838
anyhow::bail!("one-shot reconcile pass was skipped");
18311839
}
1840+
if targeted && !report.errors.is_empty() {
1841+
anyhow::bail!("targeted one-shot reconcile pass reported errors");
1842+
}
18321843
return Ok(());
18331844
}
18341845

tests/exec_backend.rs

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@
22
33
use std::collections::BTreeMap;
44
use std::fs;
5-
use std::process::Command;
65
use std::thread::sleep;
76
use std::time::Duration;
87

tests/materialize.rs

Lines changed: 0 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -159,14 +159,6 @@ fn task_selector_cli_modes_fail_closed() {
159159
"--task",
160160
"host.a.x",
161161
],
162-
vec![
163-
"up",
164-
"--catalog",
165-
tmp.path().to_str().unwrap(),
166-
"--once",
167-
"--task",
168-
"host.a.x",
169-
],
170162
vec![
171163
"up",
172164
"--catalog",

tests/nomad_survival.rs

Lines changed: 25 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -191,7 +191,10 @@ impl Fixture {
191191
let bin_dir = self.xdg.join("bin");
192192
std::fs::create_dir_all(&bin_dir).unwrap();
193193
let installed = bin_dir.join("st2");
194-
std::fs::copy(env!("CARGO_BIN_EXE_st2"), &installed).unwrap();
194+
let staged = installed.with_extension("installing");
195+
std::fs::copy(env!("CARGO_BIN_EXE_st2"), &staged).unwrap();
196+
std::fs::File::open(&staged).unwrap().sync_all().unwrap();
197+
std::fs::rename(&staged, &installed).unwrap();
195198
installed
196199
}
197200

@@ -216,14 +219,27 @@ impl Fixture {
216219
}
217220

218221
fn spawn_loop_from(&self, binary: &Path) -> Child {
219-
self.st2_from(binary)
220-
.arg("up")
221-
.arg(&self.catalog)
222-
.args(["--host", HOST, "--interval", "60"])
223-
.stdout(Stdio::null())
224-
.stderr(Stdio::null())
225-
.spawn()
226-
.unwrap()
222+
for attempt in 0..5 {
223+
let result = self
224+
.st2_from(binary)
225+
.arg("up")
226+
.arg(&self.catalog)
227+
.args(["--host", HOST, "--interval", "60"])
228+
.stdout(Stdio::null())
229+
.stderr(Stdio::null())
230+
.spawn();
231+
match result {
232+
Ok(child) => return child,
233+
Err(error) if error.raw_os_error() == Some(libc::ETXTBSY) && attempt + 1 < 5 => {
234+
// Some Linux filesystems briefly retain the writer exclusion after installing
235+
// a copied executable. Retry only that transient; every other spawn error stays
236+
// loud and immediate.
237+
std::thread::sleep(Duration::from_millis(20));
238+
}
239+
Err(error) => panic!("spawning fixture control plane failed: {error}"),
240+
}
241+
}
242+
unreachable!("the bounded spawn loop always returns or panics")
227243
}
228244

229245
/// One `st2 up --once` pass; returns its stdout (where launched/adopted/torn-down is reported).

tests/run.rs

Lines changed: 190 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,198 @@ use std::path::Path;
88
use st2::message;
99
use st2::reconcile::{Session, TaskTarget};
1010
use st2::run::Runner;
11-
use st2::run::{CrashLoop, surface_crash_loop, up_once_selected_specs};
11+
use st2::run::{CrashLoop, surface_crash_loop, up_once_selected, up_once_selected_specs};
1212
use st2::spec::{AgentSpec, JobType, Task, TaskKind};
1313

14+
fn selected_catalog_agent(identity: &str, workspace: &Path, render: &str) -> String {
15+
format!(
16+
r#"agent "{identity}" {{
17+
host "host"
18+
type "service"
19+
workspace "{}"
20+
pty "work" {{
21+
id "host.{identity}.work"
22+
command "true"
23+
}}
24+
render {{
25+
{render}
26+
}}
27+
}}
28+
"#,
29+
workspace.display()
30+
)
31+
}
32+
33+
fn write_selected_catalog(
34+
catalog: &Path,
35+
owner_workspace: &Path,
36+
sibling_workspace: &Path,
37+
owner_render: &str,
38+
) {
39+
fs::create_dir_all(owner_workspace).unwrap();
40+
fs::create_dir_all(sibling_workspace).unwrap();
41+
write(
42+
catalog,
43+
"agents/host/owner/agent.kdl",
44+
&selected_catalog_agent("owner", owner_workspace, owner_render),
45+
);
46+
write(
47+
catalog,
48+
"agents/host/sibling/agent.kdl",
49+
&selected_catalog_agent(
50+
"sibling",
51+
sibling_workspace,
52+
r#"file "SIBLING.txt" "sibling""#,
53+
),
54+
);
55+
}
56+
57+
#[test]
58+
fn selected_catalog_two_agent_kdl_recording_runner_matrix() {
59+
enum Actual {
60+
Missing,
61+
Live,
62+
Dead,
63+
}
64+
65+
for actual in [Actual::Missing, Actual::Live, Actual::Dead] {
66+
let tmp = tempfile::tempdir().unwrap();
67+
let catalog = tmp.path().join("catalog");
68+
let owner_workspace = tmp.path().join("owner-workspace");
69+
let sibling_workspace = tmp.path().join("sibling-workspace");
70+
write_selected_catalog(
71+
&catalog,
72+
&owner_workspace,
73+
&sibling_workspace,
74+
r#"file "OWNER.txt" "owner""#,
75+
);
76+
77+
let mut sessions = vec![live("host.sibling.work")];
78+
match actual {
79+
Actual::Missing => {}
80+
Actual::Live => sessions.push(live("host.owner.work")),
81+
Actual::Dead => sessions.push(dead("host.owner.work")),
82+
}
83+
let runner = FakeRunner {
84+
sessions,
85+
..Default::default()
86+
};
87+
88+
let report = up_once_selected(&catalog, "host.owner.work", "host", &runner).unwrap();
89+
90+
assert_eq!(runner.list_calls.get(), 1);
91+
assert_eq!(
92+
fs::read_to_string(owner_workspace.join("OWNER.txt")).unwrap(),
93+
"owner"
94+
);
95+
assert!(
96+
!sibling_workspace.join("SIBLING.txt").exists(),
97+
"the unrelated owner must not be materialized"
98+
);
99+
assert!(runner.killed.borrow().is_empty());
100+
assert!(runner.removed.borrow().is_empty());
101+
match actual {
102+
Actual::Missing => {
103+
assert_eq!(runner.spawned.borrow().as_slice(), ["host.owner.work"]);
104+
assert!(runner.reaped.borrow().is_empty());
105+
assert_eq!(report.launched, ["host.owner.work"]);
106+
}
107+
Actual::Live => {
108+
assert!(runner.spawned.borrow().is_empty());
109+
assert!(runner.reaped.borrow().is_empty());
110+
assert_eq!(report.adopted, ["owner"]);
111+
}
112+
Actual::Dead => {
113+
assert_eq!(runner.reaped.borrow().as_slice(), ["host.owner.work"]);
114+
assert_eq!(runner.spawned.borrow().as_slice(), ["host.owner.work"]);
115+
assert_eq!(report.gc, ["host.owner.work"]);
116+
assert_eq!(report.launched, ["host.owner.work"]);
117+
}
118+
}
119+
assert!(
120+
runner
121+
.spawned
122+
.borrow()
123+
.iter()
124+
.chain(runner.reaped.borrow().iter())
125+
.all(|id| id == "host.owner.work")
126+
);
127+
}
128+
}
129+
130+
#[test]
131+
fn selected_catalog_surfaces_unrelated_malformed_diagnostics_without_blocking_owner() {
132+
let tmp = tempfile::tempdir().unwrap();
133+
let catalog = tmp.path().join("catalog");
134+
let owner_workspace = tmp.path().join("owner-workspace");
135+
let sibling_workspace = tmp.path().join("sibling-workspace");
136+
write_selected_catalog(
137+
&catalog,
138+
&owner_workspace,
139+
&sibling_workspace,
140+
r#"file "OWNER.txt" "owner""#,
141+
);
142+
write(
143+
&catalog,
144+
"agents/host/sibling/broken.kdl",
145+
r#"agent "broken" {"#,
146+
);
147+
let runner = FakeRunner {
148+
sessions: vec![live("host.sibling.work")],
149+
..Default::default()
150+
};
151+
152+
let report = up_once_selected(&catalog, "host.owner.work", "host", &runner).unwrap();
153+
154+
assert_eq!(runner.spawned.borrow().as_slice(), ["host.owner.work"]);
155+
assert_eq!(
156+
fs::read_to_string(owner_workspace.join("OWNER.txt")).unwrap(),
157+
"owner"
158+
);
159+
assert!(!sibling_workspace.join("SIBLING.txt").exists());
160+
assert!(
161+
report
162+
.errors
163+
.iter()
164+
.any(|error| error.contains("broken.kdl") && error.contains("KDL")),
165+
"{:?}",
166+
report.errors
167+
);
168+
}
169+
170+
#[test]
171+
fn selected_catalog_owner_render_failure_refuses_runner_actions() {
172+
let tmp = tempfile::tempdir().unwrap();
173+
let catalog = tmp.path().join("catalog");
174+
let owner_workspace = tmp.path().join("owner-workspace");
175+
let sibling_workspace = tmp.path().join("sibling-workspace");
176+
write_selected_catalog(
177+
&catalog,
178+
&owner_workspace,
179+
&sibling_workspace,
180+
r#"copy "_templates/missing" "OWNER.txt""#,
181+
);
182+
let runner = FakeRunner {
183+
sessions: vec![live("host.sibling.work")],
184+
..Default::default()
185+
};
186+
187+
let report = up_once_selected(&catalog, "host.owner.work", "host", &runner).unwrap();
188+
189+
assert_eq!(runner.list_calls.get(), 0);
190+
assert_refusal(&runner);
191+
assert!(!owner_workspace.join("OWNER.txt").exists());
192+
assert!(!sibling_workspace.join("SIBLING.txt").exists());
193+
assert!(
194+
report
195+
.errors
196+
.iter()
197+
.any(|error| error.contains("_templates/missing")),
198+
"{:?}",
199+
report.errors
200+
);
201+
}
202+
14203
#[test]
15204
fn selected_one_shot_unknown_refuses_before_runner_list() {
16205
let runner = FakeRunner::default();

0 commit comments

Comments
 (0)