1108 lines
46 KiB
Rust
1108 lines
46 KiB
Rust
//! The claude-facing half of this daemon: spawn a subagent turn, track it
|
|
//! only while it's alive, and push exactly one todo when it ends — saying
|
|
//! whether it finished or was killed.
|
|
//!
|
|
//! **No task files, no restart recovery.** The daemon's only state is an
|
|
//! in-memory `name -> Option<Cancel>` map (see `State`'s own doc for what
|
|
//! the `None`/`Some` split is for) plus a `name -> dir` memory so `start`'s
|
|
//! `dir` doesn't have to be repeated on every later `continue`/`status`
|
|
//! (`State::resolve_dir`) — both live for exactly as long as the process
|
|
//! is — a daemon restart means whatever was running gets killed with it
|
|
//! (`tokio`'s own child-process drop semantics), not adopted, and the
|
|
//! remembered `dir` is gone too. The durable
|
|
//! record of a subagent's existence is `hive_claude::SessionStore` — claude's
|
|
//! own on-disk session, found again by name. `continue` is how a caller
|
|
//! reattaches to it, whether that's "give it a new turn" or "the daemon
|
|
//! restarted and I want to pick this back up" — after a restart, re-supply
|
|
//! `dir` once if the session isn't in the daemon's own default directory.
|
|
|
|
//! **A killed turn is not a finished turn.** A child that died on a signal
|
|
//! arrives as a `hive_claude::Error::Exit` carrying its `ExitStatus`, so the
|
|
//! "how" is there to be read: `classify_end` takes the signal out of it and
|
|
//! `State::finish_turn` remembers it against the name, which is what lets
|
|
//! `status`, the end-of-turn todo and `continue`'s own reply all say the
|
|
//! session was killed rather than let it read as finished. See
|
|
//! `docs/tools/subagent.md`.
|
|
//!
|
|
//! **No mid-turn compaction.** Building on `hive_claude::Claude::spawn` +
|
|
//! `RunningClaude::wait` directly (not `InfiniteSession::run`) is what makes
|
|
//! `interrupt` possible at all — `InfiniteSession` has no cancel handle to
|
|
//! reach in from the outside, only `RunningClaude::cancel_handle` does. The
|
|
//! trade: this daemon doesn't get `InfiniteSession`'s reactive-compact-on-
|
|
//! overflow or proactive-checkpoint-compact for free: a turn that overflows
|
|
//! the context window surfaces as a plain `Error::PromptTooLong` to the
|
|
//! caller instead of self-healing. Subagents are meant to be bounded,
|
|
//! single-batch work (see the `base:claude-subagents` skill), not sessions
|
|
//! long-lived enough to need in-place compaction — a real follow-up if that
|
|
//! assumption stops holding, not shipped here.
|
|
|
|
use std::collections::HashMap;
|
|
use std::os::unix::process::ExitStatusExt as _;
|
|
use std::path::PathBuf;
|
|
use std::sync::{Arc, Mutex, PoisonError};
|
|
|
|
use hive_claude::{Attach, Cancel, Claude, Config, NoopSink, SessionStore};
|
|
|
|
/// How a subagent's turn ended, as far as the daemon can tell from what
|
|
/// [`hive_claude::RunningClaude::wait`] returned.
|
|
///
|
|
/// The distinction that matters is `Killed` vs everything else: a child that
|
|
/// died on a signal was cut off mid-turn by something outside this daemon
|
|
/// (the kernel's OOM killer, a `systemctl stop`, an operator's `kill`) or by
|
|
/// an `interrupt` call here — either way its work stopped wherever it got
|
|
/// to, which is not what `Complete` means.
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
enum TurnEnd {
|
|
/// The turn ran to completion.
|
|
Complete,
|
|
/// The child was terminated by `signal` instead of exiting on its own.
|
|
Killed { signal: i32 },
|
|
/// claude exited on its own but did not complete the turn — a
|
|
/// recognized sentinel (rate limit, prompt too long, …) or a plain
|
|
/// non-zero exit. Carries the message shown to the caller.
|
|
Failed(String),
|
|
}
|
|
|
|
/// Name a signal number for a human: `9` reads as `SIGKILL (signal 9)`. Only
|
|
/// the three that end a subagent in practice are named — anything else keeps
|
|
/// the number, which is still enough to look up.
|
|
fn describe_signal(signal: i32) -> String {
|
|
match signal {
|
|
libc::SIGKILL => "SIGKILL (signal 9)".to_owned(),
|
|
libc::SIGTERM => "SIGTERM (signal 15)".to_owned(),
|
|
libc::SIGINT => "SIGINT (signal 2)".to_owned(),
|
|
other => format!("signal {other}"),
|
|
}
|
|
}
|
|
|
|
/// Read how the turn ended out of what the driver returned. A signalled
|
|
/// child surfaces as [`hive_claude::Error::Exit`] whose `ExitStatus` has a
|
|
/// `signal()` — the driver's own docs point at exactly this check — so the
|
|
/// "how" is preserved by the time it reaches here rather than having to be
|
|
/// recovered.
|
|
fn classify_end(outcome: hive_claude::Result<()>) -> TurnEnd {
|
|
let Err(error) = outcome else {
|
|
return TurnEnd::Complete;
|
|
};
|
|
if let hive_claude::Error::Exit { status, .. } = &error
|
|
&& let Some(signal) = status.signal()
|
|
{
|
|
return TurnEnd::Killed { signal };
|
|
}
|
|
TurnEnd::Failed(format!("claude error: {error}"))
|
|
}
|
|
|
|
/// This daemon's whole state: which names currently have a live process or
|
|
/// a reservation in flight, and where to push the completion todo.
|
|
/// `Arc`-wrapped so the background task that drives a turn to completion
|
|
/// can outlive the tool call that started it.
|
|
///
|
|
/// The map value is `Option<Cancel>`: `None` means `name` is reserved for
|
|
/// an in-flight `start`/`continue` that hasn't reached a confirmed
|
|
/// `Claude::spawn` yet; `Some(cancel)` means a real process is tracked and
|
|
/// interruptible. The `None` state exists to close a real TOCTOU window a
|
|
/// reviewer caught in the original check-then-insert version: checking "is
|
|
/// `name` free" and committing to it are two different lock acquisitions
|
|
/// unless the check *is* the reservation — see `reserve`.
|
|
///
|
|
/// `killed` is the other half of that map's story: `running` says what is in
|
|
/// flight *now*, `killed` remembers the names whose last turn ended on a
|
|
/// signal rather than on its own, keyed to the signal number. Without it a
|
|
/// killed session is indistinguishable from a finished one the moment its
|
|
/// entry leaves `running` — the whole point of this record.
|
|
///
|
|
/// ⚠️ This concurrency guard is keyed by `name` alone — a `start`/`continue`
|
|
/// for `name` with a *different* `dir` than one already in flight under
|
|
/// that name is refused as "already running," even though the two would
|
|
/// resolve to entirely separate on-disk sessions. Deliberate: `name` is the
|
|
/// caller's one chosen identity for a subagent, not `(name, dir)` — reuse a
|
|
/// name across directories at your own risk, the tool doesn't disambiguate
|
|
/// it (flagged in review when `dir` was added).
|
|
pub struct State {
|
|
running: Mutex<HashMap<String, Option<Cancel>>>,
|
|
dirs: Mutex<HashMap<String, String>>,
|
|
killed: Mutex<HashMap<String, i32>>,
|
|
socket: PathBuf,
|
|
}
|
|
|
|
impl State {
|
|
#[must_use]
|
|
pub fn new(socket: PathBuf) -> Self {
|
|
Self {
|
|
running: Mutex::new(HashMap::new()),
|
|
dirs: Mutex::new(HashMap::new()),
|
|
killed: Mutex::new(HashMap::new()),
|
|
socket,
|
|
}
|
|
}
|
|
|
|
/// `Some(true)` — a live process is tracked, interruptible. `Some(false)`
|
|
/// — reserved for an in-flight start/continue, not yet a confirmed
|
|
/// spawn. `None` — nothing tracked under `name` at all.
|
|
fn occupancy(&self, name: &str) -> Option<bool> {
|
|
self.running
|
|
.lock()
|
|
.unwrap_or_else(PoisonError::into_inner)
|
|
.get(name)
|
|
.map(Option::is_some)
|
|
}
|
|
|
|
/// Atomically claim `name` for an in-flight start/continue: check
|
|
/// "is anything tracked under this name" and "commit to this call
|
|
/// owning it" in the *same* lock acquisition, so two calls racing the
|
|
/// same name can't both pass a check before either commits (the exact
|
|
/// same-name concurrent-run hazard this module's doc warns about).
|
|
/// Returns `false` (reserving nothing) if `name` is already reserved
|
|
/// or running.
|
|
fn reserve(&self, name: &str) -> bool {
|
|
let mut running = self.running.lock().unwrap_or_else(PoisonError::into_inner);
|
|
if running.contains_key(name) {
|
|
return false;
|
|
}
|
|
running.insert(name.to_owned(), None);
|
|
true
|
|
}
|
|
|
|
/// Release a reservation that never made it to a real spawn (an error
|
|
/// on the slow path between `reserve` and `Claude::spawn` succeeding).
|
|
/// A no-op if the entry was already upgraded to `Some` — this only ever
|
|
/// clears a still-`None` placeholder, never a live process.
|
|
fn release_reservation(&self, name: &str) {
|
|
let mut running = self.running.lock().unwrap_or_else(PoisonError::into_inner);
|
|
if matches!(running.get(name), Some(None)) {
|
|
running.remove(name);
|
|
}
|
|
}
|
|
|
|
/// Retire the finished turn's tracking for `name` and remember how it
|
|
/// ended: a `Killed` end is recorded so `status`, the end-of-turn todo
|
|
/// and a later `continue` can all say so; any other end clears a stale
|
|
/// record from an earlier turn.
|
|
///
|
|
/// The kill is recorded *before* the `running` entry goes away, so there
|
|
/// is no instant in which `name` is neither running nor known-killed —
|
|
/// a `status` landing between the two would otherwise read the session
|
|
/// as plainly idle, which is the exact confusion this record exists to
|
|
/// remove.
|
|
fn finish_turn(&self, name: &str, end: &TurnEnd) {
|
|
let mut killed = self.killed.lock().unwrap_or_else(PoisonError::into_inner);
|
|
match end {
|
|
TurnEnd::Killed { signal } => killed.insert(name.to_owned(), *signal),
|
|
TurnEnd::Complete | TurnEnd::Failed(_) => killed.remove(name),
|
|
};
|
|
drop(killed);
|
|
self.running
|
|
.lock()
|
|
.unwrap_or_else(PoisonError::into_inner)
|
|
.remove(name);
|
|
}
|
|
|
|
/// The signal `name`'s last turn died on, if it died on one.
|
|
fn killed_by(&self, name: &str) -> Option<i32> {
|
|
self.killed
|
|
.lock()
|
|
.unwrap_or_else(PoisonError::into_inner)
|
|
.get(name)
|
|
.copied()
|
|
}
|
|
|
|
/// Forget that `name`'s last turn was killed — called once a new turn is
|
|
/// confirmed spawned, since the record describes the turn before it and
|
|
/// would otherwise keep flagging a session that has since run again.
|
|
fn clear_kill(&self, name: &str) {
|
|
self.killed
|
|
.lock()
|
|
.unwrap_or_else(PoisonError::into_inner)
|
|
.remove(name);
|
|
}
|
|
|
|
/// An explicit `dir` is remembered against `name` and returned as-is; an
|
|
/// omitted one (`None`) falls back to whatever was last remembered for
|
|
/// `name`, so a `start` that gave a `dir` doesn't force every later
|
|
/// `continue`/`status` against the same name to repeat it. Explicit
|
|
/// always wins and updates the memory — there's no way to say "forget
|
|
/// it, use the default" once something's been remembered short of
|
|
/// giving a genuinely different `dir`, a deliberate simplicity trade,
|
|
/// not an oversight. In-memory only, same durability envelope as
|
|
/// `running`: a daemon restart forgets it same as it forgets everything
|
|
/// else here — the caller re-supplies `dir` once, same as it always
|
|
/// 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> {
|
|
let mut dirs = self.dirs.lock().unwrap_or_else(PoisonError::into_inner);
|
|
match dir {
|
|
Some(d) => {
|
|
dirs.insert(name.to_owned(), d.to_owned());
|
|
Some(d.to_owned())
|
|
}
|
|
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
|
|
/// are: a single safe [`hive_types::Ident`] segment, which doubles as
|
|
/// claude's own `--name`/`--resume` session title.
|
|
fn validate_name(name: &str) -> anyhow::Result<()> {
|
|
hive_types::Ident::parse(name)
|
|
.map(|_| ())
|
|
.map_err(|e| anyhow::anyhow!("invalid subagent name {name:?}: {e}"))
|
|
}
|
|
|
|
/// Extend the ambient `OTEL_RESOURCE_ATTRIBUTES` with a `subagent=<name>`
|
|
/// attribute, so every token/cost/tool-call data point this subagent's own
|
|
/// claude process emits carries it alongside the parent's `agent=<name>`
|
|
/// label. `Config.env` applies after the inherited environment, so this one
|
|
/// entry overriding the ambient value is the intended shape, not a
|
|
/// wholesale replacement.
|
|
fn subagent_otel_attrs(name: &str) -> String {
|
|
match std::env::var("OTEL_RESOURCE_ATTRIBUTES") {
|
|
Ok(existing) if !existing.is_empty() => format!("{existing},subagent={name}"),
|
|
_ => format!("subagent={name}"),
|
|
}
|
|
}
|
|
|
|
/// Build the `Config` one subagent turn runs against. `model` maps straight
|
|
/// onto `Config::model` — `--model`, omitted when `None` so claude falls
|
|
/// back to its own default. `effort` does not: an omitted `effort` defaults
|
|
/// to `"medium"` here rather than falling through to claude's own default
|
|
/// (`high` on most models) — a deliberate hive policy for subagent work
|
|
/// specifically, not a reflection of Anthropic's own recommendation, on the
|
|
/// same "cheaper than you" cost-consciousness the `base:claude-subagents`
|
|
/// skill already asks of `model`. `prompt_file`, when
|
|
/// given, becomes `--append-system-prompt-file` — the subagent's task
|
|
/// instructions. `dir`, when given, becomes `Config::cwd` (e.g. a worktree
|
|
/// the caller already prepared); `None` inherits this daemon's own working
|
|
/// directory, same as before this field existed. Always
|
|
/// `--dangerously-skip-permissions --strict-mcp-config` — the safety
|
|
/// property is `strict_mcp_config: true` with no ambient MCP discovery, not
|
|
/// an unconditional absence of `--mcp-config`: a subagent gets exactly the
|
|
/// `hyperhive.extraMcpServers` entries an operator has explicitly opted in
|
|
/// via `availableToSubagents = true` (`crate::mcp_config::build`), nothing
|
|
/// implicit and nothing more. With no entry opted in — the default — that
|
|
/// resolves to `None` and the invocation is unchanged from before this
|
|
/// toggle existed: zero MCP servers, full stop.
|
|
fn build_config(
|
|
name: &str,
|
|
model: Option<String>,
|
|
effort: Option<String>,
|
|
prompt_file: Option<&str>,
|
|
dir: Option<&str>,
|
|
) -> Config {
|
|
let mut extra_args = vec!["--dangerously-skip-permissions".to_owned()];
|
|
if let Some(path) = prompt_file {
|
|
extra_args.push("--append-system-prompt-file".to_owned());
|
|
extra_args.push(path.to_owned());
|
|
}
|
|
Config {
|
|
model,
|
|
effort: Some(effort.unwrap_or_else(|| "medium".to_owned())),
|
|
cwd: dir.map(PathBuf::from),
|
|
mcp_config: crate::mcp_config::build(),
|
|
strict_mcp_config: true,
|
|
extra_args,
|
|
env: vec![(
|
|
"OTEL_RESOURCE_ATTRIBUTES".to_owned(),
|
|
subagent_otel_attrs(name),
|
|
)],
|
|
..Default::default()
|
|
}
|
|
}
|
|
|
|
/// The [`SessionStore`] a subagent's turn actually runs against — same
|
|
/// resolution `hive_claude::Claude` itself uses, so a lookup here can't
|
|
/// disagree with what the driver does a moment later.
|
|
fn build_store(config: &Config) -> std::io::Result<SessionStore> {
|
|
Ok(SessionStore::new(
|
|
config.resolved_claude_home()?,
|
|
config.resolved_cwd()?,
|
|
))
|
|
}
|
|
|
|
/// Start a fresh subagent under `name`. A prior *finished* session under
|
|
/// the same name is archived first (so this is a real fresh start, not a
|
|
/// silent resume of old history) — a *currently running* one is refused
|
|
/// outright, since `hive_claude::InfiniteSession`'s own docs warn that two
|
|
/// concurrent runs against the same name corrupt both.
|
|
///
|
|
/// # Errors
|
|
///
|
|
/// A name already running, an invalid name, an archive failure, or the
|
|
/// underlying `Claude::spawn` failing (binary missing, etc.) — the last
|
|
/// case is the only one that can happen *after* commit-to-run, and it's
|
|
/// exactly why nothing is registered in `running` until spawn actually
|
|
/// succeeds.
|
|
pub fn start(
|
|
state: &Arc<State>,
|
|
name: &str,
|
|
model: Option<String>,
|
|
effort: Option<String>,
|
|
prompt_file: &str,
|
|
trigger: String,
|
|
dir: Option<&str>,
|
|
) -> anyhow::Result<String> {
|
|
validate_name(name)?;
|
|
if !state.reserve(name) {
|
|
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,
|
|
effort,
|
|
prompt_file,
|
|
trigger,
|
|
dir.as_deref(),
|
|
);
|
|
if result.is_err() {
|
|
state.release_reservation(name);
|
|
}
|
|
result
|
|
}
|
|
|
|
/// The slow, fallible part of `start`, run only after `reserve` has
|
|
/// already closed the TOCTOU window — split out so `start` can release the
|
|
/// reservation on any error path here without duplicating that logic per
|
|
/// failure site.
|
|
fn start_reserved(
|
|
state: &Arc<State>,
|
|
name: &str,
|
|
model: Option<String>,
|
|
effort: Option<String>,
|
|
prompt_file: &str,
|
|
trigger: String,
|
|
dir: Option<&str>,
|
|
) -> anyhow::Result<String> {
|
|
let config = build_config(name, model, effort, Some(prompt_file), dir);
|
|
let store = build_store(&config)?;
|
|
if store.find_by_title(name).is_some() {
|
|
tracing::info!(
|
|
name,
|
|
"start: archiving a finished prior session for a fresh start"
|
|
);
|
|
store
|
|
.archive_by_title(name)
|
|
.map_err(|e| anyhow::anyhow!("archiving the prior `{name}` session failed: {e}"))?;
|
|
}
|
|
spawn_and_track(
|
|
state,
|
|
name,
|
|
&config,
|
|
&Attach::Create(name.to_owned()),
|
|
trigger,
|
|
)
|
|
}
|
|
|
|
/// Give an existing named session a new turn — resuming it whether that
|
|
/// means "the previous turn finished, here's the next instruction" or "the
|
|
/// daemon restarted, reattaching." Refuses a name with no session on disk
|
|
/// at all (nothing to continue) or one already running (same
|
|
/// concurrent-run hazard as `start`).
|
|
///
|
|
/// Resuming a session whose last turn was *killed* is allowed — that is
|
|
/// often exactly what the caller wants — but never silent: the reply says
|
|
/// so, since a caller that never ran `status` and missed the todo would
|
|
/// otherwise carry on from cut-off work believing it was finished work.
|
|
///
|
|
/// # Errors
|
|
///
|
|
/// No session under `name`, an invalid name, one already running, or
|
|
/// `Claude::spawn` failing.
|
|
pub fn continue_(
|
|
state: &Arc<State>,
|
|
name: &str,
|
|
prompt: String,
|
|
model: Option<String>,
|
|
effort: Option<String>,
|
|
dir: Option<&str>,
|
|
) -> anyhow::Result<String> {
|
|
validate_name(name)?;
|
|
if !state.reserve(name) {
|
|
anyhow::bail!(
|
|
"subagent `{name}` is already running — use `interrupt` first if you meant to redirect it"
|
|
);
|
|
}
|
|
// Read before the spawn, which clears the record as soon as the new turn
|
|
// is confirmed — this reply is the last chance to mention it.
|
|
let killed = state.killed_by(name);
|
|
// 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, effort, dir.as_deref());
|
|
if result.is_err() {
|
|
state.release_reservation(name);
|
|
}
|
|
result.map(|msg| note_resumed_after_kill(&msg, killed))
|
|
}
|
|
|
|
/// Append the "you are resuming a killed session" note to `continue`'s reply
|
|
/// when its previous turn was signalled; pass the reply through unchanged
|
|
/// otherwise.
|
|
fn note_resumed_after_kill(msg: &str, killed: Option<i32>) -> String {
|
|
match killed {
|
|
None => msg.to_owned(),
|
|
Some(signal) => format!(
|
|
"{msg} — note: its previous turn was killed ({}) rather than finishing, so this turn \
|
|
resumes from work that was cut off mid-way",
|
|
describe_signal(signal)
|
|
),
|
|
}
|
|
}
|
|
|
|
/// The slow, fallible part of `continue_`, run only after `reserve` has
|
|
/// already closed the TOCTOU window — same split rationale as
|
|
/// `start_reserved`.
|
|
fn continue_reserved(
|
|
state: &Arc<State>,
|
|
name: &str,
|
|
prompt: String,
|
|
model: Option<String>,
|
|
effort: Option<String>,
|
|
dir: Option<&str>,
|
|
) -> anyhow::Result<String> {
|
|
let config = build_config(name, model, effort, None, dir);
|
|
let store = build_store(&config)?;
|
|
if store.find_by_title(name).is_none() {
|
|
anyhow::bail!(
|
|
"no session named `{name}` exists — `continue` resumes an existing subagent, `start` \
|
|
creates one"
|
|
);
|
|
}
|
|
spawn_and_track(
|
|
state,
|
|
name,
|
|
&config,
|
|
&Attach::Resume(name.to_owned()),
|
|
prompt,
|
|
)
|
|
}
|
|
|
|
/// Spawn the child (synchronous — returns with a real pid the instant the
|
|
/// process exists, which *is* "confirmed running": there is no stronger
|
|
/// signal to wait for without slowing every call down for no reason), track
|
|
/// it in `running`, and hand the actual turn off to a background task so
|
|
/// the caller returns immediately instead of blocking on the whole turn.
|
|
/// Not `async` itself — `tokio::spawn` needs an active runtime to spawn
|
|
/// *onto*, not an `async` caller to spawn *from*.
|
|
fn spawn_and_track(
|
|
state: &Arc<State>,
|
|
name: &str,
|
|
config: &Config,
|
|
attach: &Attach,
|
|
prompt: String,
|
|
) -> anyhow::Result<String> {
|
|
let running = Claude::spawn(config, attach)
|
|
.map_err(|e| anyhow::anyhow!("starting the subagent process failed: {e}"))?;
|
|
// Upgrades the `None` reservation `reserve` already placed here to a
|
|
// real cancel handle — same key, so there's no window where `name`
|
|
// reads as unoccupied between the reservation and this insert.
|
|
state
|
|
.running
|
|
.lock()
|
|
.unwrap_or_else(PoisonError::into_inner)
|
|
.insert(name.to_owned(), Some(running.cancel_handle()));
|
|
// This turn supersedes whatever the previous one did, including having
|
|
// been killed — the record is about the turn before this one.
|
|
state.clear_kill(name);
|
|
|
|
let state = Arc::clone(state);
|
|
let task_name = name.to_owned();
|
|
tokio::spawn(async move {
|
|
let end = classify_end(running.wait(&prompt, &NoopSink).await);
|
|
match &end {
|
|
TurnEnd::Complete => {}
|
|
TurnEnd::Killed { signal } => {
|
|
tracing::warn!(
|
|
name = %task_name,
|
|
signal,
|
|
"subagent: turn killed — the child died on a signal, it did not finish"
|
|
);
|
|
}
|
|
TurnEnd::Failed(e) => {
|
|
tracing::warn!(name = %task_name, error = %e, "subagent: turn failed");
|
|
}
|
|
}
|
|
state.finish_turn(&task_name, &end);
|
|
push_turn_end_todo(&state.socket, &task_name, &end).await;
|
|
});
|
|
|
|
Ok(format!("subagent `{name}` started"))
|
|
}
|
|
|
|
/// Report whether `name` is currently running — a zero-cost check that
|
|
/// never launches a process, unlike `continue`. Distinguishes five states:
|
|
/// running, starting (reserved, not yet a confirmed spawn — see `State`'s
|
|
/// doc), killed (its last turn died on a signal), idle (a session exists,
|
|
/// its last turn finished, nothing is in flight), and no such session at
|
|
/// all.
|
|
///
|
|
/// # Errors
|
|
///
|
|
/// An invalid name, or no session — running, killed or on disk — under
|
|
/// `name`.
|
|
pub fn status(state: &State, name: &str, dir: Option<&str>) -> anyhow::Result<String> {
|
|
validate_name(name)?;
|
|
// 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);
|
|
let occupancy = state.occupancy(name);
|
|
let killed = state.killed_by(name);
|
|
// Nothing on disk to look for while something is tracked in memory —
|
|
// those states answer on their own, and the store read is the only
|
|
// expensive part of this call.
|
|
let session_exists = if occupancy.is_none() && killed.is_none() {
|
|
let config = build_config(name, None, None, None, dir.as_deref());
|
|
build_store(&config)?.find_by_title(name).is_some()
|
|
} else {
|
|
false
|
|
};
|
|
describe_status(name, occupancy, killed, session_exists)
|
|
}
|
|
|
|
/// Render `status`'s answer from the three facts it gathers. Split out from
|
|
/// the gathering so the killed-versus-idle distinction is exercisable
|
|
/// without a real spawn, a real signal and a real on-disk claude session.
|
|
///
|
|
/// A recorded kill outranks the on-disk session: the session file exists
|
|
/// either way, so "a session is there" is precisely the fact that cannot
|
|
/// tell the two apart.
|
|
///
|
|
/// Every answer is self-contained — it names the one state the caller got
|
|
/// and what to do next — because the tool description deliberately doesn't
|
|
/// enumerate the state space (the operator's ruling on this surface:
|
|
/// describe the tool, explain the state when returning it). Keep the split:
|
|
/// a terser answer here has nowhere left to be explained from.
|
|
fn describe_status(
|
|
name: &str,
|
|
occupancy: Option<bool>,
|
|
killed: Option<i32>,
|
|
session_exists: bool,
|
|
) -> anyhow::Result<String> {
|
|
match occupancy {
|
|
Some(true) => {
|
|
return Ok(format!(
|
|
"subagent `{name}` is running — its turn is still in flight, so there's nothing \
|
|
to do but let it work: the daemon pushes a todo when the turn ends, or \
|
|
`interrupt` it if you want it stopped early."
|
|
));
|
|
}
|
|
Some(false) => {
|
|
return Ok(format!(
|
|
"subagent `{name}` is starting — a `start`/`continue` has claimed the name but \
|
|
its process isn't confirmed spawned yet, which is normally over in well under a \
|
|
second: check again shortly rather than starting anything else under this name."
|
|
));
|
|
}
|
|
None => {}
|
|
}
|
|
if let Some(signal) = killed {
|
|
return Ok(format!(
|
|
"subagent `{name}` was killed — its last turn died on {}, so its work stopped \
|
|
wherever it had got to rather than finishing. `continue` still resumes it, but \
|
|
whatever it was told to do is unfinished: check what it actually left behind before \
|
|
trusting it.",
|
|
describe_signal(signal)
|
|
));
|
|
}
|
|
if session_exists {
|
|
Ok(format!(
|
|
"subagent `{name}` is idle — its session exists, its last turn ended on its own \
|
|
rather than being cut off, and nothing is in flight: that turn's own todo says how \
|
|
it went, and `continue` gives it another."
|
|
))
|
|
} else {
|
|
anyhow::bail!(
|
|
"no subagent named `{name}` exists — nothing is running under that name and there's \
|
|
no session on disk to resume, so `start` is what creates one (check the name if you \
|
|
expected something here)."
|
|
)
|
|
}
|
|
}
|
|
|
|
/// Signal `name`'s running process — `force` picks SIGKILL over SIGINT (see
|
|
/// `hive_claude::Cancel::cancel`). Refuses a name with nothing running: no
|
|
/// entry at all, or one still in the brief `reserve`d-but-not-yet-spawned
|
|
/// window (nothing to signal yet — the reservation is put back so a
|
|
/// concurrent `start`/`continue` for the same name still gets refused).
|
|
///
|
|
/// # Errors
|
|
///
|
|
/// An invalid name, nothing tracked under `name`, or `name` is still
|
|
/// starting (reserved, not yet a confirmed spawn).
|
|
pub fn interrupt(state: &State, name: &str, force: bool) -> anyhow::Result<String> {
|
|
validate_name(name)?;
|
|
let mut running = state.running.lock().unwrap_or_else(PoisonError::into_inner);
|
|
match running.remove(name) {
|
|
None => anyhow::bail!("no subagent named `{name}` is currently running"),
|
|
Some(None) => {
|
|
running.insert(name.to_owned(), None);
|
|
anyhow::bail!(
|
|
"subagent `{name}` is still starting — not yet confirmed running, try again \
|
|
shortly"
|
|
);
|
|
}
|
|
Some(Some(cancel)) => {
|
|
drop(running);
|
|
cancel.cancel(force);
|
|
Ok(format!("interrupt sent to subagent `{name}`"))
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Push `name`'s one-shot end-of-turn todo. Best-effort: a connect/write
|
|
/// failure is logged and swallowed, matching every other in-agent-socket
|
|
/// producer in this codebase — there's no retry queue to fall back to, and
|
|
/// the caller has already moved on by the time this fires.
|
|
async fn push_turn_end_todo(socket: &std::path::Path, name: &str, end: &TurnEnd) {
|
|
let req = hive_agent_sock::Request::UpsertTodo {
|
|
subsystem: "subagent".to_owned(),
|
|
key: Some(name.to_owned()),
|
|
summary: turn_end_summary(name, end),
|
|
source: None,
|
|
reopen_if_acked: false,
|
|
};
|
|
if let Err(e) = hive_sock_client::notify(socket, &req, hive_sock_client::Retry::None).await {
|
|
tracing::warn!(name, error = ?e, "subagent: end-of-turn todo push failed");
|
|
}
|
|
}
|
|
|
|
/// The todo text for a turn that has ended. A killed turn deliberately does
|
|
/// not use the "finished" wording the other two share: this todo is the only
|
|
/// thing the owning agent is shown without asking, so it has to read as the
|
|
/// interruption it is rather than as one more completed subagent.
|
|
fn turn_end_summary(name: &str, end: &TurnEnd) -> String {
|
|
match end {
|
|
TurnEnd::Complete => format!("subagent `{name}` finished: turn complete"),
|
|
TurnEnd::Failed(e) => format!("subagent `{name}` finished: {e}"),
|
|
TurnEnd::Killed { signal } => format!(
|
|
"subagent `{name}` was KILLED mid-turn ({}) — it did not finish, and its work stopped \
|
|
wherever it had got to. Check what it left behind before you act on it; `continue` \
|
|
resumes the session if you want it carried on.",
|
|
describe_signal(*signal)
|
|
),
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
// `Cancel` can only be constructed from a real spawned process (no test
|
|
// fixture in `hive_claude` for it), so these exercise the reservation
|
|
// half of `State` directly — the actual TOCTOU-closure logic — rather
|
|
// than the full start/spawn path.
|
|
|
|
#[test]
|
|
fn reserve_is_exclusive_for_the_same_name() {
|
|
let state = State::new(PathBuf::from("/dev/null"));
|
|
assert!(state.reserve("dup"), "first reservation should succeed");
|
|
assert!(
|
|
!state.reserve("dup"),
|
|
"a second reservation for the same name must be refused — this is the exact race \
|
|
argus found: two calls both passing a check before either commits"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn reserve_does_not_cross_block_different_names() {
|
|
let state = State::new(PathBuf::from("/dev/null"));
|
|
assert!(state.reserve("a"));
|
|
assert!(
|
|
state.reserve("b"),
|
|
"unrelated names must not block each other"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn release_reservation_frees_the_name_for_reuse() {
|
|
let state = State::new(PathBuf::from("/dev/null"));
|
|
assert!(state.reserve("n"));
|
|
state.release_reservation("n");
|
|
assert!(
|
|
state.reserve("n"),
|
|
"releasing a still-`None` reservation must free the name again"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn occupancy_reflects_the_reserved_but_not_running_state() {
|
|
let state = State::new(PathBuf::from("/dev/null"));
|
|
assert_eq!(state.occupancy("never-reserved"), None);
|
|
state.reserve("n");
|
|
assert_eq!(
|
|
state.occupancy("n"),
|
|
Some(false),
|
|
"reserved-but-not-yet-spawned must read as occupied-but-not-running"
|
|
);
|
|
}
|
|
|
|
// One test, not two: `subagent_otel_attrs` reads the real process-wide
|
|
// `OTEL_RESOURCE_ATTRIBUTES` env var, and cargo runs tests in parallel
|
|
// threads by default — two separate tests each mutating that global
|
|
// raced each other (and, in this container, lost to the agent's own
|
|
// real ambient value). Sequencing both assertions in one test removes
|
|
// the race instead of papering over it with a mutex.
|
|
#[test]
|
|
fn otel_attrs_append_ambient_value_or_stand_alone() {
|
|
// SAFETY: test-only env mutation; sequenced within this one test so
|
|
// no other test's concurrent read/write of the same var can race it.
|
|
unsafe {
|
|
std::env::remove_var("OTEL_RESOURCE_ATTRIBUTES");
|
|
}
|
|
assert_eq!(subagent_otel_attrs("batch-1"), "subagent=batch-1");
|
|
|
|
unsafe {
|
|
std::env::set_var("OTEL_RESOURCE_ATTRIBUTES", "agent=damocles");
|
|
}
|
|
assert_eq!(
|
|
subagent_otel_attrs("batch-1"),
|
|
"agent=damocles,subagent=batch-1"
|
|
);
|
|
unsafe {
|
|
std::env::remove_var("OTEL_RESOURCE_ATTRIBUTES");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn build_config_only_appends_system_prompt_when_given() {
|
|
let with = build_config("n", None, None, Some("/tmp/p.md"), None);
|
|
assert!(
|
|
with.extra_args
|
|
.contains(&"--append-system-prompt-file".to_owned())
|
|
);
|
|
assert!(with.extra_args.contains(&"/tmp/p.md".to_owned()));
|
|
|
|
let without = build_config("n", None, None, None, None);
|
|
assert!(
|
|
!without
|
|
.extra_args
|
|
.contains(&"--append-system-prompt-file".to_owned())
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn resolve_dir_remembers_an_explicit_dir_and_falls_back_to_it_when_omitted() {
|
|
let state = State::new(PathBuf::from("/dev/null"));
|
|
assert_eq!(
|
|
state.resolve_dir("n", None),
|
|
None,
|
|
"nothing remembered yet — omitted dir has nothing to fall back to"
|
|
);
|
|
assert_eq!(
|
|
state.resolve_dir("n", Some("/tmp/worktree")),
|
|
Some("/tmp/worktree".to_owned()),
|
|
"an explicit dir is returned as-is"
|
|
);
|
|
assert_eq!(
|
|
state.resolve_dir("n", None),
|
|
Some("/tmp/worktree".to_owned()),
|
|
"a later omitted dir falls back to what was just remembered"
|
|
);
|
|
assert_eq!(
|
|
state.resolve_dir("n", Some("/tmp/other")),
|
|
Some("/tmp/other".to_owned()),
|
|
"a later explicit dir overrides the memory, not just reads it"
|
|
);
|
|
assert_eq!(
|
|
state.resolve_dir("n", None),
|
|
Some("/tmp/other".to_owned()),
|
|
"the fallback now reflects the override"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn resolve_dir_does_not_cross_names() {
|
|
let state = State::new(PathBuf::from("/dev/null"));
|
|
state.resolve_dir("a", Some("/tmp/a"));
|
|
assert_eq!(
|
|
state.resolve_dir("b", None),
|
|
None,
|
|
"an unrelated name's memory must stay empty"
|
|
);
|
|
}
|
|
|
|
#[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,
|
|
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"
|
|
);
|
|
}
|
|
|
|
/// The error the driver hands back for a child that died on `signal` —
|
|
/// `ExitStatus::from_raw` takes a raw `wait(2)` status, whose low seven
|
|
/// bits are the terminating signal, so this is the same value
|
|
/// `RunningClaude::wait` would have produced from a real kill.
|
|
fn signalled_exit(signal: i32) -> hive_claude::Error {
|
|
hive_claude::Error::Exit {
|
|
status: std::process::ExitStatus::from_raw(signal),
|
|
stderr_tail: String::new(),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn classify_end_separates_a_signalled_child_from_a_clean_one() {
|
|
assert_eq!(classify_end(Ok(())), TurnEnd::Complete);
|
|
assert_eq!(
|
|
classify_end(Err(signalled_exit(libc::SIGKILL))),
|
|
TurnEnd::Killed { signal: 9 },
|
|
"a SIGKILLed child must not read as a turn that ended on its own"
|
|
);
|
|
assert_eq!(
|
|
classify_end(Err(signalled_exit(libc::SIGTERM))),
|
|
TurnEnd::Killed { signal: 15 }
|
|
);
|
|
assert!(
|
|
matches!(
|
|
classify_end(Err(hive_claude::Error::PromptTooLong)),
|
|
TurnEnd::Failed(_)
|
|
),
|
|
"claude exiting on its own is a failure, not a kill"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn status_reports_killed_not_idle_for_a_signalled_session() {
|
|
// Both sessions exist on disk and neither is running: the only thing
|
|
// that can tell them apart is the recorded signal.
|
|
let killed = describe_status("n", None, Some(libc::SIGKILL), true).expect("killed status");
|
|
let idle = describe_status("n", None, None, true).expect("idle status");
|
|
assert!(
|
|
killed.contains("killed") && killed.contains("SIGKILL (signal 9)"),
|
|
"a killed session must say so, and name the signal: {killed}"
|
|
);
|
|
assert!(
|
|
!killed.contains("idle"),
|
|
"a killed session must not also read as idle: {killed}"
|
|
);
|
|
assert!(idle.contains("idle"), "a finished turn still reads idle");
|
|
assert_ne!(
|
|
killed, idle,
|
|
"collapsing the two back together is the bug this reports"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn every_status_answer_names_its_state_and_the_next_move() {
|
|
// The tool description no longer lists the states, so each answer
|
|
// has to carry its own explanation — checked one state at a time,
|
|
// which is all a caller ever gets back.
|
|
let running = describe_status("n", Some(true), None, false).expect("running status");
|
|
assert!(
|
|
running.contains("running") && running.contains("`interrupt`"),
|
|
"a running answer must say the turn is in flight and how to stop it: {running}"
|
|
);
|
|
let starting = describe_status("n", Some(false), None, false).expect("starting status");
|
|
assert!(
|
|
starting.contains("starting") && starting.contains("check again"),
|
|
"a starting answer must say the spawn isn't confirmed yet and to retry: {starting}"
|
|
);
|
|
let idle = describe_status("n", None, None, true).expect("idle status");
|
|
assert!(
|
|
idle.contains("idle") && idle.contains("`continue`"),
|
|
"an idle answer must say the last turn ended on its own and how to give it another: \
|
|
{idle}"
|
|
);
|
|
let killed = describe_status("n", None, Some(libc::SIGKILL), true).expect("killed status");
|
|
assert!(
|
|
killed.contains("killed") && killed.contains("`continue`"),
|
|
"a killed answer must name the kill and say resuming is still possible: {killed}"
|
|
);
|
|
let missing = describe_status("n", None, None, false)
|
|
.expect_err("nothing tracked and nothing on disk is an error, not a state");
|
|
assert!(
|
|
missing.to_string().contains("`start`"),
|
|
"the no-such-session answer must point at what creates one: {missing}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn a_killed_turn_and_a_clean_one_do_not_land_in_the_same_state() {
|
|
// The whole path a real turn takes, minus the process: what the
|
|
// driver returned -> what the daemon records -> what `status` says.
|
|
let state = State::new(PathBuf::from("/dev/null"));
|
|
for (name, outcome) in [
|
|
("gone", Err(signalled_exit(libc::SIGKILL))),
|
|
("done", Ok(())),
|
|
] {
|
|
assert!(state.reserve(name));
|
|
state.finish_turn(name, &classify_end(outcome));
|
|
assert_eq!(
|
|
state.occupancy(name),
|
|
None,
|
|
"the turn is over either way — nothing stays tracked as running"
|
|
);
|
|
}
|
|
assert_eq!(state.killed_by("gone"), Some(9));
|
|
assert_eq!(state.killed_by("done"), None);
|
|
|
|
let gone = describe_status("gone", None, state.killed_by("gone"), true).expect("status");
|
|
let done = describe_status("done", None, state.killed_by("done"), true).expect("status");
|
|
assert!(gone.contains("killed"), "{gone}");
|
|
assert!(done.contains("idle"), "{done}");
|
|
}
|
|
|
|
#[test]
|
|
fn a_new_turn_clears_the_previous_turn_s_kill() {
|
|
let state = State::new(PathBuf::from("/dev/null"));
|
|
state.finish_turn("n", &TurnEnd::Killed { signal: 9 });
|
|
assert_eq!(state.killed_by("n"), Some(9));
|
|
// What `spawn_and_track` does once the next turn is confirmed
|
|
// spawned, and then what its own clean end does.
|
|
state.clear_kill("n");
|
|
assert_eq!(state.killed_by("n"), None);
|
|
state.finish_turn("n", &TurnEnd::Complete);
|
|
assert_eq!(
|
|
state.killed_by("n"),
|
|
None,
|
|
"a turn that finished must not leave the old kill standing"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn the_killed_todo_does_not_read_like_a_completion() {
|
|
let complete = turn_end_summary("n", &TurnEnd::Complete);
|
|
let killed = turn_end_summary("n", &TurnEnd::Killed { signal: 9 });
|
|
assert_eq!(
|
|
complete, "subagent `n` finished: turn complete",
|
|
"the ordinary completion todo is unchanged"
|
|
);
|
|
assert!(
|
|
killed.contains("KILLED mid-turn") && killed.contains("SIGKILL (signal 9)"),
|
|
"the killed todo must state the kill and the signal: {killed}"
|
|
);
|
|
assert!(
|
|
!killed.contains("finished"),
|
|
"the one notification pushed without being asked must not read as a finished turn: \
|
|
{killed}"
|
|
);
|
|
assert_ne!(complete, killed);
|
|
}
|
|
|
|
#[test]
|
|
fn continue_tells_the_caller_it_is_resuming_a_killed_session() {
|
|
let plain = note_resumed_after_kill("subagent `n` started", None);
|
|
assert_eq!(
|
|
plain, "subagent `n` started",
|
|
"an ordinary resume is unchanged"
|
|
);
|
|
let after_kill = note_resumed_after_kill("subagent `n` started", Some(libc::SIGKILL));
|
|
assert!(
|
|
after_kill.contains("killed") && after_kill.contains("SIGKILL (signal 9)"),
|
|
"resuming a killed session is allowed, but the caller has to be told: {after_kill}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn describe_signal_names_the_ones_that_end_a_subagent() {
|
|
assert_eq!(describe_signal(9), "SIGKILL (signal 9)");
|
|
assert_eq!(describe_signal(15), "SIGTERM (signal 15)");
|
|
assert_eq!(describe_signal(2), "SIGINT (signal 2)");
|
|
assert_eq!(describe_signal(7), "signal 7");
|
|
}
|
|
|
|
#[test]
|
|
fn build_config_sets_cwd_only_when_a_dir_is_given() {
|
|
let with = build_config("n", None, None, None, Some("/tmp/some-worktree"));
|
|
assert_eq!(with.cwd, Some(PathBuf::from("/tmp/some-worktree")));
|
|
|
|
let without = build_config("n", None, None, None, None);
|
|
assert_eq!(without.cwd, None);
|
|
}
|
|
|
|
#[test]
|
|
fn build_config_sets_effort_alongside_model() {
|
|
let config = build_config(
|
|
"n",
|
|
Some("opus".to_owned()),
|
|
Some("high".to_owned()),
|
|
None,
|
|
None,
|
|
);
|
|
assert_eq!(config.model, Some("opus".to_owned()));
|
|
assert_eq!(config.effort, Some("high".to_owned()));
|
|
}
|
|
|
|
#[test]
|
|
fn build_config_defaults_effort_to_medium_when_omitted() {
|
|
// Unlike `model`, an omitted `effort` does not fall through to
|
|
// claude's own default (`high` on most models) — this daemon picks
|
|
// `medium` itself, a deliberate cost-conscious choice for subagent
|
|
// work.
|
|
let config = build_config("n", None, None, None, None);
|
|
assert_eq!(config.effort, Some("medium".to_owned()));
|
|
}
|
|
}
|