Watch
0
0
Fork
You've already forked hyperhive
0

Compare commits

..
5 changed files with 70 additions and 837 deletions

View file

@ -30,17 +30,10 @@ status(name)
interrupt(name, force?) interrupt(name, force?)
``` ```
`start` and `continue` return once the turn is under way, not once it `start` and `continue` return as soon as the process is confirmed
finishes — a completion lands as a todo (`get_loose_ends`), same as any running, not once it finishes — a completion lands as a todo
other producer. `continue` takes the extra moment to confirm the resume (`get_loose_ends`), same as any other producer. Use `status` for a
actually attached, so a `continue` naming a session that isn't there zero-cost "is it still going" check; reach for `continue` only once you
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. actually have a new instruction for it, since that spends a turn.
`interrupt` genuinely stops a running turn (`force: true` for SIGKILL). `interrupt` genuinely stops a running turn (`force: true` for SIGKILL).

View file

@ -26,70 +26,12 @@ Served under the `subagent` MCP server (`mcp__subagent__<tool>`): `start`,
## State ## State
In-memory only: what's running now, where each name's session lives, how In-memory only: a map of currently running processes, live only as long
each name's last turn ended, and when each running turn last produced as the daemon process is. A daemon restart stops whatever was running
output. All of it lives only as long as the daemon process does. A daemon rather than adopting it. The durable record of a subagent's existence is
restart stops whatever was running rather than adopting it. The durable claude's own on-disk session (`hive_claude::SessionStore`), which
record of a subagent's existence is claude's own on-disk session `continue` reattaches to independent of the daemon's own lifetime — a
(`hive_claude::SessionStore`), which `continue` reattaches to independent restart loses the _in-flight turn_, not the subagent's history.
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 ## A killed turn

View file

@ -24,12 +24,6 @@ tokio.workspace = true
tracing.workspace = true tracing.workspace = true
tracing-subscriber.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. # `hive-subagent-daemon` — long-running per-agent claude-subagent runner.
# Independent of `hive-bash-mcp` (own crate, own binary, own MCP server) — # 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`/ # see lib.rs's module doc for why. Serves its MCP tools (`start`/

View file

@ -79,12 +79,8 @@ struct ContinueArgs {
#[serde(default)] #[serde(default)]
effort: Option<String>, effort: Option<String>,
/// Omit to reuse whatever `dir` `start` (or a prior `continue`) used for /// Omit to reuse whatever `dir` `start` (or a prior `continue`) used for
/// this name — the daemon remembers it until it restarts, and a restart /// this name — the daemon remembers it. Only pass this to point the
/// is exactly when you're most likely to be reaching for `continue`. So /// session at a *different* directory than last time.
/// 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)] #[serde(default)]
dir: Option<String>, dir: Option<String>,
} }
@ -149,16 +145,12 @@ impl SubagentMcp {
description = "Give an existing named subagent session a new turn — whether that's \ 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 \ 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 \ reattaching after this daemon restarted (the session itself survives independently \
of the daemon that spawned it). Returns once the turn is underway rather than the \ of the daemon that spawned it). Returns as soon as confirmed running, same as \
instant the process exists — a second or so, not the length of the turn — so a \ `start`. Refuses a name with no session on disk at all, or one already running. \
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 \ 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." that turn's work stopped wherever it had got to."
)] )]
async fn r#continue(&self, Parameters(args): Parameters<ContinueArgs>) -> String { fn r#continue(&self, Parameters(args): Parameters<ContinueArgs>) -> String {
match session::continue_( match session::continue_(
&self.state, &self.state,
&args.name, &args.name,
@ -166,9 +158,7 @@ impl SubagentMcp {
args.model, args.model,
args.effort, args.effort,
args.dir.as_deref(), args.dir.as_deref(),
) ) {
.await
{
Ok(msg) => msg, Ok(msg) => msg,
Err(e) => format!("continue error: {e:#}"), Err(e) => format!("continue error: {e:#}"),
} }
@ -191,9 +181,7 @@ impl SubagentMcp {
#[tool( #[tool(
description = "Report whether a subagent is currently running — a zero-cost check that \ 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 \ never launches a process, unlike `continue`. The answer says what state it found \
and what to do about it. For a running one it also reports how long since that turn \ and what to do about it."
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 { fn status(&self, Parameters(args): Parameters<StatusArgs>) -> String {
match session::status(&self.state, &args.name, args.dir.as_deref()) { match session::status(&self.state, &args.name, args.dir.as_deref()) {

File diff suppressed because it is too large Load diff