fix(kanban): dashboard estimate calls declare a relay-affinity key too
The dashboard's estimate endpoints make the same headless auxiliary call as specify/decompose but never bound an affinity scope, so they still sent no x-opencode-session and the OpenCode Go relay answered 400 MissingSessionID (#112043). Declare kanban:<task_id> for an existing task and a stable kanban:estimate key for the create dialog (no task yet), unless a scope is already bound. Test: _run_estimate captured header None before; now kanban:t_1 / kanban:estimate and nothing leaks past the call.
This commit is contained in:
@@ -1019,7 +1019,7 @@ class EstimateBody(BaseModel):
|
||||
@router.post("/estimate")
|
||||
def estimate_text_endpoint(payload: EstimateBody):
|
||||
"""Estimate from raw title/body (create dialog, before a task exists)."""
|
||||
return _run_estimate(payload.title, payload.body)
|
||||
return _run_estimate(payload.title, payload.body, task_id=None)
|
||||
|
||||
|
||||
@router.post("/tasks/{task_id}/estimate")
|
||||
@@ -1027,7 +1027,7 @@ def estimate_task_endpoint(task_id: str, board: Optional[str] = Query(None)):
|
||||
"""Estimate for an existing task; ``{ok, est_tokens, complexity, rationale, model}``."""
|
||||
with _board_conn(board) as (board, conn):
|
||||
task = _require_task(conn, task_id)
|
||||
return _run_estimate(task.title, task.body)
|
||||
return _run_estimate(task.title, task.body, task_id=task_id)
|
||||
|
||||
|
||||
def _cap(s: Optional[str], n: int) -> str:
|
||||
@@ -1035,7 +1035,7 @@ def _cap(s: Optional[str], n: int) -> str:
|
||||
return s if len(s) <= n else s[:n] + "…"
|
||||
|
||||
|
||||
def _run_estimate(title: str, body: Optional[str]) -> dict:
|
||||
def _run_estimate(title: str, body: Optional[str], *, task_id: Optional[str]) -> dict:
|
||||
"""Never raises — config/parse/API errors become ``{"ok": False, "reason"}`` so the UI renders them inline."""
|
||||
if not (title or "").strip():
|
||||
return {"ok": False, "reason": "a title is required to estimate"}
|
||||
@@ -1044,6 +1044,11 @@ def _run_estimate(title: str, body: Optional[str]) -> dict:
|
||||
except Exception:
|
||||
return {"ok": False, "reason": "auxiliary client unavailable"}
|
||||
user_msg = f"Title: {_cap(title, 400)}\n\nDescription:\n{_cap(body, 4000) or '(none)'}"
|
||||
# Headless like specify/decompose's _call_aux: without a bound affinity scope the relay-affinity
|
||||
# headers are omitted and the OpenCode Go relay answers 400 MissingSessionID (#112043). The
|
||||
# create dialog has no task yet, so it shares one stable key.
|
||||
from agent.portal_tags import get_affinity_scope, reset_affinity_scope, set_affinity_scope
|
||||
affinity_token = None if get_affinity_scope() else set_affinity_scope(f"kanban:{task_id or 'estimate'}")
|
||||
try:
|
||||
resp = call_llm(
|
||||
task="kanban_estimator",
|
||||
@@ -1051,6 +1056,9 @@ def _run_estimate(title: str, body: Optional[str]) -> dict:
|
||||
temperature=0.0, max_tokens=300, timeout=60)
|
||||
except Exception as exc:
|
||||
return {"ok": False, "reason": f"LLM error: {type(exc).__name__}"}
|
||||
finally:
|
||||
if affinity_token is not None:
|
||||
reset_affinity_scope(affinity_token)
|
||||
try:
|
||||
raw = (resp.choices[0].message.content or "").strip()
|
||||
model = getattr(resp, "model", None)
|
||||
|
||||
@@ -4,6 +4,9 @@ relay-affinity key — the OpenCode Go relay rejects a request without ``x-openc
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import patch
|
||||
|
||||
@@ -46,3 +49,27 @@ def test_in_turn_caller_keeps_its_declared_affinity_key():
|
||||
finally:
|
||||
reset_affinity_scope(token)
|
||||
assert seen == ["conversation-root"]
|
||||
|
||||
|
||||
def _dashboard_plugin_api():
|
||||
mod_name = "hermes_dashboard_plugin_kanban_aux_affinity_test"
|
||||
if mod_name not in sys.modules:
|
||||
plugin_file = Path(__file__).resolve().parents[2] / "plugins" / "kanban" / "dashboard" / "plugin_api.py"
|
||||
spec = importlib.util.spec_from_file_location(mod_name, plugin_file)
|
||||
mod = importlib.util.module_from_spec(spec)
|
||||
sys.modules[mod_name] = mod
|
||||
spec.loader.exec_module(mod)
|
||||
return sys.modules[mod_name]
|
||||
|
||||
|
||||
def test_dashboard_estimate_declares_an_affinity_key_too():
|
||||
"""The dashboard estimate endpoints are the same headless aux call: per task when one exists,
|
||||
one stable key for the create dialog (no task yet)."""
|
||||
from agent.portal_tags import get_affinity_scope
|
||||
api = _dashboard_plugin_api()
|
||||
seen: list = []
|
||||
with patch("agent.auxiliary_client.call_llm", _capturing_call_llm(seen)):
|
||||
api._run_estimate("title", "body", task_id="t_1")
|
||||
api._run_estimate("title", "body", task_id=None)
|
||||
assert seen == ["kanban:t_1", "kanban:estimate"]
|
||||
assert get_affinity_scope() is None
|
||||
|
||||
@@ -61,7 +61,7 @@ You need at least one way to connect to an LLM. Use `hermes model` to switch pro
|
||||
| **LM Studio** | `hermes model` → "LM Studio" (provider: `lmstudio`, optional `LM_API_KEY`) |
|
||||
| **Custom Endpoint** | `hermes model` → choose "Custom endpoint" (saved in `config.yaml`) |
|
||||
|
||||
All three OpenCode providers send an opaque, per-conversation `x-opencode-session` header on every request (main turns on every transport plus auxiliary calls such as compression and titles; headless Kanban `specify`/`decompose` calls use a per-task key). OpenCode uses it to pin a conversation to one backend so its prompt cache stays warm; the value is derived from the Hermes session id (or the Kanban task id) and carries no personal data.
|
||||
All three OpenCode providers send an opaque, per-conversation `x-opencode-session` header on every request (main turns on every transport plus auxiliary calls such as compression and titles; headless Kanban `specify`/`decompose` and dashboard estimate calls use a per-task key). OpenCode uses it to pin a conversation to one backend so its prompt cache stays warm; the value is derived from the Hermes session id (or the Kanban task id) and carries no personal data.
|
||||
|
||||
For the official API-key path, see the dedicated [Google Gemini guide](/guides/google-gemini).
|
||||
|
||||
|
||||
Reference in New Issue
Block a user