diff --git a/Agent.md b/Agent.md index 02ff918f..3cf6abb8 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` (1096) — import check: `uv run python -c "from emrg.client.app import run_client"` +Python: `uv run pytest tests/ -v` (1099) — 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` (78 vitest: 5 snapshot-store + 9 utils + 3 ErrorBoundary + 2 App smoke + 11 commands + 4 copywriting + 11 i18n + 11 markdown + 15 transcript + 7 TranscriptView) + `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 58ac37b2..41b924e6 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-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 + # the provider re-anchors (countable metric, not just a log line). + self._missing_anchor_est: dict[str, tuple[int, str]] = {} # Rant 2026-08-23T13:54:14: per-session dynamic-context snapshot # (session_id → (text, injected_ms)) so unchanged context is not # re-sent (context_refresh_interval_ms gating). @@ -2586,6 +2591,7 @@ async def _run_tool_loop( self._usage_anchors[session.session_id] = ( pt, self._estimate_tokens(messages) ) + self._record_anchor_drift(session, pt) full_content = "".join(content_parts) # llm.py yields the ACCUMULATED reasoning snapshot on every chunk @@ -2986,6 +2992,17 @@ def _warn_missing_usage_anchor( 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) + loss_ts = datetime.now().astimezone().isoformat() + self._missing_anchor_est[session.session_id] = (estimated, loss_ts) + try: + _append_usage_anchor_event({ + "type": "anchor_loss", + "timestamp": loss_ts, + "session": session.session_id, + "est": estimated, + }) + except OSError as exc: + logger.warning("usage-anchor stats append failed: %s", exc) logger.warning( "auto-compact: usage anchor missing for established session %s " "(est=%d) — provider not returning prompt_tokens; gate is " @@ -2994,6 +3011,33 @@ def _warn_missing_usage_anchor( session.session_id, estimated, ) + def _record_anchor_drift(self, session, real_pt: int) -> None: + """Measure estimator drift after an anchor-loss window (community + feedback 2026-08-26T07:18:29 — heinrichneb: the post-compact + estimator-only round's worst case must be measured, not assumed). + + When a session that lost its usage anchor finally re-anchors on a + provider-reported prompt_tokens, append an anchor_drift event + (est_at_loss, real_after, delta) so the estimator's drift — the + #946 148K-vs-222K failure mode — is countable over time instead + of anecdotal. One measurement per loss window. + """ + stored = self._missing_anchor_est.pop(session.session_id, None) + if not stored: + return + est_before, loss_ts = stored + try: + _append_usage_anchor_event({ + "type": "anchor_drift", + "session": session.session_id, + "est_at_loss": est_before, + "real_after": real_pt, + "delta": real_pt - est_before, + "loss_ts": loss_ts, + }) + except OSError as exc: + logger.warning("usage-anchor stats append failed: %s", exc) + def _estimate_tokens(self, messages: list[dict]) -> int: """Rough token estimation from OpenAI-format messages. @@ -4185,6 +4229,39 @@ def _write_exit_record(reason: str, exit_code: int, traceback_text: str | None) logger.warning("failed to write exit record to %s: %s", _EXIT_RECORD_PATH, exc) +_USAGE_ANCHOR_STATS_PATH = Path.home() / ".emrg" / "logs" / "usage-anchor.jsonl" + + +def _append_usage_anchor_event(record: dict, path: Path | None = None) -> int: + """Append a countable usage-anchor event (one JSON line) and return the + cumulative total (community feedback 2026-08-26T07:18:29 — heinrichneb: + the fail-LOUD warning must land somewhere countable, not just a log line + that can go missing; an append-only file survives restarts and log + rotation, unlike the per-process in-memory dedupe set). + + ``total`` is the cumulative event count, so the metric is readable as a + plain number without parsing every line. Callers swallow OSError — a + stats write must never take the daemon down. + """ + path = path or _USAGE_ANCHOR_STATS_PATH + total = 0 + try: + with open(path, "r", encoding="utf-8") as fh: + total = sum(1 for _ in fh) + except OSError: + pass # first event / unreadable file — start the count at 0 + try: + path.parent.mkdir(parents=True, exist_ok=True) + record = {**record} + record.setdefault("timestamp", datetime.now().astimezone().isoformat()) + record["total"] = total + 1 + with open(path, "a", encoding="utf-8") as fh: + fh.write(json.dumps(record, ensure_ascii=False) + "\n") + except OSError: + raise + return total + 1 + + def _asyncio_exception_handler(loop, context) -> None: """Route background-task crashes into emrgd.log (rant 2026-08-25T09:25:32). diff --git a/tests/test_daemon.py b/tests/test_daemon.py index d97d269c..d18cc325 100644 --- a/tests/test_daemon.py +++ b/tests/test_daemon.py @@ -19,6 +19,7 @@ from emrg.config import LlmConfig from emrg.protocol import InstanceIdentity +from emrg.server import daemon as daemon_mod from emrg.server.daemon import EmrgServer from emrg.server.scheduler import TaskHandler, TaskScheduler from emrg.session import Session @@ -601,6 +602,89 @@ def test_manual_compact_drop_marks_anchor(caplog): assert sid not in server._usage_anchor_dropped_by_compact # consumed +# ── usage-anchor countable stats (community feedback 2026-08-26T07:18:29) ── + + +def test_usage_anchor_loss_event_is_countable(tmp_path, monkeypatch): + """The fail-LOUD anchor-loss warning must land somewhere countable — a + persistent JSONL metric with a cumulative total, not just a log line + (heinrichneb: "does it also land somewhere countable (a metric, not just + a line)? We once had errors that produced an EMPTY log"). One event per + session, totals cumulative across sessions.""" + stats = tmp_path / "usage-anchor.jsonl" + monkeypatch.setattr(daemon_mod, "_USAGE_ANCHOR_STATS_PATH", stats) + server = _make_server() + messages = [ + {"role": "system", "content": "sys"}, + {"role": "user", "content": "hi"}, + {"role": "assistant", "content": "hello"}, + {"role": "user", "content": "more"}, + ] + server._warn_missing_usage_anchor(_SidSession("s1"), messages, 50_000) + server._warn_missing_usage_anchor(_SidSession("s2"), messages, 60_000) + # Same session again — warn-once semantics, no second event + server._warn_missing_usage_anchor(_SidSession("s1"), messages, 70_000) + + lines = stats.read_text(encoding="utf-8").strip().splitlines() + assert len(lines) == 2 + e1, e2 = json.loads(lines[0]), json.loads(lines[1]) + assert e1["type"] == "anchor_loss" and e1["session"] == "s1" + assert e1["total"] == 1 and "timestamp" in e1 + assert e2["session"] == "s2" and e2["est"] == 60_000 and e2["total"] == 2 + + +def test_usage_anchor_stats_survive_restart(tmp_path, monkeypatch): + """The metric must survive daemon restarts: the in-memory dedupe re-arms, + but the append-only file keeps counting — the whole point of countable.""" + stats = tmp_path / "usage-anchor.jsonl" + monkeypatch.setattr(daemon_mod, "_USAGE_ANCHOR_STATS_PATH", stats) + messages = [ + {"role": "assistant", "content": "x"}, + {"role": "user", "content": "y"}, + ] + # "first daemon run" + server1 = _make_server() + server1._warn_missing_usage_anchor(_SidSession("a"), messages, 10_000) + # "daemon restarted" — fresh server, same stats file + server2 = _make_server() + server2._warn_missing_usage_anchor(_SidSession("b"), messages, 20_000) + + lines = stats.read_text(encoding="utf-8").strip().splitlines() + assert len(lines) == 2 + assert json.loads(lines[1])["total"] == 2 + + +def test_usage_anchor_drift_measured_on_reanchor(tmp_path, monkeypatch): + """The estimator-only window's drift must be measured, not assumed: when a + warned session finally re-anchors on provider-reported prompt_tokens, + record est_at_loss vs real_after (the #946 148K-vs-222K failure mode).""" + stats = tmp_path / "usage-anchor.jsonl" + monkeypatch.setattr(daemon_mod, "_USAGE_ANCHOR_STATS_PATH", stats) + server = _make_server() + sid = "drift-test" + messages = [ + {"role": "assistant", "content": "x"}, + {"role": "user", "content": "y"}, + ] + server._warn_missing_usage_anchor(_SidSession(sid), messages, 100_000) + # Provider returns real prompt_tokens on a later round -> re-anchor + server._record_anchor_drift(_SidSession(sid), 180_000) + + lines = stats.read_text(encoding="utf-8").strip().splitlines() + assert len(lines) == 2 + loss, drift = json.loads(lines[0]), json.loads(lines[1]) + assert loss["type"] == "anchor_loss" + assert drift["type"] == "anchor_drift" + assert drift["est_at_loss"] == 100_000 + assert drift["real_after"] == 180_000 + assert drift["delta"] == 80_000 + assert drift["loss_ts"] == loss["timestamp"] + + # Re-anchor measured once — a second call (no pending loss) is a no-op + server._record_anchor_drift(_SidSession(sid), 200_000) + assert len(stats.read_text(encoding="utf-8").strip().splitlines()) == 2 + + def test_context_message_injection_format(tmp_path): """Context message carries tz-aware time and lands before the user prompt.