Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
15 changes: 15 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Changed

- **The Config screen edits settings you select, starting with the dispatch
cap.** It used to bind a letter per value — `V` for the default viewer, `A`
for the default agent — and `max_running`, the one queue option with no TUI
surface at all, was next in line for a third. Instead the screen has grown a
**Settings** list above the viewers: the default agent, the default viewer,
and the dispatch cap, each showing the value in force and whether it came from
your `voro.toml` or is Voro's own default. One selection runs over the
settings and the viewers together, and ⏎ (or `e`) edits whatever it is on —
the same pickers `V` and `A` used to open, and a numeric entry for the cap.
Raising the cap while the queue reads `⏸ dispatch at capacity` brings the
dispatch rows back on that keypress. `a` adds a viewer and `d` deletes one as
before; `V` and `A` are unbound, and the `?` map no longer lists them. Every
write still goes through the comment-preserving writer, so your file's
formatting and comments survive it.

- **A quick propose is named for its project, like the planning session beside
it.** `n` used to launch its agent under `voro-propose-<project_id>` while `N`
on the same project opened `voro-plan-<project>`, so the two read in
Expand Down
63 changes: 46 additions & 17 deletions crates/voro-core/src/agent.rs
Original file line number Diff line number Diff line change
Expand Up @@ -859,6 +859,17 @@ impl RawCosts {
}
}

/// Why a negative dispatch cap is refused, in one place: the file and the
/// Config screen's editor (DESIGN.md §5) both write the cap, and an operator
/// who meets the refusal at one surface should meet the same sentence at the
/// other.
pub(crate) fn negative_max_running(n: i64) -> String {
format!(
"max_running is {n} — it counts dispatches in flight, so it cannot be negative (0 stops \
the queue offering dispatches at all)"
)
}

/// Validate one agent's verb templates, shared by the built-ins and the user
/// file. `dispatch` (or its alias `cmd`) must be present and carry the
/// prompt-file placeholder; the session verbs carry their placeholders when
Expand Down Expand Up @@ -1102,8 +1113,11 @@ pub struct AgentsConfig {
/// The attention price band the queue ranks by (DESIGN.md §7), defaults
/// with any `[costs]` overrides layered on.
costs: AttentionCosts,
/// How many dispatches ride at once before the queue stops offering more.
max_running: i64,
/// How many dispatches ride at once before the queue stops offering more,
/// as the file spells it — `None` when the key is absent, which is what
/// lets the Config screen say whether the cap in force is the operator's
/// or Voro's own (DESIGN.md §5).
max_running: Option<i64>,
path: PathBuf,
}

Expand Down Expand Up @@ -1159,7 +1173,7 @@ impl AgentsConfig {
viewers: BTreeMap::new(),
default_viewer: None,
costs: AttentionCosts::default(),
max_running: DEFAULT_MAX_RUNNING,
max_running: None,
path: path.to_path_buf(),
}
}
Expand Down Expand Up @@ -1190,19 +1204,14 @@ impl AgentsConfig {
provenance.insert(name.clone(), prov);
agents.insert(name, agent);
}
let max_running = match raw.max_running {
None => DEFAULT_MAX_RUNNING,
Some(n) if n >= 0 => n,
Some(n) => {
return Err(Error::AgentConfigInvalid {
path: path.to_path_buf(),
message: format!(
"max_running is {n} — it counts dispatches in flight, so it cannot be \
negative (0 stops the queue offering dispatches at all)"
),
});
}
};
if let Some(n) = raw.max_running
&& n < 0
{
return Err(Error::AgentConfigInvalid {
path: path.to_path_buf(),
message: negative_max_running(n),
});
}
let costs = match raw.costs {
Some(costs) => costs.resolve(path)?,
None => AttentionCosts::default(),
Expand All @@ -1215,7 +1224,7 @@ impl AgentsConfig {
viewers: raw.viewers,
default_viewer: raw.default_viewer,
costs,
max_running,
max_running: raw.max_running,
path: path.to_path_buf(),
})
}
Expand All @@ -1228,9 +1237,29 @@ impl AgentsConfig {
/// The dispatch WIP cap (DESIGN.md §7): how many tasks may be running
/// before the queue stops offering dispatches.
pub fn max_running(&self) -> i64 {
self.max_running.unwrap_or(DEFAULT_MAX_RUNNING)
}

/// The cap as `voro.toml` spells it, `None` when the file names none — the
/// provenance the Config screen's settings list shows beside the value
/// (DESIGN.md §5), which the resolved [`max_running`](Self::max_running)
/// alone cannot tell apart from a cap that happens to equal the default.
pub fn max_running_from_file(&self) -> Option<i64> {
self.max_running
}

/// `default_agent` as the file spells it, before the PATH probe that
/// [`default_name`](Self::default_name) falls back to.
pub fn default_agent_from_file(&self) -> Option<&str> {
self.default.as_deref()
}

/// `default_viewer` as the file spells it, before the resolution rules
/// [`default_viewer_name`](Self::default_viewer_name) falls back to.
pub fn default_viewer_from_file(&self) -> Option<&str> {
self.default_viewer.as_deref()
}

/// Every agent name defined in the config, for the TUI's dispatch picker
/// (DESIGN.md §8/§9). `agents` is a `BTreeMap`, so this is already sorted.
pub fn agent_names(&self) -> Vec<String> {
Expand Down
63 changes: 63 additions & 0 deletions crates/voro-core/src/config_edit.rs
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,19 @@ pub fn set_default_agent(path: &Path, name: &str) -> Result<()> {
write_doc(path, &doc)
}

/// Set `max_running`, the dispatch WIP cap (DESIGN.md §7), refusing a negative
/// count in the same words [`crate::agent::AgentsConfig::load`] refuses one
/// read from the file. `0` is legal and means what it means there: the queue
/// offers no dispatches at all.
pub fn set_max_running(path: &Path, n: i64) -> Result<()> {
if n < 0 {
return Err(invalid(crate::agent::negative_max_running(n)));
}
let mut doc = load_doc(path)?;
doc["max_running"] = value(n);
write_doc(path, &doc)
}

/// Whether a viewer command lacks the `{path}` placeholder — a warning, not an
/// error (DESIGN.md §5): such a command runs in the checkout's own directory,
/// which is occasionally what a `git difftool -d` wants but usually a mistake.
Expand Down Expand Up @@ -425,6 +438,56 @@ cmd = \"git -C {path} difftool -d {base}...{branch}\" # inline note
std::fs::remove_dir_all(&dir).unwrap();
}

/// The dispatch cap is written like any other key: comments and the
/// operator's own content survive it, an absent file is created by it, `0`
/// is legal, and a negative count is refused in the words the loader uses
/// (DESIGN.md §5/§7).
#[test]
fn set_max_running_writes_the_cap_and_refuses_a_negative_one() {
let dir = scratch("cap");
let path = dir.join("voro/voro.toml");
assert!(!path.exists());

set_max_running(&path, 3).unwrap();
assert_eq!(AgentsConfig::load(&path).unwrap().max_running(), 3);
assert_eq!(
AgentsConfig::load(&path).unwrap().max_running_from_file(),
Some(3)
);

// rewriting the file leaves what the operator wrote around it alone
let original = "\
# how many at once
max_running = 3 # was 5

[viewers.zed]
cmd = \"zed {path}\"
";
std::fs::write(&path, original).unwrap();
set_max_running(&path, 9).unwrap();
let text = std::fs::read_to_string(&path).unwrap();
assert!(text.contains("# how many at once"), "{text}");
assert!(text.contains("[viewers.zed]"), "{text}");
assert!(text.contains("max_running = 9"), "{text}");
assert_eq!(AgentsConfig::load(&path).unwrap().max_running(), 9);

// 0 is a cap, not an absence: the queue offers nothing
set_max_running(&path, 0).unwrap();
assert_eq!(AgentsConfig::load(&path).unwrap().max_running(), 0);

// and the refusal is the loader's own sentence, not a second wording
let refused = set_max_running(&path, -1).unwrap_err().to_string();
assert!(refused.contains("cannot be negative"), "{refused}");
assert_eq!(AgentsConfig::load(&path).unwrap().max_running(), 0);

let hand_written = dir.join("by-hand.toml");
std::fs::write(&hand_written, "max_running = -1\n").unwrap();
let from_file = AgentsConfig::load(&hand_written).unwrap_err().to_string();
assert!(from_file.contains(&refused), "{from_file} vs {refused}");

std::fs::remove_dir_all(&dir).unwrap();
}

#[test]
fn set_defaults_validate_and_record() {
let dir = scratch("defaults");
Expand Down
Loading