Skip to content
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,20 @@ versioning follows [Semantic Versioning](https://semver.org/).

### Added

- **`hew loop run --scope={ready|epics}` — scoped run queue
(`hew-b3yl`).** Operators (and calling agents) now declare which
slice of `bd ready` counts as the queue for a run: `--scope=ready`
(everything ready — current behavior) or
`--scope=epics --epics=<csv>` (only tasks transitively under the
listed epics). The dispatcher resolves descendants once at startup
and filters every tick against that set. Interactive runs get a
picker; non-interactive runs without `--scope` fail with
`HewError::MissingFlag { flag: "scope" }` so an agent-driven loop
never accidentally consumes the rest of the graph. `run.json` gains
a `scope` field; legacy runs without it load as `None` and
`hew loop summary` renders them as `scope: ready (legacy)`. End-of-
run summary now carries a `scope:` line between `outcomes` and
`tokens`. See `docs/LOOP.md` § Scope.
- **`hew loop --jobs N` — parallel worker slots via per-worker git
worktrees.** Default `1` keeps today's single-threaded loop
byte-for-byte (no worktree, no merge-back, no manifest). `N >= 2`
Expand Down
22 changes: 15 additions & 7 deletions commands/loop.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,14 @@ ready-queue empty, stop-file, max-iter, runtime error).
Common invocations:

```sh
hew loop run --dry-run --max-iter 1 # smoke: prompt-assemble, no spawn
hew loop run --until-empty # drain the ready queue (default on)
hew loop run --max-iter 5 --strict # bounded run, craft warnings = fail
hew loop run --budget-tokens 200000 # cap cumulative tokens
hew loop run --budget-wall 30m # cap wall clock
hew loop run --runtime=codex # drive codex-cli instead of claude
hew loop run --fallback-runtime=codex # swap to codex on primary RuntimeError
hew loop run --scope=ready --dry-run --max-iter 1 # smoke: prompt-assemble, no spawn
hew loop run --scope=ready --until-empty # drain the ready queue (default on)
hew loop run --scope=epics --epics=hew-6az # only tasks under one epic
hew loop run --scope=ready --max-iter 5 --strict # bounded run, craft warnings = fail
hew loop run --scope=ready --budget-tokens 200000 # cap cumulative tokens
hew loop run --scope=ready --budget-wall 30m # cap wall clock
hew loop run --scope=ready --runtime=codex # drive codex-cli instead of claude
hew loop run --scope=ready --fallback-runtime=codex # swap to codex on primary RuntimeError

hew loop list # recent runs + state
hew loop logs --tail 5 # last 5 iters of latest run
Expand All @@ -39,5 +40,12 @@ cache hits; `prompt_prefix_hash` in the iter log lets you verify that.
(`--fallback-cooldown-iters`, default 3); see `docs/LOOP.md` for the
full state machine.

**Scope is required.** Every `hew loop run` invocation declares which
slice of `bd ready` it consumes — `--scope=ready` for the whole queue,
or `--scope=epics --epics=<csv>` for tasks transitively under one or
more epics. Interactive runs get a picker; agent-driven runs that omit
the flag exit with `MissingFlag { flag: "scope" }`. See `docs/LOOP.md`
§ Scope.

For the full design + the 10 locked decisions behind the loop, see
`docs/LOOP.md` and the `hew-gr1` epic.
44 changes: 44 additions & 0 deletions docs/LOOP.md
Original file line number Diff line number Diff line change
Expand Up @@ -412,6 +412,50 @@ hash in each iter log makes this easy to spot.

---

## Scope

A run is *scoped* — `hew loop run` needs to know which set of bd tasks
counts as the queue. Two shapes ship today:

- **`--scope=ready`** — every `bd ready` task. Current behavior; the
agent picks the top of the ready list every iter.
- **`--scope=epics --epics=<csv>`** — restricted to tasks transitively
under the listed epics. The dispatcher walks `bd children` for each
epic id at startup and filters every `dispatch_tick` against that
set, so workers never see siblings of unrelated epics. Epic ids
themselves are included so an "epic only ever closes when its
children are done" graph still resolves.

**Resolution order:**

1. CLI args (`--scope=...`, `--epics=...`) win.
2. Interactive picker fires when stderr is a TTY and `--scope` was
omitted. Operators get a list-of-checkboxes prompt for epic ids
when they pick `epics`.
3. Non-interactive without `--scope` returns
`HewError::MissingFlag { flag: "scope" }` (exit 2). Agents calling
agents *must* pass `--scope` explicitly — there is no fallback to
"everything is ready," because that's how a parallel `--jobs N`
run accidentally consumes the rest of the graph.

Scope is persisted on the run's `run.json` as the `scope` field:

```json
{ "scope": { "kind": "ready" } }
{ "scope": { "kind": "epics", "epic_ids": ["hew-6az"] } }
```

Pre-scope `run.json` files (no field) load as `None` and `hew loop
summary` renders them as `scope: ready (legacy)` so post-mortems can
tell "scope defaulted before the field existed" apart from
`Some(Ready)` ("operator explicitly chose ready").

**v1 non-goals:** priority / label / branch filters, a persistent
config default knob, mid-run scope changes. `/hew:auto` is already
epic-scoped (per `hew-6n0v`) and stays out of this surface.

---

## Stop signals

- `hew loop cancel` — touches `.hew/loop/<run-id>/.stop`.
Expand Down
165 changes: 154 additions & 11 deletions hew-core/src/dispatcher.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -73,13 +74,34 @@ pub struct Dispatcher {
slots: Vec<SlotState>,
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<String>, base_sha: impl Into<String>) -> 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<String>,
base_sha: impl Into<String>,
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 {
Expand Down Expand Up @@ -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<String> = match &self.scope {
Scope::Ready => HashSet::new(),
Scope::Epics { epic_ids } => scope::resolve_descendants(bd, epic_ids)?,
};
let ready: Vec<ReadyTask> =
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);
Expand Down Expand Up @@ -226,6 +260,9 @@ mod tests {
/// against another agent). Failure is one-shot per id —
/// removed after the first attempt.
claim_fails: RefCell<StdHashSet<String>>,
/// `parent_id → [child_id, …]` for `bd children <id> --json`
/// responses. Used by the `Scope::Epics` filter tests.
children: RefCell<BTreeMap<String, Vec<String>>>,
}

impl MockBd {
Expand All @@ -240,6 +277,17 @@ mod tests {
fn claimed(&self) -> Vec<String> {
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 {
Expand Down Expand Up @@ -282,6 +330,24 @@ mod tests {
self.claimed.borrow_mut().push(id);
return Ok(BdOutput { stdout: String::new(), stderr: String::new() });
}
// tasks::children → ["children", <id>, "--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::<Vec<_>>()
.join(",");
return Ok(BdOutput { stdout: format!("[{body}]"), stderr: String::new() });
}
Ok(BdOutput { stdout: String::new(), stderr: String::new() })
}
}
Expand All @@ -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());
Expand All @@ -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);
Expand All @@ -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();
Expand All @@ -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);
Expand All @@ -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");
Expand All @@ -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);
Expand All @@ -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");
Expand All @@ -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());
Expand All @@ -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);
Expand All @@ -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");
}
}
1 change: 1 addition & 0 deletions hew-core/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Loading
Loading