hive-subagent-mcp: fix resolve_dir commit ordering + add non-committing peek_dir
This commit is contained in:
parent
25499ce8df
commit
101a786dff
1 changed files with 87 additions and 3 deletions
|
|
@ -118,6 +118,15 @@ impl State {
|
||||||
/// `running`: a daemon restart forgets it same as it forgets everything
|
/// `running`: a daemon restart forgets it same as it forgets everything
|
||||||
/// else here — the caller re-supplies `dir` once, same as it always
|
/// else here — the caller re-supplies `dir` once, same as it always
|
||||||
/// could.
|
/// could.
|
||||||
|
///
|
||||||
|
/// ⚠️ **Callers must only call this once the call is known to proceed**
|
||||||
|
/// (i.e. after `reserve` has already succeeded in `start`/`continue_`) —
|
||||||
|
/// see `peek_dir` for the non-committing variant a call that might still
|
||||||
|
/// be refused (or that shouldn't persist its `dir` at all, like
|
||||||
|
/// `status`) needs instead. This one argus caught in review: calling it
|
||||||
|
/// unconditionally before `reserve`'s check meant a *rejected* concurrent
|
||||||
|
/// call still overwrote the remembered `dir` for the name it was refused
|
||||||
|
/// against.
|
||||||
fn resolve_dir(&self, name: &str, dir: Option<&str>) -> Option<String> {
|
fn resolve_dir(&self, name: &str, dir: Option<&str>) -> Option<String> {
|
||||||
let mut dirs = self.dirs.lock().unwrap_or_else(PoisonError::into_inner);
|
let mut dirs = self.dirs.lock().unwrap_or_else(PoisonError::into_inner);
|
||||||
match dir {
|
match dir {
|
||||||
|
|
@ -128,6 +137,25 @@ impl State {
|
||||||
None => dirs.get(name).cloned(),
|
None => dirs.get(name).cloned(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Same resolution as `resolve_dir` (explicit wins, omitted falls back
|
||||||
|
/// to the remembered value) but never writes — for a call that must not
|
||||||
|
/// change what a later bare `continue`/`status` resolves to. `status`'s
|
||||||
|
/// own doc offers `dir` as a one-off "check a different directory"
|
||||||
|
/// knob; if that call committed the same way `start`/`continue_` do, the
|
||||||
|
/// one-off peek would silently become the new remembered default (the
|
||||||
|
/// second bug argus flagged in the same review).
|
||||||
|
fn peek_dir(&self, name: &str, dir: Option<&str>) -> Option<String> {
|
||||||
|
match dir {
|
||||||
|
Some(d) => Some(d.to_owned()),
|
||||||
|
None => self
|
||||||
|
.dirs
|
||||||
|
.lock()
|
||||||
|
.unwrap_or_else(PoisonError::into_inner)
|
||||||
|
.get(name)
|
||||||
|
.cloned(),
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A caller-chosen name, validated the same way `hive-bash-mcp`'s task ids
|
/// A caller-chosen name, validated the same way `hive-bash-mcp`'s task ids
|
||||||
|
|
@ -215,10 +243,12 @@ pub fn start(
|
||||||
dir: Option<&str>,
|
dir: Option<&str>,
|
||||||
) -> anyhow::Result<String> {
|
) -> anyhow::Result<String> {
|
||||||
validate_name(name)?;
|
validate_name(name)?;
|
||||||
let dir = state.resolve_dir(name, dir);
|
|
||||||
if !state.reserve(name) {
|
if !state.reserve(name) {
|
||||||
anyhow::bail!("subagent `{name}` is already running — use `continue` or `interrupt`");
|
anyhow::bail!("subagent `{name}` is already running — use `continue` or `interrupt`");
|
||||||
}
|
}
|
||||||
|
// Only commit the remembered `dir` now that `reserve` has actually
|
||||||
|
// claimed `name` — see `resolve_dir`'s doc for why the order matters.
|
||||||
|
let dir = state.resolve_dir(name, dir);
|
||||||
let result = start_reserved(state, name, model, prompt_file, trigger, dir.as_deref());
|
let result = start_reserved(state, name, model, prompt_file, trigger, dir.as_deref());
|
||||||
if result.is_err() {
|
if result.is_err() {
|
||||||
state.release_reservation(name);
|
state.release_reservation(name);
|
||||||
|
|
@ -276,12 +306,14 @@ pub fn continue_(
|
||||||
dir: Option<&str>,
|
dir: Option<&str>,
|
||||||
) -> anyhow::Result<String> {
|
) -> anyhow::Result<String> {
|
||||||
validate_name(name)?;
|
validate_name(name)?;
|
||||||
let dir = state.resolve_dir(name, dir);
|
|
||||||
if !state.reserve(name) {
|
if !state.reserve(name) {
|
||||||
anyhow::bail!(
|
anyhow::bail!(
|
||||||
"subagent `{name}` is already running — use `interrupt` first if you meant to redirect it"
|
"subagent `{name}` is already running — use `interrupt` first if you meant to redirect it"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
// Only commit the remembered `dir` now that `reserve` has actually
|
||||||
|
// claimed `name` — see `resolve_dir`'s doc for why the order matters.
|
||||||
|
let dir = state.resolve_dir(name, dir);
|
||||||
let result = continue_reserved(state, name, prompt, model, dir.as_deref());
|
let result = continue_reserved(state, name, prompt, model, dir.as_deref());
|
||||||
if result.is_err() {
|
if result.is_err() {
|
||||||
state.release_reservation(name);
|
state.release_reservation(name);
|
||||||
|
|
@ -374,7 +406,10 @@ fn spawn_and_track(
|
||||||
/// An invalid name, or no session — running or on disk — under `name`.
|
/// An invalid name, or no session — running or on disk — under `name`.
|
||||||
pub fn status(state: &State, name: &str, dir: Option<&str>) -> anyhow::Result<String> {
|
pub fn status(state: &State, name: &str, dir: Option<&str>) -> anyhow::Result<String> {
|
||||||
validate_name(name)?;
|
validate_name(name)?;
|
||||||
let dir = state.resolve_dir(name, dir);
|
// Read-only: an explicit `dir` here is a one-off "check this other
|
||||||
|
// directory's session" per this fn's own doc, not a new remembered
|
||||||
|
// default — `peek_dir` resolves the same way but never writes.
|
||||||
|
let dir = state.peek_dir(name, dir);
|
||||||
match state.occupancy(name) {
|
match state.occupancy(name) {
|
||||||
Some(true) => return Ok(format!("subagent `{name}` is running")),
|
Some(true) => return Ok(format!("subagent `{name}` is running")),
|
||||||
Some(false) => {
|
Some(false) => {
|
||||||
|
|
@ -580,6 +615,55 @@ mod tests {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn peek_dir_resolves_like_resolve_dir_but_never_writes() {
|
||||||
|
let state = State::new(PathBuf::from("/dev/null"));
|
||||||
|
state.resolve_dir("n", Some("/tmp/remembered"));
|
||||||
|
assert_eq!(
|
||||||
|
state.peek_dir("n", Some("/tmp/one-off")),
|
||||||
|
Some("/tmp/one-off".to_owned()),
|
||||||
|
"an explicit dir still resolves as given"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
state.resolve_dir("n", None),
|
||||||
|
Some("/tmp/remembered".to_owned()),
|
||||||
|
"but the one-off peek must not have overwritten the remembered value"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn start_does_not_corrupt_the_remembered_dir_when_the_name_is_already_running() {
|
||||||
|
// The exact scenario argus caught in review: a rejected concurrent
|
||||||
|
// `start` used to commit its `dir` before the reservation check
|
||||||
|
// refused it, corrupting what a later bare `continue` would resolve
|
||||||
|
// to. Reordering `reserve` before `resolve_dir` in `start` closes it
|
||||||
|
// — a name that's already reserved must never reach `resolve_dir` at
|
||||||
|
// all, so `dirs` stays exactly as a caller left it.
|
||||||
|
let state = Arc::new(State::new(PathBuf::from("/dev/null")));
|
||||||
|
state.resolve_dir("dup", Some("/tmp/original"));
|
||||||
|
assert!(
|
||||||
|
state.reserve("dup"),
|
||||||
|
"simulate an in-flight start/continue already owning the name"
|
||||||
|
);
|
||||||
|
let result = start(
|
||||||
|
&state,
|
||||||
|
"dup",
|
||||||
|
None,
|
||||||
|
"/tmp/prompt.md",
|
||||||
|
"trigger".to_owned(),
|
||||||
|
Some("/tmp/rejected"),
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
result.is_err(),
|
||||||
|
"a name already reserved must be refused, not spawned"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
state.resolve_dir("dup", None),
|
||||||
|
Some("/tmp/original".to_owned()),
|
||||||
|
"the rejected call's dir must not have overwritten the remembered one"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn build_config_sets_cwd_only_when_a_dir_is_given() {
|
fn build_config_sets_cwd_only_when_a_dir_is_given() {
|
||||||
let with = build_config("n", None, None, Some("/tmp/some-worktree"));
|
let with = build_config("n", None, None, Some("/tmp/some-worktree"));
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue