Skip to content

Phase 5 Step 7 (gated): registry becomes the single subagent store — do not merge until Steps 5-6 are stable - #44235

Closed
claw3649 wants to merge 64 commits into
NousResearch:mainfrom
claw3649:feat/phase5-step7-cutover-prep
Closed

Phase 5 Step 7 (gated): registry becomes the single subagent store — do not merge until Steps 5-6 are stable#44235
claw3649 wants to merge 64 commits into
NousResearch:mainfrom
claw3649:feat/phase5-step7-cutover-prep

Conversation

@claw3649

Copy link
Copy Markdown

Stacked on #44233 (→ #43422#43039). Draft with an explicit merge gate: per the design doc's own rule, this cutover must NOT merge until Phase 5 Steps 5-6 have been stable in production — it is staged here so the chain is visible and CI-checked, not to jump the queue.

Phase 5 Step 7 — the registry becomes the single subagent store

  • Legacy module globals removed from tools/delegate_tool.py (_active_subagents + lock, _spawn_paused + lock, _register_subagent/_unregister_subagent — repo-wide grep confirms zero live references; no test patched them).
  • Public names survive as thin wrappers over AgentTaskRegistry: list_active_subagents() maps records (filtered by the new is_subagent discriminator) onto the byte-identical legacy dict shape — including the last_tool-only-after-first-tool quirk; interrupt_subagent keeps the legacy "Interrupted via TUI" reason; set/is_spawn_paused delegate to the registry flag.
  • is_subagent exists because intent can't discriminate: task.submit delegate batch parents are also intent="delegate".
  • Untouched per doc risk R2: AIAgent._active_children interrupt propagation.
  • One test assertion updated to single-ledger semantics (the dual-ledger pin stays on the parent branch, which is still pre-cutover).

Verification: 637+ green across delegate/gateway/registry/server suites; cutover-vs-baseline failure sets diffed via detached worktree — identical (zero regressions).

🤖 Generated with Claude Code

claw3649 and others added 30 commits June 10, 2026 02:01
…res in the UI

Model-resolution reliability and picker UX, including the corrections found in
review of the in-flight batch:

- model_switch: discover keyless local endpoints (ollama/llama.cpp/LM Studio)
  so `ollama rm`'d models stop lingering in the picker — but only when no
  explicit `models:` subset is pinned, preserving an intentional narrowing
  (parity with the custom-providers section; fixes 2 test regressions).
- models: bypass the disk cache for custom/local providers (always live),
  falling through to the shared stale-beats-empty path so a transiently
  unreachable endpoint keeps its last-known-good list instead of going empty.
- model_metadata: pin local Gemma 4 26B to its served 64k num_ctx ahead of the
  /api/show GGUF-max probe so the token budget can't over-run and silently
  truncate; reconcile a stale cache entry in place instead of re-probing every
  call (the previous guard never converged).
- web/desktop picker: await the /model switch and surface a rejected switch
  (result.error) inline — keep the dialog open, roll back the optimistic model,
  toast benign advisories — instead of closing as though it worked.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- launchd_restart: try a graceful SIGUSR1 self-restart before terminate, with a
  hard-stop fallback; bind the force-kill to the freshly-resolved live PID (not
  the captured one) so PID reuse can't SIGKILL an unrelated process.
- hermes launcher: re-exec into ./venv/bin/python on Python <3.11, but only
  after verifying that interpreter is itself >=3.11 — a stale venv no longer
  silently bypasses the version guard and crashes later with an opaque error.
- tests: cover the SIGUSR1 graceful path and reconcile the terminate-drain
  fallback test with the new graceful-first behavior.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ontract

Phase 2 of the Central Brain + Action Runtime design (docs/architecture): a
single honest-status schema the gateway's exec handlers funnel through.

- contract: ExecutionTask / ExecutionResult / Status / ErrorType / ExecError /
  SideEffect / NeedsInput / Constraints. `outputs` is a lossless payload bag so
  each handler's native shape round-trips back to its exact wire dict.
- adapters: shell / cli / plugin / slash, each a pure (to_result, to_wire) pair
  with a byte-identical round-trip guarantee.
- tests assert the round-trip identity (the byte-compat oracle).

Pure dataclasses with no dependency on the gateway (server imports this, not
the reverse). Nothing consumes the rich fields yet — that is Phase 4.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… Runtime

- slash.exec: report a rejected live side-effect (/model, busy) and a raised
  plugin handler as a structured result.error (additive — TUI/web that read
  only output/warning are unaffected), keying off _SlashSideEffect.kind rather
  than prefix-matching the message text.
- shell.exec / cli.exec / slash.exec / plugin path now build an ExecutionResult
  and render it back through the adapters — byte-identical wire, so the three
  frontends need no change. A non-zero exit is an honest FAILED result whose
  wire shape (result.code) is unchanged.
- desktop typed `/model` path: surface result.error instead of rendering a
  rejected switch as a success line; add error? to SlashExecResponse.
- add honest-status regression coverage for the plugin error field and for the
  already-honest shell.exec / cli.exec paths (none existed before).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A design doc for separating the Orchestration Core (reasoning/planning/memory)
from the Action Runtime (execution/tool-use), grounded in the current Hermes
gateway. Covers the decided terminology (OpenClaw = compat/reference only), the
ExecutionTask/ExecutionResult contract, the phased migration (1a honest-status →
2 unified path → 3 SessionState + Brain-host → 4 intent/idempotency → 5
multi-runtime), cross-cutting rules (additive-first, test-first, observability,
idempotency), and a decision log tracking what has landed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…n sites

Phase 3 Step 1 (Central Brain + Action Runtime design, §11). The per-session
state was an untyped dict at _sessions[sid] (~58 refs, ~30 keys, locks floating
as dict entries). Introduce a SessionState type to anchor the decomposition.

SessionState subclasses `dict`, so every existing session["key"] / .get /
.setdefault / "k" in session call keeps EXACT dict semantics — zero handler
churn. A safety analysis confirmed the gateway has no membership test, bare pop,
or iteration that relies on the session value dict, and that absent keys must
stay absent (a dict subclass gives that for free, so no field defaults need
tuning — notably agent_ready stays absent on the eager path and personality
keeps its config fallback).

Typed @Property accessors (history, history_lock, running, agent, …) let
handlers migrate to attribute access incrementally in later steps; the locks
stay dict items until the subscript sites are converted. Wraps the two real
creation literals (_init_session, session.create); test-constructed plain dicts
still work since SessionState IS a dict.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
SessionState type introduced and committed; note the next step (Step 2 lock-core
methods). Also reflects the verified AIAgent instantiation-site count.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…robe

Generalizes the root cause behind the Gemma4-26B 64k pin: Ollama's /api/show
reports the GGUF training max, which over-states the window a LOCAL server
actually serves (the user-set Modelfile num_ctx) and silently truncates
conversations once they outgrow it. Steps 2b and 5e now resolve local
endpoints num_ctx-first (query_ollama_num_ctx) while hosted servers keep the
GGUF-first probe (their users can't set num_ctx); discovered values persist
under the same lmstudio cache exclusion the sibling steps use. The explicit
Gemma pin stays as a tested guard above these steps.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Opening the picker live-probes every custom/local endpoint (keyless-local
discovery in sections 3/4 and the live "custom" branch of provider_model_ids);
with a server down each open burns the full HTTP timeout. Add a 30s in-process
memo around fetch_api_models — keyed by endpoint + credential fingerprint and
pinned to the exact function object so monkeypatched tests stay hermetic
(plus an autouse clear in tests/hermes_cli). Empty results and exceptions are
never memoized, so a down server still retries on the next open and the
stale-beats-empty disk-cache semantics are untouched.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…istry

Pre-existing cross-file pollution: the dispatch-pool tests overwrite entries in
the module-global _methods registry (slash.exec, session.compress, …) without
restoring them, so four session.compress/snapshot tests in
test_tui_gateway_server.py fail whenever both files run in one process. The
server fixture now snapshots and restores _methods in place (same dict object)
plus the directly-assigned module globals (_real_stdout, _hermes_home,
_cfg_cache/_cfg_mtime/_cfg_path). Tests-only; both files also stay green alone.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Phase 3 Step 2 (Central Brain + Action Runtime design, §11). The compaction
snapshot + compare-and-swap, the turn-release finally, and the one-shot
agent-build guard now live as SessionState methods — snapshot_history,
commit_compaction, begin_turn, end_turn, build_once — each a verbatim lift of
the server.py block it replaced (same lock, same .get defaults, same order).
Five gateway sites migrated; sites whose critical sections interleave extra
work with the CAS (the prompt.submit busy-gate + truncate, the poller gates,
bare running-flag cleanups, the mid-turn history CAS that logs under the lock)
stay deliberately inline to preserve atomicity, each noted in place.

Test helpers that feed the migrated paths now construct SessionState (a dict
subclass, so semantics are unchanged); 9 unit tests pin the method contracts.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…esult wire

Phase 4 (Central Brain + Action Runtime design, §11): make the rich
ExecutionResult consumable.

- slash.exec accepts an optional task_id and echoes it additively (the wire is
  byte-identical when absent — existing protocol tests pass unmodified).
- result_to_wire_rich renders the full contract: task_id, status, outputs,
  error {type, retryable, message}, side_effects.
- New task.submit RPC: pilot scope intent="slash" only (reversible commands,
  e.g. /model), other intents → 4030. The slash.exec body is extracted into a
  shared _slash_exec_core returning the ExecutionResult; slash.exec renders the
  legacy wire exactly as today, task.submit renders the rich wire.
- Idempotency: a locked in-process store (10-min TTL, 1024-entry cap) replays
  the recorded result with replayed=true instead of re-executing; task_id
  defaults to a fresh uuid4.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…-flight token

Phase 4 race fix (from the PR review): selectModel applied optimistic switches
with no in-flight correlation, so a slow switch A failing after a faster
switch B succeeded rolled the store back to the pre-A model, clobbering B.

Each selectModel call now captures a monotonically-increasing token; once a
newer call starts, the older call is stale — a stale success commits nothing
and a stale failure skips the store/cache rollback (the snapshot predates the
newer switch) while keeping the failure toast so the user still learns their
requested switch failed. refreshCurrentModel gains an optional staleness guard
used by the persistGlobal paths; external callers are unaffected. The slash.exec
request also carries an additive task_id for forward correlation; staleness is
decided purely by the local token. Two race tests cover both interleavings.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…sion log

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…us surfacing

Upstream moved the web model picker's chat path from slash.exec to config.set
and added the expensive-model confirmation flow (pendingConfirm). Resolution
keeps upstream's architecture and folds our honest-status work into it: all
three modes (standalone onApply, gateway config.set, legacy onSubmit fallback)
funnel through one awaited try/catch — a failed switch surfaces inline via
submitError and the dialog stays open; a benign post-switch warning fires the
new onWarning prop, which ChatSidebar toasts. The now-dead slash.exec
onModelSubmit path is removed with upstream.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ttributes

Phase 3 Step 3: 107 square-bracket sites in tui_gateway/server.py now use the
typed accessors for the 12 hot fields (history_lock=25, session_key=22,
agent=17, running=11, history_version=8, history=7, the rest 2-3 each).
Mechanical and behavior-preserving by rule: only ["x"] reads/writes were
converted — every .get()/.setdefault()/.pop() stays untouched because absent-key
semantics differ (None/default vs KeyError), and non-session dicts (DB rows,
payload/info dicts) are excluded. Tests that inject plain-dict sessions into
the migrated paths now wrap them in SessionState (a dict subclass — no
behavioral change to the tests themselves).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Phase 3 Brain-host proof-of-life (design §11 3c/3d): agent/brain_host.py hosts
AgentSpec + a process-singleton BrainHost whose build_agent() constructs the
SAME AIAgent the call site would (parity is asserted by test, not assumed).
_make_agent now assembles its kwargs once and, only when HERMES_BRAIN_HOST=1,
routes through the host (lazy import inside the branch — default-off adds zero
import cost, proven via sys.modules in tests). V1 is deliberately the seam
only; credential-pool/tool-schema/memory-session sharing arrives as the other
~20 construction sites migrate.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ision log

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ns through BrainHost

Second and third construction sites behind the HERMES_BRAIN_HOST flag (default
off, byte-equivalent when unset): gateway/run.py's per-turn LRU-miss site
(intent "gateway-run") and the API server's _create_agent (intent
"api-server"). Same assemble-kwargs-then-gate pattern as _make_agent; seven new
gate tests (the api-server gate is unit-driven; the gateway-run gate is
compile+source asserted — its 1200-line async host method is not unit-drivable).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…gistry)

Grounded scoping of delegation.*/spawn_tree.*/delegate_tool state (registries,
locks, persistence, interrupt trees) and the unified design: one
AgentTaskRegistry keyed by task_id speaking ExecutionTask/ExecutionResult,
with a 6-step dual-write migration, risks R1-R7 (notably: the AIAgent-internal
_active_children interrupt chain must not be replaced), and the maintainer
decisions Q1-Q6 required before implementation starts.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
build_agent(intent, **kwargs) is the compact form of the HERMES_BRAIN_HOST
gate for migrating the remaining AIAgent construction sites. It lives in
its own light module (imports only os) so the flag-off path never imports
agent.brain_host — the zero-footprint invariant the gate tests assert —
and run_agent is only imported at call time, matching the lazy-import
style of the call sites it replaces.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…n_host_gate

Converts the 14 construction sites that were still calling AIAgent()
directly to build_agent("<intent>", ...) — exact same kwargs, callable
swap only — and collapses the three pre-existing inline HERMES_BRAIN_HOST
gate blocks (tui_gateway/_make_agent, gateway-run LRU-miss,
api_server._create_agent) into single build_agent calls with their
original intent strings.

New intents: prompt-size, oneshot, cli-background, cli, background-review,
curator, acp, cron, compress, history-hygiene, gateway-background,
feishu-comment, tui-background, preview-restart, batch, run-agent-cli,
delegate.

Deliberately untouched: cli.py's lazy AIAgent wrapper (re-export shim),
scripts/tool_search_livetest.py (dev script), and everything around
_active_subagents / spawn trees / interrupt handling in delegate_tool.

Tests: 4 unit tests for the gate helper (flag off/zero/on + kwargs
parity) and a parametrized site-table source check covering all 16
files / 20 intents, replacing the old grep-based gate-exists test whose
marker strings the gate collapse removed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…scripts to typed attributes

Adds 10 property accessors to SessionState (attached_images,
pending_title, image_counter, show_reasoning, tool_progress_mode,
tool_started_at, personality, model_override, explicit_cwd,
edit_snapshots) with exact self["key"] read/write semantics, and
converts every server.py subscript on those keys to attribute access.

Two sites deliberately stay as subscripts: session["model_override"] in
_apply_model_switch (one caller passes a plain {"agent": None} dict on
the no-session global-switch path — the isinstance guard there exists
for exactly this) and session["_finalized"] (single private marker).

Accessor-sync and write-through tests extended to cover the 10 new
accessors.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…3 Step 4 in the decision log

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…stions Q1-Q2, Q4-Q6

Q3 (Status.RUNNING placement) stays open pending an impact sweep the
maintainer requested: a consumer census of Status/ExecutionResult and
proof that record-level TaskStatus loses no running-task visibility.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…sus, visibility map, lifecycle)

Three-agent sweep: option A touches ~8 sites with one genuine breakage
(the idempotency replay store would cache a non-terminal snapshot as the
permanent answer) plus silent-success hazards on every legacy wire;
option B touches zero existing sites and loses no running-task
visibility, since every in-flight view (delegation.status, the
subagent.*/tool.* event stream, bg counters) lives outside
ExecutionResult. Recommendation recorded as option B, pending the
maintainer's ruling.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… unblocked

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…itive)

The in-process ledger for live and completed agent tasks, encoding the
Q1-Q6 rulings: standalone module (not a BrainHost tenant), ephemeral
records and replay store, a record-level TaskStatus enum carrying
RUNNING while contract.Status stays terminal-only, and method-private
storage so a shared backend can replace the dicts later.

Nothing imports it yet — Step 2 dual-writes from delegate_tool. The
interrupt path resolves the stored agent (weakref or direct) and calls
agent.interrupt() outside the registry lock, the same mechanic as
interrupt_subagent today. The replay store mirrors _TASK_RESULTS
semantics (600s TTL, 1024 cap) ahead of the Step 5 fold-in.

17 unit tests, including two Q3-ruling pins: contract.Status has no
RUNNING, and TaskStatus terminal values mirror contract.Status exactly.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…d the landed Step 1 path

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… Phase 5 Step 2

Each child registers an AgentTaskRecord (intent="delegate", weakref
agent_ref) right after _register_subagent and completes it in the same
finally block that unregisters the legacy dict entry, mapping the
existing per-child entry to a terminal ExecutionResult: completed →
SUCCEEDED, interrupted/timeout → FAILED + ErrorType.TRANSPORT
(non-retryable, per the doc ruling), failed → PROVIDER_ERROR, escaped
exception → INTERNAL, died-without-entry → complete(None) → FAILED.

Both registry calls are try/except-guarded so a registry bug can never
break a real delegate run. _active_subagents, _active_children,
heartbeat and timeout paths are untouched — the registry is a parallel
ledger until the Step 7 cutover.

8 new tests; delegate suites 172 green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
claw3649 and others added 12 commits June 11, 2026 10:37
…sh refactors, keep honest-status surfacing

Upstream restructured executeSlashCommand into a slash-action table with
a generic runExec path (and then made slash commands registry-driven in
#42351); /model-with-args now rides runExec, so our result.error
honest-status check lives there — covering every exec-style command
instead of just the typed /model path. The refreshCurrentModel guard
keeps BOTH protections: upstream's live-session-owns-the-footer guard
and our in-flight staleness token. The model-controls test file unions
upstream's two refreshCurrentModel tests with our four selectModel
rollback/race tests (6/6 green under --environment jsdom).

Desktop src/ suite shows the byte-identical failure set to pure
origin/main (6 pre-existing); python gateway suites green.

Note: this replaces an earlier broken push of the same resolution — a
mid-merge stash for a baseline measurement silently dropped MERGE_HEAD,
so 46a668be2 landed as a single-parent commit with partial upstream
content. Reset and redone as a true merge; baselines are measured in
detached worktrees from now on.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…e up the stack

Single conflict: config.set reasoning_effort gained
_persist_live_session_runtime + a session.info emit upstream while this
branch had converted the same lines to SessionState attribute access —
resolution keeps both (attribute style per Phase 3 Step 3).

Suites: 489 green (single known browser-hint env failure).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… closes review gap 1

delegate_task gains an optional registry_parent_task_id (default None =
byte-identical engine default); when the gateway's intent="delegate"
handler passes its task_id, child registry records link to the
submitted parent record per the doc's Step 5 ruling. Only the
AgentTaskRecord relabels — the legacy TUI dict keeps the engine's
_parent_subagent_id, so interrupt propagation, depth display and the
spawn-tree overlay are untouched. Non-string values degrade to the
engine default.

4 new tests pin both ends of the chain (engine register site + gateway
kwarg pass-through). 343 green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Session-keyed tasks store mirroring the subagents store: seeded from
task.list on every gateway open (doubling as reconnect reconciliation —
running rows absent from the live list drop rather than getting an
invented terminal status), updated live from task.started/
task.completed (snapshot session_id is authoritative; events without
one are dropped, never attached to the focused chat). TaskBoard renders
one row per task — status glyph, intent badge, label, tool progress,
age — terminal rows linger 60s then drop. Mounted above the spawn tree
in AgentsView.

9 store/render tests + canary suites green; tsc and eslint clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
After Step 7 the legacy-shaped list_active_subagents() is a thin
wrapper over the registry, so a caller-supplied parent task id shows
through it — the dual-ledger assertion (engine id retained in the TUI
dict) only holds pre-cutover and stays pinned on the trace-events
branch.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…op board, measured BrainHost cache

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…er merge

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…eview system

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…d client

Mirrors the tools-card architecture exactly: seeded once via task.list
on the JSON-RPC sidecar after session.create, live-updated from
task.started/task.completed frames on the SAME /api/events socket the
tools card uses. Rows: status glyph, intent badge, label/goal, live
age, tool detail or error; terminal rows linger 60s. Pure reducer
helpers extracted dependency-free (web/ has no test runner yet).

Typecheck clean; web lint baseline untouched (44 pre-existing problems
in unrelated files).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@claw3649

Copy link
Copy Markdown
Author

🧪 Dogfooding dossier — evidence toward the "Steps 5-6 stable" merge gate

Drove the full stack on this branch (real stdio JSON-RPC gateway, HERMES_BRAIN_HOST=1, sandboxed HERMES_HOME, model traffic on a local Ollama endpoint — $0 spend, real ~/.hermes untouched and mtime-verified).

Exercise Result
session.create + lazy agent build under BrainHost ✅ PASS
task.submit intent=slash rich wire + idempotent replay (replayed:true, byte-identical, 0.00s) ✅ PASS
task.list / task.status / task.cancel semantics (misses = found:false, not protocol errors) ✅ PASS
bg_* lifecycle: registered while running, task.started/task.completed on the wire, honest terminal result ✅ PASS
Live delegate batch (2 children): parent/child linkage in task.list and _tasks.jsonl, legacy delegation.status wrapper serving mid-batch, aggregate succeeded, replay no-rerun ✅ PASS
stderr/honesty watch: zero tracebacks, zero deadlocks, all exits rc=0, every succeeded had real output ✅ PASS
Protocol robustness (malformed JSON → -32700, unknown method → -32601, bad params/intents → 4xxx) ✅ PASS

9 warts catalogued in the exercise; the 4 substantive ones (child session-id namespace split, in-flight idempotency gap for key-only retries, child results missing task_id, aggregate entries missing child ids) have fixes in flight on the parent branch and will flow into this PR via merge. The remaining 5 are cosmetic/documented-by-design and recorded in the design doc.

This is one sustained synthetic session, not production mileage — posting it as the first stability data point for the gate, not the last.

@alt-glitch alt-glitch added type/refactor Code restructuring, no behavior change comp/agent Core agent runtime: loop, agent_init, prompt builder, context-compression, responses endpoint comp/tui Terminal UI (ui-tui/ + tui_gateway/) tool/delegate Subagent delegation P3 Low — cosmetic, nice to have labels Jun 11, 2026
…light key replay, child result ids

WART 1 — session-id namespace split: delegate batch children registered
under the agent's persistent session key while the parent carried the
gateway sid, splitting _tasks.jsonl across two spawn-trees dirs and
emitting child task.* events with a session_id matching no live gateway
session (dropped on per-session WS transports). delegate_task gains an
optional registry_session_id threaded to the child register site;
_task_submit_delegate passes the sid it already resolved. Default None
keeps non-gateway delegate runs unchanged.

WART 2 — in-flight idempotency gap: the duplicate guard only checked
the caller's task_id, so a broken-pipe retry reusing the
idempotency_key with a fresh task_id mid-batch would re-execute.
registry.find_running_by_key + the key stamped on the parent record;
retries now get the same 4034 rejection naming the running task_id.
The after-completion replayed:true path is untouched.

WART 3 — child ExecutionResults now embed the child's task_id (was
None, contradicting the always-carry rule parents follow).

WART 4 — delegate aggregate results entries gain an additive task_id
key so a Core caller can correlate entries to registry child records
without relying on the sa-{index}-{hex} naming convention (all existing
consumers use .get() access — verified by grep).

Found by live dogfooding (dossier on the Step 7 PR). 351 green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@claw3649
claw3649 marked this pull request as ready for review June 11, 2026 12:18
claw3649 and others added 8 commits June 11, 2026 19:19
…over

The wart fixes land canonically on the parent branch (as promised in the
Step 7 PR dossier comment); this merge adapts the child-register site to
the post-cutover single-ledger shape. Replaces a rogue direct commit
(805c83407, never pushed) that an agent made here against instructions —
that work was preserved and ported to the parent first.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ent wrapper

Three CLI tests stubbed construction by patching cli.AIAgent — a pure
pass-through wrapper the BrainHost migration no longer routes through —
so the real AIAgent ran and each test failed on its environment (CI
caught this; our targeted macOS runs never covered these files).
Investigated each as a potential real regression first: the MCP
wait-before-build ordering, the background-thread callback registration
order, and the single resolve_runtime_provider call per init are all
unchanged in production (the extra counted resolutions came from real
AIAgent internals that the old patch merely prevented from running).
Test-only fix: patch agent.brain_host_gate.build_agent — the seam
production actually uses — with every behavioral assertion unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… idle reaper

test_ws_disconnect_preserves_and_repoints_reconnectable_session seeds
bare session dicts with no created_at/last_active. The module-level
idle reaper reads missing timestamps as 0.0 — idle since the epoch — so
the moment handle_ws's finally closes the transport, the session is
evictable, and a reaper tick landing in the close-to-assert window pops
it: KeyError 'plain'. The 300s tick makes this a once-in-many-runs
flake that only bites long-lived pytest processes (CI's per-file
subprocesses exit before the first tick), which is why it resisted
plain-loop reproduction.

A probe driving the production scan body at a fast tick reproduced the
exact failure 199/200 before the fix and 0/200 after. Production is
immune: every real creation site stamps both timestamps.

Test-only: a _fresh_session helper stamps time.time() on both seeded
sessions.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…tyle repoint works

After Phase 3 Step 3 the WS-disconnect repoint path sets
session.transport = _detached_ws_transport (attribute style); on a
plain-dict seed that raises AttributeError, silently skipping the
repoint, so test_ws_disconnect_preserves_and_repoints_reconnectable_session
saw the session keep its live WSTransport (CI caught this — our targeted
runs never ran this file's slice). _fresh_session now returns a
SessionState, matching every real creation site. Folds the two reasons
the seed must be a SessionState (timestamps + attribute access) into one
docstring.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@claw3649

Copy link
Copy Markdown
Author

Mileage dossier round 2 — flag-on BrainHost / Steps 5–6 stability

Run context:

  • Branch: feat/phase5-trace-events at cc5538a5c (contains wart-fix commit b2ed81b97; this branch is an ancestor of the Step 7 PR head 767fb5db9)
  • Flag: HERMES_BRAIN_HOST=1
  • Gateway: real stdio JSON-RPC via python -m tui_gateway.entry
  • Model/backend: local Ollama, gemma4:12b-it-qat-64k through http://127.0.0.1:11434/v1 (no external spend)
  • Sandbox: both HOME and HERMES_HOME pointed at a temp home
  • Gateway session id: 6f05c6e7
  • Full live dossier JSON was written under the temp sandbox at .hermes/mileage-r2-dossier.json during the run

Pre-live regression pins

Targeted wart/parity pins with the flag on:

HERMES_BRAIN_HOST=1 pytest -q   tests/tools/test_delegate_registry_dualwrite.py::test_delegate_task_threads_caller_session_id_to_parallel_child_records   tests/tools/test_delegate_registry_dualwrite.py::test_delegate_task_threads_caller_parent_id_to_parallel_child_records   tests/tui_gateway/test_protocol.py::test_task_submit_delegate_threads_gateway_session_id_to_children   tests/tui_gateway/test_protocol.py::test_task_submit_delegate_links_children_to_submitted_task_id   tests/tui_gateway/test_protocol.py::test_task_submit_delegate_inflight_duplicate_idempotency_key_is_rejected   tests/tui_gateway/test_protocol.py::test_task_submit_delegate_echoes_trace_id   tests/tui_gateway/test_protocol.py::test_register_emits_task_started_event   tests/tui_gateway/test_protocol.py::test_complete_emits_task_completed_event
→ 8 passed in 0.91s

Broader protocol/registry suite:

HERMES_BRAIN_HOST=1 pytest -q   tests/action_runtime/test_task_registry.py   tests/tools/test_delegate_registry_dualwrite.py   tests/tui_gateway/test_protocol.py
→ 169 passed in 7.24s

Git tree after both test runs stayed unchanged except the pre-existing untracked .claude/.

Live JSON-RPC checks

Protocol robustness:

  • malformed JSON → -32700 parse error
  • unknown method → -32601 unknown method
  • bad task.status params → 4000 task_id required

Session/create + sandbox:

  • session.create returned session_id=6f05c6e7, lazy=true, model gemma4:12b-it-qat-64k, branch feat/phase5-trace-events
  • task.list before any run: tasks: []
  • delegation.status before any run: active: [], paused: false, max_spawn_depth: 1, max_concurrent_children: 3
  • shell.exec inside the gateway process showed the sandbox environment:
HOME=/var/folders/.../hermes-mileage-r2-home-pe6qxcdc
HERMES_HOME=/var/folders/.../hermes-mileage-r2-home-pe6qxcdc/.hermes
ZERO=/bin/sh
PWD=/Users/claw2/.hermes/hermes-agent

Delegate single + replay

task.submit intent="delegate", task_id=task-live-single-r2, trace_id=trace-live-r2-single:

  • final status: succeeded
  • child: sa-0-594224b2
  • child summary: SINGLE_OK
  • child model: gemma4:12b-it-qat-64k
  • child api_calls=1, tool_trace=[]
  • aggregate outputs.results[0].task_id == sa-0-594224b2
  • parent result echoed trace_id=trace-live-r2-single
  • completed in ~90s live wall time
  • idempotent replay after completion returned the same rich result with replayed: true and did not rerun

Delegate parallel + in-flight duplicate + replay

task.submit intent="delegate", task_id=task-live-parallel-r2, trace_id=trace-live-r2-parallel, two children:

  • final status: succeeded
  • children:
    • sa-0-6c3d1488PARALLEL_A_OK
    • sa-1-ef9fd8ebPARALLEL_B_OK
  • aggregate outputs.results[] includes both child task_ids
  • parent result echoed trace_id=trace-live-r2-parallel
  • completed in ~159s live wall time
  • in-flight duplicate with same idempotency_key and no fresh task_id returned:
{"code":4034,"message":"task already running: task-live-parallel-r2"}
  • idempotent replay after completion returned the same rich result with replayed: true and did not rerun

Dual-write parity / live task board

During the live single and parallel delegate runs I polled task.list and delegation.status repeatedly while the tasks were in flight.

  • samples collected: 125
  • registry-vs-legacy active-child mismatches: 0
  • representative states:
    • single in-flight: registry child ids ['sa-0-594224b2'] == legacy active ids ['sa-0-594224b2']
    • parallel in-flight: registry child ids ['sa-0-6c3d1488', 'sa-1-ef9fd8eb'] == legacy active ids ['sa-0-6c3d1488', 'sa-1-ef9fd8eb']
    • after child A completed: registry child ids ['sa-1-ef9fd8eb'] == legacy active ids ['sa-1-ef9fd8eb']
  • final task.list: tasks: []
  • final delegation.status: active: [], paused: false

This directly exercises the Step 7 cutover risk: while tasks were running, the registry child set and legacy _active_subagents view did not diverge.

Wart 1 / session namespace + ledger

Only one registry ledger was written:

spawn-trees/6f05c6e7/_tasks.jsonl

It contains 5 completed records, all with session_id=6f05c6e7:

  • sa-0-594224b2 parent task-live-single-r2
  • task-live-single-r2 parent null
  • sa-0-6c3d1488 parent task-live-parallel-r2
  • sa-1-ef9fd8eb parent task-live-parallel-r2
  • task-live-parallel-r2 parent null

So the child records did not split into the agent persistent session namespace; they landed in the gateway session namespace.

spawn_tree.list with the correct session_id=6f05c6e7 returns 5 registry entries from that _tasks.jsonl, and spawn_tree.load on task-live-parallel-r2 synthesizes a legacy-shaped tree from the registry ledger successfully.

task.* push events

Observed task events:

  • task.started / task.completed for task-live-single-r2
  • task.started / task.completed for sa-0-594224b2
  • task.started / task.completed for task-live-parallel-r2
  • task.started / task.completed for sa-0-6c3d1488
  • task.started / task.completed for sa-1-ef9fd8eb

Parent task events carried the submitted trace ids:

  • task-live-single-r2trace-live-r2-single
  • task-live-parallel-r2trace-live-r2-parallel

Caveats / wart candidates to triage, not fixed live

  1. Child task events have trace_id: null. Parent task records/results/events carry the submitted trace id correctly, and the current protocol tests pin parent echoing. If the intended contract is “trace id on every child event too,” then this dossier found a follow-up wart candidate. I did not change code during mileage.

  2. The “real ~/.hermes mtime unchanged” check is not usable in this desktop session. The live gateway ran with sandbox HOME/HERMES_HOME, and its ledger/config/session data were all under the temp sandbox. However the ambient Hermes desktop/session also wrote normal state/cache files under /Users/claw2/.hermes during the same wall-clock window (state.db-wal, cache/auth/model-cache files), so a top-level mtime comparison reports “changed.” The repo working tree itself remained unchanged (git status --short --branch still only shows the pre-existing ?? .claude/).

  3. spawn_tree.list without params returns the default session and therefore no entries; with session_id=6f05c6e7 it returns the expected registry entries. This was a caller mistake in my first probe, not a server regression.

Proposed measurable gate definition

To make “do not merge until Steps 5–6 are stable” actionable, I suggest using a concrete gate such as:

  • at least 3 calendar days or 10 flag-on sessions with HERMES_BRAIN_HOST=1, including both single and parallel delegate runs;
  • no registry/legacy active-child divergence during in-flight polling;
  • no missing parent/child records from the gateway session _tasks.jsonl ledger;
  • task.started / task.completed events observed for parent and children;
  • idempotency invariants hold: in-flight duplicate rejected with 4034, completed replay returns replayed:true without rerun;
  • any new wart candidates (currently: child event trace propagation, if required) are either fixed or explicitly declared out-of-scope before Step 7 merge.

Verdict for this run: core Steps 5–6 registry/delegate/idempotency/session-namespace/parity behavior held under a real flag-on live session; keep #44235 gated pending more mileage and triage of the child trace-id contract.

@claw3649 claw3649 closed this Jun 11, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

comp/agent Core agent runtime: loop, agent_init, prompt builder, context-compression, responses endpoint comp/tui Terminal UI (ui-tui/ + tui_gateway/) P3 Low — cosmetic, nice to have tool/delegate Subagent delegation type/refactor Code restructuring, no behavior change

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants