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:
teknium1
2026-09-15 14:59:07 -07:00
committed by Teknium
parent 1a8d922003
commit f7ea39481a
3 changed files with 39 additions and 4 deletions

View File

@@ -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)

View File

@@ -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

View File

@@ -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).