From 3f746c569f9cff32d12d4494a26501374adf2829 Mon Sep 17 00:00:00 2001 From: 2witstudios <2witstudios@gmail.com> Date: Sat, 7 Mar 2026 09:27:44 -0600 Subject: [PATCH 1/3] feat: Add worktree support for scheduled agents with validation Scheduled agents previously always spawned as root agents (hardcoded root: true). This adds --root and --name flags to `pu schedule create` so schedules can spawn into isolated worktrees, matching `pu spawn` semantics. Default is worktree mode (--name required); --root opts into the previous root-agent behavior for read-only/cross-project tasks. Additionally adds ScheduleDef::validate() that enforces consistency between root and agent_name fields. Validation is called on save and on load (scan_dir/find_in_dir), rejecting malformed schedules upfront. Co-Authored-By: Claude Opus 4.6 --- crates/pu-cli/assets/SKILL.md | 29 +++++-- crates/pu-cli/src/commands/schedule.rs | 11 +++ crates/pu-cli/src/main.rs | 10 +++ crates/pu-cli/src/output.rs | 10 +++ crates/pu-core/src/protocol.rs | 18 +++++ crates/pu-core/src/schedule_def.rs | 102 +++++++++++++++++++++++++ crates/pu-engine/src/engine.rs | 26 +++++-- 7 files changed, 192 insertions(+), 14 deletions(-) diff --git a/crates/pu-cli/assets/SKILL.md b/crates/pu-cli/assets/SKILL.md index ea39e98..2345b96 100644 --- a/crates/pu-cli/assets/SKILL.md +++ b/crates/pu-cli/assets/SKILL.md @@ -85,16 +85,29 @@ pu schedule list # list all schedules pu schedule list --json # machine-readable pu schedule show # show schedule details pu schedule show --json # machine-readable -pu schedule create \ - --start-at "2026-03-07T14:00:00Z" \ - --recurrence none \ + +# Worktree spawn (default) — needs --name +pu schedule create overnight-build \ + --start-at "2026-03-07T22:30:00" \ + --name overnight-build \ + --trigger inline-prompt \ + --trigger-prompt "build a feature" # spawns in worktree pu/overnight-build + +# Root spawn — for read-only/cross-project tasks +pu schedule create morning-status \ + --start-at "2026-03-07T08:00:00" \ + --root \ --trigger inline-prompt \ - --trigger-prompt "do the thing" # one-shot scheduled agent -pu schedule create \ - --start-at "2026-03-07T14:00:00Z" \ + --trigger-prompt "scan commits" # spawns in project root + +# Recurring with agent def +pu schedule create nightly-review \ + --start-at "2026-03-07T03:00:00" \ --recurrence daily \ + --root \ --trigger agent-def \ - --trigger-name my-agent # recurring from saved agent def + --trigger-name security-review # recurring root agent from saved def + pu schedule enable # enable a disabled schedule pu schedule disable # disable without deleting pu schedule delete # remove a schedule @@ -104,6 +117,8 @@ pu schedule delete # remove a schedule **Trigger types**: `inline-prompt` (with `--trigger-prompt`), `agent-def` (with `--trigger-name`), `swarm-def` (with `--trigger-name`). +**Spawn mode**: By default, scheduled agents spawn into a worktree (requires `--name`). Use `--root` to spawn in the project root instead (for read-only or cross-project tasks). + **Scope**: `--scope local` (default, project-level) or `--scope global`. ### Other diff --git a/crates/pu-cli/src/commands/schedule.rs b/crates/pu-cli/src/commands/schedule.rs index 263151d..a14f9a4 100644 --- a/crates/pu-cli/src/commands/schedule.rs +++ b/crates/pu-cli/src/commands/schedule.rs @@ -30,11 +30,20 @@ pub async fn run_create( agent: &str, vars: Vec, scope: &str, + root: bool, + agent_name: Option, json: bool, ) -> Result<(), CliError> { daemon_ctrl::ensure_daemon(socket).await?; let project_root = commands::cwd_string()?; + // Validate: --name is required when not --root + if !root && agent_name.is_none() { + return Err(CliError::Other( + "--name is required when not using --root".into(), + )); + } + let start_at_dt = parse_datetime(start_at)?; let trigger = match trigger_type { @@ -77,6 +86,8 @@ pub async fn run_create( trigger, target: String::new(), scope: scope.to_string(), + root, + agent_name, }, ) .await?; diff --git a/crates/pu-cli/src/main.rs b/crates/pu-cli/src/main.rs index f748b84..7cb001a 100644 --- a/crates/pu-cli/src/main.rs +++ b/crates/pu-cli/src/main.rs @@ -412,6 +412,12 @@ enum ScheduleAction { /// Scope: local or global #[arg(long, default_value = "local")] scope: String, + /// Spawn as root agent (in project root, not a worktree) + #[arg(long)] + root: bool, + /// Worktree/branch name (required when not --root) + #[arg(long = "name")] + agent_name: Option, /// Output as JSON #[arg(long)] json: bool, @@ -622,6 +628,8 @@ async fn main() { agent, vars, scope, + root, + agent_name, json, } => { commands::schedule::run_create( @@ -635,6 +643,8 @@ async fn main() { &agent, vars, &scope, + root, + agent_name, json, ) .await diff --git a/crates/pu-cli/src/output.rs b/crates/pu-cli/src/output.rs index f542bab..265be2f 100644 --- a/crates/pu-cli/src/output.rs +++ b/crates/pu-cli/src/output.rs @@ -458,11 +458,17 @@ pub fn print_response(response: &Response, json_mode: bool) { next_run, trigger, scope, + root, + agent_name, .. } => { println!("{} ({})", name.bold(), scope.dimmed()); println!(" Enabled: {enabled}"); println!(" Recurrence: {recurrence}"); + println!(" Root: {root}"); + if let Some(an) = agent_name { + println!(" Agent name: {an}"); + } println!(" Start at: {}", start_at.format("%Y-%m-%d %H:%M UTC")); if let Some(nr) = next_run { println!(" Next run: {}", nr.format("%Y-%m-%d %H:%M UTC")); @@ -890,6 +896,8 @@ mod tests { project_root: "/test".into(), target: String::new(), scope: "local".into(), + root: true, + agent_name: None, created_at: chrono::Utc::now(), }], }; @@ -917,6 +925,8 @@ mod tests { project_root: "/test".into(), target: String::new(), scope: "local".into(), + root: true, + agent_name: None, created_at: chrono::Utc::now(), }; print_response(&resp, false); diff --git a/crates/pu-core/src/protocol.rs b/crates/pu-core/src/protocol.rs index da3ff5e..6c789fa 100644 --- a/crates/pu-core/src/protocol.rs +++ b/crates/pu-core/src/protocol.rs @@ -220,6 +220,10 @@ pub enum Request { #[serde(default)] target: String, scope: String, + #[serde(default = "crate::serde_defaults::default_true")] + root: bool, + #[serde(default)] + agent_name: Option, }, DeleteSchedule { project_root: String, @@ -332,6 +336,10 @@ pub struct ScheduleInfo { pub project_root: String, pub target: String, pub scope: String, + #[serde(default = "crate::serde_defaults::default_true")] + pub root: bool, + #[serde(default)] + pub agent_name: Option, pub created_at: DateTime, } @@ -499,6 +507,10 @@ pub enum Response { project_root: String, target: String, scope: String, + #[serde(default = "crate::serde_defaults::default_true")] + root: bool, + #[serde(default)] + agent_name: Option, created_at: DateTime, }, DiffResult { @@ -1915,6 +1927,8 @@ mod tests { }, target: String::new(), scope: "local".into(), + root: true, + agent_name: None, }; let json = serde_json::to_string(&req).unwrap(); let parsed: Request = serde_json::from_str(&json).unwrap(); @@ -1990,6 +2004,8 @@ mod tests { project_root: "/test".into(), target: String::new(), scope: "local".into(), + root: true, + agent_name: None, created_at: Utc::now(), }], }; @@ -2019,6 +2035,8 @@ mod tests { project_root: "/test".into(), target: String::new(), scope: "local".into(), + root: false, + agent_name: Some("overnight-build".into()), created_at: Utc::now(), }; let json = serde_json::to_string(&resp).unwrap(); diff --git a/crates/pu-core/src/schedule_def.rs b/crates/pu-core/src/schedule_def.rs index b7ff658..51710c4 100644 --- a/crates/pu-core/src/schedule_def.rs +++ b/crates/pu-core/src/schedule_def.rs @@ -58,12 +58,40 @@ pub struct ScheduleDef { pub project_root: String, #[serde(default)] pub target: String, + /// Whether the scheduled agent spawns in the project root (true) or a worktree (false) + #[serde(default = "crate::serde_defaults::default_true")] + pub root: bool, + /// Worktree/branch name when `root` is false + #[serde(default)] + pub agent_name: Option, /// "local" or "global" — set at load time, not serialized #[serde(skip)] pub scope: String, pub created_at: DateTime, } +impl ScheduleDef { + /// Validate that `root` and `agent_name` are consistent: + /// - root=true → agent_name must be None or empty + /// - root=false → agent_name must be Some(non-empty) + pub fn validate(&self) -> Result<(), std::io::Error> { + if self.root { + if self.agent_name.as_ref().is_some_and(|n| !n.is_empty()) { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "agent_name must not be set when root is true", + )); + } + } else if !self.agent_name.as_ref().is_some_and(|n| !n.is_empty()) { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "agent_name is required when root is false", + )); + } + Ok(()) + } +} + /// Scan both local and global schedule definition directories. Local defs take priority. pub fn list_schedule_defs(project_root: &Path) -> Vec { let mut seen = HashMap::new(); @@ -111,6 +139,7 @@ pub fn find_schedule_def(project_root: &Path, name: &str) -> Option /// Save a schedule definition as a YAML file. Creates the directory if needed. pub fn save_schedule_def(dir: &Path, def: &ScheduleDef) -> Result<(), std::io::Error> { crate::validation::validate_name(&def.name)?; + def.validate()?; if def.project_root.is_empty() { return Err(std::io::Error::new( std::io::ErrorKind::InvalidInput, @@ -258,6 +287,10 @@ fn scan_dir(dir: &Path, scope: &str) -> Vec { if let Ok(content) = std::fs::read_to_string(&path) { match serde_yml::from_str::(&content) { Ok(mut def) => { + if let Err(e) = def.validate() { + eprintln!("warning: invalid schedule {}: {e}", path.display()); + continue; + } def.scope = scope.to_string(); defs.push(def); } @@ -277,6 +310,9 @@ fn find_in_dir(dir: &Path, name: &str, scope: &str) -> Option { if path.is_file() { if let Ok(content) = std::fs::read_to_string(&path) { if let Ok(mut def) = serde_yml::from_str::(&content) { + if def.validate().is_err() { + return None; + } def.scope = scope.to_string(); return Some(def); } @@ -308,6 +344,8 @@ mod tests { trigger: make_trigger(), project_root: "/projects/myapp".to_string(), target: String::new(), + root: true, + agent_name: None, scope: String::new(), created_at: Utc::now(), } @@ -354,6 +392,70 @@ created_at: "2025-06-01T00:00:00Z" assert_eq!(def.recurrence, Recurrence::None); // default none assert_eq!(def.target, ""); // default empty assert!(def.next_run.is_none()); // default none + assert!(def.root); // default true (backward compat) + assert!(def.agent_name.is_none()); // default none + } + + #[test] + fn given_schedule_with_worktree_fields_should_round_trip() { + let yaml = r#" +name: overnight-build +start_at: "2025-06-01T22:30:00Z" +trigger: + type: inline_prompt + prompt: "build a feature" +project_root: /projects/myapp +root: false +agent_name: overnight-build +created_at: "2025-06-01T00:00:00Z" +"#; + let def: ScheduleDef = serde_yml::from_str(yaml).unwrap(); + assert!(!def.root); + assert_eq!(def.agent_name.as_deref(), Some("overnight-build")); + + // Round-trip through YAML + let serialized = serde_yml::to_string(&def).unwrap(); + let reparsed: ScheduleDef = serde_yml::from_str(&serialized).unwrap(); + assert!(!reparsed.root); + assert_eq!(reparsed.agent_name.as_deref(), Some("overnight-build")); + } + + // --- Validation --- + + #[test] + fn given_root_true_with_no_agent_name_should_validate() { + let def = make_schedule_def("test"); + assert!(def.validate().is_ok()); + } + + #[test] + fn given_root_true_with_agent_name_should_reject() { + let mut def = make_schedule_def("test"); + def.agent_name = Some("bad".to_string()); + assert!(def.validate().is_err()); + } + + #[test] + fn given_root_false_with_agent_name_should_validate() { + let mut def = make_schedule_def("test"); + def.root = false; + def.agent_name = Some("my-worktree".to_string()); + assert!(def.validate().is_ok()); + } + + #[test] + fn given_root_false_with_no_agent_name_should_reject() { + let mut def = make_schedule_def("test"); + def.root = false; + assert!(def.validate().is_err()); + } + + #[test] + fn given_root_false_with_empty_agent_name_should_reject() { + let mut def = make_schedule_def("test"); + def.root = false; + def.agent_name = Some(String::new()); + assert!(def.validate().is_err()); } #[test] diff --git a/crates/pu-engine/src/engine.rs b/crates/pu-engine/src/engine.rs index 9872e97..c584bf0 100644 --- a/crates/pu-engine/src/engine.rs +++ b/crates/pu-engine/src/engine.rs @@ -367,6 +367,8 @@ impl Engine { trigger, target, scope, + root, + agent_name, } => { self.handle_save_schedule( &project_root, @@ -377,6 +379,8 @@ impl Engine { trigger, &target, &scope, + root, + agent_name, ) .await } @@ -2526,6 +2530,8 @@ impl Engine { trigger: ScheduleTriggerPayload, target: &str, scope: &str, + root: bool, + agent_name: Option, ) -> Response { let dir = match Self::resolve_scope_dir( project_root, @@ -2565,6 +2571,8 @@ impl Engine { trigger: Self::payload_to_trigger(&trigger), project_root: project_root.to_string(), target: target.to_string(), + root, + agent_name, scope: scope.to_string(), created_at: now, }; @@ -2700,6 +2708,8 @@ impl Engine { project_root: d.project_root, target: d.target, scope: d.scope, + root: d.root, + agent_name: d.agent_name, created_at: d.created_at, } } @@ -2715,6 +2725,8 @@ impl Engine { project_root: d.project_root, target: d.target, scope: d.scope, + root: d.root, + agent_name: d.agent_name, created_at: d.created_at, } } @@ -2833,12 +2845,12 @@ impl Engine { pu_core::schedule_def::ScheduleTrigger::AgentDef { name } => { // Resolve agent def to get its type and prompt let pr = schedule.project_root.clone(); - let root = Path::new(&pr); - if let Some(def) = pu_core::agent_def::find_agent_def(root, name) { + let project_path = Path::new(&pr); + if let Some(def) = pu_core::agent_def::find_agent_def(project_path, name) { let prompt = if let Some(ref ip) = def.inline_prompt { ip.clone() } else if let Some(ref tpl_name) = def.template { - pu_core::template::find_template(root, tpl_name) + pu_core::template::find_template(project_path, tpl_name) .map(|t| t.body) .unwrap_or_else(|| { format!("Scheduled: agent def '{name}' (template not found)") @@ -2850,9 +2862,9 @@ impl Engine { project_root: pr, prompt, agent: def.agent_type, - name: None, + name: schedule.agent_name.clone(), base: None, - root: true, + root: schedule.root, worktree: None, }) .await @@ -2876,9 +2888,9 @@ impl Engine { project_root: schedule.project_root.clone(), prompt: prompt.clone(), agent: agent.clone(), - name: None, + name: schedule.agent_name.clone(), base: None, - root: true, + root: schedule.root, worktree: None, }) .await From 689dbe94d9c2ee50cf18747768c17f3943e9b42b Mon Sep 17 00:00:00 2001 From: 2witstudios <2witstudios@gmail.com> Date: Sat, 7 Mar 2026 09:37:29 -0600 Subject: [PATCH 2/3] fix: Simplify boolean expression to satisfy clippy Use is_none_or instead of !is_some_and for clearer boolean logic in ScheduleDef::validate(). Co-Authored-By: Claude Opus 4.6 --- crates/pu-core/src/schedule_def.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/pu-core/src/schedule_def.rs b/crates/pu-core/src/schedule_def.rs index 51710c4..decb64e 100644 --- a/crates/pu-core/src/schedule_def.rs +++ b/crates/pu-core/src/schedule_def.rs @@ -82,7 +82,7 @@ impl ScheduleDef { "agent_name must not be set when root is true", )); } - } else if !self.agent_name.as_ref().is_some_and(|n| !n.is_empty()) { + } else if self.agent_name.as_ref().is_none_or(|n| n.is_empty()) { return Err(std::io::Error::new( std::io::ErrorKind::InvalidInput, "agent_name is required when root is false", From 3657aadea67547bdaa2a9f67f6f870db02d6a7ab Mon Sep 17 00:00:00 2001 From: 2witstudios <2witstudios@gmail.com> Date: Sat, 7 Mar 2026 09:39:34 -0600 Subject: [PATCH 3/3] fix: Address review feedback on schedule validation - Reject --root + --name as mutually exclusive in CLI (schedule.rs) - Reject any Some(_) agent_name when root=true in validate(), not just non-empty strings, preventing Some("") from persisting - Add test for root=true + empty agent_name rejection Co-Authored-By: Claude Opus 4.6 --- crates/pu-cli/src/commands/schedule.rs | 7 ++++++- crates/pu-core/src/schedule_def.rs | 11 +++++++++-- 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/crates/pu-cli/src/commands/schedule.rs b/crates/pu-cli/src/commands/schedule.rs index a14f9a4..0d06786 100644 --- a/crates/pu-cli/src/commands/schedule.rs +++ b/crates/pu-cli/src/commands/schedule.rs @@ -37,12 +37,17 @@ pub async fn run_create( daemon_ctrl::ensure_daemon(socket).await?; let project_root = commands::cwd_string()?; - // Validate: --name is required when not --root + // Validate: --name is required when not --root, and mutually exclusive with --root if !root && agent_name.is_none() { return Err(CliError::Other( "--name is required when not using --root".into(), )); } + if root && agent_name.is_some() { + return Err(CliError::Other( + "--root and --name are mutually exclusive".into(), + )); + } let start_at_dt = parse_datetime(start_at)?; diff --git a/crates/pu-core/src/schedule_def.rs b/crates/pu-core/src/schedule_def.rs index decb64e..b937a0e 100644 --- a/crates/pu-core/src/schedule_def.rs +++ b/crates/pu-core/src/schedule_def.rs @@ -72,11 +72,11 @@ pub struct ScheduleDef { impl ScheduleDef { /// Validate that `root` and `agent_name` are consistent: - /// - root=true → agent_name must be None or empty + /// - root=true → agent_name must be None /// - root=false → agent_name must be Some(non-empty) pub fn validate(&self) -> Result<(), std::io::Error> { if self.root { - if self.agent_name.as_ref().is_some_and(|n| !n.is_empty()) { + if self.agent_name.is_some() { return Err(std::io::Error::new( std::io::ErrorKind::InvalidInput, "agent_name must not be set when root is true", @@ -435,6 +435,13 @@ created_at: "2025-06-01T00:00:00Z" assert!(def.validate().is_err()); } + #[test] + fn given_root_true_with_empty_agent_name_should_reject() { + let mut def = make_schedule_def("test"); + def.agent_name = Some(String::new()); + assert!(def.validate().is_err()); + } + #[test] fn given_root_false_with_agent_name_should_validate() { let mut def = make_schedule_def("test");