feat(injects,conversations,scheduler,gateway_tools,api): wake priority, master and parent aliases, scheduled injects start a turn

This commit is contained in:
hh
2026-08-30 20:34:12 +02:00
parent 064379ca90
commit a8491330fa
11 changed files with 215 additions and 47 deletions
+51 -7
View File
@@ -103,6 +103,9 @@ __all__ = [
_log = logging.getLogger("beaver_gateway.core.conversations") _log = logging.getLogger("beaver_gateway.core.conversations")
MASTER_ALIAS = "master"
PARENT_ALIAS = "parent"
SEEDS = ("clean", "morning", "copy", "brief") SEEDS = ("clean", "morning", "copy", "brief")
_STATUSES = ("open", "merged", "closed", "archived") _STATUSES = ("open", "merged", "closed", "archived")
_DEFAULT_MERGE_PROMPT = ( _DEFAULT_MERGE_PROMPT = (
@@ -342,6 +345,23 @@ class Conversations:
async with self._db.session() as session: async with self._db.session() as session:
return await session.get(Conversation, row_id) return await session.get(Conversation, row_id)
async def resolve(
self, key: str, *, origin: Conversation | None = None
) -> Conversation | None:
"""A conversation by public id or alias.
``master`` is the open master, ``parent`` the parent of ``origin`` -
the names a job or a branch can use without knowing today's ids.
"""
key = key.strip()
if key == MASTER_ALIAS:
return await self._open_master()
if key == PARENT_ALIAS:
if origin is None or origin.parent_id is None:
return None
return await self.get_row(origin.parent_id)
return await self.get(key)
async def find( async def find(
self, self,
*, *,
@@ -596,7 +616,7 @@ class Conversations:
if live is not None and live.busy: if live is not None and live.busy:
return True return True
pending = await self._queue.pending(cast("int", row.id)) pending = await self._queue.pending(cast("int", row.id))
return any(i.priority in ("user", "urgent") for i in pending) return any(i.priority in ("user", "urgent", "wake") for i in pending)
# ---- routing ------------------------------------------------------- # ---- routing -------------------------------------------------------
@@ -841,6 +861,22 @@ class Conversations:
origin: str = "system", origin: str = "system",
interrupt: bool = True, interrupt: bool = True,
) -> InjectQueueItem: ) -> InjectQueueItem:
if conv.kind == "master" and conv.status != "open":
live = await self._open_master()
if live is None:
_log.error(
"inject (%s) for closed master %s: no open master, it stays there",
origin,
conv.external_id,
)
else:
_log.info(
"inject (%s) for closed master %s goes to %s",
origin,
conv.external_id,
live.external_id,
)
conv = live
item = await self._queue.push( item = await self._queue.push(
conversation_id=cast("int", conv.id), conversation_id=cast("int", conv.id),
priority=urgency, priority=urgency,
@@ -923,12 +959,20 @@ class Conversations:
return result return result
async def schedule( async def schedule(
self, conv: Conversation, at: str, text: str, *, dedupe_key: str | None = None self,
conv: Conversation,
at: str,
text: str,
*,
urgency: Priority = "wake",
dedupe_key: str | None = None,
) -> tuple[int | None, datetime]: ) -> tuple[int | None, datetime]:
if self.scheduler is None: if self.scheduler is None:
msg = "no scheduler; `schedule` is unavailable" msg = "no scheduler; `schedule` is unavailable"
raise RuntimeError(msg) raise RuntimeError(msg)
return await self.scheduler.schedule(conv, at, text, dedupe_key=dedupe_key) return await self.scheduler.schedule(
conv, at, text, urgency=urgency, dedupe_key=dedupe_key
)
async def schedules(self, conv: Conversation | None = None) -> list[dict[str, Any]]: async def schedules(self, conv: Conversation | None = None) -> list[dict[str, Any]]:
return await self.scheduler.scheduled(conv) if self.scheduler else [] return await self.scheduler.scheduled(conv) if self.scheduler else []
@@ -1632,15 +1676,15 @@ class Conversations:
) -> tuple[list[InjectQueueItem] | None, float | None]: ) -> tuple[list[InjectQueueItem] | None, float | None]:
if not items: if not items:
return None, None return None, None
normals = [i for i in items if i.priority == "normal"]
head = items[0] head = items[0]
if head.priority == "urgent": if head.priority == "urgent":
return [head], None return [head], None
if head.priority == "user": tail = [i for i in items if i is not head and i.priority in ("wake", "normal")]
return [head, *normals], None if head.priority in ("user", "wake"):
return [head, *tail], None
age = (datetime.now(UTC) - _aware(head.created_at)).total_seconds() age = (datetime.now(UTC) - _aware(head.created_at)).total_seconds()
if age >= self._normal_window: if age >= self._normal_window:
return normals, None return [head, *tail], None
return None, max(self._normal_window - age, 1.0) return None, max(self._normal_window - age, 1.0)
async def _run_batch( async def _run_batch(
+45 -13
View File
@@ -12,8 +12,18 @@ from typing import TYPE_CHECKING, Any, cast
from claude_agent_sdk import create_sdk_mcp_server, tool from claude_agent_sdk import create_sdk_mcp_server, tool
from beaver_gateway.core.injects import URGENCY
from beaver_gateway.core.kinds import as_kind from beaver_gateway.core.kinds import as_kind
URGENCY_HELP = (
"normal waits for the hourly batch or rides with the next turn, wake "
"starts a turn as soon as the target is idle, urgent cuts a running turn"
)
TARGET_HELP = (
"conversation id, or `master` for the open master, `parent` for the "
"parent of this conversation"
)
if TYPE_CHECKING: if TYPE_CHECKING:
from collections.abc import Iterable from collections.abc import Iterable
@@ -57,6 +67,12 @@ def _tools(conversations: Conversations, key: str) -> list[SdkMcpTool[Any]]:
raise LookupError(msg) raise LookupError(msg)
return conv return conv
async def target(raw: Any) -> Any:
conv = await conversations.resolve(str(raw), origin=await current())
if conv is None:
_log.warning("gateway tool in %s: conversation %r not found", key, raw)
return conv
@tool( @tool(
"read_conversation", "read_conversation",
"Read another conversation (a branch, the master, a deep chat) as plain " "Read another conversation (a branch, the master, a deep chat) as plain "
@@ -64,16 +80,16 @@ def _tools(conversations: Conversations, key: str) -> list[SdkMcpTool[Any]]:
{ {
"type": "object", "type": "object",
"properties": { "properties": {
"id": {"type": "string", "description": "conversation id"}, "id": {"type": "string", "description": TARGET_HELP},
"window": {"type": "integer", "minimum": 1}, "window": {"type": "integer", "minimum": 1},
}, },
"required": ["id"], "required": ["id"],
}, },
) )
async def read_conversation(args: dict[str, Any]) -> dict[str, Any]: async def read_conversation(args: dict[str, Any]) -> dict[str, Any]:
conv = await conversations.get(str(args["id"])) conv = await target(args["id"])
if conv is None: if conv is None:
return _error(f"conversation {args['id']} not found") return _error(f"conversation {args['id']} not found ({TARGET_HELP})")
text = await conversations.read(conv, window=args.get("window")) text = await conversations.read(conv, window=args.get("window"))
return _text(text or "(empty)") return _text(text or "(empty)")
@@ -137,14 +153,26 @@ def _tools(conversations: Conversations, key: str) -> list[SdkMcpTool[Any]]:
@tool( @tool(
"schedule", "schedule",
"Promise yourself an inject later: `at` is `+15m`, `+2h`, `+1d` or an " "Promise yourself an inject later: `at` is `+15m`, `+2h`, `+1d` or an "
"ISO datetime; `text` arrives in this conversation at that time.", "ISO datetime; `text` arrives in this conversation at that time and "
{"at": str, "text": str}, "starts a turn (urgency wake). " + URGENCY_HELP + ".",
{
"type": "object",
"properties": {
"at": {"type": "string"},
"text": {"type": "string"},
"urgency": {"type": "string", "enum": list(URGENCY), "default": "wake"},
},
"required": ["at", "text"],
},
) )
async def schedule(args: dict[str, Any]) -> dict[str, Any]: async def schedule(args: dict[str, Any]) -> dict[str, Any]:
conv = await current() conv = await current()
try: try:
job_id, when = await conversations.schedule( job_id, when = await conversations.schedule(
conv, str(args["at"]), str(args["text"]) conv,
str(args["at"]),
str(args["text"]),
urgency=cast("Any", args.get("urgency") or "wake"),
) )
except (RuntimeError, ValueError) as exc: except (RuntimeError, ValueError) as exc:
return _error(str(exc)) return _error(str(exc))
@@ -152,15 +180,17 @@ def _tools(conversations: Conversations, key: str) -> list[SdkMcpTool[Any]]:
@tool( @tool(
"inject", "inject",
"Put a system-origin message into another conversation's queue.", "Put a system-origin message into another conversation's queue. "
+ URGENCY_HELP
+ ". An error here means nothing was delivered.",
{ {
"type": "object", "type": "object",
"properties": { "properties": {
"conversation": {"type": "string"}, "conversation": {"type": "string", "description": TARGET_HELP},
"text": {"type": "string"}, "text": {"type": "string"},
"urgency": { "urgency": {
"type": "string", "type": "string",
"enum": ["normal", "urgent"], "enum": list(URGENCY),
"default": "normal", "default": "normal",
}, },
}, },
@@ -168,11 +198,13 @@ def _tools(conversations: Conversations, key: str) -> list[SdkMcpTool[Any]]:
}, },
) )
async def inject(args: dict[str, Any]) -> dict[str, Any]: async def inject(args: dict[str, Any]) -> dict[str, Any]:
target = await conversations.get(str(args["conversation"])) conv = await target(args["conversation"])
if target is None: if conv is None:
return _error(f"conversation {args['conversation']} not found") return _error(
f"conversation {args['conversation']} not found ({TARGET_HELP})"
)
item = await conversations.inject( item = await conversations.inject(
target, conv,
str(args["text"]), str(args["text"]),
urgency=cast("Any", args.get("urgency") or "normal"), urgency=cast("Any", args.get("urgency") or "normal"),
origin="агент", origin="агент",
+19 -7
View File
@@ -1,10 +1,13 @@
"""Persisted per-conversation queue with priorities ``urgent > user > normal`` (§3.4). """Persisted per-conversation queue, ``urgent > user > wake > normal`` (§3.4).
One ``ClaudeSDKClient`` runs one turn at a time, so ordering has to happen One ``ClaudeSDKClient`` runs one turn at a time, so ordering has to happen
before the client: the rows here are the queue, ``core/conversations`` before the client: the rows here are the queue, ``core/conversations``
runs one worker per conversation over them. A row that is still runs one worker per conversation over them. ``urgent`` cuts a running
``running`` when the gateway starts was cut by a restart; it is flagged turn, ``user`` is the human, ``wake`` starts a turn as soon as the
``interrupted`` and never re-run. conversation is idle and takes the queued normals with it, ``normal``
waits for the batching window or rides with the next turn. A row that is
still ``running`` when the gateway starts was cut by a restart; it is
flagged ``interrupted`` and never re-run.
""" """
from __future__ import annotations from __future__ import annotations
@@ -24,10 +27,19 @@ if TYPE_CHECKING:
from beaver_gateway.storage.db import Database from beaver_gateway.storage.db import Database
from beaver_gateway.storage.models import Conversation from beaver_gateway.storage.models import Conversation
__all__ = ["PRIORITY_RANK", "InjectContext", "InjectQueue", "Priority", "inject_header"] __all__ = [
"PRIORITY_RANK",
"URGENCY",
"InjectContext",
"InjectQueue",
"Priority",
"inject_header",
]
Priority = Literal["urgent", "user", "normal"] Priority = Literal["urgent", "user", "wake", "normal"]
PRIORITY_RANK: dict[str, int] = {"urgent": 0, "user": 1, "normal": 2} PRIORITY_RANK: dict[str, int] = {"urgent": 0, "user": 1, "wake": 2, "normal": 3}
URGENCY: tuple[Priority, ...] = ("normal", "wake", "urgent")
"""What the API and the tools accept for ``urgency``: every priority but ``user``."""
INTERRUPTED_TURN = ( INTERRUPTED_TURN = (
"[этот инжект прервал предыдущий тёрн: «Request interrupted» выше - " "[этот инжект прервал предыдущий тёрн: «Request interrupted» выше - "
+18 -9
View File
@@ -93,6 +93,9 @@ class JobRun:
) -> bool: ) -> bool:
master = await self.master() master = await self.master()
if master is None: if master is None:
_log.error(
"job %s: no open master, inject lost: %s", self.job.name, text[:200]
)
return False return False
await self.conversations.inject( await self.conversations.inject(
master, text, urgency=urgency, origin=origin or self.job.name master, text, urgency=urgency, origin=origin or self.job.name
@@ -311,9 +314,14 @@ class Scheduler:
at: str, at: str,
text: str, text: str,
*, *,
urgency: Priority = "normal", urgency: Priority = "wake",
dedupe_key: str | None = None, dedupe_key: str | None = None,
) -> tuple[int | None, datetime]: ) -> tuple[int | None, datetime]:
"""A deferred inject.
``wake`` by default: it was promised for a time, so it starts a turn
then instead of waiting for the normal window.
"""
if self._queries is None: if self._queries is None:
msg = "scheduler needs postgres; `schedule` is unavailable" msg = "scheduler needs postgres; `schedule` is unavailable"
raise RuntimeError(msg) raise RuntimeError(msg)
@@ -355,19 +363,20 @@ class Scheduler:
async def _deliver(self, job: PgJob) -> None: async def _deliver(self, job: PgJob) -> None:
data = _decode(job.payload) data = _decode(job.payload)
conv = await self.conversations.get(str(data.get("conversation", ""))) key = str(data.get("conversation", ""))
if conv is not None and conv.status != "open" and conv.kind == "master": conv = await self.conversations.resolve(key)
masters = await self.conversations.find(
kind="master", status="open", limit=1
)
conv = masters[0] if masters else None
if conv is None: if conv is None:
_log.warning("scheduled inject #%s: conversation gone", job.id) _log.error(
"scheduled inject #%s lost: conversation %r is gone: %s",
job.id,
key,
str(data.get("text", ""))[:200],
)
return return
await self.conversations.inject( await self.conversations.inject(
conv, conv,
str(data.get("text", "")), str(data.get("text", "")),
urgency=cast("Priority", data.get("urgency") or "normal"), urgency=cast("Priority", data.get("urgency") or "wake"),
origin="schedule", origin="schedule",
) )
+16 -8
View File
@@ -31,6 +31,7 @@ from sqlmodel import col, select
from beaver_gateway.core import audit from beaver_gateway.core import audit
from beaver_gateway.core.auth import VALID_SCOPES, hash_token from beaver_gateway.core.auth import VALID_SCOPES, hash_token
from beaver_gateway.core.conversations import SEEDS, implied_title from beaver_gateway.core.conversations import SEEDS, implied_title
from beaver_gateway.core.injects import URGENCY
from beaver_gateway.core.kinds import Kind, as_kind from beaver_gateway.core.kinds import Kind, as_kind
from beaver_gateway.frontends._auth import require_token from beaver_gateway.frontends._auth import require_token
from beaver_gateway.frontends._sse import ( from beaver_gateway.frontends._sse import (
@@ -145,7 +146,7 @@ def build_app(runtime: GatewayRuntime, *, memory_root: Path | None = None) -> Fa
return data return data
async def conv_of(public_id: str) -> Conversation: async def conv_of(public_id: str) -> Conversation:
conv = await conversations.get(public_id) conv = await conversations.resolve(public_id)
if conv is None: if conv is None:
raise HTTPException( raise HTTPException(
status.HTTP_404_NOT_FOUND, f"unknown conversation {public_id}" status.HTTP_404_NOT_FOUND, f"unknown conversation {public_id}"
@@ -158,6 +159,14 @@ def build_app(runtime: GatewayRuntime, *, memory_root: Path | None = None) -> Fa
raise HTTPException(status.HTTP_400_BAD_REQUEST, f"missing `{key}`") raise HTTPException(status.HTTP_400_BAD_REQUEST, f"missing `{key}`")
return text return text
def urgency_of(data: dict[str, Any], default: str) -> Any:
urgency = str(data.get("urgency") or default)
if urgency not in URGENCY:
raise HTTPException(
status.HTTP_400_BAD_REQUEST, f"urgency must be one of {URGENCY}"
)
return urgency
def int_or_none(data: dict[str, Any], key: str) -> int | None: def int_or_none(data: dict[str, Any], key: str) -> int | None:
value = data.get(key) value = data.get(key)
if value is None: if value is None:
@@ -343,15 +352,11 @@ def build_app(runtime: GatewayRuntime, *, memory_root: Path | None = None) -> Fa
token = await require_token(request, runtime, scope=SCOPE) token = await require_token(request, runtime, scope=SCOPE)
conv = await conv_of(public_id) conv = await conv_of(public_id)
data = await body_of(request) data = await body_of(request)
urgency = str(data.get("urgency") or "normal") urgency = urgency_of(data, "normal")
if urgency not in ("normal", "urgent"):
raise HTTPException(
status.HTTP_400_BAD_REQUEST, "urgency must be normal|urgent"
)
item = await conversations.inject( item = await conversations.inject(
conv, conv,
text_of(data), text_of(data),
urgency=cast("Any", urgency), urgency=urgency,
origin=str(data.get("origin") or "api"), origin=str(data.get("origin") or "api"),
) )
await audit.log( await audit.log(
@@ -599,7 +604,10 @@ def build_app(runtime: GatewayRuntime, *, memory_root: Path | None = None) -> Fa
data = await body_of(request) data = await body_of(request)
try: try:
job_id, when = await conversations.schedule( job_id, when = await conversations.schedule(
conv, text_of(data, "at"), text_of(data, "text") conv,
text_of(data, "at"),
text_of(data, "text"),
urgency=urgency_of(data, "wake"),
) )
except (RuntimeError, ValueError) as exc: except (RuntimeError, ValueError) as exc:
raise HTTPException(status.HTTP_400_BAD_REQUEST, str(exc)) from exc raise HTTPException(status.HTTP_400_BAD_REQUEST, str(exc)) from exc
+1 -1
View File
@@ -147,7 +147,7 @@ class ConversationBinding(SQLModel, table=True):
class InjectQueueItem(SQLModel, table=True): class InjectQueueItem(SQLModel, table=True):
"""Persisted per-conversation queue (§3.4), priorities ``urgent > user > normal``. """Persisted per-conversation queue (§3.4), ``urgent > user > wake > normal``.
``status`` walks ``queued -> running -> done``; a row still ``running`` ``status`` walks ``queued -> running -> done``; a row still ``running``
at startup was cut by a restart and becomes ``interrupted`` - it is at startup was cut by a restart and becomes ``interrupted`` - it is
+61
View File
@@ -781,3 +781,64 @@ async def test_observer_publishes_tool_results_for_the_panel(world: World) -> No
assert result["parent_tool_use_id"] == "toolu_0" assert result["parent_tool_use_id"] == "toolu_0"
assert result["turn_id"] == "t1" and result["is_error"] is False assert result["turn_id"] == "t1" and result["is_error"] is False
assert result["content"] == "a\nb" assert result["content"] == "a\nb"
async def test_wake_injects_start_a_turn_and_take_normals_along(world: World) -> None:
conv = await world.conversations.create(kind="master", agent="a", origin="test")
await world.conversations.inject(conv, "digest", urgency="normal", origin="watch")
await asyncio.sleep(0.2)
assert await world.statuses(conv) == [("normal", "queued")]
await world.conversations.inject(
conv, "reminder", urgency="wake", origin="schedule"
)
await world.settle(conv, 2)
prompts = ScriptedClient.instances[0].prompts
assert len(prompts) == 1
assert prompts[0].startswith("[инжект: schedule")
assert "reminder" in prompts[0]
assert "[инжект: watch" in prompts[0]
assert "digest" in prompts[0]
assert await world.statuses(conv) == [("normal", "done"), ("wake", "done")]
async def test_wake_does_not_cut_a_running_turn(world: World) -> None:
conv = await world.conversations.create(kind="master", agent="a", origin="test")
ScriptedClient.hold = asyncio.Event()
await world.conversations.post(conv, "first")
await asyncio.sleep(0.2)
await world.conversations.inject(conv, "later", urgency="wake", origin="schedule")
await asyncio.sleep(0.2)
assert await world.statuses(conv) == [("user", "running"), ("wake", "queued")]
assert not ScriptedClient.instances[0].interrupted
assert await world.conversations.busy(conv)
ScriptedClient.hold.set()
await world.settle(conv, 2)
assert await world.statuses(conv) == [("user", "done"), ("wake", "done")]
assert ScriptedClient.instances[0].prompts[1].endswith("later")
async def test_resolve_aliases_master_and_parent(world: World) -> None:
master = await world.conversations.create(kind="master", agent="a", origin="test")
branch = await world.conversations.create(
kind="branch", agent="a", parent=master, origin="test"
)
resolve = world.conversations.resolve
assert (await resolve("master")).id == master.id
assert (await resolve(" master ")).id == master.id
assert (await resolve("parent", origin=branch)).id == master.id
assert await resolve("parent", origin=master) is None
assert await resolve("parent") is None
assert (await resolve(branch.external_id)).id == branch.id
assert await resolve("nope") is None
await world.conversations.set_status(master, "closed")
assert await resolve("master") is None
async def test_inject_into_a_closed_master_lands_in_the_open_one(world: World) -> None:
old = await world.conversations.create(kind="master", agent="a", origin="test")
old = await world.conversations.set_status(old, "closed")
new = await world.conversations.create(kind="master", agent="a", origin="test")
item = await world.conversations.inject(old, "late", urgency="normal", origin="x")
assert item.conversation_id == new.id
assert await world.statuses(old) == []
assert await world.statuses(new) == [("normal", "queued")]
+1
View File
@@ -88,6 +88,7 @@ async def test_schedule_survives_a_restart(world: World, pg: Pg) -> None:
prompt = ScriptedClient_prompts(world)[0] prompt = ScriptedClient_prompts(world)[0]
assert prompt.startswith("[инжект: schedule") assert prompt.startswith("[инжект: schedule")
assert prompt.endswith("push X") assert prompt.endswith("push X")
assert await world.statuses(conv) == [("wake", "done")]
assert await world.conversations.schedules(conv) == [] assert await world.conversations.schedules(conv) == []
await second.stop() await second.stop()
+1 -1
View File
@@ -200,7 +200,7 @@ export class ApiClient {
inject( inject(
id: string, id: string,
text: string, text: string,
urgency: "normal" | "urgent", urgency: "normal" | "wake" | "urgent",
origin = "panel" origin = "panel"
): Promise<{ id: string; item: number; priority: string }> { ): Promise<{ id: string; item: number; priority: string }> {
return this.post(`/api/conversations/${id}/inject`, { return this.post(`/api/conversations/${id}/inject`, {
+1 -1
View File
@@ -47,7 +47,7 @@ export interface QueueItem {
created_at: string; created_at: string;
id: number; id: number;
origin: string; origin: string;
priority: "urgent" | "user" | "normal"; priority: "urgent" | "user" | "wake" | "normal";
status: "queued" | "running" | "done" | "failed" | "interrupted"; status: "queued" | "running" | "done" | "failed" | "interrupted";
text: string; text: string;
} }
+1
View File
@@ -10,6 +10,7 @@
normal: "text-note", normal: "text-note",
urgent: "text-destructive", urgent: "text-destructive",
user: "text-foreground", user: "text-foreground",
wake: "text-foreground",
}; };
</script> </script>