Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6e2de33f26 | ||
|
|
31c76ddf32 | ||
|
|
307df77948 |
5 changed files with 837 additions and 70 deletions
|
|
@ -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).
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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`/
|
||||
|
|
|
|||
|
|
@ -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
Loading…
Reference in a new issue