Skip to content

fix(runtime): harden the ACP/Hermes runtime path — /v1 switch, instance pinning, serialization, context usage#1891

Merged
protoquinn[bot] merged 1 commit into
mainfrom
feat/hermes-runtime-hardening
Jul 7, 2026
Merged

fix(runtime): harden the ACP/Hermes runtime path — /v1 switch, instance pinning, serialization, context usage#1891
protoquinn[bot] merged 1 commit into
mainfrom
feat/hermes-runtime-hardening

Conversation

@mabry1985

@mabry1985 mabry1985 commented Jul 7, 2026

Copy link
Copy Markdown
Contributor

What

Hardening batch on top of #1889 (protoagent hermes preset), all four fixes validated against a live acp:hermes server (isolated instance, gateway-less — Hermes as the only brain).

Fixes

  1. Non-streaming turns now honor agent_runtime — the OpenAI-compat /v1 endpoint, desktop /api/chat fallback, and internal self-prompts silently ran the native loop under an acp:* config. A gateway-less ACP-only setup (exactly the Hermes-preset persona) can't serve that path at all. New _acp_turn_collected mirrors the streaming switch and folds frames into the single-message shape (+ OpenAI-shape usage). Live: /v1/chat/completions answered on an instance with api_key_configured: false.
  2. Operator MCP child pinned to the resolved instance root — the ACP agent spawns the MCP server with a replaced env; only PROTOAGENT_INSTANCE was forwarded, so a PROTOAGENT_HOME-scoped instance's child booted the default stores. Found live: the smoke agent's task_create landed on the operator's prod board. Now carries resolved PROTOAGENT_HOME + PROTOAGENT_BOX_ROOT. (Leaked test row removed; store integrity verified.)
  3. Per-thread serialization of ACP turnsAcpClient forbids concurrent prompts on one session; _acp_acquire refcount only guards eviction. Both switches now hold the same _thread_lock the native turns use (also excludes compact/rewind races).
  4. Context-pressure telemetry, honestly — ACP-native usage_update ({used, size}; hermes-acp sends one per response — live: 24891/256000) is recorded and surfaced on the usage frame as context_used_tokens/context_window_tokens. Tokens/cost stay 0: estimates must not pollute cost telemetry. Console can render a context ring off this (follow-up).
  5. Frozen sidecar spawn — new operator-mcp dispatch verb; the frozen entrypoint rejects -m server.operator_mcp argv (fix(fleet): frozen-sidecar member spawn + surface boot failures #1603's class). Source checkouts unchanged.
  6. CLI polishruntime use prints protoagent down && protoagent up when a live server still runs the old runtime.

Deep smoke (live, isolated instance on :7897)

Check Result
scripts/live_smoke.py PASSED
Boot acp:hermes, headless setup (ACP-only, no gateway key)
/api/chat non-streaming turn ✓ 200 in 10.5s (first turn, incl. MCP registration)
task_create → instance store round-trip ✓ (post-fix; pre-fix it leaked to the default box)
Multi-turn session statefulness ✓ recalled prior turn without tools
A2A SendStreamingMessage ✓ 9 frames, append/lastChunk, TASK_STATE_COMPLETED
Telemetry row model=acp:hermes, 0 cost, tool_calls=1
/v1/chat/completions ✓ (surface was broken pre-fix)
usage_update capture {used: 24891, size: 256000}

Gates: ruff ✓, import contracts 3/3 ✓, full suite 3415 passed.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Added support for the new operator-mcp command in the CLI.
    • Chat responses now include better usage/context information for ACP-based sessions.
  • Bug Fixes

    • Switching runtimes now gives a clearer hint when a server is already running, helping avoid confusion.
    • ACP chat handling now works more reliably in non-streaming mode and under concurrent session activity.
  • Refactor

    • Improved runtime environment handling so operator tools launch with the correct instance-specific settings.

…ce pinning, serialization, context usage (ADR 0033)

Four fixes surfaced by making Hermes a first-class runtime, all validated
against a live acp:hermes server:

- Non-streaming turns (OpenAI-compat /v1, desktop /api/chat fallback,
  internal self-prompts) now honor agent_runtime — they silently ran the
  native loop, which a gateway-less ACP-only setup (the Hermes preset
  persona) cannot serve. New _acp_turn_collected folds the frame stream
  into the single-message shape those callers expect.
- Operator MCP child env now pins the RESOLVED instance root
  (PROTOAGENT_HOME) + forwards PROTOAGENT_BOX_ROOT. The ACP agent spawns
  the MCP server with a replaced env, so a PROTOAGENT_HOME-scoped
  instance's child booted the DEFAULT stores — found live when a smoke
  agent's task_create landed on the operator's prod board.
- ACP turns are serialized per thread (_thread_lock) in both the streaming
  and non-streaming switches — AcpClient forbids concurrent prompts on one
  session; the acquire/release refcount only guarded eviction.
- ACP-native usage_update ({used, size} context pressure — hermes-acp
  sends it; live: 24891/256000) is recorded by AcpClient and surfaced on
  the usage frame as context_used_tokens/context_window_tokens. Tokens and
  cost stay 0 — estimates must not pollute cost telemetry.
- Frozen desktop sidecar: operator MCP now launches via a new
  `operator-mcp` dispatch verb (sys.executable rejects `-m
  server.operator_mcp` argv there — #1603's class).
- `protoagent runtime use` hints `down && up` when a live server still
  runs the previous runtime.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@protoquinn

protoquinn Bot commented Jul 7, 2026

Copy link
Copy Markdown

👀 Quinn is reviewing — verdict (PASS / WARN / FAIL) + findings to follow.

@coderabbitai

coderabbitai Bot commented Jul 7, 2026

Copy link
Copy Markdown

Review Change Stack

Walkthrough

This PR adds context-usage tracking through AcpClient and AcpRuntime, surfaces it as usage frames in server/chat.py's streaming and new non-streaming ACP paths, wraps ACP streaming in per-thread locking, propagates additional environment variables and frozen-dispatch behavior in operator_mcp_server_spec, adds a running-server check with restart hints to runtime/cli.py, and forwards a new operator-mcp verb in server/cli.py.

Changes

ACP Usage Tracking and Chat Integration

Layer / File(s) Summary
AcpClient usage tracking
plugins/coding_agent/acp_client.py
Adds last_usage field and parses usage_update session updates into {used, size}.
AcpRuntime usage accessor
runtime/acp_runtime.py
Adds last_usage() method returning the client's usage dict.
Streaming usage frame and thread locking
server/chat.py
Emits a usage frame with context token counts before done; wraps A2A streaming ACP drive-turn iteration in a per-thread lock.
Non-streaming ACP turn collection
server/chat.py, tests/test_acp_runtime.py
Adds _acp_turn_collected to collapse ACP frames into an OpenAI-style assistant message with usage, and routes _chat_langgraph_impl to it for acp:* runtimes.

Operator MCP Environment Propagation and CLI Updates

Layer / File(s) Summary
Operator MCP spec env/dispatch
runtime/acp_runtime.py, tests/test_acp_runtime.py
Propagates PROTOAGENT_HOME, PROTOAGENT_BOX_ROOT, PROTOAGENT_INSTANCE, OPERATOR_MCP_TOOLS; uses operator-mcp dispatch verb with sys.executable when frozen.
server CLI operator-mcp forwarding
server/cli.py, tests/test_cli.py
Forwards operator-mcp verb to server.operator_mcp:main and documents it in help.
Runtime CLI restart hint
runtime/cli.py, tests/test_runtime_cli.py
Adds _running_server_port() TCP probe and updates _cmd_use to hint a restart when a server is already running.

Estimated code review effort: 3 (Moderate) | ~25 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Client as Chat Client
  participant Chat as server/chat.py
  participant Runtime as AcpRuntime
  participant AcpClient as AcpClient

  Client->>Chat: chat request (acp runtime)
  Chat->>Runtime: acquire runtime + _acp_drive_turn
  Runtime->>AcpClient: run_turn / stream updates
  AcpClient-->>Runtime: usage_update (used, size)
  Runtime-->>Chat: last_usage()
  Chat->>Chat: build usage frame (context_used_tokens, context_window_tokens)
  Chat-->>Client: usage frame + done frame (or collected assistant message)
Loading

Possibly related PRs

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly summarizes the main ACP/Hermes runtime hardening work and mentions the key fixes without being vague.
Description check ✅ Passed The description is detailed and relevant, with a summary-like section and a strong test report, though it doesn't match the template headings exactly.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/hermes-runtime-hardening

Comment @coderabbitai help to get the list of available commands.

@protoquinn protoquinn Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

QA Audit — PR #1891 | fix(runtime): harden the ACP/Hermes runtime path — /v1 switch, instance pinning, serialization, context usage

VERDICT: WARN (pending CI — all 8 checks still settling; auto-promote to APPROVE on terminal-green)


CI Status

  • All 8 checks: queued / in_progress
  • Author reports local pass: ruff ✓, import contracts 3/3 ✓, 3415 tests passed
  • Live smoke (isolated acp:hermes on :7897): all 7 checks including /v1/chat/completions, task_create store isolation, A2A, multi-turn — PASSED

Diff Review (8 files, +299/-15)

  • server/chat.py: New _acp_turn_collected mirrors the streaming ACP switch for non-streaming /v1 and /api/chat callers — drives the turn under _thread_lock, collects frames, returns OpenAI-shape usage. The streaming path's usage frame now surfaces context_used_tokens/context_window_tokens when the agent reports ACP-native usage_update (hermes-acp does). Tokens/cost stay 0 — no pollution of cost telemetry.
  • runtime/acp_runtime.py: operator_mcp_server_spec now forwards PROTOAGENT_HOME + PROTOAGENT_BOX_ROOT to the child MCP, fixing a live-found instance-leak where a smoke agent's task_create landed on the operator's prod board. Frozen sidecar dispatch uses operator-mcp verb instead of -m server.operator_mcp argv. last_usage() accessor added.
  • plugins/coding_agent/acp_client.py: _handle_update now captures usage_update into self.last_usage dict with try/except guard.
  • runtime/cli.py: _cmd_use detects a live server on the old runtime and tells the user to protoagent down && protoagent up — good UX.

Observations

  • CLAWPATCH/MEDIUM: runtime/acp_runtime.py:44-45 — bare acp: config (empty agent after split) falls through as a valid ACP runtime with an empty agent string. Pre-existing, not introduced here; worth a follow-up validation guard.
  • CLAWPATCH/LOW: server/cli.py:107-112 — pidfile reader catches OSError/ValueError but not UnicodeDecodeError on corrupt pidfiles. Pre-existing.
  • CLAWPATCH/LOW: server/cli.py:115-120_pid_alive misreports running processes as dead on PermissionError. Pre-existing.
  • CLAWPATCH/LOW: runtime/acp_runtime.py:243,252-260 — aux ACP clients in _AUX_CLIENTS are never explicitly closed at shutdown. Pre-existing leak.
  • No blocking defects identified in the diff itself. All four core fixes are well-scoped, defensively coded, and validated against a live instance.

The approve-on-green policy will promote this to APPROVED automatically once every check reaches terminal-green — no further re-review needed on a clean CI pass.

— Quinn, QA Engineer

@protoquinn

protoquinn Bot commented Jul 7, 2026

Copy link
Copy Markdown

Submitted COMMENT review on #1891.

@protoquinn

protoquinn Bot commented Jul 7, 2026

Copy link
Copy Markdown

✅ CI went terminal-green with no blockers on the prior review — promoting it to APPROVED per the approve-on-green policy (#748).

@protoquinn protoquinn Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

CI terminal-green, no blockers on prior review — auto-approving on green (#748).

@protoquinn protoquinn Bot merged commit cdb3e37 into main Jul 7, 2026
9 of 10 checks passed
@protoquinn

protoquinn Bot commented Jul 7, 2026

Copy link
Copy Markdown

🔀 Approved and terminal-green, but GitHub's auto-merge never completed (phantom BLOCKED — ws-5sc); the reconciliation sweep completed the squash merge.

@protoquinn protoquinn Bot deleted the feat/hermes-runtime-hardening branch July 7, 2026 10:06

@protoreview protoreview Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

QA panel review — WARN

code-review-structural · head a890a3079285 · shadow — comment-only

[review-synthesizer completed: workflow code-review-structural:report]

Prose Brief

This PR carries low overall risk. The single confirmed finding — an unhandled AttributeError in _running_server_port when a pidfile contains valid JSON that isn't a dict — is a minor edge-case correctness gap. It's the one to fix first (and only). The panel had no disagreements; the two findings the structural pass (protopatch) flagged about workspace test coverage and the acp: edge case were both verified as genuine defects but dropped here because neither file is touched by PR #1891 — they're pre-existing issues, not regressions introduced by this change. Nothing in the diff raises blocking concerns.

Gap: The structural pass surfaced two real pre-existing defects outside the diff (untested workspace lifecycle, opaque acp: error message). They merit their own tracking tickets but are out of scope for this PR review.

[
  {
    "file": "runtime/cli.py",
    "line": 275,
    "severity": "minor",
    "category": "correctness",
    "claim": "`_running_server_port` catches (OSError, ValueError, TypeError) but not AttributeError — if the pid file contains valid JSON that is not a dict (e.g. a bare array `[]`), `rec.get(\"port\")` raises unhandled AttributeError instead of cleanly returning None, crashing `_cmd_use`.",
    "evidence": "The except clause on line 275: `except (OSError, ValueError, TypeError):` — but `json.loads` of `[]` returns a Python list, and calling `.get(\"port\")` on a list raises `AttributeError`, which is not a subclass of any of the three caught types.",
    "verdict": "confirmed",
    "note": "Read runtime/cli.py `_running_server_port()` from the PR head: try-block calls `rec.get(\"port\")` after `json.loads(...)`. If pidfile contains `[]`, `json.loads` returns a list; `[].get(\"port\")` → AttributeError, not caught by `except (OSError, ValueError, TypeError)`. Genuine gap, though low-probability (pidfile is server-authored as a dict)."
  }
]

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@runtime/cli.py`:
- Around line 274-278: The `server.pid` parsing in the `runtime use` path
assumes `json.loads` returns a dict, so a non-dict JSON value can make
`rec.get("port")` raise an uncaught `AttributeError`. Update the logic in the
`runtime/cli.py` code path that reads `instance_paths().instance_root /
"server.pid"` to validate that `rec` is a dict before accessing `"port"`, and
treat any non-dict or malformed content the same as the existing failure cases
by returning `None`. Keep the fix localized near the `json.loads` / `port`
extraction block.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 7876d4e2-a8fa-478b-b81a-bbc31e9609ea

📥 Commits

Reviewing files that changed from the base of the PR and between 844c7e5 and a890a30.

📒 Files selected for processing (8)
  • plugins/coding_agent/acp_client.py
  • runtime/acp_runtime.py
  • runtime/cli.py
  • server/chat.py
  • server/cli.py
  • tests/test_acp_runtime.py
  • tests/test_cli.py
  • tests/test_runtime_cli.py

Comment thread runtime/cli.py
Comment on lines +274 to +278
try:
rec = json.loads((instance_paths().instance_root / "server.pid").read_text(encoding="utf-8"))
port = int(rec.get("port") or 0)
except (OSError, ValueError, TypeError):
return None

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Guard against non-dict server.pid content.

rec.get("port") assumes rec is a dict. If the pidfile ever contains a non-dict JSON value (e.g. truncated/corrupted during a concurrent write by protoagent up), .get raises AttributeError, which isn't in the caught exception tuple, crashing runtime use instead of gracefully returning None.

🛡️ Proposed fix
-    try:
-        rec = json.loads((instance_paths().instance_root / "server.pid").read_text(encoding="utf-8"))
-        port = int(rec.get("port") or 0)
-    except (OSError, ValueError, TypeError):
+    try:
+        rec = json.loads((instance_paths().instance_root / "server.pid").read_text(encoding="utf-8"))
+        port = int(rec.get("port") or 0) if isinstance(rec, dict) else 0
+    except (OSError, ValueError, TypeError, AttributeError):
         return None
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
try:
rec = json.loads((instance_paths().instance_root / "server.pid").read_text(encoding="utf-8"))
port = int(rec.get("port") or 0)
except (OSError, ValueError, TypeError):
return None
try:
rec = json.loads((instance_paths().instance_root / "server.pid").read_text(encoding="utf-8"))
port = int(rec.get("port") or 0) if isinstance(rec, dict) else 0
except (OSError, ValueError, TypeError, AttributeError):
return None
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@runtime/cli.py` around lines 274 - 278, The `server.pid` parsing in the
`runtime use` path assumes `json.loads` returns a dict, so a non-dict JSON value
can make `rec.get("port")` raise an uncaught `AttributeError`. Update the logic
in the `runtime/cli.py` code path that reads `instance_paths().instance_root /
"server.pid"` to validate that `rec` is a dict before accessing `"port"`, and
treat any non-dict or malformed content the same as the existing failure cases
by returning `None`. Keep the fix localized near the `json.loads` / `port`
extraction block.

protoquinn Bot pushed a commit that referenced this pull request Jul 7, 2026
….96.0 cut (#1895)

Nine PRs merged since v0.95.1 shipped without a changelog entry, so the
generated release notes (updater + Discord, single-source from CHANGELOG) would
have omitted a headline feature and a security-relevant default change. Backfill
before rolling the release:

- Added: Hermes first-class runtime (#1889 + hardening #1891)
- Changed: filesystem.allow_run defaults OFF on the headless tier (#1888)
- Fixed: fleet members no longer inherit hub identity (#1886/#1882/#1881),
  whole trace on a dedicated Langfuse project (#1893), background knowledge
  ingest wakes the agent (#1887), quieter plugin hot-reload logs (#1885)

The already-present entries (#1880 tracing, #1890 plugin-view 401, #1879
subagent max_turns) are unchanged.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant