Watch
0
0
Fork
You've already forked hyperhive
0

Compare commits

..
Author SHA1 Message Date
atlas
6e2de33f26 subagent: make a missed continue the tool call's own error
`continue` returned "started" the instant `Claude::spawn` handed back a
pid, and a resume that matched nothing only surfaced later, as an
end-of-turn todo. By then the caller had moved on believing it had a
running subagent.

A pid is proof enough for `start`, which creates its session: the spawn
succeeding is the whole story. It is not proof for a resume — claude
exits non-zero a fraction of a second *after* the process exists. So
`continue` now waits for the first real answer and reports a miss as its
own `Err`, carrying claude's message and the directory searched.

The wait ends on whichever comes first, so a successful `continue` pays
no fixed delay: the turn's first non-terminal stream event settles it at
about the same moment a miss's exit would have. Measured on this box:
14 runs of the driver's own invocation against a missing session took
550-1087 ms spawn to exit, and a healthy turn's first event lands at
roughly 500 ms. The five-second cap is ~4.6x the slowest miss and is only
ever reached by a child that neither speaks nor exits.

The underway signal reads the event's kind, not its content: a missed
resume is not silent — it emits a terminal `result` event and stderr
before exiting — so "any sink callback" would have reported every miss as
a successful start. Liveness still counts all three callbacks.

The end-of-turn todo is unchanged for every failure later in the turn;
the only one it no longer repeats is the miss the caller was just handed.

Refs #4405
2026-09-14 20:56:16 +02:00
atlas
31c76ddf32 subagent: say the dir a session was started in, not "pass dir"
A session cannot be moved between directories, so a hint reading
"pass dir" could be misread as pointing an existing session at any
directory. Say instead that dir names the directory the session was
started in.

Refs #4405
2026-09-14 20:56:16 +02:00
atlas
307df77948 subagent: report turn liveness, and stop pre-checking continue
`status` could only answer running / starting / idle / killed / none,
because every turn ran against `&NoopSink` and the whole stream-json
stream was discarded. "Running" describes a wedged subagent exactly as
well as a busy one, leaving a caller to tell them apart from `ps` output
and CPU-time deltas.

So the daemon now keeps a `name -> last_event_at` clock, bumped by
`LivenessSink` on every line of every stream — stream-json events, plain
stdout chatter and stderr alike — and `status` reports its age on a
running answer: a few seconds means working, an age climbing into the
minutes with no end-of-turn todo means wedged. Nothing is read out of the
content; classifying *what* a subagent is doing is a separate question
and waits on its own driver work. In memory with the rest of this
daemon's state, dropped when the turn ends, no persistence.

The clock is seeded at the spawn rather than at the first line, so a
subagent that wedged before emitting anything still reports a climbing
age rather than no age at all — the case an age is worth most in.

Separately, `continue`'s existence pre-check is gone. It could only
repeat the lookup `Claude::spawn` was about to do, and its message —
"no session named `x` exists" — was false in the common failure: the
session existed, just not under the claude home + cwd `build_store`
resolved from. claude's own `--resume` is the authority and exits
non-zero (`does not match any session title`) rather than quietly
starting a fresh session, so the turn fails on its own. `classify_end`
appends the one fact the CLI's message lacks — the directory searched:

  claude error: no session matched the requested id or title (searched
  <claude_home> for cwd <cwd>; if the session was started elsewhere,
  pass `dir`)

The `dirs` map's durability is untouched; whether to persist it stays an
open operator decision.

Module doc, `docs/tools/subagent.md`, the `continue`/`status` tool
descriptions and the `base:claude-subagents` skill all updated — including
`continue`'s `dir` doc, which said "the daemon remembers it" without
saying that a restart is both when it forgets and when you most want it.

Refs #4330
Refs #4405
2026-09-14 20:56:16 +02:00
5 changed files with 837 additions and 70 deletions

View file

@ -30,10 +30,17 @@ status(name)
interrupt(name, force?)
```
`start` and `continue` return as soon as the process is confirmed
running, not once it finishes — a completion lands as a todo
(`get_loose_ends`), same as any other producer. Use `status` for a
zero-cost "is it still going" check; reach for `continue` only once you
`start` and `continue` return once the turn is under way, not once it
finishes — a completion lands as a todo (`get_loose_ends`), same as any
other producer. `continue` takes the extra moment to confirm the resume
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
says which directory it searched, which is usually the fix (pass `dir`).
Use `status` for a
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
means it's working and an age climbing into the minutes means it's
wedged and worth an `interrupt`. Reach for `continue` only once you
actually have a new instruction for it, since that spends a turn.
`interrupt` genuinely stops a running turn (`force: true` for SIGKILL).

View file

@ -26,12 +26,70 @@ Served under the `subagent` MCP server (`mcp__subagent__<tool>`): `start`,
## State
In-memory only: a map of currently running processes, live only as long
as the daemon process is. A daemon restart stops whatever was running
rather than adopting it. The durable record of a subagent's existence is
claude's own on-disk session (`hive_claude::SessionStore`), which
`continue` reattaches to independent of the daemon's own lifetime — a
restart loses the _in-flight turn_, not the subagent's history.
In-memory only: what's running now, where each name's session lives, how
each name's last turn ended, and when each running turn last produced
output. All of it lives only as long as the daemon process does. A daemon
restart stops whatever was running rather than adopting it. The durable
record of a subagent's existence is claude's own on-disk session
(`hive_claude::SessionStore`), which `continue` reattaches to independent
of the daemon's own lifetime — a restart loses the _in-flight turn_, not
the subagent's history.
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
other than the daemon's own working directory — and a restart is the
situation you reach for `continue` in most often.
## Is it working, or is it wedged?
`status` reporting **running** says a process is tracked, which a wedged
subagent satisfies as fully as a busy one. A running answer therefore
carries the age of that turn's last event too: seconds means it's working,
an age climbing into the minutes with no end-of-turn todo means it's
stuck. That one number replaces inferring the same thing from `ps` output
and CPU-time deltas.
Every line the subagent's `claude` process writes bumps the timestamp —
stream-json events, plain stdout chatter and stderr alike — and what the
subagent actually said is never read. The record says the child is alive,
not what it's doing. The clock starts at the spawn, so a subagent that wedged
before it ever emitted anything still reports a climbing age rather than
no age at all. It's dropped when the turn ends, since a finished turn has
no progress left to describe.
## A `continue` that finds no session
`continue` doesn't check for the session before spawning. claude's own
`--resume` is the authority, and it exits non-zero rather than quietly
starting a fresh session, so the check could only duplicate the lookup
the driver was about to do — while answering as though the session were
gone. The usual truth is that the session exists somewhere else.
`continue` waits for that answer instead. Where `start` returns the
instant the process exists — it creates its session, so the spawn
succeeding is the whole story — a resumed turn can fail a moment _after_
a pid exists, and a pid is no proof that turn began. `continue` holds the
tool call open until the turn is underway or the resume has come back
missed, and reports a miss as the call's own error, carrying claude's
message plus the location this daemon searched:
```
continue error: claude error: no session matched the requested id or title (searched /home/agent/.claude for cwd /home/agent/work; if it was started elsewhere, pass the `dir` it was started in)
```
The directory is the part claude's own message never names, and the part
that resolves the confusion: pass `dir` to point `continue` at the
directory the session was started in.
Nothing here is a fixed delay on the way to a successful turn. The wait
ends on whichever comes first — the turn's first stream event or its
early exit — and on this box both land inside a second, so a `continue`
that works answers about as fast as it did before. A five-second cap
bounds the one case neither covers: a child that neither speaks nor
exits, reported as started, with the end-of-turn todo left to say how it
goes. That todo still carries every failure that happens later in the
turn, exactly as before; the only one it no longer repeats is the miss
the caller has just been handed to its face.
## A killed turn

View file

@ -24,6 +24,12 @@ tokio.workspace = true
tracing.workspace = true
tracing-subscriber.workspace = true
# `test-util` for `#[tokio::test(start_paused = true)]`: the `continue`
# resume-grace tests assert what happens when the bound is actually reached,
# and paused time gets that answer without a five-second unit test.
[dev-dependencies]
tokio = { workspace = true, features = ["test-util"] }
# `hive-subagent-daemon` — long-running per-agent claude-subagent runner.
# Independent of `hive-bash-mcp` (own crate, own binary, own MCP server) —
# see lib.rs's module doc for why. Serves its MCP tools (`start`/

View file

@ -79,8 +79,12 @@ struct ContinueArgs {
#[serde(default)]
effort: Option<String>,
/// Omit to reuse whatever `dir` `start` (or a prior `continue`) used for
/// this name — the daemon remembers it. Only pass this to point the
/// session at a *different* directory than last time.
/// this name — the daemon remembers it until it restarts, and a restart
/// is exactly when you're most likely to be reaching for `continue`. So
/// pass it when pointing the session at a *different* directory than
/// last time, and pass it again after a restart if the session lives
/// anywhere other than the daemon's own working directory. A resume that
/// finds nothing says which directory it searched.
#[serde(default)]
dir: Option<String>,
}
@ -145,12 +149,16 @@ impl SubagentMcp {
description = "Give an existing named subagent session a new turn — whether that's \
because its previous turn finished and you have a follow-up instruction, or you're \
reattaching after this daemon restarted (the session itself survives independently \
of the daemon that spawned it). Returns as soon as confirmed running, same as \
`start`. Refuses a name with no session on disk at all, or one already running. \
of the daemon that spawned it). Returns once the turn is underway rather than the \
instant the process exists — a second or so, not the length of the turn — so a \
reply saying the turn started means it started; a name already running is refused. \
A name with no session to resume is not refused up front, because claude's own \
`--resume` decides that: a miss comes back as this call's own error, naming the \
directory that was searched, so check `dir` before concluding the session is gone. \
Resuming a session whose last turn was killed is allowed — the reply says so, since \
that turn's work stopped wherever it had got to."
)]
fn r#continue(&self, Parameters(args): Parameters<ContinueArgs>) -> String {
async fn r#continue(&self, Parameters(args): Parameters<ContinueArgs>) -> String {
match session::continue_(
&self.state,
&args.name,
@ -158,7 +166,9 @@ impl SubagentMcp {
args.model,
args.effort,
args.dir.as_deref(),
) {
)
.await
{
Ok(msg) => msg,
Err(e) => format!("continue error: {e:#}"),
}
@ -181,7 +191,9 @@ impl SubagentMcp {
#[tool(
description = "Report whether a subagent is currently running — a zero-cost check that \
never launches a process, unlike `continue`. The answer says what state it found \
and what to do about it."
and what to do about it. For a running one it also reports how long since that turn \
last produced any output, which is how you tell a subagent that's working from one \
that has wedged without resorting to `ps`."
)]
fn status(&self, Parameters(args): Parameters<StatusArgs>) -> String {
match session::status(&self.state, &args.name, args.dir.as_deref()) {

File diff suppressed because it is too large Load diff