Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
39 commits
Select commit Hold shift + click to select a range
1c10b5c
feat: resolve exact task selectors purely
myobie Jul 29, 2026
37cad58
test: cover exact task selector resolution
myobie Jul 29, 2026
8533c56
test: complete task selector matrix
myobie Jul 29, 2026
9d124b3
test: prove selector owner fields and immutability
myobie Jul 29, 2026
934601c
feat: add pure selected reconcile plan
myobie Jul 29, 2026
2b1a587
fix: construct selected reconcile plan directly
myobie Jul 29, 2026
1d96725
fix: preserve selected keep task semantics
myobie Jul 29, 2026
9577f0a
test: cover selected reconcile action matrix
myobie Jul 29, 2026
e777511
test: complete selected reconcile matrix
myobie Jul 29, 2026
e35b6e7
test: format and strengthen selected reconcile proofs
myobie Jul 29, 2026
fc719b2
test: prove selected second-pass sibling no-op
myobie Jul 29, 2026
0ffee0c
feat: add exact task materialization selector
myobie Jul 29, 2026
5732269
test: reject unknown task selector before mutation
myobie Jul 29, 2026
10b94d7
fix: reject deferred task once selection
myobie Jul 29, 2026
b77a3f7
test: cover task selector CLI refusal modes
myobie Jul 29, 2026
fadadf3
test: prove task selector owner-only materialization
myobie Jul 29, 2026
110c665
test: harden owner materialization receipt
myobie Jul 29, 2026
e307ab4
test: prove ambiguous task selector refuses mutation
myobie Jul 29, 2026
b7f7272
test: prove wrong-host task selector refusal
myobie Jul 29, 2026
fdfa7b8
test: refuse task selectors on single-file specs
myobie Jul 29, 2026
d7a22b3
feat: add selected in-memory one-shot path
myobie Jul 29, 2026
c26c8d8
fix: gate selected one-shot launches
myobie Jul 29, 2026
219d991
test: count selected one-shot runner listings
myobie Jul 29, 2026
7e7a8b3
test: reject unknown selected one-shot before listing
myobie Jul 29, 2026
4b80712
test: cover selected one-shot refusal matrix
myobie Jul 29, 2026
0d47171
test: isolate selected reconcile actions
myobie Jul 29, 2026
8aa9b7e
test: prove selected sibling isolation
myobie Jul 29, 2026
45fcf8a
test: inject selected codex gate hooks
myobie Jul 29, 2026
c501ec3
test: harden selected codex gate regression
myobie Jul 29, 2026
a308086
fix: keep linux runner test compiling
myobie Jul 29, 2026
eabdb2f
feat: add selected folder catalog runner helper
myobie Jul 29, 2026
72dd916
feat: complete selected catalog execution seam
myobie Jul 29, 2026
ecd8f4a
fix: preserve selected catalog report semantics
myobie Jul 29, 2026
b88619c
docs: require supervisors and durable error propagation
myobie Jul 29, 2026
92b0d55
docs: define targeted reconciliation contract
myobie Jul 29, 2026
5c77dcf
docs: reserve R18 for hook resilience
myobie Jul 29, 2026
41c7ee5
feat: complete targeted reconcile CLI
myobie Jul 29, 2026
348f815
Merge remote-tracking branch 'origin/main' into agent/issue-29-target…
myobie Jul 29, 2026
138acbc
Merge main into targeted reconcile
myobie Jul 30, 2026
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
9 changes: 9 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,15 @@ st2 up --catalog "$CATALOG" --host <host> --once

There is intentionally no resident macOS service path.

For a shortest-path change to one exact task, render only its owning agent and reconcile only that
task in a bounded pass:

```sh
st2 up --catalog "$CATALOG" --host <host> --once --task <host.agent.task>
```

Unknown, ambiguous, and wrong-host task selectors refuse before workspace writes or PTY inspection.

`st2 doctor` accepts the absence of a live host lock as the normal manual/`--once` mode. For a
resident `st2 up` deployment, use `st2 doctor --require-supervisor` to make a missing loop fail the
health check. A stale lock left by a dead supervisor is always a failure. The underlying
Expand Down
6 changes: 6 additions & 0 deletions docs/vrs/requirements.md
Original file line number Diff line number Diff line change
Expand Up @@ -97,3 +97,9 @@ accepted.
- **R17 Durable error propagation:** Lifecycle, harness/eval, provider-turn,
task/exec/PTY, hook, and delivery errors are durably reported to the
responsible supervisor with agent/task identity and actionable context.
- **R19 Targeted reconciliation:** An exact agent/task selector resolves its
identity and pinned host before mutation; unknown, ambiguous, and wrong-host
targets refuse before writes, listing, or actions. Materialization, hook
gates, PTY inspection, and plan execution are limited to the selected
owner/task; unrelated diagnostics remain visible while unrelated workspaces,
tasks, and live PTY PID/generation stay unchanged.
19 changes: 13 additions & 6 deletions docs/vrs/spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -141,14 +141,21 @@ positive declaration/template wakes, negative runtime/bus events, bounded
discovery/materialization/PTY queries and writes, continuous-event starvation,
and no-op desired-equals-actual behavior.

## Open design questions
## Targeted reconciliation (R19)

`st2 up --materialize-only --task <host.agent.task>` resolves one exact local
task before writing and renders only its owning agent. `st2 up --once --task
<host.agent.task>` performs the same owner-only materialization, then inspects
PTY/exec state and executes a plan containing only that task. Unknown,
ambiguous, and wrong-host selectors refuse before writes or runner inspection;
unrelated discovery diagnostics remain visible without preventing the selected
owner/task path.

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

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

- **DQ1 Scheduled work:** The vision includes per-machine schedulers that form a
distributed workflow engine, but the KDL shape, event inbox, deduplication
Expand Down
2 changes: 1 addition & 1 deletion src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -45,5 +45,5 @@ pub use host_lock::HostLock;
pub use reconcile::{Launch, ReconcilePlan, Session, TaskLaunch, TaskTarget, Teardown, reconcile};
pub use run::{
PtyCli, Runner, SystemRunner, UpReport, detect_host, down, down_specs, exec_state_dir, execute,
up_loop, up_loop_specs, up_once, up_once_specs,
up_loop, up_loop_specs, up_once, up_once_selected, up_once_selected_specs, up_once_specs,
};
40 changes: 36 additions & 4 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -53,9 +53,13 @@ enum Command {
/// Materialize every local agent's render block and exit without reconciling or spawning.
#[arg(long, conflicts_with = "once")]
materialize_only: bool,
/// Limit materialization/reconciliation to one declared agent identity.
/// Limit materialization to one declared agent identity.
#[arg(long)]
agent: Option<String>,
/// Select one exact local task. Use with --materialize-only to render only its owner, or
/// with --once to render its owner and reconcile only that task.
#[arg(long, conflicts_with = "agent")]
task: Option<String>,
/// Seconds between timer-driven reconcile passes when looping (folder changes reconcile
/// immediately regardless).
#[arg(long, default_value_t = 30)]
Expand Down Expand Up @@ -533,9 +537,16 @@ fn main() -> Result<()> {
materialize_only,
interval,
agent,
task,
} => {
let root = catalog_arg(root)?;
up(&root, host, once, materialize_only, interval, agent)
if task.is_some() && !materialize_only && !once {
anyhow::bail!("--task requires --once or --materialize-only");
}
if agent.is_some() && !materialize_only {
anyhow::bail!("--agent requires --materialize-only");
}
up(&root, host, once, materialize_only, interval, agent, task)
}
Command::Message(cmd) => message_cmd(cmd),
Command::Context(cmd) => context_cmd(cmd),
Expand Down Expand Up @@ -1753,10 +1764,14 @@ fn up(
materialize_only: bool,
interval: u64,
agent: Option<String>,
task: Option<String>,
) -> Result<()> {
// An st2-SPEC path (a `*.kdl` file, or a folder with one top-level spec `*.kdl`) supervises its
// top-level team directly — no catalog discovery. Otherwise, the classic catalog reconcile loop.
if let Some(spec_file) = st2::eval_run::resolve_spec_path(root) {
if task.is_some() {
anyhow::bail!("--task is for folder catalogs, not single-file specs");
}
if materialize_only {
anyhow::bail!(
"--materialize-only is for folder catalogs with agent render{{}} blocks, not single-file specs"
Expand All @@ -1770,6 +1785,14 @@ fn up(

if materialize_only {
let mut found = discover(&catalog_root);
if let Some(selector) = task.as_deref() {
let (owner, _, _) = st2::reconcile::resolve_task(&found.specs, selector, &this_host)?;
let owner_identity = owner.identity.clone();
let owner_path = owner.path.clone();
found
.specs
.retain(|spec| spec.identity == owner_identity && spec.path == owner_path);
}
if let Some(identity) = agent.as_deref() {
found
.specs
Expand Down Expand Up @@ -1809,7 +1832,7 @@ fn up(
return Ok(());
}

let runner = SystemRunner::new(catalog_root, exec_state_dir(&this_host));
let runner = SystemRunner::new(catalog_root.clone(), exec_state_dir(&this_host));

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

if once {
let report = up_once(root, &this_host, &runner)?;
let targeted = task.is_some();
let report = match task.as_deref() {
Some(selector) => {
st2::run::up_once_selected(&catalog_root, selector, &this_host, &runner)?
}
None => up_once(root, &this_host, &runner)?,
};
println!("reconcile pass on host '{this_host}':");
print_report(&report);
if report.skipped {
anyhow::bail!("one-shot reconcile pass was skipped");
}
if targeted && !report.errors.is_empty() {
anyhow::bail!("targeted one-shot reconcile pass reported errors");
}
return Ok(());
}

Expand Down
99 changes: 99 additions & 0 deletions src/reconcile.rs
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,105 @@ pub struct ReconcilePlan<'a> {
pub gc: Vec<String>,
}

/// Resolve one exact local task selector (`host.agent.task` or explicit task id) without mutation.
pub fn resolve_task<'a>(
specs: &'a [AgentSpec],
selector: &str,
this_host: &str,
) -> anyhow::Result<(&'a AgentSpec, &'a crate::spec::Task, String)> {
let mut matches = Vec::new();
for spec in specs {
if spec.resolved_host(this_host) != this_host {
continue;
}
for task in &spec.tasks {
let runtime = task
.id
.clone()
.unwrap_or_else(|| format!("{}.{}", spec.bus_id(this_host), task.name));
let qualified = format!("{}.{}", spec.bus_id(this_host), task.name);
if selector == runtime || selector == qualified {
matches.push((spec, task, runtime));
}
}
}
match matches.as_slice() {
[(spec, task, runtime)] => Ok((*spec, *task, runtime.clone())),
[] => anyhow::bail!("task selector {selector:?} did not resolve to one local task"),
_ => anyhow::bail!("task selector {selector:?} is ambiguous"),
}
}

/// Pure task-scoped plan: resolve first, then retain only the selected runtime target.
pub fn reconcile_selected<'a>(
specs: &'a [AgentSpec],
sessions: &[Session],
this_host: &str,
selector: &str,
) -> anyhow::Result<ReconcilePlan<'a>> {
let (owner, task, runtime) = resolve_task(specs, selector, this_host)?;
let mut plan = ReconcilePlan::default();
let actual = sessions.iter().find(|s| s.pty_id == runtime);
if owner.retired {
if let Some(s) = actual {
if s.alive {
plan.teardown.push(Teardown {
spec: owner,
pty_ids: vec![runtime],
});
} else if !(task.keep || owner.keep) {
plan.gc.push(runtime);
}
}
return Ok(plan);
}
let launch = match (&task.command, &task.argv) {
(Some(command), None) => TaskLaunch::Shell(command.clone()),
(None, Some(argv)) => TaskLaunch::Argv(argv.clone()),
(None, None) => {
plan.unrunnable.push(owner);
return Ok(plan);
}
(Some(_), Some(_)) => {
unreachable!("discovery rejects tasks carrying both command and argv")
}
};
let bus_id = owner.bus_id(this_host);
let mut env = task.env.clone();
if let Some(supervisor) = &owner.supervisor {
env.insert("ST_SUPERVISOR".into(), supervisor.clone());
} else {
env.remove("ST_SUPERVISOR");
}
let target = TaskTarget {
kind: task.kind,
pty_id: runtime.clone(),
bus_id,
name: task.name.clone(),
launch,
cwd: task.cwd.clone(),
workspace: owner.workspace.clone(),
tags: task.tags.clone(),
env,
keep: task.keep || owner.keep,
};
match actual {
Some(s) if s.alive || target.keep => plan.adopt.push(owner),
Some(_) => {
plan.gc.push(runtime);
plan.launch.push(Launch {
spec: owner,
tasks: vec![target],
});
}
_ => plan.launch.push(Launch {
spec: owner,
tasks: vec![target],
}),
}
Ok(plan)
}

/// The state of a declared task's session in the ACTUAL world.
enum SessionState {
Alive,
Expand Down
Loading
Loading