feat(memory): the morning seed carries the process start and the code freshness
This commit is contained in:
1 parent
ae0a16bc17
commit
6cd937249b
2 files changed
+139
-3
No files matched your search
@@ -2,6 +2,9 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import time
|
||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
from beaver_gateway.frontends.turn_record import slugify
|
from beaver_gateway.frontends.turn_record import slugify
|
||||||
@@ -15,12 +18,11 @@ from beaver_agent.vault import (
|
|||||||
PORTRAIT,
|
PORTRAIT,
|
||||||
STATE,
|
STATE,
|
||||||
VAULT,
|
VAULT,
|
||||||
|
ZONE,
|
||||||
read,
|
read,
|
||||||
)
|
)
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
from beaver_gateway.conversations.distill import DistillContext
|
from beaver_gateway.conversations.distill import DistillContext
|
||||||
from beaver_gateway.conversations.rotation import HandoutContext
|
from beaver_gateway.conversations.rotation import HandoutContext
|
||||||
from beaver_gateway.conversations.texts import NewDayContext, SeedContext
|
from beaver_gateway.conversations.texts import NewDayContext, SeedContext
|
||||||
@@ -32,8 +34,67 @@ def latest_handout() -> Path | None:
|
|||||||
return files[-1] if files else 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:
|
def seed_body(ctx: SeedContext) -> str | None:
|
||||||
"""Утренний сид: последний хендаут, шапка состояния, портрет Бобра."""
|
"""Утренний сид: хендаут, шапка состояния, портрет Бобра, отметка о выкате."""
|
||||||
if ctx.seed != "morning":
|
if ctx.seed != "morning":
|
||||||
return None
|
return None
|
||||||
handout = latest_handout()
|
handout = latest_handout()
|
||||||
@@ -54,6 +115,9 @@ def seed_body(ctx: SeedContext) -> str | None:
|
|||||||
"Портрет Бобра (шапка "
|
"Портрет Бобра (шапка "
|
||||||
f"`{PORTRAIT.relative_to(VAULT)}`, лента - там же):\n{portrait}"
|
f"`{PORTRAIT.relative_to(VAULT)}`, лента - там же):\n{portrait}"
|
||||||
)
|
)
|
||||||
|
mark = deploy_mark()
|
||||||
|
if mark:
|
||||||
|
parts.append(mark)
|
||||||
return "\n\n".join(parts)
|
return "\n\n".join(parts)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
Reference in new issue
Block a user