Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Agent.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -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 路径不受影响)
Expand Down
77 changes: 77 additions & 0 deletions emrg/server/daemon.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -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).
Expand DownExpand Up@@ -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
Expand DownExpand Up@@ -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 "
Expand All@@ -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.

Expand DownExpand Up@@ -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).

Expand Down
84 changes: 84 additions & 0 deletions tests/test_daemon.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -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
Expand DownExpand Up@@ -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.

Expand Down
Loading