From 6cd937249ba2ff4977dd96e75a124f7a5c72e377 Mon Sep 17 00:00:00 2001 From: h Date: Thu, 17 Sep 2026 04:11:08 +0000 Subject: [PATCH] feat(memory): the morning seed carries the process start and the code freshness --- beaver_agent/memory/handouts.py | 70 ++++++++++++++++++++++++++++++-- tests/test_handouts.py | 72 +++++++++++++++++++++++++++++++++ 2 files changed, 139 insertions(+), 3 deletions(-) create mode 100644 tests/test_handouts.py diff --git a/beaver_agent/memory/handouts.py b/beaver_agent/memory/handouts.py index 7a9f18c..4865197 100644 --- a/beaver_agent/memory/handouts.py +++ b/beaver_agent/memory/handouts.py @@ -2,6 +2,9 @@ from __future__ import annotations +import time +from datetime import datetime +from pathlib import Path from typing import TYPE_CHECKING from beaver_gateway.frontends.turn_record import slugify @@ -15,12 +18,11 @@ from beaver_agent.vault import ( PORTRAIT, STATE, VAULT, + ZONE, read, ) if TYPE_CHECKING: - from pathlib import Path - from beaver_gateway.conversations.distill import DistillContext from beaver_gateway.conversations.rotation import HandoutContext from beaver_gateway.conversations.texts import NewDayContext, SeedContext @@ -32,8 +34,67 @@ def latest_handout() -> Path | None: return files[-1] if files else None +PROC_INIT = Path("/proc/1") +"""mtime PID 1 - момент старта контейнера, проверено в бою.""" +PACKAGE = Path(__file__).resolve().parent.parent +"""Корень пакета: свежесть кода меряем от себя, а не от пути в контейнере.""" + +DEPLOY_MARK = "Выкат: процесс поднят {start} ({ago}), свежесть кода {code} - {verdict}." +CODE_OLDER = "код старше старта, значит крутится то, что выкатывали" +CODE_NEWER = "код НОВЕЕ старта: правка на диске есть, рестарта после неё не было" +SAME_MINUTE = "выкат и правка в одну минуту" +HOUR = 3600 + + +def _ago(seconds: float) -> str: + """Целыми часами, а меньше часа - минутами.""" + if seconds < HOUR: + return f"{int(seconds // 60)} мин назад" + return f"{int(seconds // HOUR)} ч назад" + + +def _clock(stamp: float) -> str: + return f"{datetime.fromtimestamp(stamp, ZONE):%Y-%m-%d %H:%M}" + + +def deploy_mark(proc: Path = PROC_INIT, package: Path = PACKAGE) -> str | None: + """Когда подняли процесс и насколько свеж код, который в нём крутится. + + Две цифры рядом: mtime PID 1 - старт контейнера, последний изменённый + `*.py` пакета - свежесть исходников. Порознь каждая врёт: код бывает + новее старта, и тогда правка на диске есть, а рестарта после неё не было. + Любая осечка (нет `/proc`, нет прав, пустой пакет) - None: сид не имеет + права упасть из-за этой отметки. + """ + try: + started = proc.stat().st_mtime + sources = [ + path.stat().st_mtime + for path in package.rglob("*.py") + if "__pycache__" not in path.parts + ] + if not sources: + return None + code = max(sources) + gap = code - started + if abs(gap) < 60: + verdict = SAME_MINUTE + elif gap > 0: + verdict = CODE_NEWER + else: + verdict = CODE_OLDER + return DEPLOY_MARK.format( + start=_clock(started), + ago=_ago(time.time() - started), + code=_clock(code), + verdict=verdict, + ) + except (OSError, ValueError): + return None + + def seed_body(ctx: SeedContext) -> str | None: - """Утренний сид: последний хендаут, шапка состояния, портрет Бобра.""" + """Утренний сид: хендаут, шапка состояния, портрет Бобра, отметка о выкате.""" if ctx.seed != "morning": return None handout = latest_handout() @@ -54,6 +115,9 @@ def seed_body(ctx: SeedContext) -> str | None: "Портрет Бобра (шапка " f"`{PORTRAIT.relative_to(VAULT)}`, лента - там же):\n{portrait}" ) + mark = deploy_mark() + if mark: + parts.append(mark) return "\n\n".join(parts) diff --git a/tests/test_handouts.py b/tests/test_handouts.py new file mode 100644 index 0000000..6853ed4 --- /dev/null +++ b/tests/test_handouts.py @@ -0,0 +1,72 @@ +import os +from datetime import datetime +from pathlib import Path +from zoneinfo import ZoneInfo + +import pytest + +from beaver_agent.memory import handouts +from beaver_agent.memory.handouts import deploy_mark + +ZONE = ZoneInfo("Europe/Warsaw") +NOW = "2026-09-17 06:30" + + +def _at(text: str) -> float: + return datetime.fromisoformat(text).replace(tzinfo=ZONE).timestamp() + + +def _touch(path: Path, when: str) -> Path: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text("", encoding="utf-8") + os.utime(path, (_at(when), _at(when))) + return path + + +def _bundle(tmp_path: Path, started: str, code: str) -> tuple[Path, Path]: + proc = _touch(tmp_path / "proc" / "1", started) + package = tmp_path / "beaver_agent" + _touch(package / "memory" / "handouts.py", code) + _touch(package / "vault.py", "2026-09-10 12:00") + # мусор компилятора не должен выглядеть свежим кодом + _touch(package / "memory" / "__pycache__" / "handouts.py", "2026-09-17 06:00") + return proc, package + + +@pytest.fixture(autouse=True) +def _now(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(handouts.time, "time", lambda: _at(NOW)) + + +def test_code_older_than_start(tmp_path: Path) -> None: + proc, package = _bundle(tmp_path, "2026-09-16 03:11", "2026-09-16 03:03") + assert deploy_mark(proc, package) == ( + "Выкат: процесс поднят 2026-09-16 03:11 (27 ч назад), свежесть кода " + "2026-09-16 03:03 - код старше старта, значит крутится то, что выкатывали." + ) + + +def test_code_newer_than_start(tmp_path: Path) -> None: + proc, package = _bundle(tmp_path, "2026-09-16 03:11", "2026-09-17 06:20") + assert deploy_mark(proc, package) == ( + "Выкат: процесс поднят 2026-09-16 03:11 (27 ч назад), свежесть кода " + "2026-09-17 06:20 - код НОВЕЕ старта: правка на диске есть, " + "рестарта после неё не было." + ) + + +def test_same_minute(tmp_path: Path) -> None: + proc, package = _bundle(tmp_path, "2026-09-17 06:00", "2026-09-17 06:00:30") + assert deploy_mark(proc, package) == ( + "Выкат: процесс поднят 2026-09-17 06:00 (30 мин назад), свежесть кода " + "2026-09-17 06:00 - выкат и правка в одну минуту." + ) + + +def test_no_source(tmp_path: Path) -> None: + proc, package = _bundle(tmp_path, "2026-09-16 03:11", "2026-09-16 03:03") + assert deploy_mark(tmp_path / "нет", package) is None + assert deploy_mark(proc, tmp_path / "пусто") is None + only_cache = tmp_path / "кеш" + _touch(only_cache / "__pycache__" / "handouts.py", "2026-09-17 06:00") + assert deploy_mark(proc, only_cache) is None