Watch
0
0
Fork
You've already forked hyperhive
0

subagent: default cwd to the work dir only when it exists

A spawn into a missing cwd fails with a bare ENOENT; a daemon newer than its
container's bind now inherits its own cwd again, as before the work-dir default.
This commit is contained in:
atlas 2026-10-15 23:02:58 +02:00 • committed by mara
commit 8c0a552792
5 changed files with 49 additions and 12 deletions

View file

@ -37,7 +37,8 @@ actually attached, so a `continue` naming a session that isn't there
fails the tool call outright instead of looking like it worked; the error fails the tool call outright instead of looking like it worked; the error
says which directory it searched, which is usually the fix (pass `dir`). says which directory it searched, which is usually the fix (pass `dir`).
`dir` defaults to your work dir, `/agents/<you>/work`, which has no `dir` defaults to your work dir, `/agents/<you>/work`, which has no
`CLAUDE.md` above it, so a subagent started there does not load yours. `CLAUDE.md` above it, so a subagent started there does not load yours
(until that directory exists, the daemon's cwd is used).
Use `status` for a Use `status` for a
zero-cost "is it still going" check — for a running subagent it also zero-cost "is it still going" check — for a running subagent it also
reports how long since that turn last produced output, so a few seconds reports how long since that turn last produced output, so a few seconds

View file

@ -76,7 +76,7 @@ claude the bare name — see below.
Because the remembered directory goes with the rest of it, a `continue` Because the remembered directory goes with the rest of it, a `continue`
after a restart has to re-supply `dir` when the session lives anywhere after a restart has to re-supply `dir` when the session lives anywhere
other than the agent's work dir (the default) — and a restart is the other than the agent's work dir (the default when it exists) — and a restart is the
situation you reach for `continue` in most often. situation you reach for `continue` in most often.
## Roles ## Roles

View file

@ -81,7 +81,8 @@ struct StartArgs {
/// you've already prepared for it, so a parallel batch of subagents /// you've already prepared for it, so a parallel batch of subagents
/// never race on the same working tree. Must exist. Omit to use your /// never race on the same working tree. Must exist. Omit to use your
/// work dir, `/agents/<you>/work` (it has no `CLAUDE.md` above it, so the /// work dir, `/agents/<you>/work` (it has no `CLAUDE.md` above it, so the
/// subagent does not load yours). The daemon /// subagent does not load yours); until that directory exists (a container
/// that predates its bind) the daemon's cwd is used. The daemon
/// remembers whichever `dir` you give here against `name`, so a later /// remembers whichever `dir` you give here against `name`, so a later
/// `continue`/`status` for the same name doesn't need to repeat it — /// `continue`/`status` for the same name doesn't need to repeat it —
/// only pass it there again if you want to point at a *different* /// only pass it there again if you want to point at a *different*

View file

@ -32,13 +32,21 @@ pub fn state_dir() -> PathBuf {
/// The agent's persistent work dir, a sibling of [`state_dir`] /// The agent's persistent work dir, a sibling of [`state_dir`]
/// (`/agents/<name>/work`). It has no `CLAUDE.md` above it, so a subagent /// (`/agents/<name>/work`). It has no `CLAUDE.md` above it, so a subagent
/// started here does not load the agent's own. This is the working /// started here does not load the agent's own.
/// directory a subagent gets when `start` is given no `dir`.
#[must_use] #[must_use]
pub fn work_dir() -> PathBuf { pub fn work_dir() -> PathBuf {
state_dir().with_file_name("work") state_dir().with_file_name("work")
} }
/// The working directory a subagent gets when `start` is given no `dir`:
/// [`work_dir`] when it exists, else `None` (the daemon's own cwd). The
/// directory only exists once the container has restarted with its bind, and
/// a spawn into a missing cwd fails with a bare ENOENT.
#[must_use]
pub fn default_cwd() -> Option<PathBuf> {
Some(work_dir()).filter(|p| p.is_dir())
}
/// Where this agent's named subagent role files live, one markdown file per /// Where this agent's named subagent role files live, one markdown file per
/// role under the agent's own [`state_dir`]. Per-agent rather than /// role under the agent's own [`state_dir`]. Per-agent rather than
/// hive-wide: the clauses a role carries came out of one agent's own prompt /// hive-wide: the clauses a role carries came out of one agent's own prompt

View file

@ -15,7 +15,7 @@
//! session, found again by name. `continue` is how a caller reattaches to //! 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 //! 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 //! want to pick this back up" — after a restart, re-supply `dir` once if
//! the session isn't in the agent's work dir (the default). //! the session isn't in the agent's work dir (the default when it exists).
//! //!
//! **A running turn is not necessarily a working turn.** A wedged child //! **A running turn is not necessarily a working turn.** A wedged child
//! satisfies "running" as fully as a busy one, so every line of the child's //! satisfies "running" as fully as a busy one, so every line of the child's
@ -1184,8 +1184,8 @@ fn subagent_otel_attrs(name: &str) -> String {
/// same "cheaper than you" cost-consciousness the `base:claude-subagents` /// same "cheaper than you" cost-consciousness the `base:claude-subagents`
/// skill already asks of `model`. `prompt_file`, when given, becomes /// skill already asks of `model`. `prompt_file`, when given, becomes
/// `--append-system-prompt-file` — the subagent's task instructions. /// `--append-system-prompt-file` — the subagent's task instructions.
/// `dir`, when given, becomes `Config::cwd` (e.g. a worktree the caller /// `dir`, when given, becomes `Config::cwd` (e.g. a prepared worktree); `None`
/// already prepared); `None` falls back to [`crate::paths::work_dir`]. /// is [`crate::paths::default_cwd`].
/// Always `--dangerously-skip-permissions --strict-mcp-config --tools` — two /// Always `--dangerously-skip-permissions --strict-mcp-config --tools` — two
/// gates covering one half each, neither substituting for the other: /// gates covering one half each, neither substituting for the other:
/// `strict_mcp_config` over **MCP** tools (no ambient discovery, so exactly /// `strict_mcp_config` over **MCP** tools (no ambient discovery, so exactly
@ -1239,7 +1239,7 @@ fn build_config(
Config { Config {
model, model,
effort: Some(effort.unwrap_or_else(|| "medium".to_owned())), effort: Some(effort.unwrap_or_else(|| "medium".to_owned())),
cwd: Some(dir.map_or_else(crate::paths::work_dir, PathBuf::from)), cwd: dir.map(PathBuf::from).or_else(crate::paths::default_cwd),
mcp_config: crate::mcp_config::build(name, signal_url), mcp_config: crate::mcp_config::build(name, signal_url),
strict_mcp_config: true, strict_mcp_config: true,
extra_args, extra_args,
@ -1387,7 +1387,8 @@ pub struct StartRequest {
pub role: Option<String>, pub role: Option<String>,
/// The first turn's prompt. A goal, when given, is appended to it. /// The first turn's prompt. A goal, when given, is appended to it.
pub trigger: String, pub trigger: String,
/// Working directory for the session; `None` means the agent's work dir. /// Working directory for the session; `None` means the agent's work dir when it
/// exists, else the daemon's cwd.
pub dir: Option<String>, pub dir: Option<String>,
/// What this session is being continued *toward*. `None` keeps the /// What this session is being continued *toward*. `None` keeps the
/// pre-continuation shape: one turn, one todo, no re-prompting. /// pre-continuation shape: one turn, one todo, no re-prompting.
@ -3349,8 +3350,34 @@ mod tests {
let with = build_config("n", None, None, None, Some("/tmp/some-worktree"), None); let with = build_config("n", None, None, None, Some("/tmp/some-worktree"), None);
assert_eq!(with.cwd, Some(PathBuf::from("/tmp/some-worktree"))); assert_eq!(with.cwd, Some(PathBuf::from("/tmp/some-worktree")));
let without = build_config("n", None, None, None, None, None); let root = std::env::temp_dir().join(format!(
assert_eq!(without.cwd, Some(crate::paths::work_dir())); "hive-subagent-mcp-test-work-dir-{}",
std::process::id()
));
let state = root.join("state");
let work = root.join("work");
std::fs::create_dir_all(&state).unwrap();
// SAFETY: test-only env mutation; the other `build_config` tests pass
// an explicit `dir` or read nothing derived from the state dir.
unsafe {
std::env::set_var("HYPERHIVE_STATE_DIR", &state);
}
let absent = build_config("n", None, None, None, None, None);
std::fs::create_dir_all(&work).unwrap();
let present = build_config("n", None, None, None, None, None);
// SAFETY: see above.
unsafe {
std::env::remove_var("HYPERHIVE_STATE_DIR");
}
let _ = std::fs::remove_dir_all(&root);
assert_eq!(
absent.cwd, None,
"a work dir that isn't there yet must not become a spawn cwd"
);
assert_eq!(present.cwd, Some(work));
} }
#[test] #[test]