Skip to content

(MOT-4377) feat(workers-dev): named stacks — select a default, create and manage them from the TUI - #742

Merged
andersonleal merged 46 commits into
mainfrom
feat/workers-dev
Aug 7, 2026
Merged

(MOT-4377) feat(workers-dev): named stacks — select a default, create and manage them from the TUI#742
andersonleal merged 46 commits into
mainfrom
feat/workers-dev

Conversation

@andersonleal

@andersonleal andersonleal commented Aug 7, 2026

Copy link
Copy Markdown
Collaborator

Closes MOT-4377.

workers-dev had one hardcoded "harness stack", and defining a different one meant quitting the TUI to hand-edit YAML. This adds named stacks you can select, and then create and manage without leaving the dashboard.

Selecting a stack

  • <repo_root>/workers-dev.yaml now auto-loads (no --config needed); --config still overrides it.
  • New keys: stacks: — a mapping of <name>: [roots…], YAML order preserved — and default_stack:. A stack's values are roots, expanded to roots + transitive deps for both grouping and starting, so a newly declared dependency joins automatically.
  • A built-in harness stack always exists; a stacks.harness: entry overrides its roots.
  • The dashboard groups by a session-scoped current stack (── stack:<name> (N) ──, full width). Ctrl+u starts the default when only one stack exists, and opens a picker when there are several — Enter switches the grouping and starts.

Creating and managing stacks

  • Space marks the selected worker (marked rows show ; the table title counts them). n names the marked set as a new stack.
  • Saving makes it the current stack immediately, with no restart — and deliberately does not start it. Press Ctrl+u when you want that.
  • In the picker: x deletes a stack (confirm dialog naming the file), * makes it the default. Deleting the default is refused; deleting the current stack falls back to the default.

How writes work

Every write is: edit the YAML text in place → validate by re-parsing with the real loader → write a temp file beside the target → rename over it. Comments, blank lines and key order survive because nothing is re-serialized. A failed write changes nothing — not the file, not the session state.

The scanner was aligned to YAML's own rules rather than approximations of them: s-white is space and tab only, # starts a comment only at line start or after whitespace, and - is a sequence indicator only before whitespace. That came out of fuzzing 40,000 generated documents against serde_yaml with a zero-byte-file invariant asserted on every success — unsafe outcomes went 768 → 0.

Deliberate refusals (refusing beats mangling, and each says so): inline stacks: {…}, duplicated top-level keys, multi-line flow sequences, nested mapping values, ? key, tab-indented keys, tiny :, -weird:, and quoted keys on the delete path.

Breaking change

harness_stack: is hard-removed. A local workers-dev.yaml still using it fails startup with a rename hint pointing at stacks: {harness: [...]}. Nothing in the repo commits such a file — only per-worktree ones are affected.

Testing

109 tests in workers-dev; cargo fmt --check, cargo clippy --all-features -- -D warnings and --all-features --all-targets -- -D warnings all clean.

Manual QA was run live under tmux against a real engine: creating a stack from marks and confirming every comment in the file survived, * moving the default (including preserving a trailing comment on that line), the delete guardrail refusing the default, deleting a non-default stack, and the inline-stacks: refusal leaving the file untouched.

Not exercised: pressing Enter in the picker (switch and start) against a live engine — it spawns real cargo run builds for every root, against an engine another session owned. The path is unit-tested and traced end to end, but not observed.

Notes

  • Purely additive: with no config file present, and for anyone who never presses Space/n/x/*, behavior matches the previous release apart from deliberately reworded status strings (started harness stackstarted stack harness).
  • workers-dev.yaml is not gitignored, so the first save leaves an untracked file in the repo root; the save notice names the path it wrote and the README says so.
  • No version bump and no CHANGELOG, per the workers-ci bot's ownership of versions.

Summary by CodeRabbit

  • New Features

    • Added named worker stacks with configurable roots and transitive dependency discovery.
    • Added stack selection, creation, deletion, default-stack management, and startup controls in the TUI.
    • Added worker marking and stack-saving interactions.
    • Status views and dashboards now display active stack membership and labels.
  • Configuration

    • Replaced the harness stack setting with configurable stacks and a default stack.
    • Added automatic configuration-file discovery and safer configuration editing.
  • Documentation

    • Updated CLI, TUI, configuration, and startup behavior documentation.

…tent

The Config example's `workers:` override omitted `console` while its
`stacks.console:` entry named it as a root. Copied verbatim, the
`console` stack silently ends up containing only `session-manager` —
config.rs drops the missing root with a warning that the TUI's
alternate screen then hides. Add `console` to the example's `workers:`
list so every root any example stack references is in the managed set.
start_stack passed a stack's roots straight to start_workers, whose
names.is_empty() branch means "start every managed worker" (~50
concurrent cargo run builds). A stack can legitimately end up with
empty roots when every root it names gets filtered out of the managed
workers: set. It was unreachable only because every current caller
happens to guard it. Bail in start_stack as soon as roots resolve
empty, before start_workers ever talks to the engine.

Also tightens the Ctrl+u stack picker: handle_stack_picker_key set
UiMode::Busy on Enter before its caller validated the pick via
stack_members(), so a failed lookup could strand the Busy dialog with
nothing in flight. Busy now only gets set in the caller's Ok arm,
alongside current_stack and the spawned start. And the picker's
stacks[*selected] indexing is now stacks.get(*selected), a no-op on
Some but no longer a raw index.
…not parse

`entry_key` recognised an existing `stacks:` entry by running it through
`valid_stack_name`, which governs what this tool may *write*, not what
`Config::load`/`parse_stacks` already accept reading back (any string key,
so `my.stack`, `'quoted'`, and `tiny :` are all fully supported stacks
today). A sibling entry in one of those shapes was invisible to the line
scanner and got silently swallowed into a neighbouring entry's edit range,
or made `remove_stack`'s "any entries left?" check false so the whole
`stacks:` block was dropped — in one repro, truncating the file to 0 bytes,
all while still producing text `validate_config_text` accepts.

Fix in two layers:
- `entry_key` now recognises any key shape `parse_stacks` would accept,
  rejecting only what's structurally not a key (empty, a list item, or
  stray whitespace around the name).
- `ensure_block_entries_recognized` refuses the whole edit if any line in
  the block still isn't recognisable as an entry or a list item under one
  (e.g. `tiny :`, a space before the colon) — refusing beats mangling.

Adds regression tests for a `my.stack` sibling surviving both `upsert_stack`
and `remove_stack` on its neighbour, a `tiny :` entry causing a refusal, and
an explicit 0-byte-file guard.
`validate_config_text` checked that `stacks:` keys were well-typed but never
that `default_stack` names one of them. A write that left it dangling (e.g.
deleting the stack it points at, or renaming one under it) passed
verification and `write_verified` replaced the file — only for the very
next `Config::load` to refuse to start.

Bail when `default_stack` is set, isn't the built-in `harness`, and names
no parsed stack. Also corrects the function's doc comment, which overclaimed
it parses "exactly as load would" — it has no repo to discover workers
against, so `load`'s managed-workers/stack-roots filtering never runs here.
…tack is empty

The "default stack has no startable workers" check ran during `Config::load`,
so a `workers:` allowlist that excludes every root of the default stack made
`status`, `logs`, and the TUI all refuse to start — not just the start path.
Before named stacks, only starting misbehaved this way; the user could still
open the dashboard to see why.

`Orchestrator::start_roots` already carries the same guard and is what
actually starts a stack, so the load-time bail only bought an earlier
message at the cost of every read-only command. Downgrade it to a warning,
matching the neighbouring warn-and-drop messages, and update the Config
struct's `default_stack` doc comment, which claimed roots were always
non-empty.
ratatui 0.29's `get_row_bounds` only ever lowers the render offset when
`selected < offset`, and the selection can never land on display row 0
here — it's a group header, and the up/down nav helpers deliberately skip
headers. So once a scroll pushed the offset to 1, it never fell back to 0
on its own, even after navigating back to the very first worker row: the
top group header stayed permanently scrolled out of view for the rest of
the session.

That header is the only place the dashboard names the current stack
(`group_label`), and creating/switching stacks is now routine, so losing
that indicator permanently matters more than it used to. Force the offset
back to 0 in `draw_table` whenever the selection lands on the first worker
row, before handing off to `render_stateful_widget`.
…elp tier

Four comments still described the implementation plan that produced this
code rather than the code itself: `StackPicker`'s doc claimed `selected`
indexes `config.stacks` (it indexes the session's `stacks` list, which
diverges after any save/delete/default change) and that empty-roots rows
are "skipped" (they're deliberately reachable, so `x` can delete them);
three comments in `tui/mod.rs` and one in `tui/stacks.rs` referenced
"Task 5" and "this task" by the implementation plan's task numbering.
Reworded all five to describe the current code.

Also: `HELP_FULL` grew to 108 (now 122, with `n` added) characters but
stayed gated at `width >= 86`, so a terminal between the gate and the
text's real width rendered it anyway and clipped its tail (`? keys · q
quit`). `n` (new stack) was also missing from every footer tier despite
being a routine key now. `draw_footer` gates `HELP_FULL` on its own
character count instead of a hand-copied number, so this can't drift out
of sync again, and `n new stack` is now listed alongside `Space mark`.
CRITICAL 1's new guard (ensure_block_entries_recognized) classified a
`stacks:` block line by indentation depth first: at exactly entry_depth it
required entry_key to match, and only accepted a list item when indented
strictly deeper. A block sequence indented at the same column as its own
key is valid YAML and the default emit style of several YAML writers, but
entry_key always returns None for a `-`-prefixed line — so a same-level
list item was misclassified as an unrecognized key and refused the whole
edit, making `n` and `x` permanently dead for any config written that way.
No data loss (refusing beats mangling), but a confusing dead end on an
ordinary file.

Classify by shape first instead: a list item qualifies at its key's depth
or deeper; a candidate key must still sit at exactly entry_depth. The
existing refusals (`tiny :`, a deeper non-list nested value) are unchanged
— neither is a list item, so neither takes the new branch.
Follow-up 1's `>` -> `>=` widening (list items recognized at their key's
own indentation, not just strictly deeper) used a bare
`line.trim_start().starts_with('-')` to spot list items. Per YAML, `-` is
only a block-sequence indicator when followed by whitespace or end-of-line;
`-weird` (no space after the dash) is an ordinary plain scalar, so
`-weird:` is a valid sibling key. The widened check had no such
requirement, so at entry depth it started matching `-weird:` too, routing
it around the `entry_key` branch and letting it slip past unrefused --
reopening Critical-1's swallowing bug through a shape that fix's changes
never considered. Reachable from the TUI itself: `valid_stack_name` allows
`-` anywhere including first (it has to, or the name prompt's
per-keystroke filter would block typing `console-dev`), so a user can
create a `-weird` stack via `n`, and a later save or delete on a sibling
can then swallow it (or, per Critical-1's own zero-byte mechanism,
truncate the file).

Fix in two layers:
- The guard: new `is_list_item` only counts a line as a list item when the
  dash is followed by whitespace or nothing, matching YAML's actual rule.
  A dash-led key without that space now falls through to the `entry_key`
  branch, where it's refused as intended (entry_key has rejected
  `-`-prefixed keys since Critical-1, for the same reason it still can't
  tell such a key apart from a real list item).
- Defense in depth: `valid_stack_name` stays unchanged (it's still the
  TUI's per-keystroke filter, so `-` must stay valid at every position).
  New `ensure_writable_name` layers a leading-dash rejection on top of it
  and is called by `upsert_stack` and `set_default_stack` -- the two
  points that write a name into the file -- so this tool can never create
  such a stack itself. `remove_stack` doesn't need it: it can only target a
  name that already exists, and the guard fix above already refuses to
  touch a block it can't fully parse regardless of which entry is targeted.
entry_key found a `stacks:` entry's name via `rest.split(':').next()` --
the text up to the FIRST `:` byte. YAML ends a plain-scalar mapping key at
the first `:` followed by whitespace or end-of-line; a `:` glued directly
to the next character is ordinary scalar content, not a key terminator. So
a stack named `web:dev` had its extracted key truncated to `web`: matching
that truncated key made `entry_range` treat an unrelated `upsert_stack(...,
"web", ...)` call as targeting the `web:dev` entry, overwriting it instead
of appending a sibling -- and made `remove_stack(..., "web:dev")` report
"not defined" (the truncated key never equals the full search name) while
`remove_stack(..., "web")` -- a name that isn't actually defined anywhere
-- silently deleted it. Both are Critical-1's exact swallow/undelete-
ability failure mode, reached through name extraction rather than line
classification, and both reachable from the TUI (`web:dev`-style names are
ordinary hand-written stacks `Config::load` already accepts).

Find the key-ending colon the way YAML would: the first `:` whose
following character is whitespace or absent, via `char_indices().find(...)`
instead of `split(':').next()`. That find's `?` subsumes the old "must
contain a colon" length check, so it's dropped; the empty/leading-dash/
surrounding-whitespace rejections are unchanged.

Line classification (`is_list_item` / `ensure_block_entries_recognized`,
the OTHER half of this module's parsing) was independently re-verified
total at 657c285 and is untouched here.
…g a stacks block

entry_key's colon-ends-a-key check (Follow-up 3) and is_list_item's
dash-starts-a-list-item check (Follow-up 2) both used char::is_whitespace
-- Rust's Unicode White_Space property -- where YAML's own grammar calls
for s-white, which is space and tab only. White_Space is a strict
superset: NBSP (U+00A0), EM SPACE (U+2003), IDEOGRAPHIC SPACE (U+3000),
NARROW NO-BREAK SPACE (U+202F), and others all pass is_whitespace but are
not s-white, so both checks over-fired on them.

That reopened the same two swallowing bugs those two Follow-ups had just
closed, one codepoint over: a colon followed by NBSP looked like a key
terminator, truncating a name like `a:<NBSP>b` to `a` and losing the real
entry on upsert (Follow-up 3's exact bug); a dash followed by NBSP looked
like a real list-item indicator, waving a name like `-<NBSP>weird` through
`ensure_block_entries_recognized` without ever reaching entry_key's
leading-dash refusal, losing the sibling on upsert and, on remove,
returning Ok("") -- the config truncated to zero bytes, since nothing was
left in the block afterward (Critical-1's own headline repro, reopened via
Follow-up 2's dash fix).

Fix: one shared `is_yaml_space` predicate (space and tab only) used at
both sites instead of char::is_whitespace. Left out YAML's line-break
codepoints (U+0085/U+2028/U+2029) deliberately -- nothing here has ever
needed them, and skipping them only means a name using one degrades to a
safe refusal at write_verified, never to loss.
Three predicates in this file each independently answered "is this line a
comment," and disagreed with each other and with YAML:

- is_blank_or_comment used Rust's Unicode trim_start(), so a colon-key
  line beginning with NBSP-then-`#` (e.g. `  <NBSP>#a:`) had its NBSP
  stripped along with real indentation and was misread as a skippable
  comment by block_range, ensure_block_entries_recognized, and
  leading_gap_run alike -- even though `#` is only a YAML comment when
  preceded by s-white, and NBSP isn't s-white, so this line is really the
  entry `<NBSP>#a`. Every function that skips "just a comment" skipped the
  entry instead: upsert_stack returned Ok with the sibling silently gone,
  and remove_stack returned Ok("") -- Critical-1's headline zero-byte-file
  repro, still live after four rounds of fixes that never touched this
  specific predicate. Pre-existing, not a regression; only bites when the
  pathological key comes after a well-formed entry in the same block.

- entry_key had no comment-awareness at all, a regression from Critical-1
  itself: before that commit, entry_key's valid_stack_name check rejected
  `#` and spaces as non-alphanumeric for free. Once Critical-1 loosened
  that check (to recognize my.stack, web:dev, etc.), an ordinary comment
  shaped like `key: value` -- e.g. `# TODO: add console` -- passed every
  remaining check and came back as key `# TODO`. entry_range then treated
  it as a competing sibling, cutting the real preceding entry's range
  short right before it; YAML doesn't care that a comment sits between two
  list items of the same key, so the list item left behind silently
  reattached to the rewritten entry on the next load (tiny: [x, state]
  instead of the requested tiny: [x]). Correct at base 7f07614.

Fix: one shared is_comment(line) predicate, built on is_yaml_space, that
is_blank_or_comment and entry_key both route through instead of carrying
their own opinions. A third site asking the same question, trailing_comment
(byte-level u8::is_ascii_whitespace, close to correct but not identical),
was brought onto is_yaml_space too, for the same reason: agreeing by
construction beats two implementations that happen to agree today.
is_list_item's leading line.trim_start() was still Rust's Unicode trim,
the one call in this module Follow-up 5 didn't reach. A line like
`  <NBSP>- weird: v` had its NBSP stripped along with real indentation,
leaving `- weird: v`, which passes the dash check; indent_len (real spaces
only) still reports the block's own entry depth, so after Follow-up 2's
`>` -> `>=` widening (list items recognized at their key's own indentation,
not just strictly deeper) the guard accepts it as an ordinary same-level
list item. But a `-` not preceded by valid YAML indentation isn't a
sequence indicator at all -- the real key is `<NBSP>- weird`, which
entry_key then rejects (same surrounding-whitespace check that already
catches `tiny :`), leaving it invisible to entry_range, block_indent, and
remove_stack's has_entry scan alike. Blocker 1's exact mechanism, one
predicate over: upsert_stack(src, "tiny", [x]) returned Ok with the
sibling silently gone, and remove_stack(src, "tiny") returned Ok("") --
Critical-1's zero-byte-file repro again. Same 16 trigger codepoints as
Follow-up 4/5; pre-existing at base, not a regression; unreachable from
the TUI (valid_stack_name doesn't admit NBSP), hand-edited files only.

Fix: is_list_item's leading trim now uses is_yaml_space too, same as its
own after-the-dash check already did (Follow-up 2) and every other
predicate in this module now does (Follow-up 5). Last Unicode-trim call
in the file.
@vercel

vercel Bot commented Aug 7, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
workers Ready Ready Preview Aug 7, 2026 1:36pm
workers-tech-spec Ready Ready Preview Aug 7, 2026 1:36pm

Request Review

@github-actions

github-actions Bot commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

skill-check — worker

0 verified, 56 skipped (no docs/).

Layer Result
structure
vale
ai
render

Four for four. Nicely done.

@coderabbitai

coderabbitai Bot commented Aug 7, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@andersonleal, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 3 minutes

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: f5365263-fd9f-41ea-bda3-8ebf88704e11

📥 Commits

Reviewing files that changed from the base of the PR and between d1051ca and 2220b9d.

📒 Files selected for processing (5)
  • workers-dev/src/commands/mod.rs
  • workers-dev/src/config.rs
  • workers-dev/src/config_write.rs
  • workers-dev/src/discover.rs
  • workers-dev/src/orchestrator.rs
📝 Walkthrough

Walkthrough

The change replaces the fixed harness stack with named, persistent stacks. Configuration validates stack definitions and defaults. Worker membership follows dependencies. CLI startup, status output, orchestration, and TUI controls now use the active stack.

Changes

Named worker stack lifecycle

Layer / File(s) Summary
Configuration and dependency membership
workers-dev/README.md, workers-dev/src/config.rs, workers-dev/src/discover.rs
Configuration now loads named stacks and a validated default stack. Discovery sorts workers and computes transitive stack membership.
Line-preserving configuration persistence
workers-dev/src/config_write.rs, workers-dev/src/tui/stacks.rs
Stack edits preserve YAML comments, spacing, and line endings. Writes validate the result and use atomic replacement.
Stack startup and status integration
workers-dev/src/commands/mod.rs, workers-dev/src/main.rs, workers-dev/src/orchestrator.rs, workers-dev/src/status.rs
Commands start the default stack. The orchestrator rejects empty roots and groups views by stack membership. Status output uses stack-specific labels.
TUI stack management and rendering
workers-dev/src/tui/*, workers-dev/src/tui/theme.rs, workers-dev/README.md
The TUI supports marking workers, creating, selecting, starting, deleting, and defaulting stacks. Stack-aware grouping, overlays, help text, and rendering tests were added.

Estimated code review effort: 4 (Complex) | ~60 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Operator
  participant TUI
  participant ConfigWriter
  participant Config
  participant Orchestrator
  participant WorkerEngine
  Operator->>TUI: mark workers and save a stack
  TUI->>ConfigWriter: upsert_stack
  ConfigWriter->>Config: validate edited YAML
  TUI->>Orchestrator: start_stack
  Orchestrator->>WorkerEngine: start stack roots
  WorkerEngine-->>Orchestrator: worker state
  Orchestrator-->>TUI: grouped dashboard snapshot
Loading

Possibly related PRs

  • iii-hq/workers#557: Both changes use dependency-aware, transitive worker-stack membership.
  • iii-hq/workers#642: Both changes update worker startup behavior and related documentation.

Suggested reviewers: sergiofilhowz

Poem

A rabbit marks roots in a neat little row,
Names a new stack, then watches it grow.
Dependencies hop through the config at night,
The dashboard groups every worker just right.
The default stack starts with a soft carrot cheer.

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely summarizes the main change: named stack selection and TUI management.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/workers-dev

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

🧹 Nitpick comments (6)
workers-dev/src/discover.rs (2)

211-232: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

Add a dependency-cycle case to this test.

stack_members relies on members.insert(name) returning false to terminate on a cyclic dependency graph. Nothing pins that today. If a future refactor moves the insert, the traversal becomes an infinite loop that hangs the TUI poll path. A two-worker mutual dependency fixture covers it in a few lines.

💚 Proposed test addition
    /// Mutual dependencies must terminate — the `members.insert` guard is the
    /// only thing preventing an infinite traversal here.
    #[test]
    fn stack_members_terminates_on_a_dependency_cycle() {
        let tmp = TempDir::new().unwrap();
        write_worker_with_deps(&tmp, "a", &["b"]);
        write_worker_with_deps(&tmp, "b", &["a"]);

        let specs = discover_repo_workers(tmp.path()).unwrap();
        let members = stack_members(&specs, &["a".to_string()]);
        assert!(members.contains("a") && members.contains("b"));
        assert_eq!(members.len(), 2);
    }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@workers-dev/src/discover.rs` around lines 211 - 232, Add a dependency-cycle
test alongside stack_members_follows_dependencies that creates two workers with
mutual dependencies, discovers them, and calls stack_members from one root.
Assert traversal terminates and returns exactly both worker names, covering the
members.insert guard behavior.

133-140: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Collapse the double map lookup into one.

contains_key(name) and get(name) hash name twice per queue pop. A single get expresses the same guard and the same traversal.

♻️ Proposed refactor
     while let Some(name) = queue.pop_front() {
-        if !deps_by_name.contains_key(name) || !members.insert(name) {
-            continue;
-        }
-        if let Some(deps) = deps_by_name.get(name) {
-            queue.extend(deps.iter().map(String::as_str));
-        }
+        let Some(deps) = deps_by_name.get(name) else {
+            continue; // not a discovered worker
+        };
+        if !members.insert(name) {
+            continue; // already visited (also breaks dependency cycles)
+        }
+        queue.extend(deps.iter().map(String::as_str));
     }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@workers-dev/src/discover.rs` around lines 133 - 140, Update the queue
traversal loop to use one deps_by_name.get(name) lookup while preserving the
members.insert(name) guard and dependency expansion behavior; reuse the
retrieved dependency value for queue.extend instead of calling contains_key and
get separately.
workers-dev/src/config_write.rs (1)

902-910: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Assert the refusal reason, not just that an error occurred.

assert!(!err.to_string().is_empty()) passes for any error. These tests are regression guards for specific misreads, so a future change that fails for an unrelated reason would still keep them green. Assert on the distinguishing text, the way refuses_a_dash_led_key_misread_as_a_list_item already does with err.to_string().contains("weird").

♻️ Proposed tightening
         let err = upsert_stack(src, "tiny", &roots(&["x"])).unwrap_err();
-        assert!(!err.to_string().is_empty());
+        assert!(err.to_string().contains("weird"), "{err:#}");
 
         let err = remove_stack(src, "tiny").unwrap_err();
-        assert!(!err.to_string().is_empty());
+        assert!(err.to_string().contains("weird"), "{err:#}");

Also applies to: 1000-1010

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@workers-dev/src/config_write.rs` around lines 902 - 910, Strengthen the
assertions in refuses_a_dash_nbsp_key_misread_as_a_list_item and the
corresponding tests around the later occurrence so each error message is checked
for the distinguishing “weird” text, matching
refuses_a_dash_led_key_misread_as_a_list_item, instead of only asserting that
the message is non-empty.
workers-dev/src/config.rs (1)

510-536: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Consider guarding the WORKERS_DEV_REPO mutation with a shared test lock.

The doc comment already states the hazard. cargo test runs tests in the same binary in parallel, so any future test that calls Config::load with repo: None will observe this env var. A small static ENV_LOCK: Mutex<()> held for the duration of the set_var/load/remove_var sequence removes the latent race and makes the constraint enforceable instead of documented. A panic between set_var and remove_var also leaks the variable today; a guard scope fixes that too.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@workers-dev/src/config.rs` around lines 510 - 536, Protect the
WORKERS_DEV_REPO environment-variable mutation in
config_path_points_at_the_loaded_file_even_when_repo_key_redirects with a shared
static ENV_LOCK Mutex. Hold the lock across set_var, Config::load, and cleanup,
and scope cleanup so the variable is removed even if loading panics; preserve
the existing assertions and test behavior.
workers-dev/src/tui/stacks.rs (1)

196-210: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Reject names that read back as non-string YAML keys at prompt time.

valid_stack_name admits an all-digit name such as 123. The name survives the prompt and upsert_stack, and is only refused later by validate_config_text when parse_stacks finds a non-string mapping key (pinned by write_verified_refuses_a_digits_only_stack_name in workers-dev/src/config_write.rs). The file stays intact, so this is not a corruption risk, but the error banner shows a YAML parse message instead of a name problem.

Adding an all-digits check to ensure_writable_name in workers-dev/src/config_write.rs produces the same clear error class as the leading-dash rule already does.

♻️ Proposed addition to `ensure_writable_name`
     if name.starts_with('-') {
         bail!("invalid stack name {name:?} (can't start with -)");
     }
+    // A digits-only name is valid YAML text but reads back as a number key,
+    // which `parse_stacks` then refuses — catch it here with a useful message.
+    if name.chars().all(|c| c.is_ascii_digit()) {
+        bail!("invalid stack name {name:?} (can't be only digits)");
+    }
     Ok(())
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@workers-dev/src/tui/stacks.rs` around lines 196 - 210, Update
ensure_writable_name in config_write.rs to reject names composed entirely of
digits, matching the existing leading-dash validation and returning the same
clear name-validation error class. Ensure handle_name_key uses this validation
before accepting or submitting a stack name so digit-only names are rejected at
the prompt rather than during YAML parsing.
workers-dev/src/tui/mod.rs (1)

915-925: 🩺 Stability & Availability | 🔵 Trivial | 💤 Low value

Panicking fallbacks defeat the stated no-panic-in-raw-mode policy. Three stack lookups guard an invariant but can still abort the process. A panic unwinds past the terminal restoration at the end of run, so raw mode and the alternate screen stay active and the user's terminal is left unusable.

  • workers-dev/src/tui/mod.rs#L915-L925: replace stacks[0].1.clone() with stacks.first() and treat the empty case as "nothing to start".
  • workers-dev/src/tui/mod.rs#L572-L582: replace stacks[0].1.clone() with stacks.first() and fall back to an empty root list.
  • workers-dev/src/tui/mod.rs#L420-L424: replace .expect(...) with a let ... else that shows an error banner, matching the other two arms.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@workers-dev/src/tui/mod.rs` around lines 915 - 925, Remove panic-prone stack
lookups in workers-dev/src/tui/mod.rs at lines 915-925 and 572-582: use
stacks.first() and treat an empty stack collection as no roots to start, using
an empty root list at lines 572-582. At lines 420-424, replace the expect-based
lookup with a let-else branch that displays an error banner consistently with
the other arms, preserving the no-panic raw-mode behavior.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@workers-dev/src/commands/mod.rs`:
- Around line 92-95: Update run_up to resolve and validate the configured
default stack roots before calling ensure_engine, returning the existing
empty-stack error without spawning iii when no roots are configured. Pass the
validated roots into the stack-start flow, while preserving the existing guard
in start_roots for the TUI path.

In `@workers-dev/src/config_write.rs`:
- Around line 90-96: Update the last-entry cleanup around has_entry and
block_range so deleting the final stacks: entry does not leave an indented
orphan comment after the stacks: header is removed. Preserve non-entry block
comments by keeping the entire block, or remove those comments explicitly
together with the block; ensure the resulting output contains no dangling
indented comment.
- Around line 436-454: The write_verified function uses a shared temporary
filename and insufficiently explicit durability semantics. Generate a unique
temporary path per write, write through a file handle, explicitly flush and sync
it before renaming, and retain cleanup on failures; also update the associated
documentation to avoid claiming that rename alone provides crash durability.

In `@workers-dev/src/status.rs`:
- Around line 42-49: Ensure stack names loaded by Config::load, including
default_stack and YAML stack keys, cannot inject terminal control characters by
validating them as printable text or escaping controls before rendering. Update
group_label and the relevant configuration-loading path so every configured name
passed to terminal output follows the same rule.

---

Nitpick comments:
In `@workers-dev/src/config_write.rs`:
- Around line 902-910: Strengthen the assertions in
refuses_a_dash_nbsp_key_misread_as_a_list_item and the corresponding tests
around the later occurrence so each error message is checked for the
distinguishing “weird” text, matching
refuses_a_dash_led_key_misread_as_a_list_item, instead of only asserting that
the message is non-empty.

In `@workers-dev/src/config.rs`:
- Around line 510-536: Protect the WORKERS_DEV_REPO environment-variable
mutation in config_path_points_at_the_loaded_file_even_when_repo_key_redirects
with a shared static ENV_LOCK Mutex. Hold the lock across set_var, Config::load,
and cleanup, and scope cleanup so the variable is removed even if loading
panics; preserve the existing assertions and test behavior.

In `@workers-dev/src/discover.rs`:
- Around line 211-232: Add a dependency-cycle test alongside
stack_members_follows_dependencies that creates two workers with mutual
dependencies, discovers them, and calls stack_members from one root. Assert
traversal terminates and returns exactly both worker names, covering the
members.insert guard behavior.
- Around line 133-140: Update the queue traversal loop to use one
deps_by_name.get(name) lookup while preserving the members.insert(name) guard
and dependency expansion behavior; reuse the retrieved dependency value for
queue.extend instead of calling contains_key and get separately.

In `@workers-dev/src/tui/mod.rs`:
- Around line 915-925: Remove panic-prone stack lookups in
workers-dev/src/tui/mod.rs at lines 915-925 and 572-582: use stacks.first() and
treat an empty stack collection as no roots to start, using an empty root list
at lines 572-582. At lines 420-424, replace the expect-based lookup with a
let-else branch that displays an error banner consistently with the other arms,
preserving the no-panic raw-mode behavior.

In `@workers-dev/src/tui/stacks.rs`:
- Around line 196-210: Update ensure_writable_name in config_write.rs to reject
names composed entirely of digits, matching the existing leading-dash validation
and returning the same clear name-validation error class. Ensure handle_name_key
uses this validation before accepting or submitting a stack name so digit-only
names are rejected at the prompt rather than during YAML parsing.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 0cc2ed3a-1f01-4081-8c71-13af805e2b93

📥 Commits

Reviewing files that changed from the base of the PR and between e618824 and d1051ca.

📒 Files selected for processing (11)
  • workers-dev/README.md
  • workers-dev/src/commands/mod.rs
  • workers-dev/src/config.rs
  • workers-dev/src/config_write.rs
  • workers-dev/src/discover.rs
  • workers-dev/src/main.rs
  • workers-dev/src/orchestrator.rs
  • workers-dev/src/status.rs
  • workers-dev/src/tui/mod.rs
  • workers-dev/src/tui/stacks.rs
  • workers-dev/src/tui/theme.rs

Comment thread workers-dev/src/commands/mod.rs
Comment thread workers-dev/src/config_write.rs Outdated
Comment thread workers-dev/src/config_write.rs
Comment thread workers-dev/src/status.rs
Comment on lines +42 to +49
/// Group header text for one stack: the Stack group is named after the
/// current stack, everything else is "other".
pub fn group_label(group: WorkerGroup, stack_name: &str) -> String {
match group {
WorkerGroup::Stack => format!("stack:{stack_name}"),
WorkerGroup::Other => "other".to_string(),
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Reject terminal control characters in configured stack names.

group_label writes stack_name directly to terminal output. Config::load accepts YAML stack keys as arbitrary strings from an auto-loaded repository file. A quoted key can contain escape characters and modify terminal state when a user runs status or opens the TUI.

Validate loaded stack names as printable text, or escape control characters before rendering. Apply the same rule to default_stack.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@workers-dev/src/status.rs` around lines 42 - 49, Ensure stack names loaded by
Config::load, including default_stack and YAML stack keys, cannot inject
terminal control characters by validating them as printable text or escaping
controls before rendering. Update group_label and the relevant
configuration-loading path so every configured name passed to terminal output
follows the same rule.

…removal

remove_stack's emptiness check (has_entry) only looked for recognized
entries, so removing the last entry from a block that still held a
comment dropped the `stacks:` header too, leaving the comment as a
dangling, un-headed indented line. Check whether the removed entry
was the *entire* original block instead: if anything else was in it
(sibling entry or a stray comment), the header stays.
write_verified's temp file was a fixed <file>.tmp sibling, so two
workers-dev instances writing the same config could clobber each
other's temp file (including via the error-path remove_file of an
unrelated instance's in-flight write). Name it with the writer's pid
instead.

Also make the "a crash mid-write cannot leave a half-written config
behind" doc comment true: std::fs::write only guarantees the bytes
reached the page cache, not disk. Write through a File, flush, and
sync_all before the rename.
Several config_write tests asserted !err.to_string().is_empty(),
which passes for any error at all -- including a future failure for
an unrelated reason. Tighten them to assert the distinguishing text,
the way refuses_a_dash_led_key_misread_as_a_list_item already does.
…ngine

run_up called ensure_engine() (which can spawn an iii process) before
start_stack, whose start_roots guard rejects an empty-roots stack --
so `workers-dev up` on a configuration that cannot start anything
still performed the external side effect of starting the engine.
Resolve the default stack's roots and check for empty first.

Extracted the roots lookup (previously duplicated in start_stack and
stack_members) into Orchestrator::stack_roots so run_up's preflight
check reuses it instead of a third copy; start_roots' own guard is
unchanged.
workers-dev.yaml auto-loads from the repo root, so a stack key or
default_stack containing an ANSI escape (e.g. via a double-quoted
\x1b) would previously reach status's group headers and the TUI
unescaped. Refuse any stack name containing a control character in
parse_stacks and for default_stack, at load time so every consumer
is covered by one rule instead of needing its own escaping.
Termination on a cyclic dependency graph rests entirely on
members.insert(name) returning false to stop re-queueing a visited
name. Nothing pinned that, and a regression there is an infinite
loop in the TUI's poll path -- a hang, not a wrong answer.
contains_key(name) followed by get(name) hashed the queue-pop name
twice per iteration. Use one get, keeping the members.insert guard
that breaks cycles (now commented in place, since the termination
test added in the previous commit gives that comment somewhere to
point to).
@andersonleal

Copy link
Copy Markdown
Collaborator Author

Thanks — all four actionable comments and all three nitpicks were valid and are addressed in 496be4d0..2220b9d0.

Reject an empty default stack before starting the engine (commands/mod.rs) — fixed in ed4ba6ee. run_up now resolves and validates the default stack's roots before ensure_engine(), so no iii process is spawned for a config that cannot start anything. The start_roots guard is untouched, since the TUI path depends on it.

Reject terminal control characters in configured stack names (status.rs) — fixed in b39995a2, and this was the best catch of the review. Auto-loading <repo_root>/workers-dev.yaml is new on this branch, so cloning a repo and running workers-dev status renders whatever that file's keys contain, and a double-quoted YAML key can carry \e. Validated at load rather than at render, so every consumer is covered by one rule: parse_stacks and default_stack now reject names containing control characters, naming the offending key.

Use a unique temp file and don't claim crash durability from the rename (config_write.rs) — fixed in 45d31d61. The temp path now carries the pid, so concurrent instances writing the same config can't clobber each other or delete one another's temp file on an error path. Rather than weaken the doc comment, the claim was made true: the write goes through a File with an explicit flush() + sync_all() before the rename.

Preserve orphaned block comments when the last entry is deleted (config_write.rs) — fixed in 496be4d0. The stacks: header is now kept when the block still holds comment lines. Deliberately not the other option on offer: this module's contract is that it never destroys what the user wrote, so tidying away their comments would be the wrong trade.

Nitpicks — all taken. The dependency-cycle test (9e19e7d1) was worth more than "trivial": termination rests entirely on the members.insert guard, and a regression there is an infinite loop in the TUI's poll path, i.e. a hang rather than a wrong answer. The double lookup is collapsed in 2220b9d0 with the guard's cycle-breaking role stated in a comment, so the new test has a named invariant. Refusal-reason assertions tightened in ffbf2f53, including a third instance of the same weak pattern sitting between the two you flagged.

Two left alone, with reasons: write_verified_refuses_text_the_loader_cannot_parse and write_verified_refuses_a_digits_only_stack_name keep their weaker error assertions — they already assert the stronger property (the original file is byte-identical afterwards), and one of them would have to pin text produced inside serde_yaml rather than by this module.

116 tests, cargo fmt --check and clippy (--all-features and --all-features --all-targets, -D warnings) all clean. Worth flagging for human reviewers: repo CI does not compile or test workers-dev — its Rust matrix is driven by directories carrying an iii.worker.yaml, and this is a dev tool — so those local gates are the only verification this PR has.

@andersonleal
andersonleal merged commit 2a8b34f into main Aug 7, 2026
17 checks passed
@andersonleal
andersonleal deleted the feat/workers-dev branch August 7, 2026 14:08
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant