Compare commits
5 changed files with 70 additions and 837 deletions
|
|
@ -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).
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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`/
|
||||||
|
|
|
||||||
|
|
@ -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
Loading…
Reference in a new issue