refactor: bind cron jobs to origin sessions

This commit is contained in:
chengyongru
2026-06-12 14:00:53 +08:00
parent 271b3651d7
commit 80524e9e88
14 changed files with 47 additions and 267 deletions
-7
View File
@@ -12,7 +12,6 @@ from nanobot.cron.session_turns import CRON_HISTORY_META, CRON_TRIGGER_META
from nanobot.providers.base import LLMResponse
from nanobot.session.goal_state import GOAL_STATE_KEY
from nanobot.session.manager import Session, SessionManager
from nanobot.session.routing import SESSION_ROUTING_METADATA_KEY
from nanobot.session.turn_continuation import (
INTERNAL_CONTINUATION_META,
INTERNAL_CONTINUATION_RUN_STARTED_AT_META,
@@ -864,12 +863,6 @@ async def test_process_message_uses_context_chat_id_for_runtime_prompt(tmp_path:
assert result.chat_id == "thread-777"
assert loop.context.build_messages.call_args.kwargs["chat_id"] == "parent-456"
assert loop._run_agent_loop.call_args.kwargs["chat_id"] == "thread-777"
session = loop.sessions.get_or_create("discord:parent-456:thread:thread-777")
assert session.metadata[SESSION_ROUTING_METADATA_KEY] == {
"channel": "discord",
"chat_id": "thread-777",
"metadata": {"context_chat_id": "parent-456"},
}
@pytest.mark.asyncio
@@ -1,5 +1,4 @@
from nanobot.session.manager import Session, SessionManager
from nanobot.session.routing import SESSION_ROUTING_METADATA_KEY
def _assert_no_orphans(history: list[dict]) -> None:
@@ -433,11 +432,6 @@ def test_fork_session_before_user_index_copies_only_prefix(tmp_path):
source.metadata["webui"] = True
source.metadata["title"] = "Old title"
source.metadata["goal_state"] = {"status": "active", "objective": "do not inherit"}
source.metadata[SESSION_ROUTING_METADATA_KEY] = {
"channel": "websocket",
"chat_id": "source",
"metadata": {},
}
source.add_message("user", "round1")
source.add_message("assistant", "answer1")
source.add_message("user", "round2 fork me")
@@ -456,7 +450,6 @@ def test_fork_session_before_user_index_copies_only_prefix(tmp_path):
assert forked.metadata["webui"] is True
assert "title" not in forked.metadata
assert "goal_state" not in forked.metadata
assert SESSION_ROUTING_METADATA_KEY not in forked.metadata
saved = manager.read_session_file("websocket:fork")
assert [m["content"] for m in saved["messages"]] == ["round1", "answer1"]
+27 -19
View File
@@ -243,7 +243,7 @@ async def test_session_automations_route_filters_by_webui_session(
@pytest.mark.asyncio
async def test_session_automations_route_uses_unified_owner_when_enabled(
async def test_session_automations_route_uses_origin_owner_when_unified_enabled(
bus: MagicMock, tmp_path: Path
) -> None:
cron = CronService(tmp_path / "cron" / "jobs.json")
@@ -255,9 +255,9 @@ async def test_session_automations_route_uses_unified_owner_when_enabled(
session_key=UNIFIED_SESSION_KEY,
)
cron.add_job(
name="Visible thread only",
name="Visible chat job",
schedule=hourly,
message="Do not show in unified mode",
message="Show for this chat",
session_key="websocket:abc",
)
channel = _ch(
@@ -274,14 +274,19 @@ async def test_session_automations_route_uses_unified_owner_when_enabled(
token = boot.json()["token"]
auth = {"Authorization": f"Bearer {token}"}
for key in ("websocket%3Aabc", "websocket%3Aother"):
resp = await _http_get(
f"http://127.0.0.1:29917/api/sessions/{key}/automations",
headers=auth,
)
assert resp.status_code == 200
body = resp.json()
assert [job["name"] for job in body["jobs"]] == ["Unified check"]
resp = await _http_get(
"http://127.0.0.1:29917/api/sessions/websocket%3Aabc/automations",
headers=auth,
)
assert resp.status_code == 200
assert [job["name"] for job in resp.json()["jobs"]] == ["Visible chat job"]
resp = await _http_get(
"http://127.0.0.1:29917/api/sessions/websocket%3Aother/automations",
headers=auth,
)
assert resp.status_code == 200
assert resp.json()["jobs"] == []
finally:
await channel.stop()
await server_task
@@ -802,17 +807,17 @@ async def test_session_delete_can_cascade_bound_automations(
@pytest.mark.asyncio
async def test_session_delete_does_not_cascade_unified_automations(
async def test_session_delete_blocks_origin_automation_when_unified_enabled(
bus: MagicMock, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setattr("nanobot.config.paths.get_data_dir", lambda: tmp_path)
sm = _seed_session(tmp_path, key="websocket:doomed")
cron = CronService(tmp_path / "cron" / "jobs.json")
cron.add_job(
name="Shared daily check",
name="Chat daily check",
schedule=CronSchedule(kind="every", every_ms=86_400_000),
message="Check the shared session",
session_key=UNIFIED_SESSION_KEY,
message="Check this chat",
session_key="websocket:doomed",
)
channel = _ch(
bus,
@@ -835,10 +840,13 @@ async def test_session_delete_does_not_cascade_unified_automations(
)
assert resp.status_code == 200
assert resp.json()["deleted"] is True
assert not path.exists()
assert [job.name for job in cron.list_bound_cron_jobs_for_session(UNIFIED_SESSION_KEY)] == [
"Shared daily check"
body = resp.json()
assert body["deleted"] is False
assert body["blocked_by_automations"] is True
assert [job["name"] for job in body["automations"]] == ["Chat daily check"]
assert path.exists()
assert [job.name for job in cron.list_bound_cron_jobs_for_session("websocket:doomed")] == [
"Chat daily check"
]
finally:
await channel.stop()
+3 -36
View File
@@ -16,7 +16,6 @@ from nanobot.cron.types import CronJob, CronPayload
from nanobot.providers.factory import ProviderSnapshot, make_provider
from nanobot.providers.openai_codex_provider import _strip_model_prefix
from nanobot.providers.registry import find_by_name
from nanobot.session.routing import SESSION_ROUTING_METADATA_KEY
from nanobot.webui.metadata import WEBUI_MESSAGE_SOURCE_METADATA_KEY, WEBUI_TURN_METADATA_KEY
runner = CliRunner()
@@ -1548,38 +1547,10 @@ def test_gateway_bound_cron_runs_as_session_turn(
)
monkeypatch.setattr("nanobot.bus.queue.MessageBus", lambda: bus)
route_metadata = {
"websocket:chat-1": {
"workspace_scope": {
"project_path": str(tmp_path),
"access_mode": "restricted",
},
SESSION_ROUTING_METADATA_KEY: {
"channel": "websocket",
"chat_id": "chat-1",
"metadata": {},
},
},
"discord:456:thread:777": {
SESSION_ROUTING_METADATA_KEY: {
"channel": "discord",
"chat_id": "777",
"metadata": {
"context_chat_id": "456",
"parent_channel_id": "456",
"thread_id": "777",
},
},
},
}
class _FakeSessionManager:
def __init__(self, _workspace: Path) -> None:
pass
def read_session_file(self, key: str) -> dict[str, object] | None:
return {"metadata": route_metadata.get(key, {})}
monkeypatch.setattr("nanobot.session.manager.SessionManager", _FakeSessionManager)
class _FakeCron:
@@ -1651,10 +1622,9 @@ def test_gateway_bound_cron_runs_as_session_turn(
assert msg.channel == "websocket"
assert msg.chat_id == "chat-1"
assert msg.sender_id == "cron"
assert msg.session_key_override == "websocket:chat-1"
assert msg.session_key_override is None
assert "Cron job: Check repository health." in msg.content
assert msg.metadata["webui"] is True
assert msg.metadata["workspace_scope"]["project_path"] == str(tmp_path)
assert msg.metadata[WEBUI_MESSAGE_SOURCE_METADATA_KEY] == {
"kind": "cron",
"label": "Repo check",
@@ -1675,7 +1645,7 @@ def test_gateway_bound_cron_runs_as_session_turn(
name="Thread check",
payload=CronPayload(
message="Check the Discord thread.",
session_key="discord:456:thread:777",
session_key="discord:777",
),
)
@@ -1686,10 +1656,7 @@ def test_gateway_bound_cron_runs_as_session_turn(
assert isinstance(msg, InboundMessage)
assert msg.channel == "discord"
assert msg.chat_id == "777"
assert msg.session_key_override == "discord:456:thread:777"
assert msg.metadata["context_chat_id"] == "456"
assert msg.metadata["parent_channel_id"] == "456"
assert msg.metadata["thread_id"] == "777"
assert msg.session_key_override is None
def test_gateway_cron_job_suppresses_intermediate_progress(
-53
View File
@@ -1,53 +0,0 @@
from nanobot.bus.events import InboundMessage
from nanobot.session.routing import routing_context_for_message
def test_routing_context_keeps_telegram_topic_without_stale_message_id() -> None:
context = routing_context_for_message(
InboundMessage(
channel="telegram",
sender_id="user-1",
chat_id="-100123",
content="set a reminder",
metadata={
"message_id": 100,
"message_thread_id": 42,
"_progress": True,
},
session_key_override="telegram:-100123:topic:42",
)
)
assert context == {
"channel": "telegram",
"chat_id": "-100123",
"metadata": {"message_thread_id": 42},
}
def test_routing_context_keeps_feishu_topic_anchor() -> None:
context = routing_context_for_message(
InboundMessage(
channel="feishu",
sender_id="ou_user",
chat_id="oc_chat",
content="set a reminder",
metadata={
"chat_type": "group",
"message_id": "om_msg",
"thread_id": "omt_thread",
"_progress": True,
},
session_key_override="feishu:oc_chat:om_root",
)
)
assert context == {
"channel": "feishu",
"chat_id": "oc_chat",
"metadata": {
"chat_type": "group",
"message_id": "om_msg",
"thread_id": "omt_thread",
},
}
+4 -4
View File
@@ -246,8 +246,8 @@ async def test_cron_tool_basic_set_context_and_execute(tmp_path) -> None:
@pytest.mark.asyncio
async def test_webui_cron_tool_uses_unified_session_when_enabled(tmp_path) -> None:
"""WebUI-created automations should follow unified session ownership."""
async def test_webui_cron_tool_uses_origin_session_when_unified_enabled(tmp_path) -> None:
"""WebUI-created cron jobs stay attached to the creating chat."""
tool = CronTool(CronService(tmp_path / "jobs.json"))
class _Tools:
@@ -271,7 +271,7 @@ async def test_webui_cron_tool_uses_unified_session_when_enabled(tmp_path) -> No
jobs = tool._cron.list_jobs()
assert len(jobs) == 1
assert jobs[0].payload.session_key == UNIFIED_SESSION_KEY
assert jobs[0].payload.session_key == "websocket:chat-123"
@pytest.mark.asyncio
@@ -280,4 +280,4 @@ async def test_cron_tool_no_context_returns_error(tmp_path) -> None:
tool = CronTool(CronService(tmp_path / "jobs.json"))
result = await tool.execute(action="add", message="test", every_seconds=60)
assert result == "Error: scheduled automations must be created from a chat session"
assert result == "Error: scheduled cron jobs must be created from a chat session"