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

@ -26,12 +26,57 @@ 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 nothing
about the content is inspected. 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` returns as soon as the process is confirmed running, same as
always, so the failure lands where every other failed turn lands: that
turn's end-of-turn todo, carrying claude's own message plus the location
this daemon searched.
```
claude error: no session matched the requested id or title (searched /home/agent/.claude for cwd /home/agent/work; if the session was started elsewhere, pass `dir`)
```
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.
## A killed turn