Skip to content

Commit fa8ff19

Browse files
committed
fix: acquire session lock in cron wake path
The cron wake path (_woke_main_agent) also bypassed session_lock_manager, running concurrently with user message processing on the same conversation. Wrap the read-build-run-persist flow in the per-session lock, matching the user message path in internal.py. - test_cron_wake_lock: asserts acquire_lock is called
1 parent bb2a6a0 commit fa8ff19

2 files changed

Lines changed: 144 additions & 55 deletions

File tree

astrbot/core/cron/manager.py

Lines changed: 57 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
from astrbot.core.platform.message_type import MessageType
2020
from astrbot.core.provider.entites import ProviderRequest
2121
from astrbot.core.utils.history_saver import persist_agent_history
22+
from astrbot.core.utils.session_lock import session_lock_manager
2223

2324
if TYPE_CHECKING:
2425
from astrbot.core.star.context import Context
@@ -448,66 +449,67 @@ async def _woke_main_agent(
448449
streaming_response=False,
449450
provider_settings=provider_settings,
450451
)
451-
req = ProviderRequest()
452-
conv = await _get_session_conv(event=cron_event, plugin_context=self.ctx)
453-
req.conversation = conv
454-
# finetine the messages
455-
context = json.loads(conv.history)
456-
if context:
457-
req.contexts = context
458-
context_dump = req._print_friendly_context()
459-
req.contexts = []
460-
req.system_prompt += (
461-
"\n\nBellow is you and user previous conversation history:\n"
462-
f"---\n"
463-
f"{context_dump}\n"
464-
f"---\n"
452+
async with session_lock_manager.acquire_lock(umo):
453+
req = ProviderRequest()
454+
conv = await _get_session_conv(event=cron_event, plugin_context=self.ctx)
455+
req.conversation = conv
456+
# finetine the messages
457+
context = json.loads(conv.history)
458+
if context:
459+
req.contexts = context
460+
context_dump = req._print_friendly_context()
461+
req.contexts = []
462+
req.system_prompt += (
463+
"\n\nBellow is you and user previous conversation history:\n"
464+
f"---\n"
465+
f"{context_dump}\n"
466+
f"---\n"
467+
)
468+
cron_job_str = json.dumps(extras.get("cron_job", {}), ensure_ascii=False)
469+
req.system_prompt += PROACTIVE_AGENT_CRON_WOKE_SYSTEM_PROMPT.format(
470+
cron_job=cron_job_str
465471
)
466-
cron_job_str = json.dumps(extras.get("cron_job", {}), ensure_ascii=False)
467-
req.system_prompt += PROACTIVE_AGENT_CRON_WOKE_SYSTEM_PROMPT.format(
468-
cron_job=cron_job_str
469-
)
470-
req.prompt = (
471-
"You are now responding to a scheduled task. "
472-
"Proceed according to your system instructions. "
473-
"Output using same language as previous conversation. "
474-
"After completing your task, summarize and output your actions and results."
475-
)
476-
if delivery_session_str:
477-
if not req.func_tool:
478-
req.func_tool = ToolSet()
479-
req.func_tool.add_tool(
480-
self.ctx.get_llm_tool_manager().get_builtin_tool(SendMessageToUserTool)
472+
req.prompt = (
473+
"You are now responding to a scheduled task. "
474+
"Proceed according to your system instructions. "
475+
"Output using same language as previous conversation. "
476+
"After completing your task, summarize and output your actions and results."
481477
)
478+
if delivery_session_str:
479+
if not req.func_tool:
480+
req.func_tool = ToolSet()
481+
req.func_tool.add_tool(
482+
self.ctx.get_llm_tool_manager().get_builtin_tool(
483+
SendMessageToUserTool
484+
)
485+
)
482486

483-
result = await build_main_agent(
484-
event=cron_event, plugin_context=self.ctx, config=config, req=req
485-
)
486-
if not result:
487-
logger.error("Failed to build main agent for cron job.")
488-
return
489-
490-
runner = result.agent_runner
491-
async for _ in runner.step_until_done(30):
492-
# agent will send message to user via using tools
493-
pass
494-
llm_resp = runner.get_final_llm_resp()
495-
cron_meta = extras.get("cron_job", {}) if extras else {}
496-
summary_note = (
497-
f"[CronJob] {cron_meta.get('name') or cron_meta.get('id', 'unknown')}: {cron_meta.get('description', '')} "
498-
f" triggered at {cron_meta.get('run_started_at', 'unknown time')}, "
499-
)
500-
if llm_resp and llm_resp.role == "assistant":
501-
summary_note += (
502-
f"I finished this job, here is the result: {llm_resp.completion_text}"
487+
result = await build_main_agent(
488+
event=cron_event, plugin_context=self.ctx, config=config, req=req
503489
)
490+
if not result:
491+
logger.error("Failed to build main agent for cron job.")
492+
return
504493

505-
await persist_agent_history(
506-
self.ctx.conversation_manager,
507-
event=cron_event,
508-
req=req,
509-
summary_note=summary_note,
510-
)
494+
runner = result.agent_runner
495+
async for _ in runner.step_until_done(30):
496+
# agent will send message to user via using tools
497+
pass
498+
llm_resp = runner.get_final_llm_resp()
499+
cron_meta = extras.get("cron_job", {}) if extras else {}
500+
summary_note = (
501+
f"[CronJob] {cron_meta.get('name') or cron_meta.get('id', 'unknown')}: {cron_meta.get('description', '')} "
502+
f" triggered at {cron_meta.get('run_started_at', 'unknown time')}, "
503+
)
504+
if llm_resp and llm_resp.role == "assistant":
505+
summary_note += f"I finished this job, here is the result: {llm_resp.completion_text}"
506+
507+
await persist_agent_history(
508+
self.ctx.conversation_manager,
509+
event=cron_event,
510+
req=req,
511+
summary_note=summary_note,
512+
)
511513
if not llm_resp:
512514
logger.warning("Cron job agent got no response")
513515
return

tests/test_cron_wake_lock.py

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
"""
2+
回归测试: cron 定时任务唤醒路径也应使用会话锁
3+
4+
背景: 与后台任务唤醒(_wake_main_agent_for_background_result)相同,
5+
cron 定时任务触发时直接跑 agent, 未获取会话锁 —— 与用户消息并发
6+
处理时可能导致上下文丢失。
7+
8+
本测试断言: 修复后, cron 唤醒流程必须获取会话锁 (acquire_lock 被调用)。
9+
"""
10+
from types import SimpleNamespace
11+
from unittest.mock import AsyncMock, MagicMock, patch
12+
13+
import pytest
14+
15+
from astrbot.core.cron.manager import CronJobManager
16+
17+
18+
def _make_manager():
19+
"""构造最小可用的 CronJobManager mock"""
20+
ctx = SimpleNamespace(
21+
get_config=lambda umo: {"admins_id": [], "provider_settings": {}},
22+
get_llm_tool_manager=MagicMock(),
23+
conversation_manager=MagicMock(update_conversation=AsyncMock()),
24+
)
25+
mgr = CronJobManager.__new__(CronJobManager) # 跳过 __init__, 只设 ctx
26+
mgr.ctx = ctx
27+
return mgr
28+
29+
30+
def _make_runner_mock():
31+
runner = MagicMock()
32+
33+
async def _step_until_done(*args, **kwargs):
34+
yield None
35+
36+
runner.step_until_done.side_effect = _step_until_done
37+
runner.get_final_llm_resp.return_value = SimpleNamespace(
38+
completion_text="done", role="assistant"
39+
)
40+
return runner
41+
42+
43+
@pytest.mark.asyncio
44+
async def test_cron_wake_acquires_session_lock():
45+
"""cron 定时任务唤醒必须获取会话锁(与用户消息/后台唤醒路径一致)"""
46+
mgr = _make_manager()
47+
runner = _make_runner_mock()
48+
49+
lock_mgr = MagicMock()
50+
lock_cm = AsyncMock()
51+
lock_cm.__aenter__.return_value = None
52+
lock_mgr.acquire_lock.return_value = lock_cm
53+
54+
fake_cron_event = SimpleNamespace(
55+
unified_msg_origin="Pstar:FriendMessage:TEST", role="member"
56+
)
57+
58+
with (
59+
patch(
60+
"astrbot.core.cron.manager.session_lock_manager", lock_mgr, create=True
61+
),
62+
patch(
63+
"astrbot.core.astr_main_agent._get_session_conv",
64+
new=AsyncMock(return_value=SimpleNamespace(history="[]", cid="conv-1")),
65+
),
66+
patch(
67+
"astrbot.core.astr_main_agent.build_main_agent",
68+
new=AsyncMock(return_value=SimpleNamespace(agent_runner=runner)),
69+
),
70+
patch(
71+
"astrbot.core.cron.manager.CronMessageEvent",
72+
return_value=fake_cron_event,
73+
),
74+
# MessageSession 需为真实类型(函数内 isinstance 判断), from_str 可解析字符串
75+
):
76+
await mgr._woke_main_agent(
77+
message="test cron job",
78+
session_str="Pstar:FriendMessage:TEST",
79+
extras={"cron_job": {"id": "job-1", "name": "t", "run_started_at": "t"}},
80+
)
81+
82+
# 核心断言: 修复后必须获取会话锁, 且锁粒度为该会话
83+
lock_mgr.acquire_lock.assert_called_once()
84+
args = lock_mgr.acquire_lock.call_args[0]
85+
assert "Pstar:FriendMessage:TEST" in args, (
86+
f"会话锁应按 unified_msg_origin 获取, 实际参数: {args}"
87+
)

0 commit comments

Comments
 (0)