feat(scheduler,rotation,envelope,api,ui): pgqueuer jobs and deferred injects, master rotation with handout, vault envelope, jobs page

This commit is contained in:
hh
2026-08-29 02:29:05 +02:00
parent fd69eae05d
commit bc4233977d
27 changed files with 2123 additions and 120 deletions
+131 -21
View File
@@ -57,7 +57,7 @@ from beaver_gateway.storage.models import (
ConversationMessage,
InjectQueueItem,
RateLimit,
Schedule,
Usage,
)
if TYPE_CHECKING:
@@ -68,9 +68,12 @@ if TYPE_CHECKING:
from beaver_gateway.agents.claude import ClaudeAgent
from beaver_gateway.backends.claude_sdk import ClaudeSdkBackend
from beaver_gateway.core.bus import EventBus
from beaver_gateway.core.envelope import Envelope
from beaver_gateway.core.events import MessageStreamEvent
from beaver_gateway.core.injects import Priority
from beaver_gateway.core.registry import AgentRegistry
from beaver_gateway.core.rotation import HandoutContext
from beaver_gateway.core.scheduler import Scheduler
from beaver_gateway.core.sessions import SessionPool
from beaver_gateway.frontends.base import Frontend
from beaver_gateway.storage.db import Database
@@ -124,6 +127,11 @@ class ConversationTexts:
"заверши тёрн сейчас, ответ придёт следующим сообщением."
)
seed: Callable[[SeedContext], Awaitable[str | None] | str | None] | None = None
handout: Callable[[HandoutContext], Awaitable[str] | str] | str = (
"Этот мастер закрывается ({reason}). Напиши хендаут за {day}: справку "
"на утро, не задание - прошедшее время, без повелительного наклонения."
)
new_day: str = "Новый день: мастер сменился, хендаут за {day} записан."
@dataclass(frozen=True, slots=True)
@@ -182,6 +190,7 @@ class Conversations:
idle_days: Sequence[int] = (2,),
idle_interval: float = 3600.0,
question_timeout: float = 600.0,
envelope: Envelope | None = None,
) -> None:
self._db = db
self._agents = agents
@@ -195,6 +204,8 @@ class Conversations:
self._idle_days = tuple(sorted(idle_days))
self._idle_interval = idle_interval
self._question_timeout = question_timeout
self._envelope = envelope
self.scheduler: Scheduler | None = None
self._questions: dict[str, _Question] = {}
self._queue = InjectQueue(db)
self._runners: dict[int, _Runner] = {}
@@ -481,6 +492,45 @@ class Conversations:
)
return list(result.all())
async def context_tokens(self, conv: Conversation) -> int:
"""Size of the context the last turn ran with, from its usage row."""
async with self._db.session() as session:
row = (
await session.exec(
select(Usage)
.where(Usage.conversation_id == conv.external_id)
.order_by(col(Usage.id).desc())
.limit(1)
)
).first()
if row is None:
return 0
return row.input_tokens + row.cache_read_tokens + row.cache_creation_tokens
async def usage_tokens(self, since: datetime) -> int:
async with self._db.session() as session:
rows = (
await session.exec(
select(Usage).where(
col(Usage.ts) >= since.astimezone(UTC).replace(tzinfo=None)
)
)
).all()
return sum(
r.input_tokens + r.output_tokens + r.cache_creation_tokens for r in rows
)
async def busy(self, conv: Conversation) -> bool:
"""A turn is running, a question is open or a message waits to run."""
row = await self.get_row(cast("int", conv.id)) or conv
if row.running_turn or row.pending_question:
return True
live = self._pool.get(row.external_id)
if live is not None and live.busy:
return True
pending = await self._queue.pending(cast("int", row.id))
return any(i.priority in ("user", "urgent") for i in pending)
# ---- routing -------------------------------------------------------
@property
@@ -541,6 +591,8 @@ class Conversations:
raise ValueError(msg)
if agent is None and kind == "branch" and parent is not None:
agent = parent.agent_name
if kind == "branch" and parent is not None and parent.kind == "master":
await self.set_flags(parent, {"streak": 0})
agent = agent or self.default_agent(kind)
if agent is None:
msg = f"no default agent for kind {kind!r}; pass `agent`"
@@ -712,6 +764,7 @@ class Conversations:
*,
urgency: Priority = "normal",
origin: str = "system",
interrupt: bool = True,
) -> InjectQueueItem:
item = await self._queue.push(
conversation_id=cast("int", conv.id),
@@ -726,7 +779,7 @@ class Conversations:
priority=urgency,
origin=origin,
)
if urgency == "urgent":
if urgency == "urgent" and interrupt:
backend = self._backend(conv.agent_name)
if await backend.interrupt(conv.external_id):
_log.info(
@@ -788,22 +841,74 @@ class Conversations:
)
return result
async def schedule(self, conv: Conversation, at: str, text: str) -> Schedule:
row = Schedule(
conversation_id=cast("int", conv.id), execute_at=parse_at(at), text=text
)
async with self._db.session() as session:
session.add(row)
await session.commit()
await session.refresh(row)
async def schedule(
self, conv: Conversation, at: str, text: str, *, dedupe_key: str | None = None
) -> tuple[int | None, datetime]:
if self.scheduler is None:
msg = "no scheduler; `schedule` is unavailable"
raise RuntimeError(msg)
return await self.scheduler.schedule(conv, at, text, dedupe_key=dedupe_key)
async def schedules(self, conv: Conversation | None = None) -> list[dict[str, Any]]:
return await self.scheduler.scheduled(conv) if self.scheduler else []
# ---- §4.5 rotation -------------------------------------------------
async def handout(self, conv: Conversation, ctx: HandoutContext) -> str:
"""The closing master's last turn: the handout prompt from the config."""
source = self._texts.handout
if isinstance(source, str):
prompt = source.format(day=ctx.day.isoformat(), reason=ctx.reason)
else:
produced: Any = source(ctx)
prompt = await produced if inspect.isawaitable(produced) else produced
self._bus.publish(
"schedule.created",
conversation_id=conv.external_id,
schedule=row.id,
execute_at=_iso(row.execute_at),
"handout.start", conversation_id=conv.external_id, day=ctx.day.isoformat()
)
try:
text, _ = await self.run_text_turn(conv, prompt, origin="handout")
except Exception: # noqa: BLE001
_log.exception("handout turn on %s failed", conv.external_id)
text = ""
self._bus.publish(
"handout.end",
conversation_id=conv.external_id,
day=ctx.day.isoformat(),
text=text[:2000],
)
return text
async def close(self, conv: Conversation) -> Conversation:
row = await self.set_status(conv, "closed")
with contextlib.suppress(LookupError):
await self._backend(conv.agent_name).close(conv.external_id)
return row
async def reparent(self, conv: Conversation, parent: Conversation) -> Conversation:
async def apply(row: Conversation) -> None:
row.parent_id = parent.id
return await self._update(conv, apply)
async def mark_closed(self, conv: Conversation) -> bool:
marked = False
for fe in self._frontends:
if conv.kind in fe.kinds:
try:
marked = await fe.mark_closed(conv) or marked
except Exception: # noqa: BLE001
_log.exception("%s could not mark %s", fe.name, conv.external_id)
return marked
async def new_day(self, conv: Conversation, *, moved: int = 0) -> InjectQueueItem:
day = datetime.now(UTC).astimezone().date().isoformat()
text = self._texts.new_day.format(day=day)
if moved:
text += f" Инжектов переехало из старого мастера: {moved}."
return await self.inject(
conv, text, urgency="urgent", origin="ротация", interrupt=False
)
# ---- §3.7 questions ------------------------------------------------
async def ask(self, key: str, payload: dict[str, Any]) -> str | None:
@@ -884,13 +989,6 @@ class Conversations:
with contextlib.suppress(LookupError):
await self._update(conv, apply)
async def schedules(self, conv: Conversation | None = None) -> list[Schedule]:
stmt = select(Schedule).order_by(col(Schedule.execute_at))
if conv is not None:
stmt = stmt.where(Schedule.conversation_id == conv.id)
async with self._db.session() as session:
return list((await session.exec(stmt)).all())
# ---- turns ---------------------------------------------------------
async def turn(
@@ -1193,6 +1291,9 @@ class Conversations:
if head.priority == "user":
origin = "user"
prompt = head.text
envelope = await self._envelope_for(conv, injects=len(batch) - 1)
if envelope:
prompt += "\n\n" + envelope
if len(batch) > 1:
prompt += "\n\n" + _bundle(batch[1:])
else:
@@ -1225,6 +1326,15 @@ class Conversations:
text=text,
)
async def _envelope_for(self, conv: Conversation, *, injects: int) -> str | None:
if conv.kind != "master":
return None
streak = int(conv.flags.get("streak", 0) or 0)
await self.set_flags(conv, {"streak": streak + 1})
if self._envelope is None:
return None
return self._envelope.build(streak=streak, injects=injects)
def _observer(
self, conv: Conversation, runner: _Runner, turn_id: str, origin: str
) -> Callable[[Any], None]: