Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
46 commits
Select commit Hold shift + click to select a range
45422a9
feat(workers-dev): auto-load workers-dev.yaml from the repo root
andersonleal Aug 6, 2026
8650128
feat(workers-dev): named stacks and default_stack in config, drop har…
andersonleal Aug 6, 2026
cd22ad2
feat(workers-dev): group dashboard by stack at view time
andersonleal Aug 6, 2026
8e87263
feat(workers-dev): start_stack by name replaces start_harness_stack
andersonleal Aug 6, 2026
a428f3e
feat(workers-dev): Ctrl+u stack picker with session current stack
andersonleal Aug 6, 2026
fc45d38
fix(workers-dev): make the shared stack member set authoritative for …
andersonleal Aug 6, 2026
99a992a
fix(workers-dev): reword CLI help for the default stack
andersonleal Aug 6, 2026
8b92420
test(workers-dev): cover stack_members' unknown-root guard
andersonleal Aug 6, 2026
4eb8e74
docs(workers-dev): document stack selection and config auto-load
andersonleal Aug 6, 2026
3856bab
docs(workers-dev): make the config example's workers list self-consis…
andersonleal Aug 6, 2026
77766d7
fix(workers-dev): refuse to start a stack with no startable workers
andersonleal Aug 6, 2026
17830c9
feat(workers-dev): edit workers-dev.yaml in place without losing comm…
andersonleal Aug 6, 2026
5528dcf
fix(workers-dev): keep column-0 comments and blank lines intact when …
andersonleal Aug 6, 2026
74389f7
fix(workers-dev): never delete a comment when replacing or removing a…
andersonleal Aug 6, 2026
3dc63bf
feat(workers-dev): verified atomic config writes and a roots-based st…
andersonleal Aug 6, 2026
137ca37
fix(workers-dev): point config_path at the file that was actually loaded
andersonleal Aug 7, 2026
9c795de
refactor(workers-dev): move the stack picker into tui/stacks.rs
andersonleal Aug 7, 2026
669912e
feat(workers-dev): mark workers with Space for stack creation
andersonleal Aug 7, 2026
55f185e
test(workers-dev): pin the mark glyph to the marked row and the colum…
andersonleal Aug 7, 2026
f021828
feat(workers-dev): create a stack from marked workers with n
andersonleal Aug 7, 2026
678c5c1
fix(workers-dev): reject stack names the loader cannot read back
andersonleal Aug 7, 2026
25a3fed
feat(workers-dev): delete and set-default stacks from the picker
andersonleal Aug 7, 2026
cad9bbb
fix(workers-dev): reach empty stacks in the picker and guard the defa…
andersonleal Aug 7, 2026
274ffe1
docs(workers-dev): document creating and managing stacks from the TUI
andersonleal Aug 7, 2026
6307751
chore(workers-dev): drop the stale dead-code allow on config_path
andersonleal Aug 7, 2026
24fc870
fix(workers-dev): keep a trailing comment on the default_stack line
andersonleal Aug 7, 2026
a1894f2
docs(workers-dev): note that deleting a harness override lasts until …
andersonleal Aug 7, 2026
7f07614
fix(workers-dev): only treat # as a comment when whitespace precedes it
andersonleal Aug 7, 2026
7344cf3
fix(workers-dev): never edit a stacks block containing entries we can…
andersonleal Aug 7, 2026
d0d1834
fix(workers-dev): verify default_stack still resolves before writing
andersonleal Aug 7, 2026
b3768da
fix(workers-dev): warn instead of refusing to load when the default s…
andersonleal Aug 7, 2026
6e76e55
fix(workers-dev): keep the first group header reachable after scrolling
andersonleal Aug 7, 2026
c472b7f
docs(workers-dev): fix stale plan-era comments and a clipped footer h…
andersonleal Aug 7, 2026
8b5635a
fix(workers-dev): accept list items indented level with their stack name
andersonleal Aug 7, 2026
657c285
fix(workers-dev): only treat a dash as a list item when YAML would
andersonleal Aug 7, 2026
8013f64
fix(workers-dev): end a stack key at the colon YAML would
andersonleal Aug 7, 2026
aeafea4
fix(workers-dev): use YAML's whitespace rule, not Rust's, when readin…
andersonleal Aug 7, 2026
373beb3
fix(workers-dev): agree with YAML on what a comment is, everywhere
andersonleal Aug 7, 2026
d1051ca
fix(workers-dev): use YAML whitespace when finding a list item's dash
andersonleal Aug 7, 2026
496be4d
fix(workers-dev): keep the stacks: header when comments remain after …
andersonleal Aug 7, 2026
45d31d6
fix(workers-dev): make config writes durable and collision-safe
andersonleal Aug 7, 2026
ffbf2f5
test(workers-dev): assert refusal reasons, not just non-empty errors
andersonleal Aug 7, 2026
ed4ba6e
fix(workers-dev): reject an empty default stack before spawning the e…
andersonleal Aug 7, 2026
b39995a
fix(workers-dev): reject control characters in stack names at load
andersonleal Aug 7, 2026
9e19e7d
test(workers-dev): pin stack_members' termination on a dependency cycle
andersonleal Aug 7, 2026
2220b9d
refactor(workers-dev): collapse stack_members' double map lookup
andersonleal Aug 7, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 44 additions & 10 deletions workers-dev/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ Workers are **discovered automatically** from top-level `*/iii.worker.yaml` in t

| Group | Workers | Started by |
|-------|---------|------------|
| **harness stack** | The stack roots (`session-manager`, `llm-router`, `context-manager`, `provider-anthropic`, `provider-openai`, `approval-gate`, `harness`) **plus everything they transitively depend on**, derived live from each worker's `iii.worker.yaml` dependencies | `workers-dev up`, `Ctrl+u` in TUI, `workers-dev start` (starts the roots; missing deps are pulled in, connected ones left alone) |
| **current stack** (default: `harness`) | The stack's roots **plus everything they transitively depend on**, derived live from each worker's `iii.worker.yaml` dependencies. The built-in `harness` stack's roots are `session-manager`, `llm-router`, `context-manager`, `provider-anthropic`, `provider-openai`, `approval-gate`, `harness`; define more stacks (or override `harness`'s roots) in `workers-dev.yaml` or from the TUI (`Space` + `n`) — see Config below | `workers-dev up` / bare `workers-dev start` start the `default_stack` (missing deps are pulled in, connected ones left alone); `Ctrl+u` in the TUI starts it directly, or opens a stack picker when more than one stack is defined |
| **other** | All remaining repo workers (e.g. `telegram-bot`, `console`, …) | `workers-dev start <name>`, `workers-dev start --all`, `Ctrl+a` in TUI |

Press `d` on any worker in the TUI to see its direct dependencies and its transitive dependents (the `r` restart blast radius), each with live status.
Expand All @@ -42,17 +42,23 @@ A worker connected to the engine but not started by this `workers-dev` shows **P
## Usage

```bash
workers-dev up # start harness stack + TUI
workers-dev up # start default stack + TUI
workers-dev # TUI only
workers-dev start # harness stack (CLI, waits for connect)
workers-dev start # default stack (CLI, waits for connect)
workers-dev start --all # every discovered Rust worker
workers-dev start telegram-bot # one worker (+ missing deps)
workers-dev restart llm-router # rebuild + restart dependents
workers-dev logs harness -f
workers-dev status
```

Starting a worker (CLI `start <name>` or `s` in the TUI) pulls in its dependencies, but a dependency **already connected to the engine is left running as-is** — no rebuild, no restart, no duplicate spawn. Explicitly requested workers always (re)start; use `restart` when a dependency itself needs a rebuild. The group commands count every member as explicitly requested: `up`, bare `start`, and `Ctrl+u` always restart the whole harness stack, `start --all` and `Ctrl+a` every managed Rust worker.
Stacks: define named stacks in `workers-dev.yaml`, or create one from the TUI
with `Space` + `n` (see Config below). `up` and bare `start` start the
`default_stack`; in the TUI, `Ctrl+u` opens a stack picker (Enter = switch the
dashboard's current stack + start it) when more than one stack is defined, and
starts the only stack directly otherwise.

Starting a worker (CLI `start <name>` or `s` in the TUI) pulls in its dependencies, but a dependency **already connected to the engine is left running as-is** — no rebuild, no restart, no duplicate spawn. Explicitly requested workers always (re)start; use `restart` when a dependency itself needs a rebuild. The group commands count every member as explicitly requested: `up`, bare `start`, and `Ctrl+u` always restart the whole stack, `start --all` and `Ctrl+a` every managed Rust worker.

Global flags: `--repo`, `--url`, `--port`, `--release`, `--config workers-dev.yaml`, `--stop-on-exit`, `--color auto|always|never`, `--ui-watch`.

Expand Down Expand Up @@ -91,6 +97,8 @@ Use `--color never` or `NO_COLOR=1` to force plain output. Default `--color auto
|-----|--------|
| `↑`/`↓` (or `k`/`j`) | Select worker (skips group headers) |
| `g`/`G` (or `Home`/`End`) | Jump to the first / last worker |
| `Space` | Mark the selected worker for a new stack |
| `n` | Name and save a new stack from the marked workers |
| `s` | Start selected worker |
| `x` | Stop selected worker |
| `r` | Restart selected worker + dependents (confirm lists the blast radius with live status) |
Expand All @@ -101,17 +109,19 @@ Use `--color never` or `NO_COLOR=1` to force plain output. Default `--color auto
| `+`/`-` | Resize the log pane (drags the divider in two columns, the height when stacked) |
| `/` | Filter workers by name (Enter applies, Esc clears) |
| `e` | Start the iii engine (`iii -c harness/engine.config.yaml`) |
| `Ctrl+u` | Start harness stack |
| `Ctrl+u` | Start stack (picker when several stacks are defined; Enter = switch + start) (in the picker: `x` delete a stack, `*` make it the default) |
| `Ctrl+a` | Start all managed Rust workers |
| `?` | Toggle the key-reference overlay |
| `q` | Quit |

On a wide terminal the dashboard is a two-column **master/detail** layout: the worker list on the left (sized to fit its columns), the selected worker's logs filling the rest on the right, with `+`/`-` dragging the divider between them. Below ~100 columns the two panes stack vertically instead, and `+`/`-` trade height.

The header shows the repo's current git branch (`⎇ feat/my-branch`, refreshed live; detached HEAD shows as `@<short-hash>`) so side-by-side instances on different worktrees or checkouts are easy to tell apart — the terminal/tmux pane title is set to `workers-dev ⎇ <branch>` too — plus an at-a-glance health summary (`●` connected, `◐` compiling, `✗` crashed, `○` stopped). When an engine status query fails the header flags `⚠ unreachable` and gains a line with the remedy (`press e to start the engine`) and the underlying error. The worker list's title shows the selection position (`Workers 3/48`). The log pane shows the **selected worker only**, scrollable through the full ring buffer, following the live tail by default. Crashed workers show their exit code inline. Lines are sanitized (no ANSI, no `\r` overwrite garbage).
The header shows the repo's current git branch (`⎇ feat/my-branch`, refreshed live; detached HEAD shows as `@<short-hash>`) so side-by-side instances on different worktrees or checkouts are easy to tell apart — the terminal/tmux pane title is set to `workers-dev ⎇ <branch>` too — plus an at-a-glance health summary (`●` connected, `◐` compiling, `✗` crashed, `○` stopped). When an engine status query fails the header flags `⚠ unreachable` and gains a line with the remedy (`press e to start the engine`) and the underlying error. The worker list's title shows the selection position (`Workers 3/48`). Each group's header row reads `── stack:<name> (N) ──` for the current stack and `── other (N) ──` for everything else, where N is the post-filter worker count. The log pane shows the **selected worker only**, scrollable through the full ring buffer, following the live tail by default. Crashed workers show their exit code inline. Lines are sanitized (no ANSI, no `\r` overwrite garbage).

## Config (`workers-dev.yaml`)

Auto-loaded from the repo root when present; `--config <path>` overrides.

```yaml
repo: /path/to/workers
engine_url: ws://127.0.0.1:49134
Expand All @@ -120,14 +130,38 @@ workers: # optional override; default = all discovered
- session-manager
- llm-router
- harness
harness_stack: # optional roots override (must be a subset of `workers`);
- session-manager # the dashboard's stack group is always these roots
- llm-router # plus their transitive dependencies
- harness
- console
stacks: # optional named stacks; values are stack ROOTS —
console: # the group shown/started is roots + transitive deps.
- console
- session-manager
harness: # overrides the built-in harness stack's roots
- session-manager
- llm-router
- harness
default_stack: console # started by `up` / bare `start`; default: harness
color: auto # auto | always | never (respects NO_COLOR)
ui_watch: false # start injectable-UI workers in watcher mode (pnpm watch + III_<WORKER>_UI_WATCH=1)
```

The built-in `harness` stack always exists. Deleting a `stacks.harness:`
override (`x` on it in the picker) removes it from that session's picker
until the next launch, when the built-in returns with its default roots. The
old `harness_stack:` key was replaced by `stacks:` + `default_stack:` and now
fails startup with a rename hint.

Stacks can also be created from the TUI: mark workers with `Space`, press `n`,
name it, Enter. The stack is written into this file (comments and formatting
are preserved) and becomes the current stack immediately — it is *not* started;
press `Ctrl+u` when you want that. In the `Ctrl+u` picker, `x` deletes a stack
and `*` makes it the default. Deleting the default stack is refused — set
another default first.

Note: `workers-dev.yaml` is not gitignored, so the first save leaves an
untracked file in the repo root. `workers-dev` writes the file by editing the
lines it owns; if `stacks:` is written inline (`stacks: {a: [b]}`) it refuses
to edit and says so.

## Troubleshooting

**Garbled log lines in the dashboard**
Expand Down
88 changes: 82 additions & 6 deletions workers-dev/src/commands/mod.rs
Original file line number Diff line number Diff line change
@@ -1,18 +1,20 @@
use std::io::{self, Write};
use std::sync::Arc;

use anyhow::Result;
use anyhow::{bail, Result};

use crate::logs;
use crate::orchestrator::Orchestrator;
use crate::status;

pub async fn run_status(orchestrator: &Orchestrator) -> Result<()> {
let (views, engine_error) = orchestrator.dashboard_snapshot().await;
let default_stack = &orchestrator.config.default_stack;
let members = orchestrator.stack_members(default_stack)?;
let (views, engine_error) = orchestrator.dashboard_snapshot(&members).await;
if let Some(err) = engine_error {
eprintln!("warning: engine unreachable: {err}");
}
status::print_status_table(&views);
status::print_status_table(&views, default_stack);
Ok(())
}

Expand All @@ -22,8 +24,9 @@ pub async fn run_start(orchestrator: &Orchestrator, workers: Vec<String>, all: b
orchestrator.start_all_managed(true).await?;
println!("started all managed workers");
} else {
orchestrator.start_harness_stack(true).await?;
println!("started harness stack");
let stack = orchestrator.config.default_stack.clone();
orchestrator.start_stack(&stack, true).await?;
println!("started stack {stack}");
}
} else {
orchestrator.start_workers(&workers, true).await?;
Expand Down Expand Up @@ -86,8 +89,81 @@ pub async fn run_logs(
Ok(())
}

/// Roots of `stack`, refusing an empty set. Same message `start_roots` bails
/// with for the same condition — this is strictly an earlier checkpoint on
/// the same rule, so `run_up` can refuse before `ensure_engine()` rather than
/// spawning the engine (an external side effect) for a stack that cannot
/// start anything.
fn startable_stack_roots(orchestrator: &Orchestrator, stack: &str) -> Result<Vec<String>> {
let roots = orchestrator.stack_roots(stack)?;
if roots.is_empty() {
bail!("stack {stack} has no startable workers");
}
Ok(roots)
}

pub async fn run_up(orchestrator: Arc<Orchestrator>) -> Result<()> {
let stack = orchestrator.config.default_stack.clone();
startable_stack_roots(&orchestrator, &stack)?;
orchestrator.ensure_engine().await?;
orchestrator.start_harness_stack(false).await?;
orchestrator.start_stack(&stack, false).await?;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
crate::tui::run(orchestrator).await
}

#[cfg(test)]
mod tests {
use super::*;
use crate::config::Config;

/// Minimal `Orchestrator` with no discovered workers — `WorkerGraph::load`
/// never touches the filesystem when `workers` is empty, so this needs no
/// repo fixture, and (more importantly) no reachable engine: the guard
/// under test must fire without either.
fn orchestrator_with_stacks(
default_stack: &str,
stacks: Vec<(String, Vec<String>)>,
) -> Orchestrator {
let config = Config {
repo_root: std::path::PathBuf::new(),
config_path: std::path::PathBuf::new(),
engine_url: crate::config::DEFAULT_ENGINE_URL.to_string(),
release: false,
poll_interval_ms: crate::config::DEFAULT_POLL_INTERVAL_MS,
connect_timeout_ms: crate::config::DEFAULT_CONNECT_TIMEOUT_MS,
workers: Vec::new(),
stacks,
default_stack: default_stack.to_string(),
worker_specs: Vec::new(),
stop_on_exit: false,
color_mode: Default::default(),
ui_watch: false,
};
Orchestrator::new(config, false).unwrap()
}

/// The regression `run_up` must never reopen: an empty default stack has
/// to be rejected here, synchronously and with no engine contact, so the
/// caller (`run_up`) never reaches `ensure_engine()` for a stack that
/// cannot start anything. `start_roots` carries the same guard for the
/// actual start; this pins the earlier checkpoint independently since
/// `run_up` itself always ends by handing off to the TUI and so can't be
/// exercised end-to-end in a unit test.
#[test]
fn startable_stack_roots_rejects_an_empty_default_stack() {
let orch = orchestrator_with_stacks("ghost", vec![("ghost".to_string(), Vec::new())]);
let err = startable_stack_roots(&orch, "ghost").unwrap_err();
assert!(err.to_string().contains("ghost"), "{err:#}");
}

#[test]
fn startable_stack_roots_allows_a_nonempty_stack() {
let orch = orchestrator_with_stacks(
"harness",
vec![("harness".to_string(), vec!["harness".to_string()])],
);
assert_eq!(
startable_stack_roots(&orch, "harness").unwrap(),
vec!["harness".to_string()]
);
}
}
Loading
Loading