aidev-bkplugin 2.2.0__tar.gz → 2.2.0.post3__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (79) hide show
  1. {aidev_bkplugin-2.2.0 → aidev_bkplugin-2.2.0.post3}/PKG-INFO +2 -2
  2. {aidev_bkplugin-2.2.0 → aidev_bkplugin-2.2.0.post3}/aidev_bkplugin/packages/checkpoint/bk_django_saver.py +26 -12
  3. {aidev_bkplugin-2.2.0 → aidev_bkplugin-2.2.0.post3}/aidev_bkplugin/services/agent_bkplugin.py +34 -2
  4. {aidev_bkplugin-2.2.0 → aidev_bkplugin-2.2.0.post3}/aidev_bkplugin/services/agent_execution.py +12 -18
  5. {aidev_bkplugin-2.2.0 → aidev_bkplugin-2.2.0.post3}/aidev_bkplugin/services/agent_session.py +20 -5
  6. {aidev_bkplugin-2.2.0 → aidev_bkplugin-2.2.0.post3}/pyproject.toml +2 -2
  7. aidev_bkplugin-2.2.0.post3/tests/packages/test_bk_django_saver.py +69 -0
  8. aidev_bkplugin-2.2.0.post3/tests/services/test_agent_completion.py +58 -0
  9. {aidev_bkplugin-2.2.0 → aidev_bkplugin-2.2.0.post3}/tests/services/test_bkplugin.py +38 -0
  10. {aidev_bkplugin-2.2.0 → aidev_bkplugin-2.2.0.post3}/aidev_bkplugin/__init__.py +0 -0
  11. {aidev_bkplugin-2.2.0 → aidev_bkplugin-2.2.0.post3}/aidev_bkplugin/apps.py +0 -0
  12. {aidev_bkplugin-2.2.0 → aidev_bkplugin-2.2.0.post3}/aidev_bkplugin/constants.py +0 -0
  13. {aidev_bkplugin-2.2.0 → aidev_bkplugin-2.2.0.post3}/aidev_bkplugin/enums.py +0 -0
  14. {aidev_bkplugin-2.2.0 → aidev_bkplugin-2.2.0.post3}/aidev_bkplugin/management/__init__.py +0 -0
  15. {aidev_bkplugin-2.2.0 → aidev_bkplugin-2.2.0.post3}/aidev_bkplugin/management/commands/__init__.py +0 -0
  16. {aidev_bkplugin-2.2.0 → aidev_bkplugin-2.2.0.post3}/aidev_bkplugin/management/commands/upgrade_sessions.py +0 -0
  17. {aidev_bkplugin-2.2.0 → aidev_bkplugin-2.2.0.post3}/aidev_bkplugin/migrations/0001_initial.py +0 -0
  18. {aidev_bkplugin-2.2.0 → aidev_bkplugin-2.2.0.post3}/aidev_bkplugin/migrations/__init__.py +0 -0
  19. {aidev_bkplugin-2.2.0 → aidev_bkplugin-2.2.0.post3}/aidev_bkplugin/models.py +0 -0
  20. {aidev_bkplugin-2.2.0 → aidev_bkplugin-2.2.0.post3}/aidev_bkplugin/openapi/__init__.py +0 -0
  21. {aidev_bkplugin-2.2.0 → aidev_bkplugin-2.2.0.post3}/aidev_bkplugin/openapi/urls.py +0 -0
  22. {aidev_bkplugin-2.2.0 → aidev_bkplugin-2.2.0.post3}/aidev_bkplugin/openapi/views.py +0 -0
  23. {aidev_bkplugin-2.2.0 → aidev_bkplugin-2.2.0.post3}/aidev_bkplugin/packages/__init__.py +0 -0
  24. {aidev_bkplugin-2.2.0 → aidev_bkplugin-2.2.0.post3}/aidev_bkplugin/packages/apigw/__init__.py +0 -0
  25. {aidev_bkplugin-2.2.0 → aidev_bkplugin-2.2.0.post3}/aidev_bkplugin/packages/apigw/permissions.py +0 -0
  26. {aidev_bkplugin-2.2.0 → aidev_bkplugin-2.2.0.post3}/aidev_bkplugin/packages/checkpoint/__init__.py +0 -0
  27. {aidev_bkplugin-2.2.0 → aidev_bkplugin-2.2.0.post3}/aidev_bkplugin/packages/drf/__init__.py +0 -0
  28. {aidev_bkplugin-2.2.0 → aidev_bkplugin-2.2.0.post3}/aidev_bkplugin/packages/drf/authentication.py +0 -0
  29. {aidev_bkplugin-2.2.0 → aidev_bkplugin-2.2.0.post3}/aidev_bkplugin/packages/drf/decorators.py +0 -0
  30. {aidev_bkplugin-2.2.0 → aidev_bkplugin-2.2.0.post3}/aidev_bkplugin/packages/drf/exception.py +0 -0
  31. {aidev_bkplugin-2.2.0 → aidev_bkplugin-2.2.0.post3}/aidev_bkplugin/packages/drf/renderers.py +0 -0
  32. {aidev_bkplugin-2.2.0 → aidev_bkplugin-2.2.0.post3}/aidev_bkplugin/permissions.py +0 -0
  33. {aidev_bkplugin-2.2.0 → aidev_bkplugin-2.2.0.post3}/aidev_bkplugin/private/__init__.py +0 -0
  34. {aidev_bkplugin-2.2.0 → aidev_bkplugin-2.2.0.post3}/aidev_bkplugin/private/permissions.py +0 -0
  35. {aidev_bkplugin-2.2.0 → aidev_bkplugin-2.2.0.post3}/aidev_bkplugin/private/urls.py +0 -0
  36. {aidev_bkplugin-2.2.0 → aidev_bkplugin-2.2.0.post3}/aidev_bkplugin/private/views.py +0 -0
  37. {aidev_bkplugin-2.2.0 → aidev_bkplugin-2.2.0.post3}/aidev_bkplugin/serializers/__init__.py +0 -0
  38. {aidev_bkplugin-2.2.0 → aidev_bkplugin-2.2.0.post3}/aidev_bkplugin/serializers/agent.py +0 -0
  39. {aidev_bkplugin-2.2.0 → aidev_bkplugin-2.2.0.post3}/aidev_bkplugin/serializers/chat_completion.py +0 -0
  40. {aidev_bkplugin-2.2.0 → aidev_bkplugin-2.2.0.post3}/aidev_bkplugin/serializers/user_operation.py +0 -0
  41. {aidev_bkplugin-2.2.0 → aidev_bkplugin-2.2.0.post3}/aidev_bkplugin/services/__init__.py +0 -0
  42. {aidev_bkplugin-2.2.0 → aidev_bkplugin-2.2.0.post3}/aidev_bkplugin/services/agent_builder.py +0 -0
  43. {aidev_bkplugin-2.2.0 → aidev_bkplugin-2.2.0.post3}/aidev_bkplugin/services/agent_config.py +0 -0
  44. {aidev_bkplugin-2.2.0 → aidev_bkplugin-2.2.0.post3}/aidev_bkplugin/services/agent_helpers.py +0 -0
  45. {aidev_bkplugin-2.2.0 → aidev_bkplugin-2.2.0.post3}/aidev_bkplugin/services/approval_resume.py +0 -0
  46. {aidev_bkplugin-2.2.0 → aidev_bkplugin-2.2.0.post3}/aidev_bkplugin/services/user_operation.py +0 -0
  47. {aidev_bkplugin-2.2.0 → aidev_bkplugin-2.2.0.post3}/aidev_bkplugin/settings.py +0 -0
  48. {aidev_bkplugin-2.2.0 → aidev_bkplugin-2.2.0.post3}/aidev_bkplugin/tasks.py +0 -0
  49. {aidev_bkplugin-2.2.0 → aidev_bkplugin-2.2.0.post3}/aidev_bkplugin/urls.py +0 -0
  50. {aidev_bkplugin-2.2.0 → aidev_bkplugin-2.2.0.post3}/aidev_bkplugin/utils.py +0 -0
  51. {aidev_bkplugin-2.2.0 → aidev_bkplugin-2.2.0.post3}/aidev_bkplugin/views/__init__.py +0 -0
  52. {aidev_bkplugin-2.2.0 → aidev_bkplugin-2.2.0.post3}/aidev_bkplugin/views/agent.py +0 -0
  53. {aidev_bkplugin-2.2.0 → aidev_bkplugin-2.2.0.post3}/aidev_bkplugin/views/base.py +0 -0
  54. {aidev_bkplugin-2.2.0 → aidev_bkplugin-2.2.0.post3}/aidev_bkplugin/views/chat.py +0 -0
  55. {aidev_bkplugin-2.2.0 → aidev_bkplugin-2.2.0.post3}/aidev_bkplugin/views/chat_group.py +0 -0
  56. {aidev_bkplugin-2.2.0 → aidev_bkplugin-2.2.0.post3}/aidev_bkplugin/views/flow_agent.py +0 -0
  57. {aidev_bkplugin-2.2.0 → aidev_bkplugin-2.2.0.post3}/aidev_bkplugin/views/session.py +0 -0
  58. {aidev_bkplugin-2.2.0 → aidev_bkplugin-2.2.0.post3}/aidev_bkplugin/views/user_operation.py +0 -0
  59. {aidev_bkplugin-2.2.0 → aidev_bkplugin-2.2.0.post3}/readme.md +0 -0
  60. {aidev_bkplugin-2.2.0 → aidev_bkplugin-2.2.0.post3}/tests/__init__.py +0 -0
  61. {aidev_bkplugin-2.2.0 → aidev_bkplugin-2.2.0.post3}/tests/conftest.py +0 -0
  62. {aidev_bkplugin-2.2.0 → aidev_bkplugin-2.2.0.post3}/tests/packages/__init__.py +0 -0
  63. {aidev_bkplugin-2.2.0 → aidev_bkplugin-2.2.0.post3}/tests/private/__init__.py +0 -0
  64. {aidev_bkplugin-2.2.0 → aidev_bkplugin-2.2.0.post3}/tests/private/test_private_views.py +0 -0
  65. {aidev_bkplugin-2.2.0 → aidev_bkplugin-2.2.0.post3}/tests/services/__init__.py +0 -0
  66. {aidev_bkplugin-2.2.0 → aidev_bkplugin-2.2.0.post3}/tests/services/agent/__init__.py +0 -0
  67. {aidev_bkplugin-2.2.0 → aidev_bkplugin-2.2.0.post3}/tests/services/agent/conftest.py +0 -0
  68. {aidev_bkplugin-2.2.0 → aidev_bkplugin-2.2.0.post3}/tests/services/agent/test_agent_config_fetcher.py +0 -0
  69. {aidev_bkplugin-2.2.0 → aidev_bkplugin-2.2.0.post3}/tests/services/agent/test_builder.py +0 -0
  70. {aidev_bkplugin-2.2.0 → aidev_bkplugin-2.2.0.post3}/tests/services/agent/test_session_manager.py +0 -0
  71. {aidev_bkplugin-2.2.0 → aidev_bkplugin-2.2.0.post3}/tests/services/test_agui_handler.py +0 -0
  72. {aidev_bkplugin-2.2.0 → aidev_bkplugin-2.2.0.post3}/tests/services/test_thread_chat_completion.py +0 -0
  73. {aidev_bkplugin-2.2.0 → aidev_bkplugin-2.2.0.post3}/tests/services/test_user_operation.py +0 -0
  74. {aidev_bkplugin-2.2.0 → aidev_bkplugin-2.2.0.post3}/tests/settings.py +0 -0
  75. {aidev_bkplugin-2.2.0 → aidev_bkplugin-2.2.0.post3}/tests/test_constants.py +0 -0
  76. {aidev_bkplugin-2.2.0 → aidev_bkplugin-2.2.0.post3}/tests/test_utils_otel.py +0 -0
  77. {aidev_bkplugin-2.2.0 → aidev_bkplugin-2.2.0.post3}/tests/views/__init__.py +0 -0
  78. {aidev_bkplugin-2.2.0 → aidev_bkplugin-2.2.0.post3}/tests/views/test_chat_completion_serializer.py +0 -0
  79. {aidev_bkplugin-2.2.0 → aidev_bkplugin-2.2.0.post3}/tests/views/test_chat_flow_agent.py +0 -0
@@ -1,9 +1,9 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: aidev-bkplugin
3
- Version: 2.2.0
3
+ Version: 2.2.0.post3
4
4
  Summary: AiDev Agent Bkplugin builtin functions
5
5
  Requires-Python: <3.12,>=3.11
6
- Requires-Dist: aidev-agent>=2.2.0
6
+ Requires-Dist: aidev-agent>=2.2.0post3
7
7
  Requires-Dist: blueapps<5.0,>=4.15.1
8
8
  Requires-Dist: django<5.0,>=4.2.28
9
9
  Description-Content-Type: text/markdown
@@ -21,10 +21,11 @@ from __future__ import annotations
21
21
  import json
22
22
  import random
23
23
  import threading
24
+ import time
24
25
  from collections.abc import AsyncIterator, Iterator, Sequence
25
26
  from typing import Any, Optional, Tuple, cast
26
27
 
27
- from django.db import connections, router, transaction
28
+ from django.db import OperationalError, close_old_connections, connections, router, transaction
28
29
  from langchain_core.runnables import RunnableConfig
29
30
  from langgraph.checkpoint.base import (
30
31
  WRITES_IDX_MAP,
@@ -40,6 +41,10 @@ from langgraph.checkpoint.base import (
40
41
  from langgraph.checkpoint.serde.jsonplus import JsonPlusSerializer
41
42
  from langgraph.checkpoint.serde.types import ChannelProtocol
42
43
 
44
+ RETRYABLE_DATABASE_ERROR_CODES = {1205, 1213}
45
+ CHECKPOINT_WRITE_MAX_RETRIES = 3
46
+ CHECKPOINT_WRITE_RETRY_DELAY_SECONDS = 0.05
47
+
43
48
 
44
49
  def bulk_upsert(model, objs, update_fields, unique_fields):
45
50
  """
@@ -605,17 +610,26 @@ class BKDjangoSaver(BaseCheckpointSaver[str]):
605
610
  serialized_metadata = json.loads(json.dumps(raw_metadata, default=str).replace("\\u0000", ""))
606
611
  # 使用Django的update_or_create方法
607
612
  with self.lock:
608
- self.checkpoint_model.objects.update_or_create(
609
- thread_id=thread_id,
610
- checkpoint_ns=checkpoint_ns,
611
- checkpoint_id=checkpoint["id"],
612
- defaults={
613
- "parent_checkpoint_id": config["configurable"].get("checkpoint_id"),
614
- "type": type_,
615
- "checkpoint": serialized_checkpoint,
616
- "metadata": serialized_metadata,
617
- },
618
- )
613
+ for attempt in range(CHECKPOINT_WRITE_MAX_RETRIES):
614
+ try:
615
+ self.checkpoint_model.objects.update_or_create(
616
+ thread_id=thread_id,
617
+ checkpoint_ns=checkpoint_ns,
618
+ checkpoint_id=checkpoint["id"],
619
+ defaults={
620
+ "parent_checkpoint_id": config["configurable"].get("checkpoint_id"),
621
+ "type": type_,
622
+ "checkpoint": serialized_checkpoint,
623
+ "metadata": serialized_metadata,
624
+ },
625
+ )
626
+ break
627
+ except OperationalError as exc:
628
+ error_code = exc.args[0] if exc.args else None
629
+ if error_code not in RETRYABLE_DATABASE_ERROR_CODES or attempt == CHECKPOINT_WRITE_MAX_RETRIES - 1:
630
+ raise
631
+ close_old_connections()
632
+ time.sleep(CHECKPOINT_WRITE_RETRY_DELAY_SECONDS * (2**attempt))
619
633
 
620
634
  return {
621
635
  "configurable": {
@@ -16,12 +16,13 @@
16
16
  from __future__ import annotations
17
17
 
18
18
  import logging
19
+ import threading
19
20
  import uuid
20
21
  from abc import ABC, abstractmethod
21
22
  from typing import Any, ClassVar
22
23
 
23
24
  from aidev_agent.config import settings as agent_settings
24
- from aidev_agent.enums import AgentBuildType, AgentType, ChannelType, PromptRole
25
+ from aidev_agent.enums import AgentBuildType, AgentType, ChannelType, PromptRole, SessionsStatus
25
26
  from aidev_agent.packages.resource_manager.agent import AgentResourceManager
26
27
  from aidev_agent.pydantic_models import ExecuteKwargs
27
28
  from aidev_agent.services.agent import AgentInstanceFactory
@@ -212,11 +213,42 @@ class BkpluginAgentRunner(ABC):
212
213
  chat_context: list[dict] | None = None,
213
214
  ) -> None:
214
215
  """Celery worker 入口:捕获异常并写 session 失败状态。"""
216
+ logger.info(
217
+ "[Bkplugin] run_worker enter session_code=%s turn_id=%s thread=%s",
218
+ session_code,
219
+ execute_payload.get("turn_id") or "",
220
+ threading.current_thread().name,
221
+ )
215
222
  try:
216
223
  self.invoke_agent(session_code, execute_payload, chat_context=chat_context or [])
217
224
  except Exception as e:
218
225
  logger.exception("[Bkplugin] worker error session_code=%s", session_code)
219
- SessionManager(self.username or "").save_stream_failure(
226
+ manager = SessionManager(self.username or "")
227
+ # 幂等保护:心跳超时误报时 producer 实际已完成(session=FINISHED),
228
+ # 不应再覆盖为 FAILED。仅当 session 仍处于非终态时才写失败,
229
+ # 避免覆盖已确定的终态(FINISHED/CANCELLED/FAILED)。
230
+ try:
231
+ current_status = str(manager.retrieve_session(session_code).get("status") or "")
232
+ except Exception:
233
+ logger.exception(
234
+ "[Bkplugin] retrieve_session failed before save_stream_failure session_code=%s",
235
+ session_code,
236
+ )
237
+ current_status = ""
238
+ if current_status in (
239
+ SessionsStatus.FINISHED.value,
240
+ SessionsStatus.CANCELLED.value,
241
+ SessionsStatus.FAILED.value,
242
+ ):
243
+ logger.warning(
244
+ "[Bkplugin] skip save_stream_failure: session already %s "
245
+ "(likely heartbeat-timeout false positive) session_code=%s exc=%r",
246
+ current_status,
247
+ session_code,
248
+ e,
249
+ )
250
+ return
251
+ manager.save_stream_failure(
220
252
  session_code,
221
253
  f"Agent 执行异常: {e}",
222
254
  turn_id=execute_payload.get("turn_id") or "",
@@ -89,7 +89,7 @@ class AgentExecutor:
89
89
  *,
90
90
  turn_id: str = "",
91
91
  ):
92
- """执行 agent 直至结束;AG-UI 流式负责事件落库并收尾会话状态。"""
92
+ """执行 agent 直至结束;后台消费者完整 drain 后统一收尾会话状态。"""
93
93
  # 后台 drain(for _ in out: pass,无 SSE 下游):标记为 background_only,
94
94
  # 使消费者读到 EOD 时不立即清理队列,保留 DLQ 历史供前端在清理窗口内接管续流。
95
95
  execute_kwargs.background_only = True
@@ -99,24 +99,18 @@ class AgentExecutor:
99
99
  handler.turn_id = turn_id
100
100
  if hasattr(handler, "set_streaming_started"):
101
101
  handler.set_streaming_started()
102
- try:
103
- out = cls(session_manager).execute_with_save(
104
- agent_instance,
105
- execute_kwargs,
106
- session_code,
107
- turn_id=turn_id,
108
- )
109
- if execute_kwargs.stream:
110
- for _ in out:
111
- pass
112
- return out
113
- finally:
114
- if (
115
- execute_kwargs.stream
116
- and isinstance(handler, BaseSessionWriter)
117
- and hasattr(handler, "set_streaming_finished")
118
- ):
102
+ out = cls(session_manager).execute_with_save(
103
+ agent_instance,
104
+ execute_kwargs,
105
+ session_code,
106
+ turn_id=turn_id,
107
+ )
108
+ if execute_kwargs.stream:
109
+ for _ in out:
110
+ pass
111
+ if isinstance(handler, BaseSessionWriter) and hasattr(handler, "set_streaming_finished"):
119
112
  handler.set_streaming_finished()
113
+ return out
120
114
 
121
115
  def wrap_generator(self, generator, session_code: str, *, turn_id: str = ""):
122
116
  """SSE 数据格式约定:
@@ -238,24 +238,39 @@ class SessionManager:
238
238
  if status in (SessionsStatus.PENDING.value, SessionsStatus.RUNNING.value):
239
239
  return PluginPollTaskState.RUNNING, ""
240
240
  if status in (SessionsStatus.FAILED.value, SessionsStatus.CANCELLED.value):
241
- return PluginPollTaskState.FAILED, "Agent 执行失败"
241
+ # 取回 save_stream_failure 写入的实际异常内容,避免 UI 只显示通用 "Agent 执行失败"
242
+ return PluginPollTaskState.FAILED, self._last_output(
243
+ self.list_session_contents(session_code),
244
+ turn_id=turn_id,
245
+ statuses=(ChatContentStatus.ERROR.value,),
246
+ ) or "Agent 执行失败"
242
247
  if status == SessionsStatus.FINISHED.value:
243
- return PluginPollTaskState.SUCCESS, self._last_assistant_output(
248
+ return PluginPollTaskState.SUCCESS, self._last_output(
244
249
  self.list_session_contents(session_code),
245
250
  turn_id=turn_id,
251
+ statuses=(ChatContentStatus.COMPLETE.value, ChatContentStatus.SUCCESS.value),
246
252
  )
247
253
  return PluginPollTaskState.RUNNING, ""
248
254
 
249
255
  @staticmethod
250
- def _last_assistant_output(items: list[dict], *, turn_id: str = "") -> str:
251
- """取最后一条有内容的 assistant/ai;有 ``turn_id`` 时仅取同轮消息。"""
256
+ def _last_output(
257
+ items: list[dict],
258
+ *,
259
+ turn_id: str = "",
260
+ statuses: tuple[str, ...],
261
+ ) -> str:
262
+ """取最后一条指定 status 的 assistant/ai 内容;有 ``turn_id`` 时仅取同轮消息。
263
+
264
+ FINISHED 取 COMPLETE/SUCCESS 的正常回复;FAILED/CANCELLED 取 ERROR 的实际异常
265
+ (save_stream_failure 写入),避免上层只看到通用 "Agent 执行失败"。
266
+ """
252
267
  assistant_roles = (PromptRole.ASSISTANT.value, PromptRole.AI.value)
253
268
  for item in reversed(items):
254
269
  if item.get("role") not in assistant_roles:
255
270
  continue
256
271
  if turn_id and (item.get("property") or {}).get("turn_id") != turn_id:
257
272
  continue
258
- if item.get("status") not in (ChatContentStatus.COMPLETE.value, ChatContentStatus.SUCCESS.value):
273
+ if item.get("status") not in statuses:
259
274
  continue
260
275
  text = str(item.get("content") or "").strip()
261
276
  if text:
@@ -1,11 +1,11 @@
1
1
  [project]
2
2
  name = "aidev-bkplugin"
3
- version = "2.2.0"
3
+ version = "2.2.0post3"
4
4
  description = "AiDev Agent Bkplugin builtin functions"
5
5
  readme = "readme.md"
6
6
  requires-python = "<3.12,>=3.11"
7
7
  dependencies = [
8
- "aidev-agent>=2.2.0",
8
+ "aidev-agent>=2.2.0post3",
9
9
  "blueapps>=4.15.1, <5.0",
10
10
  "django>=4.2.28,<5.0",
11
11
  ]
@@ -0,0 +1,69 @@
1
+ # -*- coding: utf-8 -*-
2
+
3
+ import threading
4
+
5
+ import pytest
6
+ from aidev_bkplugin.packages.checkpoint.bk_django_saver import BKDjangoSaver
7
+ from django.db import OperationalError
8
+
9
+
10
+ @pytest.fixture
11
+ def saver(mocker):
12
+ instance = object.__new__(BKDjangoSaver)
13
+ instance.lock = threading.Lock()
14
+ instance.checkpoint_model = mocker.Mock()
15
+ instance.serde = mocker.Mock()
16
+ instance.serde.dumps_typed.return_value = ("json", b"checkpoint")
17
+ return instance
18
+
19
+
20
+ @pytest.fixture
21
+ def checkpoint_args():
22
+ return (
23
+ {"configurable": {"thread_id": "thread-id"}},
24
+ {"id": "checkpoint-id"},
25
+ {},
26
+ {},
27
+ )
28
+
29
+
30
+ @pytest.mark.parametrize("error_code", [1205, 1213])
31
+ def test_put_retries_transient_database_lock_error(mocker, saver, checkpoint_args, error_code):
32
+ saver.checkpoint_model.objects.update_or_create.side_effect = [
33
+ OperationalError(error_code, "retryable"),
34
+ (mocker.Mock(), True),
35
+ ]
36
+ close_old_connections = mocker.patch("aidev_bkplugin.packages.checkpoint.bk_django_saver.close_old_connections")
37
+ sleep = mocker.patch("aidev_bkplugin.packages.checkpoint.bk_django_saver.time.sleep")
38
+
39
+ saver.put(*checkpoint_args)
40
+
41
+ assert saver.checkpoint_model.objects.update_or_create.call_count == 2
42
+ close_old_connections.assert_called_once_with()
43
+ sleep.assert_called_once_with(0.05)
44
+
45
+
46
+ def test_put_does_not_retry_other_database_error(mocker, saver, checkpoint_args):
47
+ error = OperationalError(2006, "server has gone away")
48
+ saver.checkpoint_model.objects.update_or_create.side_effect = error
49
+ sleep = mocker.patch("aidev_bkplugin.packages.checkpoint.bk_django_saver.time.sleep")
50
+
51
+ with pytest.raises(OperationalError) as exc_info:
52
+ saver.put(*checkpoint_args)
53
+
54
+ assert exc_info.value is error
55
+ assert saver.checkpoint_model.objects.update_or_create.call_count == 1
56
+ sleep.assert_not_called()
57
+
58
+
59
+ def test_put_raises_after_database_lock_retries_exhausted(mocker, saver, checkpoint_args):
60
+ error = OperationalError(1213, "deadlock")
61
+ saver.checkpoint_model.objects.update_or_create.side_effect = error
62
+ sleep = mocker.patch("aidev_bkplugin.packages.checkpoint.bk_django_saver.time.sleep")
63
+
64
+ with pytest.raises(OperationalError) as exc_info:
65
+ saver.put(*checkpoint_args)
66
+
67
+ assert exc_info.value is error
68
+ assert saver.checkpoint_model.objects.update_or_create.call_count == 3
69
+ assert [call.args[0] for call in sleep.call_args_list] == [0.05, 0.1]
@@ -0,0 +1,58 @@
1
+ # -*- coding: utf-8 -*-
2
+ """后台 Agent 流式终态写入回归测试。"""
3
+
4
+ import sys
5
+ from unittest.mock import MagicMock, patch
6
+
7
+ import pytest
8
+
9
+ if "pkg_resources" not in sys.modules:
10
+ sys.modules["pkg_resources"] = MagicMock()
11
+
12
+ sys.modules.setdefault("aidev_bkplugin.models", MagicMock())
13
+ _bk_plugin_framework = MagicMock()
14
+ _bk_plugin_framework.kit.decorators.inject_user_token = lambda func: func
15
+ sys.modules.setdefault("bk_plugin_framework", _bk_plugin_framework)
16
+ sys.modules.setdefault("bk_plugin_framework.kit", _bk_plugin_framework.kit)
17
+ sys.modules.setdefault("bk_plugin_framework.kit.decorators", _bk_plugin_framework.kit.decorators)
18
+
19
+
20
+ class TestAgentExecutorCompletion:
21
+ def test_sets_finished_after_stream_is_drained(self):
22
+ from aidev_agent.services.event_handlers.base import BaseSessionWriter
23
+ from aidev_bkplugin.services.agent_execution import AgentExecutor
24
+
25
+ lifecycle = []
26
+
27
+ def stream():
28
+ yield "chunk"
29
+ lifecycle.append("drained")
30
+
31
+ handler = MagicMock(spec=BaseSessionWriter)
32
+ handler.set_streaming_finished.side_effect = lambda: lifecycle.append("finished")
33
+ agent = MagicMock(event_handler=handler)
34
+ execute_kwargs = MagicMock(stream=True)
35
+ with patch.object(AgentExecutor, "execute_with_save", return_value=stream()):
36
+ AgentExecutor.run_agent_to_completion(agent, execute_kwargs, "session-abc", MagicMock())
37
+
38
+ assert execute_kwargs.background_only is True
39
+ assert lifecycle == ["drained", "finished"]
40
+
41
+ def test_does_not_set_finished_when_stream_drain_fails(self):
42
+ from aidev_agent.services.event_handlers.base import BaseSessionWriter
43
+ from aidev_bkplugin.services.agent_execution import AgentExecutor
44
+
45
+ def failing_stream():
46
+ yield "chunk"
47
+ raise RuntimeError("producer heartbeat timeout")
48
+
49
+ handler = MagicMock(spec=BaseSessionWriter)
50
+ agent = MagicMock(event_handler=handler)
51
+ execute_kwargs = MagicMock(stream=True)
52
+ with (
53
+ patch.object(AgentExecutor, "execute_with_save", return_value=failing_stream()),
54
+ pytest.raises(RuntimeError, match="heartbeat timeout"),
55
+ ):
56
+ AgentExecutor.run_agent_to_completion(agent, execute_kwargs, "session-abc", MagicMock())
57
+
58
+ handler.set_streaming_finished.assert_not_called()
@@ -266,3 +266,41 @@ class TestBkpluginExecution:
266
266
  assert runner.execute_kwargs["session_code"] == "thread-x"
267
267
  assert runner.plugin_context == [{"k": "v"}]
268
268
  mock_resolve.assert_called_once_with("alice")
269
+
270
+ def test_run_worker_skips_save_stream_failure_when_session_finished(self, monkeypatch):
271
+ """心跳超时误报:producer 实际已完成(session=FINISHED),不应覆盖为 FAILED。"""
272
+ from aidev_bkplugin.services.agent_bkplugin import BkpluginChat
273
+
274
+ agent = BkpluginChat(chat_history=[], execute_kwargs={}, username="alice")
275
+ monkeypatch.setattr(
276
+ agent, "invoke_agent", MagicMock(side_effect=RuntimeError("生产者心跳超时"))
277
+ )
278
+ mock_sm = MagicMock()
279
+ mock_sm.retrieve_session.return_value = {"status": SessionsStatus.FINISHED.value}
280
+ monkeypatch.setattr(
281
+ "aidev_bkplugin.services.agent_bkplugin.SessionManager", lambda username: mock_sm
282
+ )
283
+
284
+ agent.run_worker("sess-1", {"turn_id": "t1"})
285
+
286
+ mock_sm.retrieve_session.assert_called_once_with("sess-1")
287
+ mock_sm.save_stream_failure.assert_not_called()
288
+
289
+ def test_run_worker_calls_save_stream_failure_when_session_running(self, monkeypatch):
290
+ """producer 真崩溃:session 仍 RUNNING 时应写失败。"""
291
+ from aidev_bkplugin.services.agent_bkplugin import BkpluginChat
292
+
293
+ agent = BkpluginChat(chat_history=[], execute_kwargs={}, username="alice")
294
+ monkeypatch.setattr(
295
+ agent, "invoke_agent", MagicMock(side_effect=RuntimeError("生产者心跳超时"))
296
+ )
297
+ mock_sm = MagicMock()
298
+ mock_sm.retrieve_session.return_value = {"status": SessionsStatus.RUNNING.value}
299
+ monkeypatch.setattr(
300
+ "aidev_bkplugin.services.agent_bkplugin.SessionManager", lambda username: mock_sm
301
+ )
302
+
303
+ agent.run_worker("sess-1", {"turn_id": "t1"})
304
+
305
+ mock_sm.save_stream_failure.assert_called_once_with("sess-1", ANY, turn_id="t1")
306
+