From 2fe599b184173434dd010c27adb125e14a042b78 Mon Sep 17 00:00:00 2001 From: droidnoob Date: Sat, 30 May 2026 08:11:02 +0530 Subject: [PATCH 1/7] feat(scope): hew_core::scope module with Scope enum + serde + backward-compat MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - New `hew_core::scope` module exporting `Scope { Ready, Epics { epic_ids } }` with `#[serde(tag = "kind", rename_all = "snake_case")]` mirroring the external_gate::GateKind tagged-union pattern. - `Default` is `Ready` so legacy RunConfig payloads without a scope key deserialize unchanged (covered by scope_serde_backward_compat_missing_field). - `Scope::includes(task_id, &epic_descendant_set)` filters via a pre-resolved HashSet so the hot path is a set lookup, not a graph walk per task. - `resolve_descendants(bd, &[epic_ids])` BFS-walks `tasks::children` with a visited set — handles shared descendants and accidental cycles. - 9 unit tests: default, includes (Ready/Epics), serde roundtrip both variants, missing-field compat, transitive resolution, multi-epic union/dedupe, empty input. Per-parent fake BdClient added inline since the shared MockBd in tasks::tests keys on argv[0] alone. Closes hew-55ne (parent epic hew-b3yl). --- hew-core/src/lib.rs | 1 + hew-core/src/scope.rs | 250 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 251 insertions(+) create mode 100644 hew-core/src/scope.rs diff --git a/hew-core/src/lib.rs b/hew-core/src/lib.rs index 1676b1d..bcd5acc 100644 --- a/hew-core/src/lib.rs +++ b/hew-core/src/lib.rs @@ -37,6 +37,7 @@ pub mod prompt; pub mod review; pub mod runner; pub mod runtime; +pub mod scope; pub mod skills; pub mod slash; pub mod stacks; diff --git a/hew-core/src/scope.rs b/hew-core/src/scope.rs new file mode 100644 index 0000000..314db00 --- /dev/null +++ b/hew-core/src/scope.rs @@ -0,0 +1,250 @@ +//! Run-scope selector for `hew loop`. +//! +//! [`Scope`] names *which* set of bd tasks counts as the queue for a +//! single `hew loop run` invocation. v1 ships two shapes: +//! +//! - [`Scope::Ready`] — every bd-ready task (current behavior). +//! - [`Scope::Epics`] — restricted to children of the listed epics. +//! +//! Downstream consumers (dispatcher, runner, loop_log, summary, CLI) +//! all read this single type so the "what counts" boundary lives in +//! one place. Serialized form is a tagged JSON union mirroring +//! `hew_core::external_gate::GateKind`: +//! +//! ```json +//! {"kind": "ready"} +//! {"kind": "epics", "epic_ids": ["hew-6az"]} +//! ``` +//! +//! [`Default`] is `Ready` so legacy callers that omit the field keep +//! the pre-scope behavior. + +use std::collections::HashSet; + +use serde::{Deserialize, Serialize}; + +use crate::bd::BdClient; +use crate::error::Result; +use crate::tasks; + +/// Which tasks count as the loop's queue. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum Scope { + /// Every bd-ready task — no scope filter. + #[default] + Ready, + /// Only tasks transitively under one of `epic_ids`. + Epics { epic_ids: Vec }, +} + +impl Scope { + /// True when `task_id` belongs to this scope. + /// + /// `epic_descendant_set` is the pre-resolved set of every task id + /// transitively under any selected epic (including the epics + /// themselves). Callers build it once per run via + /// [`resolve_descendants`] and pass it in for every filter check. + pub fn includes(&self, task_id: &str, epic_descendant_set: &HashSet) -> bool { + match self { + Self::Ready => true, + Self::Epics { .. } => epic_descendant_set.contains(task_id), + } + } +} + +/// Walk every epic in `epic_ids` and return the union of all their +/// transitive descendants plus the epics themselves. +/// +/// Uses `bd children ` (via [`tasks::children`]) to resolve one +/// level at a time and BFS-walks via a visited set, so a graph with +/// shared descendants or accidental cycles still terminates. +pub fn resolve_descendants(bd: &dyn BdClient, epic_ids: &[String]) -> Result> { + let mut out: HashSet = HashSet::new(); + let mut queue: Vec = Vec::new(); + + for id in epic_ids { + if out.insert(id.clone()) { + queue.push(id.clone()); + } + } + + while let Some(parent) = queue.pop() { + let kids = tasks::children(bd, &parent)?; + for c in kids { + if out.insert(c.id.clone()) { + queue.push(c.id); + } + } + } + + Ok(out) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::bd::{BdClient, BdOutput, BdVersion, ReadyTask, StatsSummary}; + use crate::error::HewError; + use std::cell::RefCell; + use std::collections::BTreeMap; + use std::ffi::OsStr; + + #[test] + fn scope_default_is_ready() { + assert_eq!(Scope::default(), Scope::Ready); + } + + #[test] + fn scope_ready_includes_everything() { + let empty: HashSet = HashSet::new(); + let s = Scope::Ready; + assert!(s.includes("hew-anything", &empty)); + assert!(s.includes("foo", &empty)); + } + + #[test] + fn scope_epics_filters_to_descendant_set() { + let s = Scope::Epics { epic_ids: vec!["hew-6az".into()] }; + let set: HashSet = + ["hew-6az", "hew-child-1", "hew-child-2"].iter().map(|s| s.to_string()).collect(); + assert!(s.includes("hew-6az", &set)); + assert!(s.includes("hew-child-1", &set)); + assert!(!s.includes("hew-stranger", &set)); + } + + #[test] + fn scope_serde_roundtrip_ready() { + let s = Scope::Ready; + let json = serde_json::to_string(&s).unwrap(); + assert_eq!(json, r#"{"kind":"ready"}"#); + let back: Scope = serde_json::from_str(&json).unwrap(); + assert_eq!(back, Scope::Ready); + } + + #[test] + fn scope_serde_roundtrip_epics() { + let s = Scope::Epics { epic_ids: vec!["hew-6az".into()] }; + let json = serde_json::to_string(&s).unwrap(); + assert_eq!(json, r#"{"kind":"epics","epic_ids":["hew-6az"]}"#); + let back: Scope = serde_json::from_str(&json).unwrap(); + assert_eq!(back, s); + } + + #[test] + fn scope_serde_backward_compat_missing_field() { + // Legacy RunConfig JSON without a scope key should deserialize + // to the default — we model this here by deserializing a + // wrapper that has scope: Option with #[serde(default)]. + #[derive(Deserialize)] + struct RunConfigCompat { + #[serde(default)] + scope: Scope, + } + let body = "{}"; + let cfg: RunConfigCompat = serde_json::from_str(body).unwrap(); + assert_eq!(cfg.scope, Scope::default()); + } + + // ── per-parent fake BdClient ──────────────────────────────────── + // + // The shared MockBd in `tasks::tests` keys on the first argv token, + // so every `bd children ` call returns the same body — that's + // fine for direct-children tests but useless for a transitive walk. + // We need a fake that maps each parent id to its own children list. + + #[derive(Debug, Default)] + struct PerParentBd { + children: BTreeMap, // parent_id → JSON array body + calls: RefCell>>, + } + + impl PerParentBd { + fn new() -> Self { + Self::default() + } + fn with_children(mut self, parent: &str, ids: &[&str]) -> Self { + let body = ids + .iter() + .map(|id| { + format!( + r#"{{"id":"{id}","title":"t-{id}","description":"","status":"open","priority":2,"issue_type":"task","closed_at":"","close_reason":null,"parent":"{parent}"}}"# + ) + }) + .collect::>() + .join(","); + self.children.insert(parent.to_string(), format!("[{body}]")); + self + } + } + + impl BdClient for PerParentBd { + fn version(&self) -> Result { + Ok(BdVersion { raw: "test".into(), semver: "0.0.0".into() }) + } + fn ready(&self) -> Result> { + Ok(Vec::new()) + } + fn stats(&self) -> Result { + Ok(StatsSummary::default()) + } + fn prime_raw(&self) -> Result { + Ok(String::new()) + } + fn memories(&self) -> Result> { + Ok(BTreeMap::new()) + } + fn remember(&self, _: &str) -> Result<()> { + Ok(()) + } + fn run_raw(&self, args: &[&OsStr]) -> Result { + let captured: Vec = + args.iter().map(|a| a.to_string_lossy().to_string()).collect(); + self.calls.borrow_mut().push(captured.clone()); + if captured.first().map(|s| s.as_str()) == Some("children") { + let parent = captured.get(1).cloned().unwrap_or_default(); + let body = self.children.get(&parent).cloned().unwrap_or_else(|| "[]".into()); + return Ok(BdOutput { stdout: body, stderr: String::new() }); + } + Err(HewError::BdNonZero { code: 1, stderr: format!("unexpected call: {captured:?}") }) + } + } + + #[test] + fn resolve_descendants_includes_self_and_transitive() { + // epic-a → [c-1, c-2]; c-1 → [g-1]; c-2 → []; g-1 → []. + let bd = PerParentBd::new() + .with_children("epic-a", &["c-1", "c-2"]) + .with_children("c-1", &["g-1"]) + .with_children("c-2", &[]) + .with_children("g-1", &[]); + let set = resolve_descendants(&bd, &["epic-a".into()]).unwrap(); + let expected: HashSet = + ["epic-a", "c-1", "c-2", "g-1"].iter().map(|s| s.to_string()).collect(); + assert_eq!(set, expected); + } + + #[test] + fn resolve_descendants_unions_multiple_epics_and_dedupes() { + // Two epics that share a descendant ("c-shared"). + let bd = PerParentBd::new() + .with_children("epic-a", &["c-shared", "c-a-only"]) + .with_children("epic-b", &["c-shared", "c-b-only"]) + .with_children("c-shared", &[]) + .with_children("c-a-only", &[]) + .with_children("c-b-only", &[]); + let set = resolve_descendants(&bd, &["epic-a".to_string(), "epic-b".to_string()]).unwrap(); + let expected: HashSet = ["epic-a", "epic-b", "c-shared", "c-a-only", "c-b-only"] + .iter() + .map(|s| s.to_string()) + .collect(); + assert_eq!(set, expected); + } + + #[test] + fn resolve_descendants_empty_input_returns_empty_set() { + let bd = PerParentBd::new(); + let set = resolve_descendants(&bd, &[]).unwrap(); + assert!(set.is_empty()); + } +} From e94c9b63136b6c125dff4d1aa2ee9be14f2f3078 Mon Sep 17 00:00:00 2001 From: droidnoob Date: Sat, 30 May 2026 08:19:29 +0530 Subject: [PATCH 2/7] feat(dispatcher): thread Scope through Dispatcher + filter dispatch_tick MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dispatcher::new now takes a Scope. dispatch_tick resolves the descendant set on every tick for Scope::Epics (so mid-run-added children are picked up) and filters the bd ready list before slot assignment. ready_seen counts the filtered set so callers detect "queue drained" per scope. Loop_cmd hands Scope::Ready for now; hew-ry5r threads the real value from RunConfig. - 4 new dispatcher unit tests cover Ready (unfiltered), Epics (descendant filter), Epics (no match → empty), Epics (recompute picks up newly-added child between ticks). - MockBd extended to answer 'children --json'. Closes hew-7ind. --- hew-core/src/dispatcher.rs | 165 ++++++++++++++++++++++++++++++++--- hew/src/commands/loop_cmd.rs | 9 +- 2 files changed, 162 insertions(+), 12 deletions(-) diff --git a/hew-core/src/dispatcher.rs b/hew-core/src/dispatcher.rs index 59c23f3..df05e6b 100644 --- a/hew-core/src/dispatcher.rs +++ b/hew-core/src/dispatcher.rs @@ -22,6 +22,7 @@ use crate::bd::{BdClient, ReadyTask}; use crate::error::Result; use crate::git::GitClient; use crate::merge_back::{self, MergeReport}; +use crate::scope::{self, Scope}; use crate::tasks; /// State of a single worker slot. @@ -73,13 +74,34 @@ pub struct Dispatcher { slots: Vec, run_id: String, base_sha: String, + scope: Scope, } impl Dispatcher { /// `jobs` is clamped to a minimum of 1 (zero workers is meaningless). - pub fn new(jobs: u32, run_id: impl Into, base_sha: impl Into) -> Self { + /// + /// `scope` decides which bd-ready tasks count as the queue. For the + /// pre-scope behavior (every bd-ready task) pass [`Scope::Ready`]. + /// For epic-scoped runs the descendant set is recomputed inside + /// [`Self::dispatch_tick`] on every tick, so children added to a + /// selected epic mid-run get picked up automatically. + pub fn new( + jobs: u32, + run_id: impl Into, + base_sha: impl Into, + scope: Scope, + ) -> Self { let n = (jobs.max(1)) as usize; - Self { slots: vec![SlotState::Idle; n], run_id: run_id.into(), base_sha: base_sha.into() } + Self { + slots: vec![SlotState::Idle; n], + run_id: run_id.into(), + base_sha: base_sha.into(), + scope, + } + } + + pub fn scope(&self) -> &Scope { + &self.scope } pub fn jobs(&self) -> u32 { @@ -135,6 +157,18 @@ impl Dispatcher { return Ok(DispatchTick::default()); } let ready = bd.ready()?; + + // Apply the run's scope filter to the raw ready list. For + // `Scope::Epics` we re-resolve the descendant set each tick so + // children added to a selected epic mid-run get picked up; for + // `Scope::Ready` the set is irrelevant and we skip the walk. + let descendant_set: HashSet = match &self.scope { + Scope::Ready => HashSet::new(), + Scope::Epics { epic_ids } => scope::resolve_descendants(bd, epic_ids)?, + }; + let ready: Vec = + ready.into_iter().filter(|t| self.scope.includes(&t.id, &descendant_set)).collect(); + let mut tick = DispatchTick { ready_seen: ready.len(), ..Default::default() }; if ready.is_empty() { return Ok(tick); @@ -226,6 +260,9 @@ mod tests { /// against another agent). Failure is one-shot per id — /// removed after the first attempt. claim_fails: RefCell>, + /// `parent_id → [child_id, …]` for `bd children --json` + /// responses. Used by the `Scope::Epics` filter tests. + children: RefCell>>, } impl MockBd { @@ -240,6 +277,17 @@ mod tests { fn claimed(&self) -> Vec { self.claimed.borrow().clone() } + + fn with_children(self, parent: &str, ids: &[&str]) -> Self { + self.children + .borrow_mut() + .insert(parent.to_string(), ids.iter().map(|s| s.to_string()).collect()); + self + } + + fn add_child(&self, parent: &str, id: &str) { + self.children.borrow_mut().entry(parent.to_string()).or_default().push(id.to_string()); + } } impl BdClient for MockBd { @@ -282,6 +330,24 @@ mod tests { self.claimed.borrow_mut().push(id); return Ok(BdOutput { stdout: String::new(), stderr: String::new() }); } + // tasks::children → ["children", , "--json"] + if args.len() == 3 + && args[0] == OsStr::new("children") + && args[2] == OsStr::new("--json") + { + let parent = args[1].to_string_lossy().to_string(); + let kids = self.children.borrow().get(&parent).cloned().unwrap_or_default(); + let body = kids + .iter() + .map(|id| { + format!( + r#"{{"id":"{id}","title":"t-{id}","description":"","status":"open","priority":2,"issue_type":"task","closed_at":"","close_reason":null,"parent":"{parent}"}}"# + ) + }) + .collect::>() + .join(","); + return Ok(BdOutput { stdout: format!("[{body}]"), stderr: String::new() }); + } Ok(BdOutput { stdout: String::new(), stderr: String::new() }) } } @@ -300,7 +366,7 @@ mod tests { #[test] fn new_clamps_jobs_to_at_least_one() { - let d = Dispatcher::new(0, "run-x", "deadbeef"); + let d = Dispatcher::new(0, "run-x", "deadbeef", Scope::Ready); assert_eq!(d.jobs(), 1); assert_eq!(d.slots().len(), 1); assert!(d.all_idle()); @@ -313,7 +379,7 @@ mod tests { // Regression for acceptance: N=1 picks the first ready task and // stops — identical to today's serial loop. let bd = MockBd::new(vec![ready("hew-a"), ready("hew-b"), ready("hew-c")]); - let mut d = Dispatcher::new(1, "run-1", "sha"); + let mut d = Dispatcher::new(1, "run-1", "sha", Scope::Ready); let tick = d.dispatch_tick(&bd).expect("tick"); assert_eq!(tick.assignments.len(), 1, "exactly one slot filled"); assert_eq!(tick.assignments[0].slot_id, 0); @@ -327,7 +393,7 @@ mod tests { #[test] fn dispatcher_fills_all_slots_when_ready_has_enough() { let bd = MockBd::new(vec![ready("hew-a"), ready("hew-b"), ready("hew-c"), ready("hew-d")]); - let mut d = Dispatcher::new(3, "run-2", "sha"); + let mut d = Dispatcher::new(3, "run-2", "sha", Scope::Ready); let tick = d.dispatch_tick(&bd).expect("tick"); assert_eq!(tick.assignments.len(), 3, "all 3 slots filled"); let ids: Vec<&str> = tick.assignments.iter().map(|a| a.task.id.as_str()).collect(); @@ -342,7 +408,7 @@ mod tests { #[test] fn dispatcher_skips_assignment_when_ready_empty() { let bd = MockBd::new(vec![]); - let mut d = Dispatcher::new(2, "run-3", "sha"); + let mut d = Dispatcher::new(2, "run-3", "sha", Scope::Ready); let tick = d.dispatch_tick(&bd).expect("tick"); assert!(tick.assignments.is_empty()); assert_eq!(tick.ready_seen, 0); @@ -354,7 +420,7 @@ mod tests { fn dispatcher_does_nothing_when_all_slots_busy() { // No `bd ready` should even be called when capacity = 0. let bd = MockBd::new(vec![ready("hew-z")]); - let mut d = Dispatcher::new(1, "run-4", "sha"); + let mut d = Dispatcher::new(1, "run-4", "sha", Scope::Ready); d.dispatch_tick(&bd).expect("first tick"); // Second tick: slot is full. let tick = d.dispatch_tick(&bd).expect("second tick"); @@ -370,7 +436,7 @@ mod tests { let bd = MockBd::new(vec![ready("hew-a"), ready("hew-b")]); bd.fail_claim("hew-a"); - let mut d = Dispatcher::new(1, "run-5", "sha"); + let mut d = Dispatcher::new(1, "run-5", "sha", Scope::Ready); let tick = d.dispatch_tick(&bd).expect("tick"); assert_eq!(tick.claim_failures.len(), 1); @@ -388,7 +454,7 @@ mod tests { // returned the task to two `ready` queries before either // claim landed. Dispatcher must not double-assign. let bd = MockBd::new(vec![ready("hew-a")]); - let mut d = Dispatcher::new(2, "run-6", "sha"); + let mut d = Dispatcher::new(2, "run-6", "sha", Scope::Ready); let tick1 = d.dispatch_tick(&bd).expect("first tick"); assert_eq!(tick1.assignments.len(), 1); assert_eq!(tick1.assignments[0].task.id, "hew-a"); @@ -404,7 +470,7 @@ mod tests { #[test] fn complete_returns_running_task_id_and_idles_slot() { let bd = MockBd::new(vec![ready("hew-a")]); - let mut d = Dispatcher::new(1, "run-7", "sha"); + let mut d = Dispatcher::new(1, "run-7", "sha", Scope::Ready); d.dispatch_tick(&bd).expect("tick"); assert_eq!(d.complete(0), Some("hew-a".into())); assert!(d.all_idle()); @@ -421,7 +487,7 @@ mod tests { // calls when its iter body returns. Two workers run, both // complete in turn, capacity restores. let bd = MockBd::new(vec![ready("hew-a"), ready("hew-b"), ready("hew-c")]); - let mut d = Dispatcher::new(2, "run-8", "sha"); + let mut d = Dispatcher::new(2, "run-8", "sha", Scope::Ready); let t1 = d.dispatch_tick(&bd).expect("tick 1"); assert_eq!(t1.assignments.len(), 2); @@ -441,4 +507,81 @@ mod tests { assert_eq!(d.complete(1), Some("hew-c".into())); assert!(d.all_idle()); } + + // ── Scope filter coverage ─────────────────────────────────────── + + #[test] + fn dispatch_tick_ready_scope_unfiltered() { + // Scope::Ready must surface every bd-ready task — no descendant + // walk, no filtering. + let bd = MockBd::new(vec![ready("hew-a"), ready("hew-b"), ready("hew-c")]); + let mut d = Dispatcher::new(3, "run-scope-ready", "sha", Scope::Ready); + let tick = d.dispatch_tick(&bd).expect("tick"); + assert_eq!(tick.ready_seen, 3); + let ids: Vec<&str> = tick.assignments.iter().map(|a| a.task.id.as_str()).collect(); + assert_eq!(ids, vec!["hew-a", "hew-b", "hew-c"]); + } + + #[test] + fn dispatch_tick_epics_scope_filters_to_descendants() { + // Two epics in the graph, only one is selected. Unrelated tasks + // are filtered out before slot assignment, and ready_seen + // counts the filtered set. + let bd = + MockBd::new(vec![ready("hew-child-1"), ready("hew-stranger"), ready("hew-child-2")]) + .with_children("hew-epic-a", &["hew-child-1", "hew-child-2"]) + .with_children("hew-epic-b", &["hew-stranger"]) + .with_children("hew-child-1", &[]) + .with_children("hew-child-2", &[]) + .with_children("hew-stranger", &[]); + let scope = Scope::Epics { epic_ids: vec!["hew-epic-a".into()] }; + let mut d = Dispatcher::new(3, "run-scope-epic", "sha", scope); + let tick = d.dispatch_tick(&bd).expect("tick"); + assert_eq!(tick.ready_seen, 2, "stranger filtered out"); + let ids: Vec<&str> = tick.assignments.iter().map(|a| a.task.id.as_str()).collect(); + assert_eq!(ids, vec!["hew-child-1", "hew-child-2"]); + assert_eq!(bd.claimed(), vec!["hew-child-1", "hew-child-2"]); + } + + #[test] + fn dispatch_tick_epics_scope_empty_when_no_match() { + // Selected epic has no descendants in the ready set. Nothing + // assigned, no claim attempted, ready_seen reports the filtered + // zero so callers see "queue drained" for this scope. + let bd = MockBd::new(vec![ready("hew-stranger"), ready("hew-other")]) + .with_children("hew-epic-empty", &[]); + let scope = Scope::Epics { epic_ids: vec!["hew-epic-empty".into()] }; + let mut d = Dispatcher::new(2, "run-scope-empty", "sha", scope); + let tick = d.dispatch_tick(&bd).expect("tick"); + assert_eq!(tick.ready_seen, 0); + assert!(tick.assignments.is_empty()); + assert!(bd.claimed().is_empty()); + assert!(d.all_idle()); + } + + #[test] + fn dispatch_tick_epics_recomputes_descendants_each_tick() { + // Mid-run a new child is added to the selected epic. The next + // dispatch_tick must re-walk descendants and pick it up + // without a Dispatcher rebuild — the cache is per-tick by + // design. + let bd = MockBd::new(vec![ready("hew-child-1")]) + .with_children("hew-epic-live", &["hew-child-1"]) + .with_children("hew-child-1", &[]); + let scope = Scope::Epics { epic_ids: vec!["hew-epic-live".into()] }; + let mut d = Dispatcher::new(2, "run-scope-live", "sha", scope); + + let t1 = d.dispatch_tick(&bd).expect("first tick"); + assert_eq!(t1.assignments.len(), 1); + assert_eq!(t1.assignments[0].task.id, "hew-child-1"); + + // New child added to the live epic + becomes ready. + bd.add_child("hew-epic-live", "hew-child-2"); + bd.ready.borrow_mut().push(ready("hew-child-2")); + + let t2 = d.dispatch_tick(&bd).expect("second tick"); + assert_eq!(t2.ready_seen, 1, "newly-added child seen after recompute"); + assert_eq!(t2.assignments.len(), 1); + assert_eq!(t2.assignments[0].task.id, "hew-child-2"); + } } diff --git a/hew/src/commands/loop_cmd.rs b/hew/src/commands/loop_cmd.rs index 92b3290..ca21216 100644 --- a/hew/src/commands/loop_cmd.rs +++ b/hew/src/commands/loop_cmd.rs @@ -668,7 +668,14 @@ fn run_loop_parallel( // Construct the Dispatcher even under --dry-run so the "invokes // Dispatcher" acceptance holds across both paths. - let mut dispatcher = hew_core::dispatcher::Dispatcher::new(args.jobs, &run_id, &base_sha); + // Scope wiring lands in hew-ry5r (RunConfig.scope → Dispatcher); for now + // the loop runs against every bd-ready task, matching pre-scope behavior. + let mut dispatcher = hew_core::dispatcher::Dispatcher::new( + args.jobs, + &run_id, + &base_sha, + hew_core::scope::Scope::Ready, + ); // v1 wiring: one tick to fill all slots, then drive each worker's // loop in a scoped thread. The dispatcher's slot-fill state machine From 35c905c65e5d96a8f9d514c66373656ba4ff0bad Mon Sep 17 00:00:00 2001 From: droidnoob Date: Sat, 30 May 2026 08:24:46 +0530 Subject: [PATCH 3/7] feat(loop_log): RunLog.scope: Option with backward-compat parse - Add Option field with serde(default, skip_serializing_if = "Option::is_none") so legacy run.json (missing scope) parses to None and Some(Ready) serializes verbatim - from_run sets scope = None; hew loop populates after RunConfig.scope lands (hew-ry5r) - Add tests/fixtures/run-log-pre-scope.json + 5 tests covering Ready/Epics persistence, backward-compat fixture load, two-id epics round-trip, and default-None invariant - Mirrors IterLog.model defensive serde pattern from hew-2cq Closes hew-6nxs. --- hew-core/src/loop_log.rs | 71 +++++++++++++++++++ .../tests/fixtures/run-log-pre-scope.json | 11 +++ 2 files changed, 82 insertions(+) create mode 100644 hew-core/tests/fixtures/run-log-pre-scope.json diff --git a/hew-core/src/loop_log.rs b/hew-core/src/loop_log.rs index 52f6250..465b027 100644 --- a/hew-core/src/loop_log.rs +++ b/hew-core/src/loop_log.rs @@ -36,6 +36,7 @@ use serde::{Deserialize, Serialize}; use crate::error::Result; use crate::runner::{Iter, IterOutcome, Run, StopReason, TokenSpend}; +use crate::scope::Scope; use crate::time::iso_now_utc; /// Default root for loop artifacts, relative to the project root. @@ -158,6 +159,14 @@ pub struct RunLog { pub max_iter: Option, pub strict: bool, pub interactive: bool, + /// Which slice of bd-ready tasks this run was scoped to. `None` in + /// pre-scope run.json files (treated as [`Scope::Ready`] by + /// downstream readers); explicit `Some(Scope::Ready)` is preserved + /// verbatim so future post-mortems can tell "scope was defaulted" + /// apart from "scope was explicitly Ready". Populated by `hew loop` + /// after `from_run` once `RunConfig.scope` lands. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub scope: Option, } impl RunLog { @@ -172,6 +181,7 @@ impl RunLog { max_iter: run.config.max_iter, strict: run.config.strict, interactive: run.config.interactive, + scope: None, } } } @@ -644,6 +654,67 @@ mod tests { assert!(parsed.model.is_none(), "missing model must deserialize to None"); } + #[test] + fn run_log_persists_scope_ready() { + let run = Run::new("loop-r", "2026-05-30T00:00:00Z", RunConfig::default()); + let mut log = RunLog::from_run(&run); + log.scope = Some(Scope::Ready); + let json = serde_json::to_string(&log).unwrap(); + // Some(Scope::Ready) must serialize verbatim — not be suppressed. + assert!(json.contains("\"scope\""), "explicit Ready scope must be serialized: {json}"); + assert!(json.contains("\"ready\"")); + let parsed: RunLog = serde_json::from_str(&json).unwrap(); + assert_eq!(parsed.scope, Some(Scope::Ready)); + } + + #[test] + fn run_log_persists_scope_epics() { + let run = Run::new("loop-e", "2026-05-30T00:00:00Z", RunConfig::default()); + let mut log = RunLog::from_run(&run); + log.scope = Some(Scope::Epics { epic_ids: vec!["hew-6az".into()] }); + let json = serde_json::to_string(&log).unwrap(); + let parsed: RunLog = serde_json::from_str(&json).unwrap(); + assert_eq!(parsed.scope, Some(Scope::Epics { epic_ids: vec!["hew-6az".into()] })); + } + + #[test] + fn run_log_backward_compat_missing_scope_deserializes_as_none() { + let path = Path::new(env!("CARGO_MANIFEST_DIR")) + .join("tests") + .join("fixtures") + .join("run-log-pre-scope.json"); + let body = std::fs::read_to_string(&path).expect("read pre-scope fixture"); + let parsed: RunLog = serde_json::from_str(&body).expect("parse pre-scope fixture"); + assert_eq!(parsed.id, "loop-pre-scope"); + assert_eq!(parsed.iter_count, 2); + assert!(parsed.scope.is_none(), "missing scope must deserialize to None"); + } + + #[test] + fn run_log_roundtrip_with_scope_epics_two_ids() { + let run = Run::new("loop-e2", "2026-05-30T00:00:00Z", RunConfig::default()); + let mut log = RunLog::from_run(&run); + log.scope = Some(Scope::Epics { epic_ids: vec!["hew-6az".into(), "hew-b3yl".into()] }); + let json = serde_json::to_string_pretty(&log).unwrap(); + let parsed: RunLog = serde_json::from_str(&json).unwrap(); + match parsed.scope { + Some(Scope::Epics { epic_ids }) => { + assert_eq!(epic_ids, vec!["hew-6az".to_string(), "hew-b3yl".to_string()]); + } + other => panic!("expected Epics scope, got {other:?}"), + } + } + + #[test] + fn run_log_from_run_defaults_scope_to_none() { + let run = Run::new("loop-d", "2026-05-30T00:00:00Z", RunConfig::default()); + let log = RunLog::from_run(&run); + assert!(log.scope.is_none()); + // None must round-trip as a missing field (skip_serializing_if). + let json = serde_json::to_string(&log).unwrap(); + assert!(!json.contains("\"scope\""), "None scope must be omitted: {json}"); + } + #[test] fn stop_reason_label_covers_all_variants() { for r in [ diff --git a/hew-core/tests/fixtures/run-log-pre-scope.json b/hew-core/tests/fixtures/run-log-pre-scope.json new file mode 100644 index 0000000..c60b559 --- /dev/null +++ b/hew-core/tests/fixtures/run-log-pre-scope.json @@ -0,0 +1,11 @@ +{ + "id": "loop-pre-scope", + "started_at": "2026-05-26T00:00:00Z", + "last_updated_at": "2026-05-26T00:01:00Z", + "iter_count": 2, + "cumulative_tokens": 1500, + "stop_reason": "ready_empty", + "max_iter": null, + "strict": true, + "interactive": false +} From cae38271186e2a485d2e9815b72ed316abc5f325 Mon Sep 17 00:00:00 2001 From: droidnoob Date: Sat, 30 May 2026 08:33:06 +0530 Subject: [PATCH 4/7] feat(runner): RunConfig.scope + thread into Dispatcher (hew-ry5r) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - RunConfig gains pub scope: Scope (default Ready) - RunLog::from_run propagates run.config.scope verbatim, replacing the hew-6nxs placeholder-None — Some(Scope::Ready) on default cfg keeps the "defaulted vs explicit Ready" distinction intact - loop_cmd grows resolve_scope(args) as the single source of truth; Dispatcher::new in run_loop_parallel and the RunConfig builder in run_worker_loop both read through it (returns Ready until hew-xhhw wires the --scope CLI flag) Closes hew-ry5r. --- hew-core/src/loop_log.rs | 24 ++++++++++++---- hew-core/src/runner.rs | 12 ++++++++ hew/src/commands/loop_cmd.rs | 54 ++++++++++++++++++++++++++++++------ 3 files changed, 76 insertions(+), 14 deletions(-) diff --git a/hew-core/src/loop_log.rs b/hew-core/src/loop_log.rs index 465b027..9162728 100644 --- a/hew-core/src/loop_log.rs +++ b/hew-core/src/loop_log.rs @@ -181,7 +181,7 @@ impl RunLog { max_iter: run.config.max_iter, strict: run.config.strict, interactive: run.config.interactive, - scope: None, + scope: Some(run.config.scope.clone()), } } } @@ -654,6 +654,17 @@ mod tests { assert!(parsed.model.is_none(), "missing model must deserialize to None"); } + #[test] + fn run_log_from_run_propagates_cfg_scope() { + let cfg = RunConfig { + scope: Scope::Epics { epic_ids: vec!["hew-6az".into()] }, + ..RunConfig::default() + }; + let run = Run::new("loop-fr", "2026-05-30T00:00:00Z", cfg); + let log = RunLog::from_run(&run); + assert_eq!(log.scope, Some(Scope::Epics { epic_ids: vec!["hew-6az".into()] })); + } + #[test] fn run_log_persists_scope_ready() { let run = Run::new("loop-r", "2026-05-30T00:00:00Z", RunConfig::default()); @@ -706,13 +717,16 @@ mod tests { } #[test] - fn run_log_from_run_defaults_scope_to_none() { + fn run_log_from_run_defaults_scope_to_explicit_ready() { + // Once `RunConfig.scope` exists (hew-ry5r), `from_run` propagates + // it verbatim — the default config gives an explicit + // `Some(Scope::Ready)`. Backward-compat for missing-field on disk + // is covered separately by the pre-scope fixture test. let run = Run::new("loop-d", "2026-05-30T00:00:00Z", RunConfig::default()); let log = RunLog::from_run(&run); - assert!(log.scope.is_none()); - // None must round-trip as a missing field (skip_serializing_if). + assert_eq!(log.scope, Some(Scope::Ready)); let json = serde_json::to_string(&log).unwrap(); - assert!(!json.contains("\"scope\""), "None scope must be omitted: {json}"); + assert!(json.contains("\"scope\"")); } #[test] diff --git a/hew-core/src/runner.rs b/hew-core/src/runner.rs index 3dc8280..af4190f 100644 --- a/hew-core/src/runner.rs +++ b/hew-core/src/runner.rs @@ -10,6 +10,7 @@ use std::time::Duration; use crate::config::LoopModelConfig; use crate::runtime::{RuntimeSpawner, SpawnFailureClass}; +use crate::scope::Scope; /// Per-run configuration. Set once at `hew loop` invocation, immutable /// for the duration of the run. @@ -36,6 +37,11 @@ pub struct RunConfig { /// `-m` override for each iter. Empty by default (no overrides; /// the spawner falls back to its own default). pub loop_model: LoopModelConfig, + /// Which slice of bd-ready tasks this run is scoped to. + /// [`Scope::Ready`] is the pre-scope default — every bd-ready + /// task counts. The CLI / picker layer resolves this once at run + /// start; the dispatcher reads it through `Dispatcher::new`. + pub scope: Scope, } impl Default for RunConfig { @@ -49,6 +55,7 @@ impl Default for RunConfig { interactive: false, unattended: false, loop_model: LoopModelConfig::default(), + scope: Scope::default(), } } } @@ -333,6 +340,11 @@ mod tests { assert!(!c.interactive); } + #[test] + fn run_config_default_scope_is_ready() { + assert_eq!(cfg().scope, Scope::Ready); + } + #[test] fn cancelled_wins_over_everything() { let mut s = StopSignals { diff --git a/hew/src/commands/loop_cmd.rs b/hew/src/commands/loop_cmd.rs index ca21216..50c63a3 100644 --- a/hew/src/commands/loop_cmd.rs +++ b/hew/src/commands/loop_cmd.rs @@ -414,6 +414,15 @@ pub fn run_loop(ctx: &Ctx, args: Args) -> miette::Result<()> { ) } +/// Resolve the run's [`Scope`] from CLI args. Single source of truth so +/// the dispatcher and `RunConfig.scope` stay in sync. The CLI flag +/// (`--scope` / `--epics`) wiring lands in hew-xhhw; until then the +/// resolver returns the pre-scope default ([`Scope::Ready`]) so every +/// bd-ready task counts. +fn resolve_scope(_args: &Args) -> hew_core::scope::Scope { + hew_core::scope::Scope::Ready +} + /// Construct the production spawner for a given runtime kind. Codex /// is wired symmetrically to Claude: same `Default`/`from_env()` path /// (HEW_LOOP_*_BIN override → PATH fallback). The fallback path uses @@ -667,15 +676,13 @@ fn run_loop_parallel( let base_sha = if args.dry_run { String::new() } else { git_head_sha(project_root)? }; // Construct the Dispatcher even under --dry-run so the "invokes - // Dispatcher" acceptance holds across both paths. - // Scope wiring lands in hew-ry5r (RunConfig.scope → Dispatcher); for now - // the loop runs against every bd-ready task, matching pre-scope behavior. - let mut dispatcher = hew_core::dispatcher::Dispatcher::new( - args.jobs, - &run_id, - &base_sha, - hew_core::scope::Scope::Ready, - ); + // Dispatcher" acceptance holds across both paths. The scope resolves + // through `resolve_scope`; hew-xhhw wires the CLI flag, today it + // defaults to `Scope::Ready` so the loop runs against every bd-ready + // task. + let scope = resolve_scope(&args); + let mut dispatcher = + hew_core::dispatcher::Dispatcher::new(args.jobs, &run_id, &base_sha, scope.clone()); // v1 wiring: one tick to fill all slots, then drive each worker's // loop in a scoped thread. The dispatcher's slot-fill state machine @@ -894,6 +901,7 @@ pub fn run_worker_loop( interactive: args.interactive, unattended: args.unattended, loop_model, + scope: resolve_scope(args), }; let collector = Collector::new(stop_path.to_path_buf()); @@ -1671,4 +1679,32 @@ mod tests { assert!(parse_duration("").is_err()); assert!(parse_duration("xs").is_err()); } + + fn default_args() -> Args { + Args { + max_iter: None, + until_empty: true, + budget_tokens: None, + budget_wall: None, + strict: true, + interactive: false, + unattended: false, + runtime: "claude".into(), + stop_file: None, + dry_run: true, + skill: "hew-execute".into(), + fallback_runtime: None, + fallback_cooldown_iters: None, + jobs: 1, + } + } + + #[test] + fn resolve_scope_defaults_to_ready_until_cli_flag_lands() { + // Pre-hew-xhhw: no `--scope` argv yet; the resolver returns the + // pre-scope default so today's behavior is byte-identical to the + // pre-scope `hew loop run`. + let args = default_args(); + assert_eq!(resolve_scope(&args), hew_core::scope::Scope::Ready); + } } From 7d8d988579ee1de88be545f45bec0908e8c50e89 Mon Sep 17 00:00:00 2001 From: droidnoob Date: Sat, 30 May 2026 08:47:22 +0530 Subject: [PATCH 5/7] feat(loop_cmd): --scope/--epics/--epic flags + resolve_scope (hew-xhhw) Wires the run-scope CLI surface for hew loop run. argv > interactive picker > non-interactive MissingFlag error. - New ScopeArg ValueEnum + --scope, --epics (CSV), --epic (singular alias merged into --epics). - resolve_scope(args, ctx, bd): validates --scope=ready against --epics conflict; validates each epic id via tasks::show (exists, is epic, open); interactive paths use inquire Select then MultiSelect; ResolvedScope::Cancelled exits 0 with a note. - Scope is resolved once at run_loop top and threaded through new run_loop_with_scope / run_worker_loop_with_scope. Legacy run_loop_with / run_worker_loop kept as Scope::Ready back-compat wrappers so loop_backpressure / loop_dynamic_model / loop_parallel_e2e don't need new args. - 8 unit tests on a FakeBd stub cover every resolve_scope branch. - 7 e2e tests cover the CLI surface: clap rejection, no-picker argv paths, MissingFlag enforcement, conflict rejection, --help. Co-Authored-By: Claude Opus 4.7 (1M context) --- hew/src/commands/loop_cmd.rs | 430 ++++++++++++++++++++++++++++++-- hew/tests/loop_backpressure.rs | 6 + hew/tests/loop_dynamic_model.rs | 3 + hew/tests/loop_parallel_e2e.rs | 3 + hew/tests/loop_scope_e2e.rs | 134 ++++++++++ 5 files changed, 554 insertions(+), 22 deletions(-) create mode 100644 hew/tests/loop_scope_e2e.rs diff --git a/hew/src/commands/loop_cmd.rs b/hew/src/commands/loop_cmd.rs index 50c63a3..867e3b1 100644 --- a/hew/src/commands/loop_cmd.rs +++ b/hew/src/commands/loop_cmd.rs @@ -20,10 +20,11 @@ use std::path::{Path, PathBuf}; use std::time::Duration; -use clap::{Args as ClapArgs, Subcommand}; +use clap::{Args as ClapArgs, Subcommand, ValueEnum}; use hew_core::backpressure::{self, GateCheck, Verdict}; use hew_core::bd::{BdClient, RealBd}; use hew_core::config::LoopModelConfig; +use hew_core::error::HewError; use hew_core::loop_log::{ IterLog, LOOP_ROOT, Manifest, ManifestWorker, RunLog, iter_log_path, new_run_id, run_dir, run_log_path, stop_file_path, write_json_atomic, write_manifest, @@ -35,7 +36,9 @@ use hew_core::runtime::{ ClaudeSpawner, CodexSpawner, FallbackConfig, RuntimeKind, RuntimeSpawner, SpawnFailureClass, SpawnOpts, }; +use hew_core::scope::Scope; use hew_core::stop_signals::Collector; +use hew_core::tasks; use hew_core::time::iso_now_utc; use hew_core::{Ctx, allowed_tools, skills}; @@ -359,6 +362,36 @@ pub struct Args { /// prevent accidental fork-bombs. #[arg(long, default_value_t = 1, value_parser = clap::value_parser!(u32).range(1..=16))] pub jobs: u32, + + /// Restrict the run's queue. `ready` = today's behavior (every + /// bd-ready task counts); `epics` = only tasks transitively under + /// the epics passed via `--epics`. Omitting the flag opens a + /// picker on a TTY and errors in non-interactive mode. + #[arg(long, value_enum)] + pub scope: Option, + + /// Comma-separated epic ids to scope this run to. Required (or + /// picked interactively) when `--scope=epics`. May be repeated. + /// Example: `--epics=hew-6az,hew-1tq`. + #[arg(long, value_delimiter = ',')] + pub epics: Vec, + + /// Ergonomic singular alias for `--epics`; merges into the same + /// list. Example: `--epic hew-6az --epic hew-1tq`. + #[arg(long = "epic", value_name = "EPIC_ID")] + pub epic: Vec, +} + +/// CLI surface of [`Scope`]. The runtime type lives in +/// `hew_core::scope` so dispatcher / runner / loop_log share a single +/// definition; this enum exists only to give clap a ValueEnum. +#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)] +#[clap(rename_all = "lowercase")] +pub enum ScopeArg { + /// Every bd-ready task counts (pre-scope default). + Ready, + /// Restrict to children of one or more epics. + Epics, } pub fn run_loop(ctx: &Ctx, args: Args) -> miette::Result<()> { @@ -395,13 +428,26 @@ pub fn run_loop(ctx: &Ctx, args: Args) -> miette::Result<()> { let project_root = std::env::current_dir().map_err(|e| miette::miette!("resolve cwd: {e}"))?; let bd = RealBd::discover().map_err(|e| miette::miette!("bd discover: {e}"))?; + + // Resolve --scope/--epics once, before any spawner is built. argv + // > picker > non-interactive error. Cancel exits 0 with a note. + let scope = match resolve_scope(&args, ctx, &bd)? { + ResolvedScope::Scope(s) => s, + ResolvedScope::Cancelled => { + if !ctx.quiet { + eprintln!("hew loop: no epics selected — run cancelled"); + } + return Ok(()); + } + }; + let spawner: Option> = if args.dry_run { None } else { Some(build_spawner_for(kind)) }; let fallback_spawner: Option> = if args.dry_run { None } else { fallback.runtime.map(build_spawner_for) }; let gate = AutoGateRunner; let loop_model = cfg.loop_cfg.model.clone(); - run_loop_with( + run_loop_with_scope( ctx, args, &bd, @@ -411,16 +457,137 @@ pub fn run_loop(ctx: &Ctx, args: Args) -> miette::Result<()> { loop_model, &gate, &project_root, + scope, ) } -/// Resolve the run's [`Scope`] from CLI args. Single source of truth so -/// the dispatcher and `RunConfig.scope` stay in sync. The CLI flag -/// (`--scope` / `--epics`) wiring lands in hew-xhhw; until then the -/// resolver returns the pre-scope default ([`Scope::Ready`]) so every -/// bd-ready task counts. -fn resolve_scope(_args: &Args) -> hew_core::scope::Scope { - hew_core::scope::Scope::Ready +/// Resolution outcome of [`resolve_scope`]. `Cancelled` means the user +/// backed out of the epic picker — the caller should exit 0 with a +/// note rather than starting a run. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ResolvedScope { + Scope(Scope), + Cancelled, +} + +/// Resolve the run's [`Scope`] from CLI args, the interactive picker, +/// or refuse with [`HewError::MissingFlag`] in non-interactive mode. +/// +/// Precedence (per hew-xhhw acceptance): +/// 1. `--scope=ready` → [`Scope::Ready`]; reject if `--epics` was set. +/// 2. `--scope=epics --epics=` → validate each id (exists, is +/// epic, open) and return [`Scope::Epics`]. +/// 3. `--scope=epics` with no `--epics`: interactive → multi-select +/// picker over open epics; non-interactive → MissingFlag("epics"). +/// 4. `--scope` omitted: interactive → single-select (ready/epics), +/// chained into the epic picker on `epics`; non-interactive → +/// MissingFlag("scope"). +/// +/// Picker UX is implemented inline against `inquire` to match the +/// existing patterns in `commands/init.rs` and `commands/remember.rs`. +pub fn resolve_scope(args: &Args, ctx: &Ctx, bd: &dyn BdClient) -> miette::Result { + // Merge --epic (singular) into --epics (plural): both feed the + // same list. Argv order is preserved so the picker echoes the + // user's intent. + let mut epics: Vec = args.epics.clone(); + epics.extend(args.epic.iter().cloned()); + + match args.scope { + Some(ScopeArg::Ready) => { + if !epics.is_empty() { + return Err(miette::miette!( + "--scope=ready does not accept --epics/--epic (got {:?})", + epics, + )); + } + Ok(ResolvedScope::Scope(Scope::Ready)) + } + Some(ScopeArg::Epics) => { + if !epics.is_empty() { + validate_epic_ids(bd, &epics)?; + Ok(ResolvedScope::Scope(Scope::Epics { epic_ids: epics })) + } else if ctx.interactive { + pick_epics(bd) + } else { + Err(HewError::MissingFlag { flag: "epics".into() }.into()) + } + } + None => { + if ctx.interactive { + pick_scope_then_epics(bd) + } else { + Err(HewError::MissingFlag { flag: "scope".into() }.into()) + } + } + } +} + +/// Confirm each id resolves to an open epic. Closed / non-epic / unknown +/// ids fail fast at resolve time so an iter never spawns against a stale +/// queue. +fn validate_epic_ids(bd: &dyn BdClient, ids: &[String]) -> miette::Result<()> { + for id in ids { + let summary = tasks::show(bd, id) + .map_err(|e| miette::miette!("--epics: id `{id}` not found in bd ({e})"))?; + if summary.issue_type != "epic" { + return Err(miette::miette!( + "--epics: id `{id}` is type `{}`, not `epic`", + summary.issue_type, + )); + } + if summary.status == "closed" { + return Err(miette::miette!("--epics: epic `{id}` is closed")); + } + } + Ok(()) +} + +/// Interactive single-select for scope kind, chained into the +/// multi-select epic picker when the user picks "epics". +fn pick_scope_then_epics(bd: &dyn BdClient) -> miette::Result { + use inquire::Select; + let labels = vec![ + "ready — every bd-ready task (default behavior)", + "epics — restrict to children of selected epics", + ]; + let pick = Select::new("Scope this run to:", labels) + .prompt() + .map_err(|e| miette::miette!("scope pick: {e}"))?; + if pick.starts_with("ready") { Ok(ResolvedScope::Scope(Scope::Ready)) } else { pick_epics(bd) } +} + +/// Multi-select picker over open epics. Empty selection cancels the +/// run (caller exits 0 with a note). +fn pick_epics(bd: &dyn BdClient) -> miette::Result { + use inquire::MultiSelect; + let mut open_epics = tasks::list( + bd, + &tasks::TaskListFilter { + status: vec!["open".into(), "in_progress".into(), "blocked".into()], + issue_type: Some("epic".into()), + ..Default::default() + }, + ) + .map_err(|e| miette::miette!("bd list open epics: {e}"))?; + open_epics.sort_by(|a, b| a.priority.cmp(&b.priority).then(a.id.cmp(&b.id))); + + if open_epics.is_empty() { + return Err(miette::miette!("no open epics to scope this run to")); + } + + let labels: Vec = open_epics.iter().map(|e| format!("{} {}", e.id, e.title)).collect(); + let picked = MultiSelect::new("Select one or more epics", labels.clone()) + .with_help_message("space to toggle, enter to confirm — empty cancels the run") + .prompt() + .map_err(|e| miette::miette!("epic pick: {e}"))?; + if picked.is_empty() { + return Ok(ResolvedScope::Cancelled); + } + let epic_ids: Vec = picked + .iter() + .filter_map(|l| labels.iter().position(|x| x == l).map(|i| open_epics[i].id.clone())) + .collect(); + Ok(ResolvedScope::Scope(Scope::Epics { epic_ids })) } /// Construct the production spawner for a given runtime kind. Codex @@ -484,6 +651,11 @@ pub struct WorkerOutcome { /// [`Worker`] over `project_root`, and delegates the iter loop to /// [`run_worker_loop`]. The behavior is byte-identical to the /// pre-split single-threaded loop. +/// Back-compat wrapper for the original `run_loop_with` signature +/// (pre-hew-xhhw). Callers that don't care about scope get +/// [`Scope::Ready`], which is byte-identical to the legacy behavior. +/// Production wiring goes through [`run_loop_with_scope`] via +/// [`run_loop`]; existing integration tests stay on this signature. #[allow(clippy::too_many_arguments)] pub fn run_loop_with( ctx: &Ctx, @@ -495,6 +667,33 @@ pub fn run_loop_with( loop_model: LoopModelConfig, gate: &dyn GateRunner, project_root: &Path, +) -> miette::Result<()> { + run_loop_with_scope( + ctx, + args, + bd, + spawner, + fallback_spawner, + fallback, + loop_model, + gate, + project_root, + Scope::Ready, + ) +} + +#[allow(clippy::too_many_arguments)] +pub fn run_loop_with_scope( + ctx: &Ctx, + args: Args, + bd: &dyn BdClient, + spawner: Option<&dyn RuntimeSpawner>, + fallback_spawner: Option<&dyn RuntimeSpawner>, + fallback: FallbackConfig, + loop_model: LoopModelConfig, + gate: &dyn GateRunner, + project_root: &Path, + scope: Scope, ) -> miette::Result<()> { // jobs == 1: today's behavior, byte-identical. Skip the dispatcher // entirely so the N=1 fast path never pays for parallel scaffolding @@ -511,6 +710,7 @@ pub fn run_loop_with( loop_model, gate, project_root, + scope, ); } run_loop_parallel( @@ -523,6 +723,7 @@ pub fn run_loop_with( loop_model, gate, project_root, + scope, ) } @@ -541,6 +742,7 @@ fn run_loop_serial( loop_model: LoopModelConfig, gate: &dyn GateRunner, project_root: &Path, + scope: Scope, ) -> miette::Result<()> { let skill = skills::find(&args.skill) .ok_or_else(|| miette::miette!("unknown skill `{}`", args.skill))?; @@ -582,7 +784,7 @@ fn run_loop_serial( }; let started_at = iso_now_utc(); - let outcome = run_worker_loop( + let outcome = run_worker_loop_with_scope( ctx, &args, bd, @@ -597,6 +799,7 @@ fn run_loop_serial( &run_id, &allowed, &stop_path, + scope.clone(), )?; // Dispatcher-shutdown manifest: lists every worker that @@ -653,6 +856,7 @@ fn run_loop_parallel( loop_model: LoopModelConfig, gate: &dyn GateRunner, project_root: &Path, + scope: Scope, ) -> miette::Result<()> { let skill = skills::find(&args.skill) .ok_or_else(|| miette::miette!("unknown skill `{}`", args.skill))?; @@ -676,11 +880,8 @@ fn run_loop_parallel( let base_sha = if args.dry_run { String::new() } else { git_head_sha(project_root)? }; // Construct the Dispatcher even under --dry-run so the "invokes - // Dispatcher" acceptance holds across both paths. The scope resolves - // through `resolve_scope`; hew-xhhw wires the CLI flag, today it - // defaults to `Scope::Ready` so the loop runs against every bd-ready - // task. - let scope = resolve_scope(&args); + // Dispatcher" acceptance holds across both paths. The scope was + // resolved once at the top of `run_loop` and threaded here. let mut dispatcher = hew_core::dispatcher::Dispatcher::new(args.jobs, &run_id, &base_sha, scope.clone()); @@ -757,7 +958,7 @@ fn run_loop_parallel( // path is correct (just sequential) for the parallel surface. let mut worker_outcomes: Vec = Vec::with_capacity(workers.len()); for worker in &workers { - let outcome = run_worker_loop( + let outcome = run_worker_loop_with_scope( ctx, &args, bd, @@ -772,6 +973,7 @@ fn run_loop_parallel( &run_id, &allowed, &stop_path, + scope.clone(), )?; worker_outcomes.push(outcome); } @@ -867,6 +1069,9 @@ fn run_loop_parallel( /// `--jobs=1` constructs a single worker with `worktree_dir = /// project_root` and `log_dir = .hew/loop//`, preserving the /// pre-split behavior byte-for-byte. +/// +/// Back-compat wrapper for tests that pre-date hew-xhhw: defaults +/// the scope to [`Scope::Ready`] (legacy behavior). #[allow(clippy::too_many_arguments)] pub fn run_worker_loop( ctx: &Ctx, @@ -883,6 +1088,43 @@ pub fn run_worker_loop( run_id: &str, allowed: &[String], stop_path: &Path, +) -> miette::Result { + run_worker_loop_with_scope( + ctx, + args, + bd, + spawner, + fallback_spawner, + fallback, + loop_model, + gate, + worker, + skill, + primer_text, + run_id, + allowed, + stop_path, + Scope::Ready, + ) +} + +#[allow(clippy::too_many_arguments)] +pub fn run_worker_loop_with_scope( + ctx: &Ctx, + args: &Args, + bd: &dyn BdClient, + spawner: Option<&dyn RuntimeSpawner>, + fallback_spawner: Option<&dyn RuntimeSpawner>, + fallback: FallbackConfig, + loop_model: LoopModelConfig, + gate: &dyn GateRunner, + worker: &Worker, + skill: &skills::Skill, + primer_text: &str, + run_id: &str, + allowed: &[String], + stop_path: &Path, + scope: Scope, ) -> miette::Result { let primary_kind: RuntimeKind = args.runtime.parse().map_err(|e: String| miette::miette!("{e}"))?; @@ -901,7 +1143,7 @@ pub fn run_worker_loop( interactive: args.interactive, unattended: args.unattended, loop_model, - scope: resolve_scope(args), + scope, }; let collector = Collector::new(stop_path.to_path_buf()); @@ -1696,15 +1938,159 @@ mod tests { fallback_runtime: None, fallback_cooldown_iters: None, jobs: 1, + scope: None, + epics: Vec::new(), + epic: Vec::new(), + } + } + + fn non_interactive_ctx() -> Ctx { + Ctx::new(true, hew_core::ctx::OutputMode::Text, true, 0) + } + + /// Stub bd that returns a single open epic for any `bd show ` / + /// `bd list` call, and otherwise errors. Enough to validate + /// resolve_scope's argv branches without touching disk. + #[derive(Debug, Default)] + struct FakeBd { + epic_id: String, + epic_status: String, + } + + impl FakeBd { + fn open_epic(id: &str) -> Self { + Self { epic_id: id.into(), epic_status: "open".into() } + } + } + + impl hew_core::bd::BdClient for FakeBd { + fn version(&self) -> hew_core::error::Result { + Ok(hew_core::bd::BdVersion { raw: "x".into(), semver: "0.0.0".into() }) + } + fn ready(&self) -> hew_core::error::Result> { + Ok(Vec::new()) + } + fn stats(&self) -> hew_core::error::Result { + Ok(Default::default()) + } + fn prime_raw(&self) -> hew_core::error::Result { + Ok(String::new()) + } + fn memories(&self) -> hew_core::error::Result> { + Ok(std::collections::BTreeMap::new()) + } + fn remember(&self, _: &str) -> hew_core::error::Result<()> { + Ok(()) + } + fn run_raw( + &self, + args: &[&std::ffi::OsStr], + ) -> hew_core::error::Result { + let argv: Vec = args.iter().map(|a| a.to_string_lossy().to_string()).collect(); + let first = argv.first().map(String::as_str).unwrap_or(""); + if first == "show" { + let id = argv.get(1).cloned().unwrap_or_default(); + if id == self.epic_id { + let body = format!( + r#"[{{"id":"{}","title":"t","description":"","status":"{}","priority":2,"issue_type":"epic","closed_at":"","close_reason":null,"parent":null}}]"#, + self.epic_id, self.epic_status, + ); + return Ok(hew_core::bd::BdOutput { stdout: body, stderr: String::new() }); + } + return Err(hew_core::error::HewError::BdNonZero { + code: 1, + stderr: format!("not found: {id}"), + }); + } + Err(hew_core::error::HewError::BdNonZero { + code: 1, + stderr: format!("unexpected: {argv:?}"), + }) } } #[test] - fn resolve_scope_defaults_to_ready_until_cli_flag_lands() { - // Pre-hew-xhhw: no `--scope` argv yet; the resolver returns the - // pre-scope default so today's behavior is byte-identical to the - // pre-scope `hew loop run`. + fn resolve_scope_ready_argv_is_ready() { + let mut args = default_args(); + args.scope = Some(ScopeArg::Ready); + let bd = FakeBd::default(); + assert_eq!( + resolve_scope(&args, &non_interactive_ctx(), &bd).unwrap(), + ResolvedScope::Scope(Scope::Ready), + ); + } + + #[test] + fn resolve_scope_ready_rejects_epics_argv() { + let mut args = default_args(); + args.scope = Some(ScopeArg::Ready); + args.epics = vec!["hew-6az".into()]; + let bd = FakeBd::default(); + let err = resolve_scope(&args, &non_interactive_ctx(), &bd).unwrap_err(); + assert!(format!("{err:?}").contains("--scope=ready does not accept --epics")); + } + + #[test] + fn resolve_scope_epics_argv_returns_epic_list() { + let mut args = default_args(); + args.scope = Some(ScopeArg::Epics); + args.epics = vec!["hew-6az".into()]; + let bd = FakeBd::open_epic("hew-6az"); + assert_eq!( + resolve_scope(&args, &non_interactive_ctx(), &bd).unwrap(), + ResolvedScope::Scope(Scope::Epics { epic_ids: vec!["hew-6az".into()] }), + ); + } + + #[test] + fn resolve_scope_epics_singular_alias_merges_into_list() { + let mut args = default_args(); + args.scope = Some(ScopeArg::Epics); + args.epic = vec!["hew-6az".into()]; + let bd = FakeBd::open_epic("hew-6az"); + assert_eq!( + resolve_scope(&args, &non_interactive_ctx(), &bd).unwrap(), + ResolvedScope::Scope(Scope::Epics { epic_ids: vec!["hew-6az".into()] }), + ); + } + + #[test] + fn resolve_scope_missing_in_non_interactive_errors() { let args = default_args(); - assert_eq!(resolve_scope(&args), hew_core::scope::Scope::Ready); + let bd = FakeBd::default(); + let err = resolve_scope(&args, &non_interactive_ctx(), &bd).unwrap_err(); + let msg = format!("{err:?}"); + assert!(msg.contains("scope"), "expected MissingFlag scope, got: {msg}"); + } + + #[test] + fn resolve_scope_epics_kind_without_epics_in_non_interactive_errors() { + let mut args = default_args(); + args.scope = Some(ScopeArg::Epics); + let bd = FakeBd::default(); + let err = resolve_scope(&args, &non_interactive_ctx(), &bd).unwrap_err(); + let msg = format!("{err:?}"); + assert!(msg.contains("epics"), "expected MissingFlag epics, got: {msg}"); + } + + #[test] + fn resolve_scope_epics_argv_rejects_closed_epic() { + let mut args = default_args(); + args.scope = Some(ScopeArg::Epics); + args.epics = vec!["hew-6az".into()]; + let mut bd = FakeBd::open_epic("hew-6az"); + bd.epic_status = "closed".into(); + let err = resolve_scope(&args, &non_interactive_ctx(), &bd).unwrap_err(); + assert!(format!("{err:?}").contains("closed")); + } + + #[test] + fn resolve_scope_epics_argv_rejects_unknown_id() { + let mut args = default_args(); + args.scope = Some(ScopeArg::Epics); + args.epics = vec!["hew-bogus".into()]; + let bd = FakeBd::open_epic("hew-6az"); + let err = resolve_scope(&args, &non_interactive_ctx(), &bd).unwrap_err(); + assert!(format!("{err:?}").contains("not found")); } } diff --git a/hew/tests/loop_backpressure.rs b/hew/tests/loop_backpressure.rs index ddf8196..1ae6c2a 100644 --- a/hew/tests/loop_backpressure.rs +++ b/hew/tests/loop_backpressure.rs @@ -130,6 +130,9 @@ fn args_one_iter() -> Args { fallback_runtime: None, fallback_cooldown_iters: None, jobs: 1, + scope: None, + epics: Vec::new(), + epic: Vec::new(), } } @@ -816,6 +819,9 @@ fn cooldown_routes_to_fallback_for_n_iters_then_retries_primary() { fallback_runtime: Some("codex".into()), fallback_cooldown_iters: Some(3), jobs: 1, + scope: None, + epics: Vec::new(), + epic: Vec::new(), }; let fallback_cfg = FallbackConfig { runtime: Some(hew_core::runtime::RuntimeKind::Codex), cooldown_iters: 3 }; diff --git a/hew/tests/loop_dynamic_model.rs b/hew/tests/loop_dynamic_model.rs index 4e6ab07..5e4ea70 100644 --- a/hew/tests/loop_dynamic_model.rs +++ b/hew/tests/loop_dynamic_model.rs @@ -64,6 +64,9 @@ fn args_one_dry_iter() -> Args { fallback_runtime: None, fallback_cooldown_iters: None, jobs: 1, + scope: None, + epics: Vec::new(), + epic: Vec::new(), } } diff --git a/hew/tests/loop_parallel_e2e.rs b/hew/tests/loop_parallel_e2e.rs index 614a05e..446d979 100644 --- a/hew/tests/loop_parallel_e2e.rs +++ b/hew/tests/loop_parallel_e2e.rs @@ -146,6 +146,9 @@ fn args_parallel(jobs: u32) -> Args { fallback_runtime: None, fallback_cooldown_iters: None, jobs, + scope: None, + epics: Vec::new(), + epic: Vec::new(), } } diff --git a/hew/tests/loop_scope_e2e.rs b/hew/tests/loop_scope_e2e.rs new file mode 100644 index 0000000..c29c9e2 --- /dev/null +++ b/hew/tests/loop_scope_e2e.rs @@ -0,0 +1,134 @@ +//! `hew loop run --scope / --epics / --epic` CLI-surface acceptance. +//! +//! Covers the resolution policy of hew-xhhw: +//! - argv > picker > non-interactive error; +//! - `--scope=ready` is the legacy default and runs without prompting; +//! - `--scope=epics --epics=` reaches resolve without prompting; +//! - missing flags in non-interactive mode emit `MissingFlag`; +//! - disallowed combinations (`--scope=ready --epics=X`) error. +//! +//! Picker UX itself (interactive `inquire` prompts) is intentionally +//! NOT exercised here — driving inquire from tests requires a faked +//! terminal we don't ship. The unit tests in `commands/loop_cmd.rs` +//! cover the `resolve_scope` branches directly against a stub `BdClient`. + +use assert_cmd::Command; +use predicates::prelude::*; +use predicates::str::contains; + +fn hew() -> Command { + let mut c = Command::cargo_bin("hew").unwrap(); + c.env("NO_COLOR", "1"); + c.env("TERM", "dumb"); + c.env("HEW_NO_UPDATE_CHECK", "1"); + c.env("HEW_NON_INTERACTIVE", "1"); + c.env_remove("HEW_LOG"); + c.env_remove("CI"); + c +} + +/// `--scope=bogus` is rejected at clap (exit 2). Pins the ValueEnum so +/// future additions stay deliberate. +#[test] +fn scope_rejects_bogus_at_clap() { + hew() + .args(["loop", "run", "--scope=bogus", "--dry-run", "--max-iter", "1"]) + .assert() + .failure() + .code(2) + .stderr(contains("ready").and(contains("epics"))); +} + +/// `--scope=ready` parses and clears the non-interactive MissingFlag +/// guard. Downstream may still fail (no bd in the test cwd) — the +/// assertion is just that we *didn't* exit on the MissingFlag path. +#[test] +fn scope_ready_flag_no_picker() { + let out = hew() + .args(["loop", "run", "--scope=ready", "--dry-run", "--max-iter", "1"]) + .assert() + .get_output() + .clone(); + let stderr = String::from_utf8_lossy(&out.stderr); + assert!( + !stderr.contains("missing required value in non-interactive mode: --scope"), + "should NOT trip the scope MissingFlag with --scope=ready, stderr={stderr}", + ); + assert!( + !stderr.contains("missing required value in non-interactive mode: --epics"), + "should NOT trip the epics MissingFlag with --scope=ready, stderr={stderr}", + ); +} + +/// `--scope=epics --epics=` reaches past the MissingFlag guard +/// without prompting. The actual bd lookup may still fail (no bd in +/// the test cwd) but the failure isn't the MissingFlag path. +#[test] +fn scope_epics_with_epics_flag_no_picker() { + let out = hew() + .args([ + "loop", + "run", + "--scope=epics", + "--epics=hew-doesnotexist", + "--dry-run", + "--max-iter", + "1", + ]) + .assert() + .get_output() + .clone(); + let stderr = String::from_utf8_lossy(&out.stderr); + assert!( + !stderr.contains("missing required value in non-interactive mode: --scope"), + "should NOT trip scope MissingFlag, stderr={stderr}", + ); + assert!( + !stderr.contains("missing required value in non-interactive mode: --epics"), + "should NOT trip epics MissingFlag with --epics= passed, stderr={stderr}", + ); +} + +/// `--scope=epics` with no `--epics` in non-interactive mode emits the +/// epics MissingFlag. Agents calling agents MUST be explicit. +#[test] +fn scope_epics_no_epics_non_interactive_errors() { + hew() + .args(["loop", "run", "--scope=epics", "--dry-run", "--max-iter", "1"]) + .assert() + .failure() + .stderr(contains("missing required value in non-interactive mode: --epics")); +} + +/// `hew loop run` with no `--scope` argv on a non-interactive runner +/// emits the scope MissingFlag. +#[test] +fn scope_omitted_non_interactive_errors() { + hew() + .args(["loop", "run", "--dry-run", "--max-iter", "1"]) + .assert() + .failure() + .stderr(contains("missing required value in non-interactive mode: --scope")); +} + +/// `--scope=ready --epics=` is a contradiction. Reject at resolve +/// time before any iter spawns. +#[test] +fn scope_ready_with_epics_argv_errors() { + hew() + .args(["loop", "run", "--scope=ready", "--epics=hew-6az", "--dry-run", "--max-iter", "1"]) + .assert() + .failure() + .stderr(contains("--scope=ready does not accept --epics")); +} + +/// `--scope` and `--epics` show up in `--help` so the explicit-argv +/// path is discoverable. +#[test] +fn scope_flags_are_in_help() { + let out = hew().args(["loop", "run", "--help"]).assert().get_output().clone(); + let stdout = String::from_utf8_lossy(&out.stdout); + assert!(stdout.contains("--scope"), "help missing --scope:\n{stdout}"); + assert!(stdout.contains("--epics"), "help missing --epics:\n{stdout}"); + assert!(stdout.contains("--epic "), "help missing --epic (singular):\n{stdout}"); +} From 2ce2ca791097fac77130abb491ff32f15254b660 Mon Sep 17 00:00:00 2001 From: droidnoob Date: Sat, 30 May 2026 09:00:24 +0530 Subject: [PATCH 6/7] feat(loop_summary): scope line + LOOP.md section + CHANGELOG (hew-cjhj) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Summary gains `scope: Option`; render() prints `scope: