From 3fee8f9b1c45d8b441a14a3226f155d4ea574668 Mon Sep 17 00:00:00 2001 From: EMRG Evolution Date: Wed, 26 Aug 2026 18:44:47 +0800 Subject: [PATCH] =?UTF-8?q?emrg:=20daemon=20=E2=80=94=20invalidate=20usage?= =?UTF-8?q?=20anchor=20on=20mid-session=20model/provider=20switch?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dev.to reader feedback (comment 3dh3g on post 3, tracked as issue #1000): the usage anchor is keyed by session_id only, so a mid-session model switch projected the OLD provider's real prompt_tokens base + the NEW estimate delta — a mixed base that can silently miss the auto-compact gate across providers (the #946 failure mode). Fix: _handle_set_model invalidates all usage anchors (and any pending anchor-drift window) when the API model actually changes, and marks sessions so the fail-LOUD missing-anchor warning treats the switch round as a legitimate re-anchor round (next response re-anchors from the new provider's real prompt_tokens). +4 tests; Agent.md Python count 1102 -> 1106. --- Agent.md | 2 +- emrg/server/daemon.py | 36 +++++++++++++++++++ tests/test_daemon.py | 80 +++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 117 insertions(+), 1 deletion(-) diff --git a/Agent.md b/Agent.md index 402e09c4..da36b5cb 100644 --- a/Agent.md +++ b/Agent.md @@ -119,7 +119,7 @@ Community needs voiced in HN agent-UI discussions map directly to EMRG's design: pkill -f "emrg.server"; rm -f ~/.emrg/emrgd.token; python -m emrg ``` -Python: `uv run pytest tests/ -v` (1102) — import check: `uv run python -c "from emrg.client.app import run_client"` +Python: `uv run pytest tests/ -v` (1106) — import check: `uv run python -c "from emrg.client.app import run_client"` GUI: `cd emrg/gui && npm test` (265: 45 daemon_client + 20 conn-manager + 22 app-commands + 132 renderer smoke + 15 i18n + 8 integration + 3 commands + 8 build-config + 7 gui-state + 2 tool-group + 3 preload-api) — syntax: `node --check main.js preload.js daemon_client.js renderer/js/*.js`; renderer React suite: `cd emrg/gui/renderer && npm run typecheck && npm test` (151 vitest: 5 snapshot-store + 9 utils + 3 ErrorBoundary + 2 App smoke + 11 commands + 4 copywriting + 11 i18n + 11 markdown + 15 transcript + 7 TranscriptView + 15 history + 22 composer + 14 Composer + 12 sidebar + 10 Sidebar) + `npm run build` → `renderer/dist/` CI: `uv run pytest` (ubuntu + **windows-2025 matrix** — Windows pytest 回归在 PR CI 即失败,v0.2.29 教训 #725) + GUI tests + **actionlint workflow lint** (`rhysd/actionlint@v1.7.12` gate, #444 — workflow 解析错误在 PR CI 即失败,如 `if:` secrets 上下文) Re-trigger: `scripts/re-trigger-ci.sh [branch]` (workflow_dispatch, #527 — 替代空 commit 重触发:Actions outage 会整段丢弃 push 事件,dispatch 走 API 路径不受影响) diff --git a/emrg/server/daemon.py b/emrg/server/daemon.py index 41b924e6..288331ae 100644 --- a/emrg/server/daemon.py +++ b/emrg/server/daemon.py @@ -250,6 +250,11 @@ def __init__(self, llm_config: LlmConfig) -> None: # (warn once per session, no per-round spam). self._usage_anchor_dropped_by_compact: set[str] = set() self._warned_missing_usage_anchor: set[str] = set() + # Community feedback 2026-08-26T18:21:30 (Dev.to comment 3dh3g): + # sessions whose usage anchor was deliberately invalidated by a + # mid-session model/provider switch — the next anchor-less round is a + # legitimate re-anchor round, not a silent provider usage-loss. + self._usage_anchor_dropped_by_switch: set[str] = set() # Community feedback 2026-08-26T07:18:29: session_id -> # (estimated_tokens, iso_ts) at anchor-loss warning time, so the # estimator drift of the anchor-less window can be measured when @@ -2965,6 +2970,24 @@ def _count_chars_for_tokens(text: str) -> int: ascii_chars += 1 return (cjk // 2) + (ascii_chars // 4) + def _invalidate_usage_anchors_on_switch(self) -> None: + """Drop every usage anchor when the active model/provider changes. + + Community feedback 2026-08-26T18:21:30 (Dev.to comment 3dh3g): the + anchor is keyed by session_id only, so a mid-session model switch + would project the OLD provider's real prompt_tokens + the NEW + estimate delta — a mixed base that can silently miss the auto-compact + gate across providers (the #946 failure mode). Invalidate anchors and + any pending drift window, and mark sessions so the fail-LOUD + missing-anchor warning treats the switch round as a legitimate + re-anchor round (the next response re-anchors from the new + provider's real prompt_tokens). + """ + switched = set(self._usage_anchors) + self._usage_anchors.clear() + self._missing_anchor_est.clear() + self._usage_anchor_dropped_by_switch.update(switched) + def _warn_missing_usage_anchor( self, session, messages: list[dict], estimated: int ) -> None: @@ -2989,6 +3012,13 @@ def _warn_missing_usage_anchor( if session.session_id in self._usage_anchor_dropped_by_compact: self._usage_anchor_dropped_by_compact.discard(session.session_id) return + # Model/provider switch round: anchor deliberately invalidated by + # _invalidate_usage_anchors_on_switch (Dev.to 3dh3g) — consume the + # marker so the NEXT anchor-less round warns if the new provider is + # also silent. + if session.session_id in self._usage_anchor_dropped_by_switch: + self._usage_anchor_dropped_by_switch.discard(session.session_id) + return if session.session_id in self._warned_missing_usage_anchor: return # already warned once for this session self._warned_missing_usage_anchor.add(session.session_id) @@ -3516,6 +3546,12 @@ async def _handle_set_model( break self.llm.config.model = api_model + if api_model != old_model: + # Community feedback 2026-08-26T18:21:30 (Dev.to 3dh3g): a real + # API model change invalidates every usage anchor — the base held + # the previous provider's real prompt_tokens, which would mix + # with the new estimate delta on the next auto-compact projection. + self._invalidate_usage_anchors_on_switch() if new_ctx is not None: self.llm.config.context_window = new_ctx if new_vision is not None: diff --git a/tests/test_daemon.py b/tests/test_daemon.py index d18cc325..b7087c6c 100644 --- a/tests/test_daemon.py +++ b/tests/test_daemon.py @@ -602,6 +602,86 @@ def test_manual_compact_drop_marks_anchor(caplog): assert sid not in server._usage_anchor_dropped_by_compact # consumed +# ── model/provider switch invalidates anchors (Dev.to comment 3dh3g) ── + + +def test_invalidate_usage_anchors_on_switch(): + """A mid-session model switch must drop every usage anchor: the anchor + holds the OLD provider's real prompt_tokens; projecting old base + new + estimate delta mixes bases and can silently miss the auto-compact gate + (community feedback 2026-08-26T18:21:30, Dev.to comment 3dh3g).""" + server = _make_server() + server._usage_anchors["s1"] = (222_000, 148_000) + server._usage_anchors["s2"] = (100_000, 60_000) + server._missing_anchor_est["s1"] = (150_000, "2026-08-26T00:00:00+08:00") + + server._invalidate_usage_anchors_on_switch() + + assert server._usage_anchors == {} + assert server._missing_anchor_est == {} + # Only sessions that held an anchor are marked for the re-anchor pass + assert server._usage_anchor_dropped_by_switch == {"s1", "s2"} + + +def test_switch_round_does_not_warn_false_positive(caplog): + """The anchor-less round right after a model switch is legitimate — the + fail-LOUD warning must stay silent, and the marker is consumed so a + SECOND consecutive anchor-less round (new provider also silent) warns.""" + server = _make_server() + sid = "switch-test" + server._usage_anchors[sid] = (222_000, 148_000) + messages = [ + {"role": "assistant", "content": "x"}, + {"role": "user", "content": "y"}, + ] + server._invalidate_usage_anchors_on_switch() + assert server._usage_anchors == {} + assert sid in server._usage_anchor_dropped_by_switch + + with caplog.at_level("WARNING", logger="emrg.server.daemon"): + server._warn_missing_usage_anchor(_SidSession(sid), messages, 100) + assert "usage anchor missing" not in caplog.text + assert sid not in server._usage_anchor_dropped_by_switch # consumed + + # New provider still silent -> the next anchor-less round now warns + caplog.clear() + with caplog.at_level("WARNING", logger="emrg.server.daemon"): + server._warn_missing_usage_anchor(_SidSession(sid), messages, 200) + assert "usage anchor missing for established session" in caplog.text + + +def test_handle_set_model_invalidates_usage_anchors(): + """set_model with a REAL api-model change must invalidate usage anchors — + a mid-session switch must not project the old provider's base with the + new estimate delta (Dev.to comment 3dh3g).""" + import asyncio + server = _make_server() + server._usage_anchors["s1"] = (222_000, 148_000) + server._usage_anchors["s2"] = (100_000, 60_000) + writer = _FakeWriter() + new_name = server.llm.config.model + "-switched" + + asyncio.run(server._handle_set_model(new_name, writer)) + + assert server._usage_anchors == {} + assert server._usage_anchor_dropped_by_switch == {"s1", "s2"} + + +def test_handle_set_model_same_api_model_keeps_anchors(): + """Re-selecting the SAME api model (e.g. display alias) must NOT + invalidate anchors — the provider/tokenizer is unchanged.""" + import asyncio + server = _make_server() + server._usage_anchors["s1"] = (222_000, 148_000) + writer = _FakeWriter() + same = server.llm.config.model + + asyncio.run(server._handle_set_model(same, writer)) + + assert server._usage_anchors == {"s1": (222_000, 148_000)} + assert server._usage_anchor_dropped_by_switch == set() + + # ── usage-anchor countable stats (community feedback 2026-08-26T07:18:29) ──