feat(jobs,vault): hourly leisure tick, the day plan lives in the vault

This commit is contained in:
hh
2026-09-07 23:00:14 +00:00
parent 18403e1df9
commit 0e7df793e5
4 changed files with 413 additions and 0 deletions
+9
View File
@@ -2,19 +2,28 @@
from __future__ import annotations from __future__ import annotations
import os
from beaver_gateway.jobs.scheduler import Job from beaver_gateway.jobs.scheduler import Job
from beaver_agent.jobs.closing import close_idle from beaver_agent.jobs.closing import close_idle
from beaver_agent.jobs.deploy import deploy from beaver_agent.jobs.deploy import deploy
from beaver_agent.jobs.komodo import komodo_alert from beaver_agent.jobs.komodo import komodo_alert
from beaver_agent.jobs.leisure import tick
from beaver_agent.jobs.memory import curate, memory from beaver_agent.jobs.memory import curate, memory
from beaver_agent.jobs.rotation import rotate from beaver_agent.jobs.rotation import rotate
from beaver_agent.jobs.t3code import t3code_event from beaver_agent.jobs.t3code import t3code_event
from beaver_agent.jobs.vibegram import vibegram from beaver_agent.jobs.vibegram import vibegram
from beaver_agent.jobs.voice import rotate_voice from beaver_agent.jobs.voice import rotate_voice
LEISURE_OFF = os.environ.get("LEISURE", "").strip().lower() in ("off", "0", "false")
"""Рубильник досуга в env; повседневный выключатель - строка в `мета/бобер/досуг.md`."""
jobs = [ jobs = [
Job("ротация", rotate, cron="0 * * * *"), Job("ротация", rotate, cron="0 * * * *"),
# Не на ":00": там ротация, а лежащий в очереди тик держит мастера занятым
# (`busy`), и она пропускала бы себя каждый час.
*([] if LEISURE_OFF else [Job("досуг", tick, cron="7 * * * *")]),
Job("вайбграм", vibegram, cron="*/10 * * * *", critical=False), Job("вайбграм", vibegram, cron="*/10 * * * *", critical=False),
Job("закрытие", close_idle, cron="20 4 * * *", critical=False), Job("закрытие", close_idle, cron="20 4 * * *", critical=False),
Job("память", memory, cron="30 4 * * 0", critical=False), Job("память", memory, cron="30 4 * * 0", critical=False),
+279
View File
@@ -0,0 +1,279 @@
"""Ежечасный тик: час принадлежит мне, раскладку суток веду я сам.
Задания в инжекте нет - только который час, какого он типа и что осталось
от суточной раскладки. Активное окно 12:00-03:59 тикает каждый час, тихое
04:00-11:59 - только те четыре часа, которые названы в плане. План лежит в
`мета/бобер/досуг.md` и пишется моделью; механизм его только читает, поэтому
переписать друг друга они не могут. Сколько досуговых слотов израсходовано,
считается из плана и текущего часа, а не из журнала - хранить нечего.
"""
from __future__ import annotations
import logging
import re
from dataclasses import dataclass
from datetime import UTC, datetime, timedelta
from typing import TYPE_CHECKING
from beaver_agent.vault import LEISURE, VAULT, ZONE, read
if TYPE_CHECKING:
from beaver_gateway.jobs.scheduler import JobRun
from beaver_gateway.storage.models import Conversation
_log = logging.getLogger(__name__)
DAY_START = 12
"""Сутки досуга начинаются в 12:00: раскладывает их первый тик суток."""
QUIET_FROM, QUIET_UNTIL = 4, 12
"""Тихое окно 04:00-11:59 - часы, в которые Бобёр за всё время не написал ни разу."""
QUIET_TICKS = (4, 6, 8, 10)
MINE_DEFAULT = (13, 15, 18, 21, 0, 2, 6)
MINE_MIN = 7
"""Меньше семи чистых слотов в сутках не бывает: недостающие механизм доберёт."""
BUSY_RETRY = timedelta(minutes=5)
BUSY_ATTEMPTS = 3
"""Занятому мастеру тик не лезет в середину реплики, а сдвигается внутри часа."""
FRESH_USER = timedelta(minutes=5)
WEEKDAYS = (
"понедельник",
"вторник",
"среда",
"четверг",
"пятница",
"суббота",
"воскресенье",
)
MONTHS = (
"января",
"февраля",
"марта",
"апреля",
"мая",
"июня",
"июля",
"августа",
"сентября",
"октября",
"ноября",
"декабря",
)
SWITCH_RE = re.compile(r"^досуг:\s*(\S+)", re.IGNORECASE)
DAY_RE = re.compile(r"^сутки:\s*(\d{4}-\d{2}-\d{2})", re.IGNORECASE)
QUIET_RE = re.compile(r"^тихие часы:\s*(.+)", re.IGNORECASE)
MINE_RE = re.compile(r"^мои часы:\s*(.+)", re.IGNORECASE)
OFF = ("выкл", "off", "нет", "0", "false")
NO_PLAN = (
"Расписания на эти сутки нет - работаю по умолчанию. Разложи часы и запиши "
"в `{path}` строками «сутки:», «тихие часы:», «мои часы:»."
)
PLANNING = (
"Первый тик суток. Разложи оставшиеся часы: какие твои, какие смешанные, "
"какие четыре часа тихого окна оставить. Запиши в `{path}` строками "
"«сутки:», «тихие часы:», «мои часы:». Своих - минимум {min}; запишешь "
"меньше - механизм доберёт сам."
)
TAIL = "Задания нет. Чем занять час - решаешь ты, «ничем» - тоже ответ."
PLAN_PATH = str(LEISURE.relative_to(VAULT))
@dataclass(frozen=True, slots=True)
class Plan:
"""Раскладка одних суток: какие тихие часы тикают и какие часы мои."""
day: str
quiet: tuple[int, ...]
mine: tuple[int, ...]
written: bool
"""План прочитан из файла на эти сутки; иначе он собран по умолчанию."""
def quiet_hour(hour: int) -> bool:
return QUIET_FROM <= hour < QUIET_UNTIL
def day_of(now: datetime) -> str:
"""Сутки, которым принадлежит момент: они начинаются в 12:00."""
return (now - timedelta(hours=DAY_START)).date().isoformat()
def tick_hours(quiet: tuple[int, ...]) -> tuple[int, ...]:
"""Часы суток по порядку, в которые тик вообще приходит."""
order = ((DAY_START + i) % 24 for i in range(24))
return tuple(h for h in order if not quiet_hour(h) or h in quiet)
def _hours(raw: str) -> tuple[int, ...]:
out: list[int] = []
for chunk in re.split(r"[,\s]+", raw.strip()):
if chunk.isdigit() and 0 <= int(chunk) < 24 and int(chunk) not in out:
out.append(int(chunk))
return tuple(out)
def _first(text: str, pattern: re.Pattern[str]) -> str | None:
for line in text.splitlines():
match = pattern.match(line.strip())
if match:
return match.group(1)
return None
def enabled(text: str | None) -> bool:
"""Выключено, только если сказано явно: по умолчанию досуг работает."""
if text is None:
return True
value = _first(text, SWITCH_RE)
return value is None or value.strip().lower() not in OFF
def plan_of(text: str | None, day: str) -> Plan:
"""План на ``day`` из файла; чего нет или что не сходится - берётся дефолт."""
quiet, mine, written = QUIET_TICKS, MINE_DEFAULT, False
if text is not None and _first(text, DAY_RE) == day:
written = True
raw_quiet = _first(text, QUIET_RE)
raw_mine = _first(text, MINE_RE)
named = _hours(raw_quiet) if raw_quiet else ()
quiet = tuple(h for h in named if quiet_hour(h)) or QUIET_TICKS
mine = _hours(raw_mine) if raw_mine else ()
ticks = tick_hours(quiet)
kept = [h for h in mine if h in ticks]
for candidate in (*MINE_DEFAULT, *ticks):
if len(kept) >= MINE_MIN:
break
if candidate in ticks and candidate not in kept:
kept.append(candidate)
order = {h: i for i, h in enumerate(ticks)}
return Plan(
day=day,
quiet=quiet,
mine=tuple(sorted(kept, key=lambda h: order[h])),
written=written,
)
def _stamp(now: datetime) -> str:
return (
f"{now:%H:%M}, {WEEKDAYS[now.weekday()]} {now.day} {MONTHS[now.month - 1]}, "
f"{ZONE.key}"
)
def _left(now: datetime) -> str:
end = now.replace(hour=DAY_START, minute=0, second=0, microsecond=0)
if end <= now:
end += timedelta(days=1)
hours = round((end - now).total_seconds() / 3600)
return f"Сутки заканчиваются через {hours} ч (в {end:%H:%M %d.%m})."
def brief(now: datetime, plan: Plan) -> str:
"""Текст инжекта: конверта в таком тёрне нет, время несёт он сам."""
hour = now.hour
ticks = tick_hours(plan.quiet)
order = {h: i for i, h in enumerate(ticks)}
here = order.get(hour, -1)
mine = hour in plan.mine
spent = [h for h in plan.mine if order[h] < here]
ahead = [h for h in plan.mine if order[h] > here]
lines = [
f"Тик {_stamp(now)}. Тип: {'досуг' if mine else 'смешанный'}"
+ (
" - час твой, дела Бобра сюда не лезут."
if mine
else " - можно и своё, и его."
),
_left(now),
"Мои часы: "
+ ", ".join(f"{h:02d}" for h in plan.mine)
+ f" - израсходовано {len(spent) + (1 if mine else 0)} из {len(plan.mine)}"
+ (
", впереди " + ", ".join(f"{h:02d}" for h in ahead)
if ahead
else ", впереди больше нет"
)
+ ".",
f"Тихое окно {QUIET_FROM:02d}:00-{QUIET_UNTIL - 1:02d}:59, тикают "
+ ", ".join(f"{h:02d}" for h in plan.quiet)
+ "; остальные часы там пропускаются.",
]
if not plan.written:
lines.append(NO_PLAN.format(path=PLAN_PATH))
if here == 0:
lines.append(PLANNING.format(path=PLAN_PATH, min=MINE_MIN))
lines.append(TAIL)
return "\n".join(lines)
def _aware(value: datetime | None) -> datetime | None:
if value is None:
return None
return value if value.tzinfo is not None else value.replace(tzinfo=UTC)
def _wait(master: Conversation, now: datetime) -> bool:
"""Не влезать в живую реплику: сдвинуться, а не пропасть."""
spoke = _aware(master.last_user_activity_at)
return spoke is not None and now - spoke < FRESH_USER
async def _riding(run: JobRun, master: Conversation, now: datetime) -> bool:
"""Есть ли впереди в этом часе отложенный инжект, с которым стоит слиться."""
edge = now.replace(minute=0, second=0, microsecond=0) + timedelta(hours=1)
try:
rows = await run.scheduler.scheduled(master)
except Exception: # noqa: BLE001
_log.exception("досуг: очередь отложенных инжектов не прочиталась")
return False
for row in rows:
raw = row.get("execute_after")
if not isinstance(raw, str):
continue
try:
when = datetime.fromisoformat(raw)
except ValueError:
continue
if now < when < edge:
return True
return False
async def tick(run: JobRun) -> None:
"""Час пробил: решить, тик ли это, дождаться тишины в мастере и разбудить."""
text = read(LEISURE)
if not enabled(text):
_log.info("досуг: выключен в %s", LEISURE.name)
return
now = datetime.now(ZONE)
plan = plan_of(text, day_of(now))
if now.hour not in tick_hours(plan.quiet):
_log.info("досуг: %02d:00 - тихий час без тика", now.hour)
return
master = await run.master()
if master is None:
_log.error("досуг: открытого мастера нет, тик пропал")
return
attempt = int(run.payload.get("attempt", 0))
if (await run.conversations.busy(master) or _wait(master, now)) and (
attempt < BUSY_ATTEMPTS
):
run.payload["attempt"] = attempt + 1
await run.retry_in(BUSY_RETRY)
_log.info("досуг: мастер занят, сдвиг на %s (попытка %s)", BUSY_RETRY, attempt)
return
riding = await _riding(run, master, now)
await run.inject_master(
brief(now, plan), urgency="normal" if riding else "wake", origin="досуг"
)
_log.info(
"досуг: тик %02d:00 (%s), слияние с отложенным инжектом: %s",
now.hour,
"досуг" if now.hour in plan.mine else "смешанный",
riding,
)
+2
View File
@@ -31,6 +31,8 @@ NOTES = BEAVER / "наблюдения"
PORTRAIT = NOTES / "Бобёр - наблюдения.md" PORTRAIT = NOTES / "Бобёр - наблюдения.md"
REPLIES = BEAVER / "реплики" REPLIES = BEAVER / "реплики"
CURATOR_JOURNAL = BEAVER / "куратор.md" CURATOR_JOURNAL = BEAVER / "куратор.md"
LEISURE = BEAVER / "досуг.md"
"""Раскладка суток по тикам досуга: пишет её модель, механизм только читает."""
STATE = BEAVER / "состояние.md" STATE = BEAVER / "состояние.md"
STATE_MAX_LINES = 60 STATE_MAX_LINES = 60
ATTACHMENTS = BEAVER / "вложения" ATTACHMENTS = BEAVER / "вложения"
+123
View File
@@ -0,0 +1,123 @@
from datetime import datetime
from zoneinfo import ZoneInfo
from beaver_agent.jobs.leisure import (
MINE_MIN,
QUIET_TICKS,
Plan,
brief,
day_of,
enabled,
plan_of,
quiet_hour,
tick_hours,
)
ZONE = ZoneInfo("Europe/Warsaw")
def at(text: str) -> datetime:
return datetime.fromisoformat(text).replace(tzinfo=ZONE)
def test_switch_defaults_to_on_and_only_explicit_off_stops_it():
assert enabled(None)
assert enabled("# досуг\n\nничего про выключатель")
assert enabled("досуг: вкл")
assert not enabled("# досуг\n\nдосуг: выкл\nсутки: 2026-09-08")
assert not enabled("досуг: off")
def test_day_starts_at_noon():
assert day_of(at("2026-09-08 12:00")) == "2026-09-08"
assert day_of(at("2026-09-08 23:30")) == "2026-09-08"
assert day_of(at("2026-09-09 03:00")) == "2026-09-08"
assert day_of(at("2026-09-09 11:59")) == "2026-09-08"
assert day_of(at("2026-09-09 12:00")) == "2026-09-09"
def test_active_window_ticks_hourly_and_quiet_window_only_by_plan():
hours = tick_hours(QUIET_TICKS)
assert hours[0] == 12
assert len(hours) == 20
assert all(h in hours for h in (12, 18, 23, 0, 3))
assert [h for h in hours if quiet_hour(h)] == [4, 6, 8, 10]
assert 5 not in hours and 11 not in hours
assert tick_hours((5, 7))[-2:] == (5, 7)
def test_plan_is_read_from_the_file_for_todays_day_only():
text = (
"# досуг\n\n"
"досуг: вкл\n"
"сутки: 2026-09-08\n"
"тихие часы: 05, 07, 09, 11\n"
"мои часы: 13, 16, 19, 22, 01, 05, 09\n"
)
plan = plan_of(text, "2026-09-08")
assert plan.written
assert plan.quiet == (5, 7, 9, 11)
assert plan.mine == (13, 16, 19, 22, 1, 5, 9)
stale = plan_of(text, "2026-09-09")
assert not stale.written
assert stale.quiet == QUIET_TICKS
def test_missing_plan_falls_back_to_defaults():
plan = plan_of(None, "2026-09-08")
assert not plan.written
assert plan.quiet == QUIET_TICKS
assert len(plan.mine) == MINE_MIN
assert set(plan.mine) <= set(tick_hours(plan.quiet))
def test_short_plan_is_topped_up_to_the_guaranteed_minimum():
text = "сутки: 2026-09-08\nмои часы: 14, 20\n"
plan = plan_of(text, "2026-09-08")
assert plan.written
assert len(plan.mine) == MINE_MIN
assert 14 in plan.mine and 20 in plan.mine
assert plan.mine == tuple(sorted(plan.mine, key=tick_hours(plan.quiet).index))
def test_unreachable_hours_are_dropped_from_the_plan():
text = "сутки: 2026-09-08\nтихие часы: 04, 06, 08, 10\nмои часы: 05, 11, 14\n"
plan = plan_of(text, "2026-09-08")
assert 5 not in plan.mine and 11 not in plan.mine
assert 14 in plan.mine and len(plan.mine) == MINE_MIN
def test_brief_names_the_type_and_counts_spent_slots():
plan = Plan(
day="2026-09-08",
quiet=QUIET_TICKS,
mine=(13, 15, 18, 21, 0, 2, 6),
written=True,
)
text = brief(at("2026-09-08 18:00"), plan)
assert "Тип: досуг" in text
assert "израсходовано 3 из 7" in text
assert "впереди 21, 00, 02, 06" in text
assert "Сутки заканчиваются через 18 ч" in text
assert "Задания нет" in text
assert "Разложи" not in text
mixed = brief(at("2026-09-08 19:00"), plan)
assert "Тип: смешанный" in mixed
assert "израсходовано 3 из 7" in mixed
def test_first_tick_of_the_day_is_the_planning_one():
text = brief(at("2026-09-08 12:00"), plan_of(None, "2026-09-08"))
assert "Первый тик суток" in text
assert "расписания на эти сутки нет" in text.lower()
assert str(MINE_MIN) in text
def test_last_night_tick_says_nothing_is_left_ahead():
plan = Plan(day="2026-09-08", quiet=QUIET_TICKS, mine=(13, 6), written=True)
text = brief(at("2026-09-09 06:00"), plan)
assert "впереди больше нет" in text
assert "израсходовано 2 из 2" in text
assert "Сутки заканчиваются через 6 ч" in text