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
This commit is contained in:
atlas 2026-09-14 19:53:05 +02:00 committed by mara
commit 307df77948
4 changed files with 490 additions and 56 deletions

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>,
}
@ -146,9 +150,11 @@ impl SubagentMcp {
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. \
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."
`start`; a name already running is refused. A name with no session to resume is not \
refused up front claude's own `--resume` decides that, and a miss arrives as a \
failed turn 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 {
match session::continue_(
@ -181,7 +187,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()) {