Compare commits

...

10 Commits

Author SHA1 Message Date
f9f08221fc chore: 整理代码风格与优化细节
本次提交包含多类代码优化:
1. 修复多处单行代码换行格式,统一代码排版
2. 为外部系统模块新增快捷菜单配置
3. 优化前端API请求参数命名一致性
4. 补充媒体下载超限错误码与领域异常类
5. 完善仓储层更新逻辑,支持显式清空字段
6. 优化部分测试用例与工具函数代码结构
7. 为微信插件白名单缓存增加过期时间
2026-07-14 15:13:29 +08:00
002e6a356b chore: 批量整理代码变更,修复多类细节问题
1.  清理测试文件中未使用的导入与冗余代码
2.  修复审计日志与批量操作的空值约束,统一填充"global"作为默认渠道
3.  调整批量消息撤回的响应语义,对齐其他端点的部分成功契约
4.  修复访问规则批量克隆的唯一约束问题,新增后缀自动处理逻辑
5.  替换anyio为asyncio并行调用,修正时间UTC导入路径
6.  优化前端外部系统概览页的刷新状态提示与缓存逻辑
7.  修复测试用例中的断言与请求方式问题,适配httpx删除请求特性
8.  重构后端路由的依赖注入,移除冗余的数据库会话依赖
9.  调整测试用例的权限校验逻辑,修正强制登出的权限判断
10. 修复语义分块测试的numpy依赖问题,清理冗余导入
2026-07-13 20:48:29 +08:00
41bd0a618c refactor: 统一将日志方法从 warn 改为 warning
将代码库中所有使用 logger.warn 的地方替换为标准的 logger.warning,对齐日志方法命名规范,修复多处测试和业务代码中的方法调用不匹配问题。
2026-07-13 17:32:29 +08:00
b10366e898 refactor(wechat-woc): 移除bridge_url scheme强制校验,简化URL校验逻辑
1.  移除`_validate_bridge_url_scheme`相关代码与校验逻辑
2.  调整WeChatWoc插件的配置校验注释与测试用例
3.  不再强制要求bridge_url使用https协议,仅保留SSRF主机校验
4.  简化host_bootstrap中GIN索引创建逻辑,移除冗余索引创建步骤
5.  新增transport_mode数据库字段与适配器实现
6.  新增WhitelistConfigAdapter白名单配置适配器
7.  优化前端仪表盘实时监控轮询逻辑,移除可见性感知轮询改为手动触发
2026-07-12 23:53:53 +08:00
44dcb028b9 fix(toolkit): 修复搜索工具参数与工具信息展示问题
1. 为web_search和tavily_search添加runtime默认值None
2. 重构工具参数提取逻辑,跳过runtime参数并适配pydantic v2字段
3. 调整工具包导入顺序与格式化
4. 为外部工具调用添加上下文透传日志字段
2026-07-11 22:08:34 +08:00
56022a9199 chore: 批量整理代码变更,修复细节与优化结构
- 修复 scheduler store 末尾多余逗号
- 将智能体名称从“语析”改为“Kris”
- 新增获取运行记录UID的权限校验接口
- 重构操作日志写入逻辑,使用独立会话避免事务污染
- 注册外部系统调度处理器
- 优化全局错误处理器,统一响应格式与序列化处理
- 新增调度任务运行日志的handler_name字段与索引
- 重构任务执行函数,新增payload覆盖与操作人审计参数
- 重写外部系统store,新增侧边栏折叠状态与持久化
- 优化会话、消息等模型的索引、约束与字段类型
- 新增多项配置项与环境变量支持
- 重构渠道相关模型,新增索引、约束与字段优化
- 新增渠道插件模型与内容审核模型的完善
2026-07-11 22:06:21 +08:00
80296b7db1 feat(web): add qrcode dependency and setup echarts按需引入
新增qrcode第三方依赖,创建echarts统一按需初始化文件,注册常用图表和组件以避免重复引入
2026-07-11 22:00:12 +08:00
1bbfdb56c3 refactor: 优化多处Vue组件代码风格与逻辑
1. 统一格式化模板标签换行与属性排版
2. 新增ErrorBoundary组件捕获页面渲染异常
3. 为FilterBar添加输入防抖优化
4. 重构异常任务卡片逻辑,后端接管异常数据过滤
5. 优化执行日志、统计页面的展示与筛选逻辑
6. 新增回收站批量操作功能
7. 调整运维中心自动刷新与配置展示逻辑
8. 简化任务编辑/创建表单的冗余代码
2026-07-11 21:56:11 +08:00
0239dea9ee refactor(external-systems): 整理外部系统模块代码风格与逻辑
本次提交对外部系统模块进行了多维度优化:
1.  新增OverviewView页面、filterBarHelpers和errorAlertHelpers工具函数
2.  统一格式化组件内联文本、调整props校验写法
3.  重构DiffView、CodeBlock等组件逻辑,修复循环引用序列化问题
4.  优化LoadingState、StatusTag等通用组件的代码结构
5.  替换部分页面的API调用为composable组合式函数,简化代码
6.  修正EnvironmentFormModal的接口注释与参数处理逻辑
7.  修复MaskedText组件的maskType校验与默认行为
8.  为ErrorRetryAlert新增traceId展示与错误解析逻辑
2026-07-11 21:55:47 +08:00
140b13f1d9 refactor: 批量优化前端组件代码质量与用户体验
本次提交对多个前端组件进行了全面优化:
1. 修复弹窗状态残留、重复请求、竞态问题,提升交互稳定性
2. 完善表单校验、加载状态与空数据提示,优化无障碍访问体验
3. 重构数据处理逻辑,增加防御性编程,避免渲染异常
4. 优化国际化与文案细节,统一组件交互规范
5. 新增导航配置校验、防抖resize等工具能力
6. 修复多处类型错误、依赖导入与代码格式问题
2026-07-11 21:55:00 +08:00
806 changed files with 28082 additions and 18655 deletions

View File

@ -7,7 +7,7 @@ from yuxi.utils.paths import (
)
PROMPT = f"""
你是一个交互式智能体语析
你是一个交互式智能体Kris
专门用来回答用户的问题请根据用户提供的信息尽可能详细地回答问题
如果你不确定答案可以说你不知道但请尽量提供相关的信息或建议请保持礼貌和专业

View File

@ -1,12 +1,14 @@
# buildin 工具包
from .install_skill import install_skill
from .tools import ask_user_question, present_artifacts
# Web 搜索与抓取工具(不依赖 LITE 模式,始终注册)
from . import search # noqa: F401
from . import crawl # noqa: F401
from . import (
crawl, # noqa: F401
search, # noqa: F401
)
from .install_skill import install_skill
# 会话通信工具仅在非 LITE 模式下注册
from .session_tools import _LITE_MODE as _session_lite_mode
from .tools import ask_user_question, present_artifacts
if not _session_lite_mode:
from .session_tools import get_agent_progress, get_session_history, list_sessions, send_to_session
@ -18,9 +20,11 @@ __all__ = [
]
if not _session_lite_mode:
__all__.extend([
"get_agent_progress",
"get_session_history",
"list_sessions",
"send_to_session",
])
__all__.extend(
[
"get_agent_progress",
"get_session_history",
"list_sessions",
"send_to_session",
]
)

View File

@ -1,4 +1,5 @@
"""web_search 与 tavily_search 工具注册。"""
import json
from langgraph.prebuilt.tool_node import ToolRuntime
@ -9,7 +10,6 @@ from .chain import build_provider_chain
from .models import SearchResult
from .rate_limiter import rate_limiter
WEB_SEARCH_DESCRIPTION = """
搜索互联网获取实时信息
@ -104,7 +104,7 @@ async def _execute_web_search(query: str, max_results: int, runtime: ToolRuntime
async def web_search(
query: str,
max_results: int = 5,
runtime: ToolRuntime,
runtime: ToolRuntime = None,
) -> str:
"""搜索互联网,返回结构化结果列表。"""
return await _execute_web_search(query, max_results, runtime)
@ -119,7 +119,7 @@ async def web_search(
async def tavily_search(
query: str,
max_results: int = 5,
runtime: ToolRuntime,
runtime: ToolRuntime = None,
) -> str:
"""Tavily 网页搜索(兼容入口,内部委托 _execute_web_search"""
return await _execute_web_search(query, max_results, runtime)

View File

@ -19,16 +19,35 @@ def _extract_tool_info(tool_obj) -> dict:
if hasattr(tool_obj, "args_schema") and tool_obj.args_schema:
schema = tool_obj.args_schema
if hasattr(schema, "schema"):
schema = schema.schema()
for arg_name, arg_info in schema.get("properties", {}).items():
info["args"].append(
{
"name": arg_name,
"type": arg_info.get("type", ""),
"description": arg_info.get("description", ""),
}
)
# 优先从 pydantic v2 ``model_fields`` 提取参数信息,避免对
# ``ToolRuntime`` 等含 callable 字段(如 ``stream_writer``)的注入式
# 参数生成完整 JSON schema 时抛出 ``PydanticInvalidForJsonSchema``。
# ``runtime`` 字段由 langgraph 自动注入,不属于用户输入参数,跳过展示。
model_fields = getattr(schema, "model_fields", None)
if model_fields:
for arg_name, field_info in model_fields.items():
if arg_name == "runtime":
continue
annotation = field_info.annotation
info["args"].append(
{
"name": arg_name,
"type": getattr(annotation, "__name__", str(annotation)),
"description": field_info.description or "",
}
)
elif hasattr(schema, "schema"):
schema_dict = schema.schema()
for arg_name, arg_info in schema_dict.get("properties", {}).items():
if arg_name == "runtime":
continue
info["args"].append(
{
"name": arg_name,
"type": arg_info.get("type", ""),
"description": arg_info.get("description", ""),
}
)
return info
@ -146,15 +165,21 @@ async def resolve_configured_runtime_tools(context) -> list[Any]:
if external_tool_names:
async with pg_manager.get_async_session_context() as db:
use_cases = create_use_cases_from_db(db)
# 透传 agent 上下文uid 作为 caller_idrun_id 作为 correlation_id
# 使 agent 调用外部工具时写入完整的可观测性字段
thread_id = getattr(context, "thread_id", None)
output = await use_cases.tool_service.build_runtime_tools(
BuildRuntimeToolsInput(slugs=external_tool_names),
BuildRuntimeToolsInput(
slugs=external_tool_names,
caller_id=getattr(context, "uid", None),
correlation_id=getattr(context, "run_id", None),
tags={"thread_id": thread_id} if thread_id else {},
),
)
selected_tools.extend(output.items)
selected_tool_names.update(tool.name for tool in output.items)
# 构建失败的工具 slug 必须显式记录,避免用户配置的工具被静默丢失
if output.failed_slugs:
logger.warning(
f"Failed to build external runtime tools, skipped: {output.failed_slugs}"
)
logger.warning(f"Failed to build external runtime tools, skipped: {output.failed_slugs}")
return selected_tools

View File

@ -42,6 +42,7 @@ from yuxi.channels.contract.ports.driven.agent_run_execution_port import (
AgentRunExecutionPort,
)
from yuxi.channels.contract.ports.driven.aggregate import DrivenAdapters
from yuxi.storage.transactions import SqlAlchemyTransactionAdapter
# 被驱动适配器(请求级 / 无状态14 个,经 create_driven_adapters 工厂装配)
from .agent_access_config_adapter import AgentAccessConfigAdapter
@ -63,7 +64,6 @@ from .redis_cache_adapter import RedisCacheAdapter
from .redis_config_adapter import RedisConfigAdapter
from .redis_realtime_metrics_adapter import RedisRealtimeMetricsAdapter
from .service_account_adapter import ServiceAccountAdapter
from .sqlalchemy_transaction_adapter import SqlAlchemyTransactionAdapter
from .structured_logger_adapter import StructuredLoggerAdapter
# 无状态适配器全局实例Masking / Logger / Tracer跨请求复用避免重复构造。
@ -174,7 +174,6 @@ __all__ = [
"StructuredLoggerAdapter",
"InMemoryTracerAdapter",
"IdentityResolverAdapter",
"SqlAlchemyTransactionAdapter",
# 独立装配路径适配器5 个,不经过工厂,不进入 DrivenAdapters 聚合)
"ContentReviewRepositoryAdapter",
"DefaultContentModerationAdapter",

View File

@ -212,7 +212,7 @@ class ARQQueueAdapter(QueuePort):
await self._arq_pool.ping()
return True
except Exception as exc:
await self._logger.warn(
await self._logger.warning(
"arq queue ping failed",
error_type=type(exc).__name__,
error=str(exc),

View File

@ -148,6 +148,7 @@ from yuxi.channels.contract.ports.driven.user_identity_repository_port import (
UserIdentityRepositoryPort,
)
from yuxi.repositories.channels import Repositories, create_repositories
from yuxi.repositories.channels.base import RepositoryConflictError, RepositoryValidationError
from yuxi.storage.postgres.models_business import (
Conversation as ConversationORM,
)
@ -315,13 +316,16 @@ class ChannelPersistenceAdapter(
def _translate_db_error(self, exc: Exception, resource: str) -> Error:
"""将数据库异常翻译为契约层错误。
``IntegrityError`` ``StaleDataError`` 映射为 ``ConflictError``
并发冲突 / 唯一约束冲突 / 乐观锁版本不匹配其余
``SQLAlchemyError`` 映射为 ``DependencyError``依赖故障
``IntegrityError````StaleDataError`` ``RepositoryConflictError``
映射为 ``ConflictError``并发冲突 / 唯一约束冲突 / 乐观锁版本不匹配
``RepositoryValidationError`` 映射为 ``ValidationError``数据校验失败
其余 ``SQLAlchemyError`` 映射为 ``DependencyError``依赖故障
禁止原生异常穿透至核心层
``StaleDataError`` ORM ``version_id_col`` flush 时抛出
表示加载后版本已被并发事务修改语义上属于并发冲突
``RepositoryConflictError`` 为仓储层对 ``StaleDataError`` 的封装
由适配器层统一翻译为 ``ConflictError``
Args:
exc: 原始数据库异常
@ -330,8 +334,10 @@ class ChannelPersistenceAdapter(
Returns:
契约层 Error 实例
"""
if isinstance(exc, IntegrityError | StaleDataError):
if isinstance(exc, IntegrityError | StaleDataError | RepositoryConflictError):
return ConflictError(resource)
if isinstance(exc, RepositoryValidationError):
return ValidationError(resource, str(exc))
return DependencyError(resource, Error(str(exc)))
# ═══════════════════════════════════════════════════════════════════════════
@ -451,6 +457,8 @@ class ChannelPersistenceAdapter(
raise self._translate_db_error(exc, "channel_account") from exc
except SQLAlchemyError as exc:
raise self._translate_db_error(exc, "channel_account") from exc
except RepositoryConflictError as exc:
raise self._translate_db_error(exc, "channel_account") from exc
except (NotFoundError, ConflictError, DependencyError):
# 契约层错误mappers 翻译的 DependencyError 与本适配器抛出的 NotFoundError/ConflictError
# 直接放行,不二次翻译,保留原始异常链与错误码
@ -659,6 +667,12 @@ class ChannelPersistenceAdapter(
except IntegrityError as exc:
raise self._translate_db_error(exc, "channel_account") from exc
except SQLAlchemyError as exc:
await self._logger.error(
"channel_persistence_sqlalchemy_error",
resource="channel_account",
error_type=type(exc).__name__,
error=str(exc),
)
raise self._translate_db_error(exc, "channel_account") from exc
# ═══════════════════════════════════════════════════════════════════════════
@ -753,7 +767,7 @@ class ChannelPersistenceAdapter(
if cmd.expected_version is None:
# 乐观锁缺口可见化:调用方未传 expected_version跳过版本检查
# (向后兼容),但记录 WARN 以便追溯潜在的丢失更新风险。
await self._logger.warn(
await self._logger.warning(
"channel_session_update_without_expected_version",
resource="channel_session",
session_id=cmd.session_id,
@ -784,6 +798,8 @@ class ChannelPersistenceAdapter(
raise self._translate_db_error(exc, "channel_session") from exc
except SQLAlchemyError as exc:
raise self._translate_db_error(exc, "channel_session") from exc
except RepositoryConflictError as exc:
raise self._translate_db_error(exc, "channel_session") from exc
except (NotFoundError, ConflictError, DependencyError):
# 契约层错误mappers 翻译的 DependencyError 与本适配器抛出的 NotFoundError/ConflictError
# 直接放行,不二次翻译,保留原始异常链与错误码
@ -1485,7 +1501,8 @@ class ChannelPersistenceAdapter(
except IntegrityError as exc:
raise self._translate_db_error(exc, "channel_pairing") from exc
except SQLAlchemyError as exc:
# StaleDataError 是 SQLAlchemyError 子类,由 _translate_db_error 统一翻译为 ConflictError
raise self._translate_db_error(exc, "channel_pairing") from exc
except RepositoryConflictError as exc:
raise self._translate_db_error(exc, "channel_pairing") from exc
except (NotFoundError, ConflictError, DependencyError):
# 契约层错误mappers 翻译的 DependencyError 与本适配器抛出的 NotFoundError/ConflictError
@ -2280,6 +2297,8 @@ class ChannelPersistenceAdapter(
"expires_at": now + timedelta(seconds=ttl_seconds),
"trace_id": cmd.trace_id,
"stream_aborted_at_chunk": cmd.stream_aborted_at_chunk,
"fan_out_batch_id": cmd.fan_out_batch_id,
"fan_out_seq": cmd.fan_out_seq,
}
)
orm = await repos.outbox.create(data, commit=commit)
@ -2302,6 +2321,96 @@ class ChannelPersistenceAdapter(
)
raise DependencyError("channel_outbox", Error(str(exc))) from exc
async def saveOutboxEntries(
self,
cmds: list[SaveOutboxEntryCmd],
tx: TransactionContext | None = None,
) -> list[OutboxId]:
"""批量创建 PENDING 状态的发件箱条目M13 单事务批量 INSERT
覆写端口默认实现循环调用单条 ``session.add_all`` + 单次
``flush`` 将所有条目在单事务内写入减少 fan-out 场景的 DB 往返
cmd 的解析逻辑account_pk / message_id / conversation_id /
channel_session_pk ``saveOutboxEntry`` 单条方法一致
Args:
cmds: 保存发件箱条目命令列表
tx: 事务上下文非空时加入应用层事务**不得** 自主提交
Raises:
NotFoundError: 消息渠道账户或渠道会话不存在
ConflictError: 唯一约束冲突
DependencyError: 数据库故障
"""
async with self._session_scope(tx) as (db, repos, commit):
try:
now = utc_now_naive()
ttl_seconds = self._outbox_config.ttl_seconds
max_retry = self._outbox_config.max_retry
expires_at = now + timedelta(seconds=ttl_seconds)
rows: list[ChannelOutboxEntryORM] = []
outbox_ids: list[OutboxId] = []
for cmd in cmds:
account_orm = await repos.account.get_by_type_and_account(cmd.channel_type, cmd.channel_account_id)
if account_orm is None:
raise NotFoundError("channel_account", cmd.channel_account_id)
account_pk = account_orm.id
try:
message_id = int(cmd.message_id)
except (TypeError, ValueError) as exc:
raise NotFoundError("message", cmd.message_id) from exc
msg_stmt = select(MessageORM.conversation_id).where(MessageORM.id == message_id)
msg_result = await db.execute(msg_stmt)
conversation_id = msg_result.scalar_one_or_none()
if conversation_id is None:
raise NotFoundError("message", cmd.message_id)
channel_session_pk: int | None = None
if cmd.channel_session_id:
session_orm = await repos.session.get_by_session_id(cmd.channel_session_id)
if session_orm is None:
raise NotFoundError("channel_session", cmd.channel_session_id)
channel_session_pk = session_orm.id
outbox_id = uuid.uuid4().hex
rows.append(
ChannelOutboxEntryORM(
outbox_id=outbox_id,
message_id=message_id,
account_id=account_pk,
conversation_id=conversation_id,
channel_session_id=channel_session_pk,
status="pending",
durability_policy=cmd.durability_policy,
max_retry=max_retry,
expires_at=expires_at,
trace_id=cmd.trace_id,
fan_out_batch_id=cmd.fan_out_batch_id,
fan_out_seq=cmd.fan_out_seq,
version=1,
created_at=now,
updated_at=now,
)
)
outbox_ids.append(OutboxId(outbox_id))
db.add_all(rows)
if commit:
await db.commit()
else:
await db.flush()
return outbox_ids
except IntegrityError as exc:
raise self._translate_db_error(exc, "channel_outbox") from exc
except SQLAlchemyError as exc:
raise self._translate_db_error(exc, "channel_outbox") from exc
except (NotFoundError, ConflictError, DependencyError):
raise
except Exception as exc:
await self._logger.error(
"channel_outbox_save_entries_failed",
resource="channel_outbox",
error=str(exc),
)
raise DependencyError("channel_outbox", Error(str(exc))) from exc
async def getOutboxEntry(self, outbox_id: str) -> OutboxEntry | None:
"""按 outbox_id 查询发件箱条目;不存在返回 None。
@ -2374,6 +2483,65 @@ class ChannelPersistenceAdapter(
)
raise DependencyError("channel_outbox", Error(str(exc))) from exc
async def getOutboxEntriesByBatch(
self,
batch_id: str,
*,
status: OutboxStatus | None = None,
max_seq: int | None = None,
tx: TransactionContext | None = None,
) -> list[OutboxEntry]:
"""查询同批次 outbox 条目,用于 M8 重试保序检查。
``batch_id`` 查询同批次 outbox 条目支持 ``status`` ``max_seq`` 过滤
``fan_out_seq`` 升序返回 ``OutboxRetryWorker`` 检查前序 PENDING
条目以维持 fan-out 投递顺序M8
@pre: batch_id 非空
@post: 返回匹配批次 ID 的条目列表status 非空时仅返回该状态条目
max_seq 非空时仅返回 seq <= max_seq 的条目
@failure: DependencyError - 数据库故障
@consistency: Strong
"""
async with self._session_scope(tx) as (db, _, _):
try:
stmt = select(ChannelOutboxEntryORM).where(
ChannelOutboxEntryORM.fan_out_batch_id == batch_id,
ChannelOutboxEntryORM.is_deleted == 0,
)
if status is not None:
stmt = stmt.where(ChannelOutboxEntryORM.status == status.value)
if max_seq is not None:
stmt = stmt.where(ChannelOutboxEntryORM.fan_out_seq <= max_seq)
stmt = stmt.order_by(ChannelOutboxEntryORM.fan_out_seq.asc())
result = await db.execute(stmt)
orms = result.scalars().all()
# 批量查询关联账户 ORM避免 N+1 查询
account_ids = {orm.account_id for orm in orms}
account_orms_map: dict[int, ChannelAccountORM] = {}
if account_ids:
acct_stmt = select(ChannelAccountORM).where(ChannelAccountORM.id.in_(account_ids))
result = await db.execute(acct_stmt)
account_orms_map = {a.id: a for a in result.scalars().all()}
return [orm_to_outbox_entry(orm, account_orms_map.get(orm.account_id)) for orm in orms]
except IntegrityError as exc:
raise self._translate_db_error(exc, "channel_outbox") from exc
except SQLAlchemyError as exc:
raise self._translate_db_error(exc, "channel_outbox") from exc
except (NotFoundError, ConflictError, DependencyError):
# 契约层错误mappers 翻译的 DependencyError 与本适配器抛出的 NotFoundError/ConflictError
# 直接放行,不二次翻译,保留原始异常链与错误码
raise
except Exception as exc:
# 兜底mappers 调用过程中可能抛出 TypeError 等非契约异常,翻译为 DependencyError
# 防止穿透至核心层INV-7保留异常链以便追踪
await self._logger.error(
"channel_outbox_get_entries_by_batch_failed",
resource="channel_outbox",
error=str(exc),
)
raise DependencyError("channel_outbox", Error(str(exc))) from exc
async def updateOutboxEntry(
self,
entry: OutboxEntry,
@ -3520,6 +3688,8 @@ class ChannelPersistenceAdapter(
raise self._translate_db_error(exc, "user_identity") from exc
except SQLAlchemyError as exc:
raise self._translate_db_error(exc, "user_identity") from exc
except RepositoryValidationError as exc:
raise self._translate_db_error(exc, "user_identity") from exc
except (NotFoundError, ConflictError, DependencyError):
# 契约层错误mappers 翻译的 DependencyError 与本适配器抛出的 NotFoundError/ConflictError
# 直接放行,不二次翻译,保留原始异常链与错误码
@ -3875,7 +4045,7 @@ class ChannelPersistenceAdapter(
await db.execute(text("SELECT 1"))
return True
except SQLAlchemyError as exc:
await self._logger.warn(
await self._logger.warning(
"db_ping_failed",
resource="db",
error=str(exc),
@ -5042,6 +5212,8 @@ class ChannelPersistenceAdapter(
raise self._translate_db_error(exc, "route_binding") from exc
except SQLAlchemyError as exc:
raise self._translate_db_error(exc, "route_binding") from exc
except RepositoryConflictError as exc:
raise self._translate_db_error(exc, "route_binding") from exc
except (NotFoundError, ConflictError, DependencyError):
raise
except Exception as exc:
@ -5091,7 +5263,16 @@ class ChannelPersistenceAdapter(
"""
async with self._session_scope(None) as (_, repos, _):
try:
orms = await repos.route_binding.list(filter=filter, limit=limit, offset=offset)
orms = await repos.route_binding.list(
channel_type=filter.channel_type,
account_id=filter.account_id,
match_source=filter.match_source,
match_value=filter.match_value,
enabled=filter.enabled,
agent_binding=filter.agent_binding,
limit=limit,
offset=offset,
)
return tuple(orm_to_route_binding(orm) for orm in orms)
except IntegrityError as exc:
raise self._translate_db_error(exc, "route_binding") from exc
@ -5118,7 +5299,14 @@ class ChannelPersistenceAdapter(
"""
async with self._session_scope(None) as (_, repos, _):
try:
return await repos.route_binding.count(filter=filter)
return await repos.route_binding.count(
channel_type=filter.channel_type,
account_id=filter.account_id,
match_source=filter.match_source,
match_value=filter.match_value,
enabled=filter.enabled,
agent_binding=filter.agent_binding,
)
except IntegrityError as exc:
raise self._translate_db_error(exc, "route_binding") from exc
except SQLAlchemyError as exc:
@ -5143,6 +5331,7 @@ class ChannelPersistenceAdapter(
Raises:
NotFoundError: binding_id 不存在含已软删除
ConflictError: 并发冲突
DependencyError: 数据库故障
"""
async with self._session_scope(tx) as (_, repos, commit):
@ -5155,6 +5344,8 @@ class ChannelPersistenceAdapter(
raise self._translate_db_error(exc, "route_binding") from exc
except SQLAlchemyError as exc:
raise self._translate_db_error(exc, "route_binding") from exc
except RepositoryConflictError as exc:
raise self._translate_db_error(exc, "route_binding") from exc
except (NotFoundError, ConflictError, DependencyError):
raise
except Exception as exc:

View File

@ -64,6 +64,7 @@ from yuxi.channels.contract.ports.driven.content_review_repository_port import (
)
from yuxi.channels.contract.ports.driven.logger_port import LoggerPort
from yuxi.repositories.channels import ChannelContentReviewRecordRepository
from yuxi.repositories.channels.base import RepositoryConflictError
from yuxi.utils.datetime_utils import UTC
if TYPE_CHECKING:
@ -607,6 +608,8 @@ class ContentReviewRepositoryAdapter(ContentReviewRepositoryPort):
raise self._translate_db_error(exc, "content_review_record") from exc
except (ConflictError, DependencyError):
raise
except RepositoryConflictError as exc:
raise ConflictError("content_review_record") from exc
except Exception as exc:
await self._logger.error(
"content_review_repository_failed",

View File

@ -93,7 +93,7 @@ class InMemoryTracerAdapter(TracerPort):
return span
except Exception as e:
# 降级:返回 fallback Span不抛异常但记录故障以便运维感知
await self._logger.warn(
await self._logger.warning(
"tracer startSpan failed, returning fallback span",
error=str(e),
name=name,
@ -125,7 +125,7 @@ class InMemoryTracerAdapter(TracerPort):
span.ended_at = utc_now_naive()
except Exception as e:
# 降级:状态/结束时间记录失败不阻断主流程,但记录故障
await self._logger.warn(
await self._logger.warning(
"tracer endSpan status update failed",
error=str(e),
span_name=span.name,

View File

@ -100,7 +100,7 @@ class MessageOpsPortAdapter(MessageOpsPort):
"""
adapter = self._registry.get(channel_type)
if adapter is None:
await self._logger.warn(
await self._logger.warning(
"message_ops_adapter_not_registered",
channel_type=str(channel_type),
)

View File

@ -137,7 +137,7 @@ class RedisCacheAdapter(CachePort):
return True
except Exception as exc:
if self._logger is not None:
await self._logger.warn(f"cache write failed: key={key}, error={exc}")
await self._logger.warning(f"cache write failed: key={key}, error={exc}")
return False
async def delete(self, key: str) -> bool:
@ -154,7 +154,7 @@ class RedisCacheAdapter(CachePort):
return deleted > 0
except Exception as exc:
if self._logger is not None:
await self._logger.warn(f"cache delete failed: key={key}, error={exc}")
await self._logger.warning(f"cache delete failed: key={key}, error={exc}")
return False
async def incr(self, key: str, amount: int = 1) -> int:
@ -248,7 +248,7 @@ class RedisCacheAdapter(CachePort):
return count
except Exception as exc:
if self._logger is not None:
await self._logger.warn(f"cache invalidate failed: pattern={pattern}, error={exc}")
await self._logger.warning(f"cache invalidate failed: pattern={pattern}, error={exc}")
return 0
async def acquireAdvisoryLock(self, key: str, ttl_seconds: int = 300) -> LockToken | None:
@ -326,7 +326,7 @@ class RedisCacheAdapter(CachePort):
return True
except Exception as exc:
if self._logger is not None:
await self._logger.warn(f"cache ping failed: error={exc}")
await self._logger.warning(f"cache ping failed: error={exc}")
return False
async def getStreamStatus(self) -> RedisStreamStatus:

View File

@ -243,7 +243,7 @@ class RedisConfigAdapter(ConfigPort):
mismatches.append((key, declared_scope.value, scope.value))
if mismatches:
await self._logger.warn(
await self._logger.warning(
"config scope mismatch in batch get",
mismatches=mismatches,
target=target or "",
@ -414,7 +414,7 @@ class RedisConfigAdapter(ConfigPort):
try:
updated_at = datetime.fromisoformat(updated_at_raw)
except ValueError as exc:
await self._logger.warn(
await self._logger.warning(
"redis config updated_at parse failed",
key=key,
raw_value=updated_at_raw,
@ -572,7 +572,7 @@ class RedisConfigAdapter(ConfigPort):
try:
updated_at = datetime.fromisoformat(updated_at_raw)
except ValueError as exc:
await self._logger.warn(
await self._logger.warning(
"redis config history updated_at parse failed",
key=key,
raw_value=updated_at_raw,
@ -687,7 +687,7 @@ class RedisConfigAdapter(ConfigPort):
if not skip_scope_check and self._key_to_scope_map is not None:
declared_scope = self._key_to_scope_map.get(key)
if declared_scope is not None and declared_scope != scope:
await self._logger.warn(
await self._logger.warning(
"config scope mismatch",
key=key,
declared_scope=declared_scope.value,
@ -787,7 +787,7 @@ class RedisConfigAdapter(ConfigPort):
await self._redis.ping()
return True
except Exception as exc:
await self._logger.warn(
await self._logger.warning(
"redis config ping failed",
error=str(exc),
)

View File

@ -1,146 +0,0 @@
"""SQLAlchemy 事务适配器。
实现 ``TransactionPort`` 契约基于 SQLAlchemy ``AsyncSession`` 提供事务
边界控制能力事务边界由应用层管道或用例编排器显式调用被驱动适配
器通过 ``TransactionContext`` 透传C-I1加入同一事务**不得** 自主提交
事务共享机制``create_driven_adapters(db, session_factory, ...)`` 将同一
请求级 ``AsyncSession`` 注入到 ``ConversationAdapter`` /
``SqlAlchemyTransactionAdapter``请求级事务边界适配器共享 ``db`` 保证
事务一致性``ChannelPersistenceAdapter`` 为无状态适配器注入
``session_factory``通过 ``_session_scope(tx)`` 按需获取 session
``tx`` 非空时复用 ``tx.get_session()`` 返回的请求级 ``db``加入应用层
事务``tx`` ``None`` 时通过 ``session_factory()`` 创建独立 session
并自主提交
"""
from __future__ import annotations
from typing import Any
from sqlalchemy.ext.asyncio import AsyncSession
from yuxi.channels.contract.ports.driven.transaction_port import (
TransactionPort,
)
__all__ = ["SqlAlchemyTransactionAdapter", "SqlAlchemyTransactionContext"]
class SqlAlchemyTransactionContext:
"""SQLAlchemy 事务上下文。
封装 ``AsyncSession`` 与其事务被驱动适配器在构造时共享同一
``AsyncSession````begin()`` 在共享 session 上开启事务所有适配器
的写操作自动加入上下文管理器退出时自动提交无异常或回滚
有异常
被驱动适配器 **不得** 调用 ``commit`` / ``rollback``仅由应用层
通过 ``TransactionPort`` 控制
"""
def __init__(self, session: AsyncSession) -> None:
"""初始化事务上下文。
Args:
session: SQLAlchemy 异步会话事务边界由本上下文控制
"""
self._session = session
self._txn: Any = None
def get_session(self) -> AsyncSession:
"""返回底层共享 ``AsyncSession``,供未在构造时共享 session 的适配器复用主事务。
C-I1``AgentRunAdapter`` 等适配器在构造时未与事务适配器共享 session
通过本方法获取主事务的共享 session复用同一事务避免独立提交产生
孤儿记录
"""
return self._session
async def commit(self) -> None:
"""提交当前事务。
仅由应用层调用被驱动适配器 **不得** 调用提交后清空事务对象
避免 ``__aexit__`` 重复提交
"""
if self._txn is not None:
await self._txn.commit()
self._txn = None
async def rollback(self) -> None:
"""回滚当前事务。
仅由应用层调用被驱动适配器 **不得** 调用回滚后清空事务对象
避免 ``__aexit__`` 重复回滚
"""
if self._txn is not None:
await self._txn.rollback()
self._txn = None
async def __aenter__(self) -> SqlAlchemyTransactionContext:
"""进入事务上下文,开启 SQLAlchemy 事务。
SQLAlchemy 2.0 autobegin 语义下前置读操作 ``getChannelSessionByPeer``
SELECT 查询会在 session 上隐式开启只读事务若不处理
``begin()`` 会抛 ``InvalidRequestError: A transaction is already
begun on this Session.``
此处检测并提交隐式事务后再开启显式事务安全性保证按适配器契约
``tx=None`` 的写操作自主提交autobegin 事务仅含读操作提交不会
产生副作用数据落库应用层显式事务边界§10.1由此方法独占控制
"""
if self._session.in_transaction():
await self._session.commit()
self._txn = await self._session.begin()
return self
async def __aexit__(self, exc_type: Any, exc: Any, tb: Any) -> None:
"""退出事务上下文。
若事务未被显式提交/回滚``self._txn`` 非空且 ``is_active``则按
异常状态自动提交无异常或回滚有异常若已被显式提交/回滚
``self._txn`` 为空或已被 SQLAlchemy 内部关闭``is_active=False``
flush 失败自动关闭事务则跳过避免 ``ResourceClosedError``
事务对象退出后释放引用避免泄漏
"""
try:
if self._txn is not None and self._txn.is_active:
if exc is None:
await self._txn.commit()
else:
await self._txn.rollback()
finally:
self._txn = None
class SqlAlchemyTransactionAdapter(TransactionPort):
"""SQLAlchemy 事务适配器。
实现 ``TransactionPort`` 契约基于共享的 ``AsyncSession`` 提供事务
边界控制事务边界由应用层显式调用 ``begin()`` 开启被驱动适配器
通过构造时共享的 session 加入同一事务
关键约束
- 事务边界 **必须** 由应用层控制
- 被驱动适配器 **不得** 自主调用 ``commit`` / ``rollback``
- 事务范围 **必须** 由应用层显式声明
"""
def __init__(self, session: AsyncSession) -> None:
"""初始化事务适配器。
Args:
session: SQLAlchemy 异步会话与被驱动适配器共享以保证事务
一致性
"""
self._session = session
def begin(self) -> SqlAlchemyTransactionContext:
"""开启一个新事务,返回 SQLAlchemy 事务上下文。
Returns:
SQLAlchemy 事务上下文被驱动适配器通过构造时共享的 session
加入同一事务上下文管理器退出时自动提交无异常或回滚
有异常
"""
return SqlAlchemyTransactionContext(self._session)

View File

@ -135,14 +135,14 @@ class StructuredLoggerAdapter(LoggerPort):
"""
self._emit("INFO", message, trace_id=trace_id, **kwargs)
async def warn(
async def warning(
self,
message: str,
*,
trace_id: str | None = None,
**kwargs: Any,
) -> None:
"""记录 WARN 级别日志。
"""记录 WARNING 级别日志。
Args:
message: 日志消息

View File

@ -40,7 +40,7 @@ from yuxi.channels.core.service.config_manager import ConfigManager
async def _log_warn(logger: LoggerPort | None, message: str, trace_id: str | None) -> None:
"""记录警告日志best-effort失败不影响业务流程"""
if logger is not None:
await logger.warn(message, trace_id=trace_id)
await logger.warning(message, trace_id=trace_id)
class WhitelistConfigAdapter:

View File

@ -168,7 +168,7 @@ class ChannelCircuitBreaker:
try:
lock_token = await self._cache.acquireAdvisoryLock(lock_key, ttl_seconds=5)
except DependencyError as exc:
await self._logger.warn(
await self._logger.warning(
"circuit_breaker state lock acquire failed, skip state transition",
channel_type=channel_type,
account_id=account_id,
@ -209,7 +209,7 @@ class ChannelCircuitBreaker:
except Exception as exc:
# fail-closed原子递减失败表示缓存依赖故障抛 DependencyError
# 由上层决定阻断或放行INV-7不回退 read-modify-write
await self._logger.warn(
await self._logger.warning(
"circuit_breaker half_open permits incr failed, fail-closed",
channel_type=channel_type,
account_id=account_id,
@ -246,7 +246,7 @@ class ChannelCircuitBreaker:
try:
lock_token = await self._cache.acquireAdvisoryLock(lock_key, ttl_seconds=5)
except DependencyError as exc:
await self._logger.warn(
await self._logger.warning(
"circuit_breaker state lock acquire failed, skip state transition",
channel_type=channel_type,
account_id=account_id,
@ -299,7 +299,7 @@ class ChannelCircuitBreaker:
try:
lock_token = await self._cache.acquireAdvisoryLock(lock_key, ttl_seconds=5)
except DependencyError as exc:
await self._logger.warn(
await self._logger.warning(
"circuit_breaker state lock acquire failed, skip state transition",
channel_type=channel_type,
account_id=account_id,
@ -352,7 +352,7 @@ class ChannelCircuitBreaker:
except Exception as exc:
# fail-closed原子递增失败表示缓存依赖故障改为抛出
# DependencyError由上层重试机制处理避免错误掩盖
await self._logger.warn(
await self._logger.warning(
"circuit_breaker incr failed, fail-closed",
channel_type=channel_type,
account_id=account_id,
@ -598,7 +598,7 @@ class ChannelCircuitBreaker:
try:
config = await self._config.get(self._CONFIG_KEY, ConfigScope.GLOBAL)
except DependencyError as exc:
await self._logger.warn(
await self._logger.warning(
"熔断阈值配置端口故障fail-fast",
error=str(exc),
)
@ -641,7 +641,7 @@ class ChannelCircuitBreaker:
try:
config = await self._config.get(self._CONFIG_KEY, ConfigScope.GLOBAL)
except DependencyError as exc:
await self._logger.warn(
await self._logger.warning(
"恢复超时配置端口故障fail-fast",
error=str(exc),
)
@ -688,7 +688,7 @@ class ChannelCircuitBreaker:
trace_id=trace_id,
)
await self._event_publisher.publish(event.toDomainEvent())
await self._logger.warn(
await self._logger.warning(
"渠道熔断器已打开",
trace_id=trace_id,
channel_type=channel_type,

View File

@ -24,12 +24,32 @@ from yuxi.channels.contract.dtos.outbound import (
RichMessage,
TrustedMessage,
)
from yuxi.channels.contract.dtos.outbox import MultiPartReceipt, OutboxEntry
from yuxi.channels.contract.dtos.outbox import MultiPartReceipt, OutboxEntry, OutboxId
from yuxi.channels.contract.dtos.streaming import StreamingCompleted
from yuxi.channels.contract.dtos.truncation import TruncationResult
from yuxi.channels.contract.errors import Error
@dataclass(frozen=True)
class FanOutTarget:
"""fan-out 扇出目标M13
描述一次 fan-out 投递中单个目标的渠道定位信息由上游阶段
``AdminMessageService`` target 列表解析后填充到
``OutboundContext.fan_out_entries`` outbox-persist 阶段批量构造
``SaveOutboxEntryCmd`` 在单事务内保存
字段
channel_type: 目标渠道类型
account_id: 目标渠道账户业务 ID
channel_session_id: 目标渠道会话 ID可选
"""
channel_type: ChannelType
account_id: str
channel_session_id: str | None = None
@dataclass
class OutboundContext:
"""出站管道可变局部上下文。
@ -58,6 +78,14 @@ class OutboundContext:
# 出站投递幂等键(可选)。跨重试稳定,供 deliver 阶段调用适配器
# ``sendMessage`` / ``sendMessageContinuation`` 时传入,实现渠道侧去重。
idempotency_key: str | None = None
# fan-out 批次 ID可选。标识同一次 fan-out 产生的一组出站请求,
# 由 AdminMessageService 在 fan-out 时填充outbox-persist 阶段据此
# 写入 OutboxEntry.fan_out_batch_id供 M8 outbox 重试保序使用。
fan_out_batch_id: str | None = None
# fan-out 批次内序号(可选)。标识同批次内的投递顺序,由
# AdminMessageService 在 fan-out 时按目标遍历顺序填充,供 M8 重试保序
# 按序号恢复原始顺序。
fan_out_seq: int | None = None
# ---- 会话定位字段 ----
conversation_id: str | None = None
@ -141,3 +169,11 @@ class OutboundContext:
# ---- 降级标志字段 ----
degraded: bool = False
degraded_reason: Error | None = None
# ---- fan-out 扇出字段M13----
# 由上游阶段(如 AdminMessageService填充outbox-persist 阶段据此
# 走批量保存路径(单事务保存所有 OutboxEntry。is_fan_out 为 False
# 或 fan_out_entries 为 None 时走原单条保存路径。
is_fan_out: bool = False
fan_out_entries: list[FanOutTarget] | None = None
fan_out_outbox_ids: list[OutboxId] | None = None

View File

@ -202,7 +202,7 @@ class MessageOperationExecutor:
trusted_fields, clean_parameters, detected_untrusted = self._extractTrustedFields(parameters)
if detected_untrusted and self._logger is not None:
keys_desc = ", ".join(detected_untrusted)
await self._logger.warn(
await self._logger.warning(
f"工具参数包含不可信字段 [{keys_desc}]已忽略并使用服务端注入的发送者FR-25 §AC-53",
)
@ -226,7 +226,7 @@ class MessageOperationExecutor:
# 可信注入拒绝:非渠道 Agent Run 试图执行受限操作,或会话
# 无所有者。记录告警日志并返回失败结果FR-25 异常边界)。
if self._logger is not None:
await self._logger.warn(
await self._logger.warning(
f"trusted injection rejected for operation '{definition.operation.value}': {exc}",
)
return ToolResult(success=False, error=str(exc))
@ -322,7 +322,7 @@ class MessageOperationExecutor:
# 操作历史为审计辅助功能,不应阻塞业务结果返回。但日志必须
# 携带 trace_idINV-10便于跨链路追踪。当前上下文无
# trace_id 来源,显式传入 None 表达契约字段完整性。
await self._logger.warn(
await self._logger.warning(
f"appendOperationHistory failed for channel_msg_id='{channel_msg_id}': {exc}",
trace_id=None,
)
@ -393,7 +393,7 @@ class MessageOperationExecutor:
) -> tuple[dict[str, Any], dict[str, Any], list[str]]:
"""从 ``parameters`` 提取可信上下文保留键并剥离不可信发送者字段。
本方法为纯数据变换不调用日志端口``LoggerPort.warn`` 为异步方法
本方法为纯数据变换不调用日志端口``LoggerPort.warning`` 为异步方法
无法在同步方法中调用检测到的不可信字段通过返回值传出由调用
方在异步上下文中记录告警日志

View File

@ -106,7 +106,7 @@ class ChannelEventBroadcaster:
for subscription_id, queue in subscriptions:
if queue.full():
dropped = queue.get_nowait()
await self._logger.warn(
await self._logger.warning(
"SSE 订阅队列已满,丢弃最旧事件",
trace_id=event.trace_id,
subscription_id=subscription_id,

View File

@ -106,7 +106,7 @@ class ConfigSourceRegistry:
except Exception as e:
strategy = source.failure_policy
strategy_val = strategy.value if hasattr(strategy, "value") else strategy
await self._logger.warn(
await self._logger.warning(
f"配置源加载失败,按策略 {strategy_val} 跳过: source_id={source.source_id}, error={e}",
source_id=source.source_id,
error=str(e),

View File

@ -120,13 +120,13 @@ class EventBus:
f"handler timeout: {self._HANDLER_TIMEOUT}s",
)
except Exception as degrade_err:
await self._logger.warn(
await self._logger.warning(
f"降级管理器调用失败: {degrade_err}",
plugin_id=entry.plugin_id,
error=str(degrade_err),
)
# handler 超时不得拖垮宿主,记录告警后继续分发其他订阅者
await self._logger.warn(
await self._logger.warning(
f"事件订阅 handler 超时: event_type={event.event_type}, strategy={strategy}",
trace_id=event.trace_id,
plugin_id=entry.plugin_id,
@ -143,13 +143,13 @@ class EventBus:
str(e),
)
except Exception as degrade_err:
await self._logger.warn(
await self._logger.warning(
f"降级管理器调用失败: {degrade_err}",
plugin_id=entry.plugin_id,
error=str(degrade_err),
)
# 插件 handler 失败不得拖垮宿主,记录日志后继续分发其他订阅者
await self._logger.warn(
await self._logger.warning(
f"事件订阅 handler 失败: event_type={event.event_type}, strategy={strategy.value}",
trace_id=event.trace_id,
plugin_id=entry.plugin_id,

View File

@ -68,5 +68,8 @@ class OutboxStateAuditHandler:
"new_status": payload.get("new_status"),
},
trace_id=event.trace_id,
# target_channel 列为 NOT NULLoutbox 状态变更为系统级事件,
# payload 不含渠道类型,填充 "global"。
target_channel="global",
)
await self._persistence.saveAuditLog(cmd)

View File

@ -53,7 +53,7 @@ class PluginLifecycleAuditHandler:
任务触发无终端用户上下文
- ``target``插件 ID从事件 payload 提取
- ``target_channel``插件绑定的渠道类型 ``PluginRegistry``
查询插件已注销时``None``
查询插件已注销时回退 ``"global"``
- ``result````"success"``正常生命周期事件 ``"failed"``
``PluginFailed`` 事件
- ``params_summary``携带 ``version`` / ``reason`` / ``error``
@ -99,8 +99,9 @@ class PluginLifecycleAuditHandler:
plugin_id: str = payload.get("plugin_id", "")
result: str = "failed" if audit_op is AuditOperationType.PLUGIN_FAILED else "success"
# 查询插件绑定的渠道类型(插件已注销时为 None
target_channel: str | None = None
# 查询插件绑定的渠道类型,插件已注销时回退 "global"
# target_channel 列为 NOT NULL注释渠道类型或 global
target_channel: str = "global"
plugin_manifest = self._plugin_registry.getPlugin(plugin_id)
if plugin_manifest is not None:
target_channel = plugin_manifest.manifest.channel_type

View File

@ -77,7 +77,7 @@ class WhitelistConfigHandler:
entries, skipped = _to_whitelist_entries(value)
if self._logger is not None:
for item in skipped:
await self._logger.warn(
await self._logger.warning(
"whitelist entry skipped: missing or invalid peer_id",
entry=item,
account_id=target,

View File

@ -81,7 +81,7 @@ class ChannelAuditLogRetentionHandler:
try:
lock_token = await self._cache_port.acquireAdvisoryLock(lock_key, ttl_seconds=self._LOCK_TTL_SECONDS)
except DependencyError as exc:
await self._logger.warn(
await self._logger.warning(
"scheduler lock acquire failed, skip this cycle",
handler=self.name,
error=str(exc),

View File

@ -83,7 +83,7 @@ class ChannelContentReviewRetentionHandler:
try:
lock_token = await self._cache_port.acquireAdvisoryLock(lock_key, ttl_seconds=self._LOCK_TTL_SECONDS)
except DependencyError as exc:
await self._logger.warn(
await self._logger.warning(
"scheduler lock acquire failed, skip this cycle",
handler=self.name,
error=str(exc),

View File

@ -72,7 +72,7 @@ class ChannelIdempotencyCleanupHandler:
try:
lock_token = await self._cache_port.acquireAdvisoryLock(lock_key, ttl_seconds=self._LOCK_TTL_SECONDS)
except DependencyError as exc:
await self._logger.warn(
await self._logger.warning(
"scheduler lock acquire failed, skip this cycle",
handler=self.name,
error=str(exc),

View File

@ -115,7 +115,7 @@ class ChannelOutboxRecoveryHandler:
try:
lock_token = await self._cache_port.acquireAdvisoryLock(lock_key, ttl_seconds=self._LOCK_TTL_SECONDS)
except DependencyError as exc:
await self._logger.warn(
await self._logger.warning(
"scheduler lock acquire failed, skip this cycle",
handler=self.name,
error=str(exc),
@ -230,7 +230,7 @@ class ChannelOutboxRecoveryHandler:
"""扫描 PENDING 状态条目,超时标记 DEAD其余入队重试。"""
while True:
if self._is_scan_expired(scan_start, scan_ttl_seconds):
await self._logger.warn(
await self._logger.warning(
"outbox 恢复扫描超时,中止 PENDING 扫描",
processed_count=len(processed_ids),
)
@ -303,7 +303,7 @@ class ChannelOutboxRecoveryHandler:
"""扫描 SENT_UNCONFIRMED 状态条目。"""
while True:
if self._is_scan_expired(scan_start, scan_ttl_seconds):
await self._logger.warn(
await self._logger.warning(
"outbox 恢复扫描超时,中止 SENT_UNCONFIRMED 扫描",
processed_count=len(processed_ids),
)
@ -398,7 +398,7 @@ class ChannelOutboxRecoveryHandler:
)
while True:
if self._is_scan_expired(scan_start, scan_ttl_seconds):
await self._logger.warn(
await self._logger.warning(
"outbox 恢复扫描超时,中止 FAILED 扫描",
processed_count=len(processed_ids),
)
@ -452,7 +452,7 @@ class ChannelOutboxRecoveryHandler:
try:
await outbox_repo.updateOutboxEntry(aggregate, expected_status=entry.status)
except IdempotencyConflictError:
await self._logger.warn(
await self._logger.warning(
"requeueForRetry conflict, skipping",
outbox_id=aggregate.outbox_id,
)
@ -519,7 +519,7 @@ class ChannelOutboxRecoveryHandler:
latest = await outbox_repo.getOutboxEntry(aggregate.outbox_id)
if latest is not None and latest.status in (OutboxStatus.SENT, OutboxStatus.DEAD):
return
await self._logger.warn(
await self._logger.warning(
"markDead conflict, skipping",
outbox_id=aggregate.outbox_id,
)
@ -540,7 +540,7 @@ class ChannelOutboxRecoveryHandler:
),
)
except Exception as exc:
await self._logger.warn(
await self._logger.warning(
"outbox 死信审计日志写入失败",
outbox_id=entry.outbox_id,
error=str(exc),

View File

@ -92,7 +92,7 @@ class ChannelOutboxTerminalCleanupHandler:
try:
lock_token = await self._cache_port.acquireAdvisoryLock(lock_key, ttl_seconds=self._LOCK_TTL_SECONDS)
except DependencyError as exc:
await self._logger.warn(
await self._logger.warning(
"scheduler lock acquire failed, skip this cycle",
handler=self.name,
error=str(exc),
@ -144,7 +144,7 @@ class ChannelOutboxTerminalCleanupHandler:
)
await self._event_publisher.publishOutboxEntryPurged(event)
except Exception as pub_exc:
await self._logger.warn(
await self._logger.warning(
"OutboxEntryPurgedEvent 发布失败",
error=str(pub_exc),
)

View File

@ -85,7 +85,7 @@ class ChannelPairingExpirationHandler:
try:
lock_token = await self._cache_port.acquireAdvisoryLock(lock_key, ttl_seconds=self._LOCK_TTL_SECONDS)
except DependencyError as exc:
await self._logger.warn(
await self._logger.warning(
"scheduler lock acquire failed, skip this cycle",
handler=self.name,
error=str(exc),

View File

@ -80,7 +80,7 @@ class ChannelPairingTerminalCleanupHandler:
try:
lock_token = await self._cache_port.acquireAdvisoryLock(lock_key, ttl_seconds=self._LOCK_TTL_SECONDS)
except DependencyError as exc:
await self._logger.warn(
await self._logger.warning(
"scheduler lock acquire failed, skip this cycle",
handler=self.name,
error=str(exc),

View File

@ -85,7 +85,7 @@ class ChannelSessionInactiveCleanupHandler:
try:
lock_token = await self._cache_port.acquireAdvisoryLock(lock_key, ttl_seconds=self._LOCK_TTL_SECONDS)
except DependencyError as exc:
await self._logger.warn(
await self._logger.warning(
"scheduler lock acquire failed, skip this cycle",
handler=self.name,
error=str(exc),

View File

@ -100,7 +100,7 @@ class ChannelProbe:
)
except TimeoutError as exc:
latency_ms = int((time.monotonic() - start) * 1000)
await self._logger.warn(
await self._logger.warning(
"渠道探测超时",
channel_type=channel_type,
timeout=str(self.PROBE_TIMEOUT),
@ -126,7 +126,7 @@ class ChannelProbe:
try:
db_ok = await self._persistence_port.ping()
except Exception as exc:
await self._logger.warn(
await self._logger.warning(
"渠道探测下游检查DB ping 异常",
channel_type=channel_type,
error=str(exc),
@ -147,7 +147,7 @@ class ChannelProbe:
try:
redis_ok = await self._cache_port.ping()
except Exception as exc:
await self._logger.warn(
await self._logger.warning(
"渠道探测下游检查Redis ping 异常",
channel_type=channel_type,
error=str(exc),
@ -165,7 +165,7 @@ class ChannelProbe:
try:
worker_status = await self._queue_port.getWorkerStatus()
except Exception as exc:
await self._logger.warn(
await self._logger.warning(
"渠道探测下游检查Worker 状态查询异常",
channel_type=channel_type,
error=str(exc),
@ -200,7 +200,7 @@ class ChannelProbe:
outcome = await adapter.probe()
except Exception as e:
latency_ms = int((time.monotonic() - start) * 1000)
await self._logger.warn(
await self._logger.warning(
"渠道探测异常",
channel_type=channel_type,
error=str(e),

View File

@ -119,7 +119,7 @@ class DiagnosticsExporter:
try:
audit_logs = await self._collectAuditLogs(request)
except Exception as e:
await self._logger.warn(f"collect audit logs failed, skip: {e}")
await self._logger.warning(f"collect audit logs failed, skip: {e}")
audit_logs = ()
# 下游依赖状态复用健康快照已采集结果,避免重复查询
@ -352,6 +352,7 @@ class DiagnosticsExporter:
trace_id=resolved_trace_id,
source_ip=operator.ip,
request_id=operator.request_id,
target_channel="global",
)
try:
await self._persistence.saveAuditLog(cmd)

View File

@ -227,7 +227,7 @@ class HealthAggregator:
)
except DependencyError as exc:
if self._logger is not None:
await self._logger.warn(
await self._logger.warning(
"singleflight lock acquire failed, fallback to direct aggregation",
error=str(exc),
)
@ -263,7 +263,7 @@ class HealthAggregator:
cached = await self._cache_port.get(self.CACHE_KEY)
except DependencyError as exc:
if self._logger is not None:
await self._logger.warn(
await self._logger.warning(
"健康快照缓存读取失败,跳过缓存继续聚合",
cache_key=self.CACHE_KEY,
error=str(exc),
@ -329,7 +329,7 @@ class HealthAggregator:
worker_status = await self._queue_port.getWorkerStatus()
except Exception as exc:
if self._logger is not None:
await self._logger.warn(f"worker status query failed: {exc}")
await self._logger.warning(f"worker status query failed: {exc}")
worker_status = None
# Redis 流状态:异常时降级为 None供诊断导出复用
@ -337,7 +337,7 @@ class HealthAggregator:
redis_stream_status = await self._cache_port.getStreamStatus()
except Exception as exc:
if self._logger is not None:
await self._logger.warn(f"redis stream status query failed: {exc}")
await self._logger.warning(f"redis stream status query failed: {exc}")
redis_stream_status = None
# 数据库连接池状态:异常时降级为 None供诊断导出复用
@ -345,7 +345,7 @@ class HealthAggregator:
db_pool_status = await self._persistence_port.getConnectionPoolStatus()
except Exception as exc:
if self._logger is not None:
await self._logger.warn(f"db connection pool status query failed: {exc}")
await self._logger.warning(f"db connection pool status query failed: {exc}")
db_pool_status = None
# 传输引擎 per-account 状态FR-18通过 TransportHealthPort 聚合,
@ -356,7 +356,7 @@ class HealthAggregator:
transport_status = await self._transport_health_port.getTransportHealth()
except Exception as exc:
if self._logger is not None:
await self._logger.warn(
await self._logger.warning(
"transport health query failed, degrade to None",
error=str(exc),
)
@ -402,7 +402,7 @@ class HealthAggregator:
accounts = await self._persistence_port.listChannelAccounts()
except Exception as exc:
if self._logger is not None:
await self._logger.warn(f"list channel accounts failed: {exc}")
await self._logger.warning(f"list channel accounts failed: {exc}")
accounts = ()
account_map: dict[ChannelType, str] = {}
for account in accounts:
@ -445,7 +445,7 @@ class HealthAggregator:
cb_state = await self._circuit_breaker.getState(channel_type, account_id)
except DependencyError as exc:
if self._logger is not None:
await self._logger.warn(
await self._logger.warning(
"circuit_breaker getState failed, degrade to None",
channel_type=channel_type,
account_id=account_id,

View File

@ -340,7 +340,7 @@ class PluginCapabilityChecker:
# 声明但未使用:记录 WARNING不阻断加载
unused = declared - used
if unused:
await self._logger.warn(
await self._logger.warning(
f"声明了未使用的 accessible_ports建议清理冗余声明: {sorted(unused)}",
unused_ports=sorted(unused),
)

View File

@ -37,6 +37,7 @@ from yuxi.channels.contract.ports.driven import (
ChannelSessionRepositoryPort,
ConfigPort,
ConversationPort,
EventPublisherPort,
IdempotencyRepositoryPort,
IdentityResolverPort,
LoggerPort,
@ -320,6 +321,17 @@ class PluginHostImpl(PluginHost):
self._checkPortAccess("PersistenceHealthPort")
return self._adapters.persistence
def getEventPublisherPort(self) -> EventPublisherPort:
"""获取事件发布端口。
返回 ``EventBus`` 实例结构化满足 ``EventPublisherPort`` Protocol
供插件适配器发布领域事件如媒体下载失败告警 ``publishEvent``
一致不做端口访问校验事件发布为通用能力不绑定特定资源
@consistency: 无状态stateless返回注入的端口实例
"""
return self._event_bus
# === 适配器注册 ===
def registerAdapter(

View File

@ -366,7 +366,7 @@ class PluginLifecycleManager:
try:
await self.stop(plugin_manifest.manifest.id)
except Exception as e:
await self._logger.warn(
await self._logger.warning(
f"停止插件异常,继续停止其他插件: {e}",
plugin_id=plugin_manifest.manifest.id,
error=str(e),
@ -483,7 +483,7 @@ class PluginLifecycleManager:
try:
result = await self.reload(plugin_id)
except Exception as e:
await self._logger.warn(
await self._logger.warning(
f"失败插件重载异常: {e}",
plugin_id=plugin_id,
error=str(e),
@ -493,7 +493,7 @@ class PluginLifecycleManager:
await self._cache.releaseAdvisoryLock(lock_token)
if result.state != "started":
await self._logger.warn(
await self._logger.warning(
f"失败插件重载未成功,等待下一轮退避: plugin_id={plugin_id}, state={result.state}",
plugin_id=plugin_id,
state=result.state,
@ -978,7 +978,7 @@ class PluginLifecycleManager:
try:
unregister_call(plugin_id)
except Exception as e:
await self._logger.warn(
await self._logger.warning(
f"注销插件扩展点失败,继续清理其他资源: {registry_attr}",
plugin_id=plugin_id,
error=str(e),
@ -989,7 +989,7 @@ class PluginLifecycleManager:
try:
await host.close()
except Exception as close_err:
await self._logger.warn(
await self._logger.warning(
f"被驱动适配器关闭失败: {close_err}",
plugin_id=plugin_id,
error=str(close_err),
@ -1019,7 +1019,7 @@ class PluginLifecycleManager:
self._plugin_registry.setState(plugin_id, LifecycleState.FAILED)
self._plugin_registry.setPluginError(plugin_id, str(error))
except Exception as state_err:
await self._logger.warn(
await self._logger.warning(
f"标记 FAILED 状态或记录错误失败(插件可能已注销): {state_err}",
plugin_id=plugin_id,
)
@ -1033,7 +1033,7 @@ class PluginLifecycleManager:
try:
await self._event_bus.publish(failed_event.toDomainEvent())
except Exception as publish_err:
await self._logger.warn(
await self._logger.warning(
f"发布 PluginFailed 事件失败,继续执行降级流程: {publish_err}",
plugin_id=plugin_id,
trace_id=trace_id,
@ -1051,7 +1051,7 @@ class PluginLifecycleManager:
)
except Exception as fail_err:
# onFail 自身失败不得阻断后续降级流程
await self._logger.warn(
await self._logger.warning(
f"onFail 钩子执行失败,忽略: {fail_err}",
plugin_id=plugin_id,
error=str(fail_err),
@ -1061,7 +1061,7 @@ class PluginLifecycleManager:
try:
await self._degradation.onPluginFailed(plugin_id, str(error))
except Exception as degrade_err:
await self._logger.warn(
await self._logger.warning(
f"降级管理器调用失败: {degrade_err}",
plugin_id=plugin_id,
)
@ -1083,7 +1083,7 @@ class PluginLifecycleManager:
try:
await self._event_bus.publish(degraded_event.toDomainEvent())
except Exception as publish_err:
await self._logger.warn(
await self._logger.warning(
f"发布 ChannelDegraded 事件失败: {publish_err}",
plugin_id=plugin_id,
trace_id=trace_id,
@ -1210,7 +1210,7 @@ class PluginLifecycleManager:
)
if version_info.version == 0:
# 键不存在:首次加载,合法降级为空列表
await self._logger.warn(
await self._logger.warning(
f"applied_migrations 配置键不存在,按空列表继续: {e}",
target=target,
trace_id=trace_id,

View File

@ -210,7 +210,7 @@ class PluginLoader:
target_plugin_dir = os.path.join(target_dir, plugin_id)
if os.path.exists(target_plugin_dir):
if not force:
await self._logger.warn(
await self._logger.warning(
f"插件目录已存在,未启用 force 覆盖: {target_plugin_dir}",
plugin_id=plugin_id,
target_plugin_dir=target_plugin_dir,

View File

@ -56,7 +56,7 @@ class RouteMatchRegistryLoader:
self._registry.applyRule(rule)
loaded += 1
except Exception as exc: # noqa: BLE001
await self._logger.warn(
await self._logger.warning(
"skip invalid route binding rule",
binding_id=rule.binding_id,
match_source=rule.match_source,

View File

@ -35,6 +35,7 @@ from yuxi.channels.contract.dtos.outbox import (
OutboxStatus,
RetryContext,
)
from yuxi.channels.contract.dtos.queue import EnqueueCmd
from yuxi.channels.contract.errors import (
ChannelDegradedError,
DependencyError,
@ -54,6 +55,7 @@ from yuxi.channels.contract.ports.driven.cache_port import CachePort
from yuxi.channels.contract.ports.driven.event_publisher_port import EventPublisherPort
from yuxi.channels.contract.ports.driven.logger_port import LoggerPort
from yuxi.channels.contract.ports.driven.outbox_repository_port import OutboxRepositoryPort
from yuxi.channels.contract.ports.driven.queue_port import QueuePort
from yuxi.channels.contract.ports.driven.transaction_port import TransactionContext
from yuxi.channels.core.event.channel import ChannelDegraded
from yuxi.channels.core.model.outbox_entry import OutboxEntry as OutboxAggregate
@ -66,6 +68,11 @@ __all__ = ["OutboxRetryWorker"]
# 的"仍有失败分片"(返回 None调用方直接返回与"全部分片已投递"(调用方
# 需传 None 给 markSent 推进至 SENT
_ALL_DELIVERED = object()
# M8: outbox 重试保序。同批次前序seq < 当前)仍有 PENDING 条目时,延后
# 重试的退避秒数。前序完成后由恢复扫描器或前序重试触发本条目重试。
_PREDECESSOR_RETRY_DELAY_SECONDS = 30
# ARQ 重试任务名,与 ChannelOutboxRecoveryHandler._RETRY_TASK_NAME 对齐。
_RETRY_TASK_NAME = "outbox_retry"
class OutboxRetryWorker:
@ -107,6 +114,7 @@ class OutboxRetryWorker:
outbox_config: OutboxConfig,
logger: LoggerPort,
cache_port: CachePort,
queue_port: QueuePort,
) -> None:
"""初始化 Outbox 重试 Worker。
@ -130,6 +138,8 @@ class OutboxRetryWorker:
logger: 日志被驱动端口记录重试过程与错误
cache_port: 缓存被驱动端口用于获取/释放分布式咨询锁串行化
同一 outbox_id 的并发重试FR-22
queue_port: 队列被驱动端口用于 M8 保序延后重试同批次前序
未完成时重新入队 ARQ 任务``scheduled_at`` 退避 30s
"""
self._persistence = persistence_port
self._adapter_registry = outbound_adapter_registry
@ -139,6 +149,7 @@ class OutboxRetryWorker:
self._outbox_config = outbox_config
self._logger = logger
self._cache = cache_port
self._queue = queue_port
async def retry(
self,
@ -242,6 +253,33 @@ class OutboxRetryWorker:
)
return
# M8: outbox 重试保序。同批次前序seq < 当前)仍有 PENDING 条目时,
# 延后重试(重新入队 ARQ退避 30s避免后序条目先于前序投递破坏
# fan-out 原始顺序。前序完成(无 PENDING 前序)或非 fan-out 场景
# batch_id 为 None时正常执行重试。
if entry.fan_out_batch_id is not None and entry.fan_out_seq is not None:
pending_predecessors = await self._persistence.getOutboxEntriesByBatch(
entry.fan_out_batch_id,
status=OutboxStatus.PENDING,
max_seq=entry.fan_out_seq - 1,
)
if pending_predecessors:
await self._queue.enqueue(
EnqueueCmd(
task_name=_RETRY_TASK_NAME,
payload={"outbox_id": outbox_id, "trace_id": trace_id},
scheduled_at=utc_now_naive() + timedelta(seconds=_PREDECESSOR_RETRY_DELAY_SECONDS),
)
)
await self._logger.info(
"outbox 重试延后:同批次前序未完成",
outbox_id=outbox_id,
batch_id=entry.fan_out_batch_id,
seq=entry.fan_out_seq,
pending_count=len(pending_predecessors),
)
return
context = await self._persistence.resolveRetryContext(entry.outbox_id)
# RetryContext.channel_type 为 str契约 DTO 解耦枚举依赖),下游
# 熔断器 / 适配器注册表 / 插件注册表等需 ChannelType 枚举,在此处
@ -322,7 +360,7 @@ class OutboxRetryWorker:
trace_id=trace_id,
)
except Exception as exc:
await self._logger.warn(
await self._logger.warning(
"queryMessageByRequestId failed, proceed to send",
trace_id=trace_id,
outbox_id=outbox_id,
@ -517,7 +555,7 @@ class OutboxRetryWorker:
aggregate.status,
self._logger,
)
await self._logger.warn(
await self._logger.warning(
"outbox 重试部分失败",
outbox_id=aggregate.outbox_id,
channel_type=channel_type,
@ -581,7 +619,7 @@ class OutboxRetryWorker:
try:
await self._persistence.updateOutboxEntry(aggregate, tx=tx, expected_status=old_status)
except IdempotencyConflictError:
await self._logger.warn(
await self._logger.warning(
"outbox 重试状态更新冲突,跳过",
outbox_id=aggregate.outbox_id,
old_status=old_status.value,
@ -636,7 +674,7 @@ class OutboxRetryWorker:
try:
await self._persistence.updateOutboxEntry(aggregate, tx=tx, expected_status=old_status)
except IdempotencyConflictError:
await self._logger.warn(
await self._logger.warning(
"outbox 重试状态更新冲突,跳过",
outbox_id=aggregate.outbox_id,
old_status=old_status.value,
@ -651,7 +689,7 @@ class OutboxRetryWorker:
aggregate.status,
self._logger,
)
await self._logger.warn(
await self._logger.warning(
"outbox 重试失败",
outbox_id=aggregate.outbox_id,
channel_type=channel_type,
@ -687,7 +725,7 @@ class OutboxRetryWorker:
try:
await self._persistence.updateOutboxEntry(aggregate, tx=tx, expected_status=old_status)
except IdempotencyConflictError:
await self._logger.warn(
await self._logger.warning(
"outbox 重试状态更新冲突,跳过",
outbox_id=aggregate.outbox_id,
old_status=old_status.value,
@ -702,7 +740,7 @@ class OutboxRetryWorker:
aggregate.status,
self._logger,
)
await self._logger.warn(
await self._logger.warning(
"流式续发不支持,标记 DEAD 避免重复投递",
outbox_id=aggregate.outbox_id,
channel_type=channel_type,
@ -752,7 +790,7 @@ class OutboxRetryWorker:
trace_id=trace_id,
)
await self._event_publisher.publish(event.toDomainEvent())
await self._logger.warn(
await self._logger.warning(
"出站投递暂停:渠道插件降级",
channel_type=channel_type,
account_id=context.account_id,

View File

@ -191,7 +191,7 @@ class Pipeline:
span = None
try:
if self.logger is not None:
await self.logger.warn(
await self.logger.warning(
f"tracer startSpan failed: {stage.id}",
trace_id=trace_id,
stage_id=stage.id,
@ -217,7 +217,7 @@ class Pipeline:
except Exception as exc:
try:
if self.logger is not None:
await self.logger.warn(
await self.logger.warning(
f"tracer endSpan failed: {stage.id}",
trace_id=trace_id,
stage_id=stage.id,
@ -291,7 +291,7 @@ class Pipeline:
# 清理阶段异常仅 warn 日志,不掩盖原始异常
if self.logger is not None:
try:
await self.logger.warn(
await self.logger.warning(
f"cleanup stage {stage.id} failed: {exc}",
trace_id=trace_id,
stage_id=stage.id,
@ -383,7 +383,7 @@ class Pipeline:
**fields,
)
else:
await self.logger.warn(
await self.logger.warning(
f"stage failed: {stage_id}",
**fields,
)

View File

@ -244,7 +244,10 @@ class AuditContextBuilder:
request_id=ctx.request_id,
message_id=message_id,
content_summary=content_summary,
target_channel=ctx.target_channel if ctx.target_channel else None,
# target_channel 列为 NOT NULL注释渠道类型或 global
# 全局操作plugin/catalog、config/get、dashboard/* 等)无特定
# 渠道时填充 "global",避免 NotNullViolationError。
target_channel=ctx.target_channel if ctx.target_channel else "global",
target_account=ctx.params.get("target_account"),
)
@ -336,7 +339,7 @@ class AuditContextBuilder:
trace_id=ctx.trace_id,
cause=e,
)
await self._logger.warn(
await self._logger.warning(
f"audit log write failed (best-effort): {e}",
trace_id=ctx.trace_id,
operation=ctx.operation,
@ -386,13 +389,13 @@ class AuditContextBuilder:
trace_id=ctx.trace_id,
source_ip=ctx.operator.ip,
request_id=ctx.request_id,
target_channel=ctx.target_channel if ctx.target_channel else None,
target_channel=ctx.target_channel if ctx.target_channel else "global",
target_account=account_id,
)
try:
await self._audit_log_repo.saveAuditLog(cmd, tx=tx)
except Exception as e:
await self._logger.warn(
await self._logger.warning(
f"callback audit write failed (cooperative): {audit_type}, account_id={account_id}, error={e}",
trace_id=ctx.trace_id,
)

View File

@ -209,7 +209,7 @@ class AccountHandler(ControlPlaneHandler):
)
except Exception as exc:
if self._logger is not None:
await self._logger.warn(
await self._logger.warning(
f"callback audit write failed: {audit_type}, account_id={account_id}, error={exc}",
trace_id=ctx.trace_id,
)
@ -691,7 +691,7 @@ class AccountHandler(ControlPlaneHandler):
is_available = False
reason = "dependency unavailable"
if self._logger is not None:
await self._logger.warn(
await self._logger.warning(
"test connection failed: dependency unavailable",
trace_id=ctx.trace_id,
channel_type=channel_type,
@ -720,7 +720,7 @@ class AccountHandler(ControlPlaneHandler):
if reason is None:
reason = f"credential validation failed: {exc}"
if self._logger is not None:
await self._logger.warn(
await self._logger.warning(
"validateCredentials dependency unavailable",
trace_id=ctx.trace_id,
channel_type=channel_type,

View File

@ -22,12 +22,11 @@
from __future__ import annotations
import asyncio
import time
from collections.abc import Awaitable, Callable
from datetime import datetime
import anyio
from yuxi.channels.application.context.control_plane_context import (
ControlPlaneContext,
)
@ -343,7 +342,7 @@ class AnalyticsHandler(ControlPlaneHandler):
"""账户活跃度分析 handlerANL-ACCOUNTS
委托 ``MessageRepositoryPort.getAccountActivity`` + ``getAccountTrend``
聚合账户活跃度指标两个查询相互独立通过 ``anyio.gather`` 并行
聚合账户活跃度指标两个查询相互独立通过 ``asyncio.gather`` 并行
执行降低延迟返回 ``AccountAnalyticsResult`` DTO ``to_dict``
统一序列化
"""
@ -366,7 +365,7 @@ class AnalyticsHandler(ControlPlaneHandler):
t0 = time.monotonic()
# 两个查询相互独立,并行执行降低延迟
by_account, trend = await anyio.gather(
by_account, trend = await asyncio.gather(
self._persistence_port.getAccountActivity(query),
self._persistence_port.getAccountTrend(query),
)
@ -425,7 +424,7 @@ class AnalyticsHandler(ControlPlaneHandler):
"""
if self._review_repository is None:
if self._logger is not None:
await self._logger.warn(
await self._logger.warning(
"analytics/content_review requested but ContentReviewRepositoryPort "
"not injected, raising NotImplementedError",
trace_id=ctx.trace_id,

View File

@ -55,7 +55,7 @@ async def log_warn(logger: LoggerPort | None, message: str, trace_id: str | None
替代原 ``DispatchStage._log_warn````logger`` ``None`` 时静默跳过
"""
if logger is not None:
await logger.warn(message, trace_id=trace_id)
await logger.warning(message, trace_id=trace_id)
class BatchExecutor:

View File

@ -412,7 +412,7 @@ class CapabilityHandler(ControlPlaneHandler):
)
except NotFoundError:
if self._logger is not None:
await self._logger.warn(
await self._logger.warning(
f"capability matrix config missing: key={key} scope={scope.value} channel_type={channel_type}",
trace_id=trace_id,
)
@ -423,7 +423,7 @@ class CapabilityHandler(ControlPlaneHandler):
# 不应因 schema 声明缺口导致整体失败,按未配置处理并标记为
# UNAVAILABLE由调用方决定是否需要补齐 schema。
if self._logger is not None:
await self._logger.warn(
await self._logger.warning(
"capability matrix config key not declared in schema: "
f"key={key} scope={scope.value} channel_type={channel_type}",
trace_id=trace_id,

View File

@ -170,7 +170,7 @@ class DirectoryHandler(ControlPlaneHandler):
cached = await self.cache_port.get(cache_key)
except DependencyError as exc:
if self._logger is not None:
await self._logger.warn(
await self._logger.warning(
"directory cache get failed, skip cache",
cache_key=cache_key,
error=str(exc),
@ -631,7 +631,7 @@ class DirectoryHandler(ControlPlaneHandler):
await self.cache_port.invalidate(pattern)
except DependencyError:
if self._logger is not None:
await self._logger.warn(
await self._logger.warning(
"directory cache invalidate failed, non-blocking",
pattern=pattern,
trace_id=ctx.trace_id,

View File

@ -629,7 +629,7 @@ class OutboxHandler(ControlPlaneHandler):
)
await self.config_manager.update(cmd)
elif self._logger is not None:
await self._logger.warn(
await self._logger.warning(
"config_manager not injected, retry policy update persisted in-memory only",
operation=ctx.operation,
trace_id=ctx.trace_id,

View File

@ -387,7 +387,7 @@ class PluginHandler(ControlPlaneHandler):
await self._plugin_repository.updatePluginState(plugin_id, state)
except Exception as exc:
if self._logger is not None:
await self._logger.warn(
await self._logger.warning(
f"plugin state sync to DB failed (non-critical): {exc}",
trace_id=trace_id,
plugin_id=plugin_id,
@ -488,7 +488,7 @@ class PluginHandler(ControlPlaneHandler):
await loader.uninstall(plugin_manifest.manifest.id)
except Exception as cleanup_exc:
if self._logger is not None:
await self._logger.warn(
await self._logger.warning(
"plugin install cleanup failed, orphan files may remain",
plugin_id=plugin_manifest.manifest.id,
cleanup_error=str(cleanup_exc),
@ -538,7 +538,7 @@ class PluginHandler(ControlPlaneHandler):
)
except Exception as exc:
if self._logger is not None:
await self._logger.warn(
await self._logger.warning(
f"plugin install record persistence failed (non-critical): {exc}",
trace_id=ctx.trace_id,
plugin_id=plugin_manifest.manifest.id,
@ -584,7 +584,7 @@ class PluginHandler(ControlPlaneHandler):
)
except Exception as exc:
if self._logger is not None:
await self._logger.warn(
await self._logger.warning(
f"plugin uninstall record soft-delete failed (non-critical): {exc}",
trace_id=ctx.trace_id,
plugin_id=plugin_id,
@ -638,7 +638,7 @@ class PluginHandler(ControlPlaneHandler):
config[field.key] = value.value if value.value is not None else field.default
except ConfigValidationError as exc:
if self._logger is not None:
await self._logger.warn(
await self._logger.warning(
f"plugin config key '{field.key}' not persisted, using schema default: {exc}",
trace_id=ctx.trace_id,
plugin_id=plugin_id,
@ -683,7 +683,7 @@ class PluginHandler(ControlPlaneHandler):
version = await self.config_manager.config_port.getVersion(field.key, scope=scope, target=target)
except ConfigValidationError as exc:
if self._logger is not None:
await self._logger.warn(
await self._logger.warning(
f"plugin config key '{field.key}' version lookup failed: {exc}",
trace_id=trace_id,
plugin_id=plugin_id,

View File

@ -296,7 +296,7 @@ class RouteBindingHandler(ControlPlaneHandler):
)
skipped = self._registry.replaceAll(rules)
if skipped:
await self._logger.warn(
await self._logger.warning(
"skip invalid route binding rule during reload",
total=len(rules),
skipped=skipped,
@ -321,7 +321,7 @@ class RouteBindingHandler(ControlPlaneHandler):
try:
self._registry.applyRule(rule)
except Exception:
await self._logger.warn(
await self._logger.warning(
"applyRule failed, fallback to full reload",
binding_id=rule.binding_id,
)

View File

@ -240,7 +240,7 @@ class SessionHandler(ControlPlaneHandler):
)
)
elif self._logger is not None:
await self._logger.warn(
await self._logger.warning(
f"session not found for session_id {cmd.session_id} after owner transfer, "
"skipping ChannelSessionUpdated event",
trace_id=ctx.trace_id,

View File

@ -430,6 +430,14 @@ class WhitelistHandler(ControlPlaneHandler):
return None
batch_result = await self._batch_executor.execute(ctx, peer_ids, _removeOne)
# 全量不存在时返回 404对齐单条删除契约与 list/add 等端点的非存在账户语义),
# 部分成功时返回 200 + failed 列表(模式 D 部分成功语义)。
if not batch_result["succeeded"] and batch_result["failed"]:
raise NotFoundError(
"whitelist_entry",
",".join(peer_ids),
trace_id=ctx.trace_id,
)
# 响应字段对齐契约 ``BatchWhitelistDeleteResult````deleted`` 承载
# 成功删除的 peer_id 元组(非 succeeded 计数),便于前端展示与审计。
return ControlPlaneResult(

View File

@ -171,7 +171,7 @@ class RateLimitStage:
if not meta.bypass_rate_limit_on_dep_failure:
raise
if self.logger is not None:
await self.logger.warn(
await self.logger.warning(
"rate_limit dep failure, fail-open for read-only diagnostic op",
trace_id=context.trace_id,
operation=context.operation,
@ -192,7 +192,7 @@ class RateLimitStage:
if not meta.bypass_rate_limit_on_dep_failure:
raise
if self.logger is not None:
await self.logger.warn(
await self.logger.warning(
"rate_limit dep failure, fail-open for read-only diagnostic op",
trace_id=context.trace_id,
operation=context.operation,

View File

@ -469,7 +469,7 @@ class AgentRunEnqueueStage:
adapter = self.channel_context_provider_registry.get(context.channel_type)
if adapter is None:
if self.logger is not None:
await self.logger.warn(
await self.logger.warning(
"channel context provider not registered, skip context note generation",
trace_id=context.trace_id,
channel_type=context.channel_type,
@ -511,7 +511,7 @@ class AgentRunEnqueueStage:
# 插件契约错误Error时记录日志并降级为原上下文避免中断 Agent 运行;
# 编程错误Exception穿透至管道层翻译为 InternalErrorINB-P0-004
if self.logger is not None:
await self.logger.warn(
await self.logger.warning(
f"context note enrichment failed, skip: {exc}",
trace_id=context.trace_id,
channel_type=context.channel_type,
@ -561,7 +561,7 @@ class AgentRunEnqueueStage:
adapter = self.tools_adapter_registry.get(context.channel_type)
if adapter is None:
if self.logger is not None:
await self.logger.warn(
await self.logger.warning(
"tools adapter not registered, skip channel tools enrichment",
trace_id=context.trace_id,
channel_type=context.channel_type,
@ -575,7 +575,7 @@ class AgentRunEnqueueStage:
# 插件契约错误Error时记录日志并降级为空列表避免中断 Agent 运行;
# 编程错误Exception穿透至管道层翻译为 InternalErrorINB-P0-004
if self.logger is not None:
await self.logger.warn(
await self.logger.warning(
f"getChannelTools failed, fallback to empty tool list: {exc}",
trace_id=context.trace_id,
channel_type=context.channel_type,
@ -631,7 +631,7 @@ class AgentRunEnqueueStage:
adapter = self.message_ops_adapter_registry.get(context.channel_type)
if adapter is None:
if self.logger is not None:
await self.logger.warn(
await self.logger.warning(
"message ops adapter not registered, skip message ops enrichment",
trace_id=context.trace_id,
channel_type=context.channel_type,
@ -645,7 +645,7 @@ class AgentRunEnqueueStage:
# 插件契约错误Error时记录日志并降级不中断 Agent 运行;
# 编程错误Exception穿透至管道层翻译为 InternalErrorINB-P0-004
if self.logger is not None:
await self.logger.warn(
await self.logger.warning(
f"getMessageOperations failed, skip message ops tools: {exc}",
trace_id=context.trace_id,
channel_type=context.channel_type,
@ -742,7 +742,7 @@ class AgentRunEnqueueStage:
# 配置项不存在为合法降级场景记录日志并回退默认值INV-7
# 配置端口故障DependencyError等不在此捕获穿透至管道层。
if self.logger is not None:
await self.logger.warn(
await self.logger.warning(
f"config read failed, fallback to default: key={key}, default={default}, error={exc}",
trace_id=context.trace_id,
channel_type=context.channel_type,

View File

@ -136,7 +136,7 @@ class ClassifyStage:
if event_type == EventType.AGENT_MENTION:
# v1 不支持多 Agent 协作,记录 warning 后按 MESSAGE 处理
if self.logger is not None:
await self.logger.warn(
await self.logger.warning(
"AGENT_MENTION event received, treating as MESSAGE (multi-agent not supported in v1)",
trace_id=context.trace_id,
)

View File

@ -164,7 +164,7 @@ class InboundIdempotencyStage:
# in_progress 视为崩溃遗留,删除重建;未超时才抛冲突。
if _isStaleInProgress(record, _IDEMPOTENCY_TIMEOUT_SECONDS):
if self.logger is not None:
await self.logger.warn(
await self.logger.warning(
"stale in_progress idempotency record detected, rebuilding",
trace_id=context.trace_id,
idempotency_key=idempotency_key,

View File

@ -173,7 +173,7 @@ class MediaFetchStage:
if adapter is None or not hasattr(adapter, "downloadAttachment"):
# AC-14: 适配器未实现 downloadAttachment
if self.logger is not None:
await self.logger.warn(
await self.logger.warning(
"adapter not implemented downloadAttachment, skip download",
trace_id=context.trace_id,
channel_type=context.channel_type,
@ -189,7 +189,7 @@ class MediaFetchStage:
# FR-49 异常边界: 渠道 SDK 原生异常包装为 InternalError
# agent_run_enqueue_stage 一致的异常翻译模式INV-7
if self.logger is not None:
await self.logger.warn(
await self.logger.warning(
"downloadAttachment raised exception, skip attachment",
trace_id=context.trace_id,
channel_type=context.channel_type,
@ -205,7 +205,7 @@ class MediaFetchStage:
if downloaded is None or downloaded.content is None:
# 下载失败
if self.logger is not None:
await self.logger.warn(
await self.logger.warning(
"downloadAttachment returned None, skip attachment",
trace_id=context.trace_id,
channel_type=context.channel_type,
@ -220,7 +220,7 @@ class MediaFetchStage:
# 步骤2: 体积校验(框架级上限,超过即剔除附件并降级)
if len(content) > self.MAX_ATTACHMENT_SIZE:
if self.logger is not None:
await self.logger.warn(
await self.logger.warning(
"image size exceeds limit, skip attachment",
trace_id=context.trace_id,
channel_type=context.channel_type,
@ -241,7 +241,7 @@ class MediaFetchStage:
except Exception as exc:
# AC-12: image_processor 原生异常包装为 InternalError剔除附件降级
if self.logger is not None:
await self.logger.warn(
await self.logger.warning(
"image_processor failed, skip attachment",
trace_id=context.trace_id,
channel_type=context.channel_type,
@ -256,7 +256,7 @@ class MediaFetchStage:
if not result.get("success"):
if self.logger is not None:
await self.logger.warn(
await self.logger.warning(
"image_processor returned failure, skip attachment",
trace_id=context.trace_id,
channel_type=context.channel_type,

View File

@ -292,7 +292,7 @@ class ReplyStage:
await self.ack_decision_maker.recordAck(idempotency_key)
except Exception as exc:
if self._logger is not None:
await self._logger.warn(
await self._logger.warning(
"post_commit recordAck failed, rolling back idempotency record",
trace_id=context.trace_id,
error=str(exc),

View File

@ -162,7 +162,7 @@ class RouteStage:
peer_id=context.peer_id,
account_id=context.account_id,
)
await self.logger.warn(
await self.logger.warning(
"temporary session message cannot route to agent, pipeline will terminate",
trace_id=context.trace_id,
peer_id=context.peer_id,
@ -182,7 +182,7 @@ class RouteStage:
is_owner = True
else:
if session.owner_peer_id is None and self.logger is not None:
await self.logger.warn(
await self.logger.warning(
"session owner missing, skip owner protection (historical data)",
trace_id=context.trace_id,
peer_id=context.peer_id,
@ -265,7 +265,7 @@ class RouteStage:
)
except Exception as exc:
if self.logger is not None:
await self.logger.warn(
await self.logger.warning(
"touch route info failed, non-fatal",
trace_id=context.trace_id,
session_id=session.session_id,

View File

@ -206,7 +206,7 @@ class SecurityStage:
context.degraded = True
identity_id = f"peer:{context.peer_id or ''}"
if self._logger is not None:
await self._logger.warn(
await self._logger.warning(
"identity resolution failed, degrade to peer-level rate limit",
channel_type=context.channel_type,
account_id=context.account_id,

View File

@ -308,7 +308,7 @@ class SessionResolveStage:
is_temporary = ChannelSession.isTemporarySession(context.peer_id)
if not is_temporary and context.peer_id.startswith("cron:"):
if self.logger is not None:
await self.logger.warn(
await self.logger.warning(
"malformed cron session key, defaulting to not temporary",
trace_id=context.trace_id,
peer_id=context.peer_id,
@ -399,7 +399,7 @@ class SessionResolveStage:
await self.persistence_port.touchChannelSessionLastMessageAt(session_id, tx=context.tx)
except Exception as exc:
if self.logger is not None:
await self.logger.warn(
await self.logger.warning(
"touch last_message_at failed, non-fatal",
trace_id=context.trace_id,
session_id=session_id,
@ -482,7 +482,7 @@ class SessionResolveStage:
return value
except ConfigValidationError as exc:
if self.logger is not None:
await self.logger.warn(
await self.logger.warning(
"cross_channel_identity_strategy read failed, fallback to isolation",
error=str(exc),
trace_id=context.trace_id,

View File

@ -112,7 +112,7 @@ class SignatureVerifyStage:
# 防止适配器误用内置异常导致管道终止M-16
if isinstance(exc, builtins.NotImplementedError) and not isinstance(exc, NotImplementedError):
if self.logger is not None:
await self.logger.warn(
await self.logger.warning(
"signature verification skipped: adapter raised built-in NotImplementedError, "
"should use contract NotImplementedError",
trace_id=context.trace_id,
@ -120,7 +120,7 @@ class SignatureVerifyStage:
account_id=context.account_id,
)
elif self.logger is not None:
await self.logger.warn(
await self.logger.warning(
"signature verification skipped: adapter not implemented",
trace_id=context.trace_id,
channel_type=context.channel_type,

View File

@ -207,7 +207,7 @@ class DeliverStage:
if not adapter.supportsOutbound():
if self.logger is not None:
await self.logger.warn(
await self.logger.warning(
"outbound adapter does not support outbound, skip deliver stage",
trace_id=context.trace_id,
channel_type=context.channel_type,
@ -431,7 +431,7 @@ class DeliverStage:
)
except NotImplementedError:
if self.logger is not None:
await self.logger.warn(
await self.logger.warning(
"stream continuation not supported, mark as FAILED",
trace_id=context.trace_id,
outbox_id=aggregate.outbox_id,
@ -439,7 +439,7 @@ class DeliverStage:
return None
except Exception:
if self.logger is not None:
await self.logger.warn(
await self.logger.warning(
"stream continuation failed, mark as FAILED",
trace_id=context.trace_id,
outbox_id=aggregate.outbox_id,
@ -542,7 +542,7 @@ class DeliverStage:
)
except Exception as exc:
if self.logger is not None:
await self.logger.warn(
await self.logger.warning(
"bot loop budget release failed on deliver failure",
trace_id=context.trace_id,
account_id=context.account_id,

View File

@ -136,7 +136,7 @@ class OutboxMarkFailed:
aggregate.markFailed("deliver compensation")
except StateTransitionError as exc:
if self.logger is not None:
await self.logger.warn(
await self.logger.warning(
"outbox-mark-failed: state transition not allowed, skip",
outbox_id=dto.outbox_id,
current_status=latest.status.value,
@ -152,7 +152,7 @@ class OutboxMarkFailed:
if latest is not None and latest.status in (OutboxStatus.SENT, OutboxStatus.DEAD):
return True
if self.logger is not None:
await self.logger.warn(
await self.logger.warning(
"compensation conflict, status may be inconsistent",
outbox_id=aggregate.outbox_id,
trace_id=context.trace_id,

View File

@ -213,35 +213,74 @@ class OutboxPersistStage:
async with self.transaction_port.begin() as tx:
message = await self.conversation_port.saveMessage(save_msg_cmd, tx=tx)
context.message_id = message.message_id
outbox_cmd = SaveOutboxEntryCmd(
channel_type=context.channel_type,
message_id=context.message_id,
channel_account_id=context.account_id,
durability_policy=durability_policy,
trace_id=context.trace_id,
channel_session_id=context.channel_session_id,
stream_aborted_at_chunk=context.stream_aborted_at_chunk,
)
outbox_id = await self.persistence_port.saveOutboxEntry(outbox_cmd, tx=tx)
# H2-O1: 使用 outbox_cmd 数据构造 DTO避免事务内二次查询
# getOutboxEntry 未传 tx 会开启独立事务,延长锁持有时间)
entry = OutboxEntry(
outbox_id=outbox_id.value,
message_id=outbox_cmd.message_id,
channel_account_id=outbox_cmd.channel_account_id,
status=OutboxStatus.PENDING,
durability_policy=outbox_cmd.durability_policy,
channel_type=outbox_cmd.channel_type,
channel_session_id=outbox_cmd.channel_session_id,
stream_aborted_at_chunk=outbox_cmd.stream_aborted_at_chunk,
)
if context.is_fan_out and context.fan_out_entries:
# M13 批量路径fan-out 场景单事务保存所有 OutboxEntry
# fan_out_seq 按批次内目标列表位置生成M8 保序),供 outbox
# 重试 worker 在同一批次内按 seq 排序投递。
cmds = [
SaveOutboxEntryCmd(
channel_type=target.channel_type,
message_id=context.message_id,
channel_account_id=target.account_id,
durability_policy=durability_policy,
trace_id=context.trace_id,
channel_session_id=target.channel_session_id,
stream_aborted_at_chunk=context.stream_aborted_at_chunk,
fan_out_batch_id=context.fan_out_batch_id,
fan_out_seq=idx,
)
for idx, target in enumerate(context.fan_out_entries)
]
outbox_ids = await self.persistence_port.saveOutboxEntries(cmds, tx=tx)
context.fan_out_outbox_ids = outbox_ids
# 使用首条 cmd 数据构造 DTO保持与单条路径一致的 PENDING 状态
first_cmd = cmds[0]
entry = OutboxEntry(
outbox_id=outbox_ids[0].value,
message_id=first_cmd.message_id,
channel_account_id=first_cmd.channel_account_id,
status=OutboxStatus.PENDING,
durability_policy=first_cmd.durability_policy,
channel_type=first_cmd.channel_type,
channel_session_id=first_cmd.channel_session_id,
stream_aborted_at_chunk=first_cmd.stream_aborted_at_chunk,
fan_out_batch_id=first_cmd.fan_out_batch_id,
fan_out_seq=first_cmd.fan_out_seq,
)
else:
outbox_cmd = SaveOutboxEntryCmd(
channel_type=context.channel_type,
message_id=context.message_id,
channel_account_id=context.account_id,
durability_policy=durability_policy,
trace_id=context.trace_id,
channel_session_id=context.channel_session_id,
stream_aborted_at_chunk=context.stream_aborted_at_chunk,
fan_out_batch_id=context.fan_out_batch_id,
fan_out_seq=context.fan_out_seq,
)
outbox_id = await self.persistence_port.saveOutboxEntry(outbox_cmd, tx=tx)
# H2-O1: 使用 outbox_cmd 数据构造 DTO避免事务内二次查询
# getOutboxEntry 未传 tx 会开启独立事务,延长锁持有时间)
entry = OutboxEntry(
outbox_id=outbox_id.value,
message_id=outbox_cmd.message_id,
channel_account_id=outbox_cmd.channel_account_id,
status=OutboxStatus.PENDING,
durability_policy=outbox_cmd.durability_policy,
channel_type=outbox_cmd.channel_type,
channel_session_id=outbox_cmd.channel_session_id,
stream_aborted_at_chunk=outbox_cmd.stream_aborted_at_chunk,
fan_out_batch_id=outbox_cmd.fan_out_batch_id,
fan_out_seq=outbox_cmd.fan_out_seq,
)
context.outbox_entry = entry
if self._logger is not None:
await self._logger.info(
"outbox entry created",
trace_id=context.trace_id,
outbox_id=outbox_id.value,
outbox_id=entry.outbox_id,
message_id=context.message_id,
durability_policy=durability_policy.value,
)
@ -359,7 +398,7 @@ class OutboxPersistStage:
# m-12: 轻量记录成功则视同正常 outbox 跟踪路径degraded 标志保留用于
# 诊断但不影响投递路径deliver_stage 降级条件 outbox_entry is None 不成立)
if self._logger is not None:
await self._logger.warn(
await self._logger.warning(
"best_effort lightweight outbox record created, continue with normal outbox tracking",
trace_id=context.trace_id,
outbox_id=entry.outbox_id,

View File

@ -116,7 +116,7 @@ class OutboxRollback:
if latest is not None and latest.status in (OutboxStatus.SENT, OutboxStatus.DEAD):
return True # 已达终态,跳过
if self.logger is not None:
await self.logger.warn(
await self.logger.warning(
"compensation conflict, status may be inconsistent",
outbox_id=aggregate.outbox_id,
trace_id=context.trace_id,

View File

@ -221,7 +221,7 @@ class StreamChunkStage:
elapsed = time.monotonic() - started_at
if elapsed > ttl_s:
if self.logger is not None:
await self.logger.warn(
await self.logger.warning(
f"streaming TTL expired (elapsed={elapsed:.2f}s, "
f"ttl={ttl_s:.2f}s), stop sending chunks "
f"(sent={len(results)})",

View File

@ -135,7 +135,7 @@ class TruncationCheckStage:
context.delivery_mode = "persistent"
if self.logger is not None:
await self.logger.warn(
await self.logger.warning(
f"truncation completion failed, fallback to persistent mode: "
f"original_length={original_length} completed_length={completed_length}",
trace_id=context.trace_id,

View File

@ -124,7 +124,7 @@ class TypingIndicatorStage:
# 记录 warning 不重复启动避免渠道侧资源泄漏L-18
if context.typing_started:
if self.logger is not None:
await self.logger.warn(
await self.logger.warning(
"typing_indicator already started, skip duplicate startTypingIndicator",
trace_id=context.trace_id,
channel_type=context.channel_type,
@ -139,7 +139,7 @@ class TypingIndicatorStage:
if ok:
context.typing_started = True
elif self.logger is not None:
await self.logger.warn(
await self.logger.warning(
"startTypingIndicator returned False, typing indicator not started",
trace_id=context.trace_id,
channel_type=context.channel_type,
@ -150,7 +150,7 @@ class TypingIndicatorStage:
# 据 ``typing_started`` 决定是否调用 ``stopTypingIndicator``
# 因此此处不置 True 即可保证不调用停止方法。
if self.logger is not None:
await self.logger.warn(
await self.logger.warning(
f"startTypingIndicator failed: {exc!r}",
trace_id=context.trace_id,
channel_type=context.channel_type,

View File

@ -112,7 +112,7 @@ class TypingStopStage:
# streaming_ttl_ms即使 endStreaming 失败也会在 TTL 到期后
# 自动清理。
if self.logger is not None:
await self.logger.warn(
await self.logger.warning(
f"endStreaming failed: {exc!r}",
trace_id=context.trace_id,
channel_type=context.channel_type,
@ -132,7 +132,7 @@ class TypingStopStage:
context.account_id, context.peer_id
)
if not ok and self.logger is not None:
await self.logger.warn(
await self.logger.warning(
"stopTypingIndicator returned False, typing indicator resource may leak",
trace_id=context.trace_id,
channel_type=context.channel_type,
@ -143,7 +143,7 @@ class TypingStopStage:
# 通常有 TTL 自动过期机制typing_ttl_ms即使停止失败也会
# 在 TTL 到期后自动消失。
if self.logger is not None:
await self.logger.warn(
await self.logger.warning(
f"stopTypingIndicator failed: {exc!r}",
trace_id=context.trace_id,
channel_type=context.channel_type,

View File

@ -188,7 +188,7 @@ class BaseTransportWorker(ABC):
timeout=shutdown_timeout,
)
except TimeoutError:
await self._logger.warn(
await self._logger.warning(
"transport worker shutdown timeout, some tasks may still be running",
transport_mode=self.transport_mode,
timeout_s=shutdown_timeout,
@ -250,7 +250,7 @@ class BaseTransportWorker(ABC):
self._restart_counts.pop(account_key, None)
if await self._circuit_breaker.isOpen(channel_type, account_id):
await self._logger.warn(
await self._logger.warning(
"circuit breaker open, skip starting account",
channel_type=channel_type,
account_id=account_id,
@ -452,7 +452,7 @@ class BaseTransportWorker(ABC):
await self._circuit_breaker.recordFailure(channel_type, account_id)
is_open = await self._circuit_breaker.isOpen(channel_type, account_id)
if is_open:
await self._logger.warn(
await self._logger.warning(
"circuit breaker open, not restarting account",
channel_type=channel_type,
account_id=account_id,
@ -550,7 +550,7 @@ class BaseTransportWorker(ABC):
try:
await adapter.onTransportReset(account_id)
except Exception as reset_exc:
await self._logger.warn(
await self._logger.warning(
"adapter onTransportReset failed after stall",
channel_type=channel_type,
account_id=account_id,
@ -560,7 +560,7 @@ class BaseTransportWorker(ABC):
await self._circuit_breaker.recordFailure(channel_type, account_id)
is_open = await self._circuit_breaker.isOpen(channel_type, account_id)
if is_open:
await self._logger.warn(
await self._logger.warning(
"circuit breaker open after stall, not restarting",
channel_type=channel_type,
account_id=account_id,
@ -705,7 +705,7 @@ class BaseTransportWorker(ABC):
except NotFoundError:
# 配置项不存在(且无 schema 默认值):合法的"未配置"状态,
# 降级为默认值并记录 warning便于运维感知
await self._logger.warn(
await self._logger.warning(
"transport config key not found, fallback to default",
key=key,
)
@ -910,7 +910,7 @@ class BaseTransportWorker(ABC):
True表示应停止当前账号循环False表示应退避后重试
"""
trace_id = error.trace_id or str(uuid.uuid4())
await self._logger.warn(
await self._logger.warning(
"transport error occurred",
trace_id=trace_id,
channel_type=channel_type,
@ -1108,7 +1108,7 @@ class BaseTransportWorker(ABC):
continue
if now - task_info.last_activity_at > stall_timeout_s:
trace_id = str(uuid.uuid4())
await self._logger.warn(
await self._logger.warning(
"transport stall detected, will restart account",
trace_id=trace_id,
channel_type=task_info.channel_type,

View File

@ -10,6 +10,7 @@ import asyncio
import dataclasses
import uuid
from collections.abc import Awaitable, Callable
from datetime import UTC, datetime
from typing import Any
from yuxi.channels.application.transport.base_worker import (
@ -19,6 +20,7 @@ from yuxi.channels.application.transport.puller_worker import PullerWorker
from yuxi.channels.application.transport.stream_worker import StreamWorker
from yuxi.channels.contract.dtos.channel import AccountFilter, AccountStatus, ChannelType
from yuxi.channels.contract.dtos.config import ConfigScope
from yuxi.channels.contract.dtos.event import ChannelTransportFailedEvent
from yuxi.channels.contract.dtos.health import TransportHealthSnapshot
from yuxi.channels.contract.dtos.option import Some
from yuxi.channels.contract.dtos.plugin import DomainEvent, EventHandler
@ -38,6 +40,17 @@ __all__ = ["TransportManager"]
# 管理循环的轮询间隔(秒):仅用于等待取消信号,不参与业务逻辑
_MANAGER_LOOP_INTERVAL_S: float = 1.0
# H6+H8分布式锁 TTL
_RESTORE_LOCK_TTL_S: int = 60
_ACCOUNT_LOCK_TTL_S: int = 30
# M11降级标记 TTL避免永久残留
_DEGRADED_TTL_S: int = 3600
# N-M1降级失败重试参数
_DEGRADE_MAX_RETRY: int = 3
_DEGRADE_RETRY_INTERVAL_S: float = 30.0
class TransportManager:
"""入站传输全局管理器。
@ -78,8 +91,9 @@ class TransportManager:
logger: 日志端口
message_deliverer: 入站消息投递回调
transport_config: 传输配置 None 时使用默认配置
cache_port: 缓存端口用于启动前预热凭证缓存 None
跳过预热向后兼容
cache_port: 缓存端口用于启动前预热凭证缓存分布式锁
H6+H8与降级状态分布式化M11 None 时跳过预热
向后兼容但分布式锁与降级状态功能不可用
"""
self._plugin_registry = plugin_registry
self._persistence_port = persistence_port
@ -98,11 +112,6 @@ class TransportManager:
self._manager_task: asyncio.Task[None] | None = None
self._running: bool = False
self._plugin_id = "transport-manager"
# H-14恢复扫描与配置热更新互斥锁进程内防同进程竞态
self._restore_lock = asyncio.Lock()
# P0-1已降级账号集合避免 StreamWorker 反复重启时反复降级。
# key 为 ``{channel_type}:{account_id}``,账号重新上线时清除。
self._degraded_accounts: set[str] = set()
async def start(self) -> None:
"""启动传输管理器。
@ -254,7 +263,7 @@ class TransportManager:
return self.getHealth()
except Exception as exc:
# 健康检查不得抛异常阻塞调用方(与 HealthAggregator 超时降级策略一致)
await self._logger.warn(
await self._logger.warning(
"transport health query failed, returning empty state",
error=str(exc),
)
@ -346,9 +355,26 @@ class TransportManager:
self._event_bus.unregister(self._plugin_id)
@staticmethod
def _make_account_key(channel_type: ChannelType, account_id: str) -> str:
"""构造账号唯一键(与 BaseTransportWorker 一致)。"""
return f"{channel_type}:{account_id}"
def _degraded_key(channel_type: ChannelType, account_id: str) -> str:
"""构造降级标记的分布式缓存键M11"""
return f"degraded:{channel_type}:{account_id}"
async def _is_degraded(self, channel_type: ChannelType, account_id: str) -> bool:
"""查询账号是否处于降级状态M11分布式"""
cached = await self._cache_port.get(self._degraded_key(channel_type, account_id))
return isinstance(cached, Some)
async def _mark_degraded(self, channel_type: ChannelType, account_id: str) -> None:
"""标记账号为降级状态M11分布式TTL=1h"""
await self._cache_port.set(
self._degraded_key(channel_type, account_id),
"1",
ttl_seconds=_DEGRADED_TTL_S,
)
async def _clear_degraded(self, channel_type: ChannelType, account_id: str) -> None:
"""清除账号降级标记M11分布式"""
await self._cache_port.delete(self._degraded_key(channel_type, account_id))
async def _on_account_online(self, event: DomainEvent) -> None:
"""处理账号上线事件。
@ -366,7 +392,7 @@ class TransportManager:
channel_type_raw = event.payload.get("channel_type")
account_id = event.payload.get("account_id")
if channel_type_raw is None or account_id is None:
await self._logger.warn(
await self._logger.warning(
"account online event missing required fields, skip",
trace_id=trace_id,
channel_type=channel_type_raw,
@ -386,9 +412,8 @@ class TransportManager:
# 重新登录触发 ChannelAccountOnline清除降级标记并停止降级启动
# 的 PullerWorker由 _startTransportForAccount 按 transport_mode
# 重新选择both 模式恢复 StreamWorkerSSE 仍不可用时再次降级)。
account_key = self._make_account_key(channel_type, account_id)
if account_key in self._degraded_accounts:
self._degraded_accounts.discard(account_key)
if await self._is_degraded(channel_type, account_id):
await self._clear_degraded(channel_type, account_id)
if self._puller_worker is not None:
await self._puller_worker.stop_account(channel_type, account_id, reason="recovered")
await self._logger.info(
@ -425,53 +450,70 @@ class TransportManager:
覆盖 ``_resolveTransportMode`` 的解析结果 P0-1 降级机制
强制以 pull 模式启动 PullerWorker 使用
"""
transport_mode = (
force_mode if force_mode is not None else await self._resolveTransportMode(channel_type, account_id)
# H6+H8账号级分布式锁防止多实例并发启动同一账号
account_lock_key = f"transport:{channel_type}:{account_id}"
account_lock_token = await self._cache_port.acquireAdvisoryLock(
account_lock_key, ttl_seconds=_ACCOUNT_LOCK_TTL_S
)
puller_adapter = self._puller_registry.get(channel_type)
stream_adapter = self._stream_connector_registry.get(channel_type)
if account_lock_token is None:
await self._logger.info(
"transport start already in progress",
trace_id=trace_id,
channel_type=channel_type,
account_id=account_id,
source=source,
)
return
try:
transport_mode = (
force_mode if force_mode is not None else await self._resolveTransportMode(channel_type, account_id)
)
puller_adapter = self._puller_registry.get(channel_type)
stream_adapter = self._stream_connector_registry.get(channel_type)
await self._logger.info(
"starting transport for account",
trace_id=trace_id,
channel_type=channel_type,
account_id=account_id,
transport_mode=transport_mode,
has_puller_adapter=puller_adapter is not None,
has_stream_adapter=stream_adapter is not None,
source=source,
)
await self._logger.info(
"starting transport for account",
trace_id=trace_id,
channel_type=channel_type,
account_id=account_id,
transport_mode=transport_mode,
has_puller_adapter=puller_adapter is not None,
has_stream_adapter=stream_adapter is not None,
source=source,
)
# 先持久化插件运行态为 runningFR-32 诊断字段best-effort 写入),
# 必须在 start_account 之前调用start_account 内部通过 asyncio.create_task
# 调度的 _runAccountLoop 后台任务会使用同一共享 AsyncSession 调用
# getChannelAccount若 updatePluginStatus 与 task 并发执行会触发
# SQLAlchemy AsyncSession 并发访问异常(该异常在 channel_persistence_adapter
# 的 except SQLAlchemyError 分支被静默翻译为 DependencyError,无原始异常日志)。
# 提前完成 updatePluginStatus 的 commit 可确保 task 启动时 session 处于干净状态。
# plugin_status 为诊断字段,即使后续 start_account 因 circuit breaker open
# 等原因未真正启动 task状态轻微不一致可接受下次状态变化时纠正
await self._touchPluginStatus(channel_type, account_id, "running", trace_id)
# 先持久化插件运行态为 runningFR-32 诊断字段best-effort 写入),
# 必须在 start_account 之前调用start_account 内部通过 asyncio.create_task
# 调度的 _runAccountLoop 后台任务会使用同一共享 AsyncSession 调用
# getChannelAccount若 updatePluginStatus 与 task 并发执行会触发
# SQLAlchemy AsyncSession 并发访问异常(该异常在 channel_persistence_adapter
# 的 except SQLAlchemyError 分支被翻译为 DependencyError
# 提前完成 updatePluginStatus 的 commit 可确保 task 启动时 session 处于干净状态。
# plugin_status 为诊断字段,即使后续 start_account 因 circuit breaker open
# 等原因未真正启动 task状态轻微不一致可接受下次状态变化时纠正
await self._touchPluginStatus(channel_type, account_id, "running", trace_id)
# 启动前预热凭证缓存CachePort 命中则跳过,未命中从 ConfigPort 解密回填。
# 覆盖重启恢复与事件驱动路径,确保 worker 首次 poll 时凭证已就绪。
if self._cache_port is not None:
await self._preheatCredentials(account_id, trace_id)
# 启动前预热凭证缓存CachePort 命中则跳过,未命中从 ConfigPort 解密回填。
# 覆盖重启恢复与事件驱动路径,确保 worker 首次 poll 时凭证已就绪。
if self._cache_port is not None:
await self._preheatCredentials(account_id, trace_id)
if transport_mode == "pull":
if puller_adapter is not None and self._puller_worker is not None:
await self._puller_worker.start_account(channel_type, account_id, puller_adapter)
elif transport_mode == "stream":
if stream_adapter is not None and self._stream_worker is not None:
await self._stream_worker.start_account(channel_type, account_id, stream_adapter)
else:
# Task 11.2: both 模式优先 StreamPuller 作为降级。
# Stream 适配器可用时仅启动 StreamStream 健康时不 poll
# Stream 适配器不可用时降级启动 Puller。
if stream_adapter is not None and self._stream_worker is not None:
await self._stream_worker.start_account(channel_type, account_id, stream_adapter)
elif puller_adapter is not None and self._puller_worker is not None:
await self._puller_worker.start_account(channel_type, account_id, puller_adapter)
if transport_mode == "pull":
if puller_adapter is not None and self._puller_worker is not None:
await self._puller_worker.start_account(channel_type, account_id, puller_adapter)
elif transport_mode == "stream":
if stream_adapter is not None and self._stream_worker is not None:
await self._stream_worker.start_account(channel_type, account_id, stream_adapter)
else:
# Task 11.2: both 模式优先 StreamPuller 作为降级。
# Stream 适配器可用时仅启动 StreamStream 健康时不 poll
# Stream 适配器不可用时降级启动 Puller。
if stream_adapter is not None and self._stream_worker is not None:
await self._stream_worker.start_account(channel_type, account_id, stream_adapter)
elif puller_adapter is not None and self._puller_worker is not None:
await self._puller_worker.start_account(channel_type, account_id, puller_adapter)
finally:
await self._cache_port.releaseAdvisoryLock(account_lock_token)
async def _preheatCredentials(self, account_id: str, trace_id: str) -> None:
"""启动前预热凭证缓存。
@ -489,7 +531,7 @@ class TransportManager:
if isinstance(cached, Some) and cached.unwrap():
return
except Exception as exc:
await self._logger.warn(
await self._logger.warning(
"credential preheat: cache read failed, continue to ConfigPort",
trace_id=trace_id,
account_id=account_id,
@ -506,6 +548,9 @@ class TransportManager:
# ConfigPort 无凭证记录(模式 A 渠道或未接入凭证),跳过预热
return
if config_value is None:
return
encrypted = config_value.value
if not encrypted:
return
@ -515,7 +560,7 @@ class TransportManager:
if decrypted:
await self._cache_port.set(cache_key, decrypted, ttl_seconds=None)
except Exception as exc:
await self._logger.warn(
await self._logger.warning(
"credential preheat: decrypt or cache write failed",
trace_id=trace_id,
account_id=account_id,
@ -536,9 +581,18 @@ class TransportManager:
参数
trace_id: 启动链路追踪 ID
"""
# H-14持有恢复锁与 _on_config_changed 的 reloadConfig 互斥,
# 防止恢复扫描与配置热更新竞态。异常时由 async with 保证锁释放。
async with self._restore_lock:
# H6+H8分布式恢复锁防止多实例并发恢复同一批账号。
# 与 _on_config_changed 共用同一锁键,互斥配置热更新与恢复扫描。
restore_lock_token = await self._cache_port.acquireAdvisoryLock(
"transport:restore", ttl_seconds=_RESTORE_LOCK_TTL_S
)
if restore_lock_token is None:
await self._logger.info(
"transport restore already in progress on another instance",
trace_id=trace_id,
)
return
try:
restored = 0
failed = 0
# 合并 puller 和 stream 注册表的渠道类型,避免遗漏
@ -550,7 +604,7 @@ class TransportManager:
AccountFilter(channel_type=channel_type, status=AccountStatus.ACTIVE)
)
except Exception as exc:
await self._logger.warn(
await self._logger.warning(
"transport restore: failed to query accounts for channel",
trace_id=trace_id,
channel_type=channel_type,
@ -569,7 +623,7 @@ class TransportManager:
restored += 1
except Exception as exc:
failed += 1
await self._logger.warn(
await self._logger.warning(
"transport restore: failed to start account",
trace_id=trace_id,
channel_type=channel_type,
@ -584,6 +638,8 @@ class TransportManager:
failed_accounts=failed,
channel_count=len(channel_types),
)
finally:
await self._cache_port.releaseAdvisoryLock(restore_lock_token)
async def _resolveTransportMode(
self,
@ -606,7 +662,7 @@ class TransportManager:
try:
account = await self._persistence_port.getChannelAccount(channel_type, account_id)
except Exception as exc:
await self._logger.warn(
await self._logger.warning(
"failed to get channel account for transport_mode resolution, fallback to manifest default",
channel_type=channel_type,
account_id=account_id,
@ -634,7 +690,7 @@ class TransportManager:
channel_type_raw = event.payload.get("channel_type")
account_id = event.payload.get("account_id")
if channel_type_raw is None or account_id is None:
await self._logger.warn(
await self._logger.warning(
"account offline event missing required fields, skip",
trace_id=trace_id,
channel_type=channel_type_raw,
@ -652,21 +708,37 @@ class TransportManager:
reason=reason,
)
if self._puller_worker is not None:
await self._puller_worker.stop_account(channel_type, account_id, reason)
# H6+H8账号级分布式锁防止多实例并发停止同一账号
account_lock_key = f"transport:{channel_type}:{account_id}"
account_lock_token = await self._cache_port.acquireAdvisoryLock(
account_lock_key, ttl_seconds=_ACCOUNT_LOCK_TTL_S
)
if account_lock_token is None:
await self._logger.info(
"transport stop already in progress",
trace_id=trace_id,
channel_type=channel_type,
account_id=account_id,
)
return
try:
if self._puller_worker is not None:
await self._puller_worker.stop_account(channel_type, account_id, reason)
if self._stream_worker is not None:
await self._stream_worker.stop_account(channel_type, account_id, reason)
if self._stream_worker is not None:
await self._stream_worker.stop_account(channel_type, account_id, reason)
# 传输任务停止后持久化插件运行态FR-32
await self._touchPluginStatus(channel_type, account_id, "stopped", trace_id)
# 传输任务停止后持久化插件运行态FR-32
await self._touchPluginStatus(channel_type, account_id, "stopped", trace_id)
finally:
await self._cache_port.releaseAdvisoryLock(account_lock_token)
async def _on_channel_degraded(self, event: DomainEvent) -> None:
"""处理渠道降级事件P0-1
StreamWorker permanent 失败时 bridge < 1.5.0 SSE 端点
降级到 PullerWorker停止 StreamWorker 账号任务 pull 模式启动
PullerWorker通过 ``_degraded_accounts`` 去重避免 StreamWorker
PullerWorker通过分布式降级标记M11去重避免 StreamWorker
反复重启时反复降级
事件由 ``base_worker._handleTransportError`` permanent 分支发布
@ -684,7 +756,7 @@ class TransportManager:
channel_type_raw = event.payload.get("channel_type")
account_id = event.payload.get("account_id")
if channel_type_raw is None or account_id is None:
await self._logger.warn(
await self._logger.warning(
"channel degraded event missing required fields, skip",
trace_id=trace_id,
channel_type=channel_type_raw,
@ -692,10 +764,9 @@ class TransportManager:
)
return
channel_type = ChannelType(channel_type_raw)
account_key = self._make_account_key(channel_type, account_id)
if account_key in self._degraded_accounts:
if await self._is_degraded(channel_type, account_id):
return
self._degraded_accounts.add(account_key)
await self._mark_degraded(channel_type, account_id)
reason = event.payload.get("reason", "permanent_failure")
await self._logger.info(
@ -712,7 +783,7 @@ class TransportManager:
name=f"transport-degrade-{channel_type}-{account_id}",
)
except RuntimeError as exc:
self._degraded_accounts.discard(account_key)
await self._clear_degraded(channel_type, account_id)
await self._logger.error(
"schedule degrade failed, no running event loop",
trace_id=trace_id,
@ -734,37 +805,63 @@ class TransportManager:
任务中执行不在事件发布栈内同步调用 ``stop_account``避免与当前
StreamWorker 任务栈形成反馈环路死锁
降级失败时清除 ``_degraded_accounts`` 标记允许下次事件重试
N-M1降级失败后启动兜底重试循环3 /30s 间隔覆盖 bridge
短暂故障重试期间降级标记保持不清除避免重复触发降级重试
仍失败则发布 ``ChannelTransportFailedEvent`` 触发告警降级标记
``_on_account_online`` / ``_on_account_config_changed`` 在账号
恢复或配置变更时清除
"""
account_key = self._make_account_key(channel_type, account_id)
try:
if self._stream_worker is not None:
await self._stream_worker.stop_account(channel_type, account_id, reason="degraded")
await self._startTransportForAccount(
for attempt in range(1, _DEGRADE_MAX_RETRY + 1):
try:
if self._stream_worker is not None:
await self._stream_worker.stop_account(channel_type, account_id, reason="degraded")
await self._startTransportForAccount(
channel_type=channel_type,
account_id=account_id,
trace_id=trace_id,
source="degraded",
force_mode="pull",
)
await self._logger.warning(
"channel transport degraded to puller mode",
trace_id=trace_id,
channel_type=channel_type,
account_id=account_id,
reason=reason,
)
return
except Exception as exc:
await self._logger.error(
"failed to degrade transport to puller mode, retrying",
trace_id=trace_id,
channel_type=channel_type,
account_id=account_id,
reason=reason,
attempt=attempt,
max_retry=_DEGRADE_MAX_RETRY,
error=str(exc),
error_type=type(exc).__name__,
)
if attempt < _DEGRADE_MAX_RETRY:
await asyncio.sleep(_DEGRADE_RETRY_INTERVAL_S)
continue
# 重试仍失败:发布 ChannelTransportFailedEvent 告警(不清除降级标记)
await self._logger.error(
"channel transport permanently failed after degrade retries",
trace_id=trace_id,
channel_type=channel_type,
account_id=account_id,
reason=reason,
)
await self._event_bus.publish(
ChannelTransportFailedEvent(
channel_type=channel_type,
account_id=account_id,
trace_id=trace_id,
source="degraded",
force_mode="pull",
)
await self._logger.warn(
"channel transport degraded to puller mode",
trace_id=trace_id,
channel_type=channel_type,
account_id=account_id,
reason=reason,
)
except Exception as exc:
self._degraded_accounts.discard(account_key)
await self._logger.error(
"failed to degrade transport to puller mode",
trace_id=trace_id,
channel_type=channel_type,
account_id=account_id,
reason=reason,
error=str(exc),
error_type=type(exc).__name__,
)
reason=f"degrade_failed_after_{_DEGRADE_MAX_RETRY}_retries",
occurred_at=datetime.now(UTC),
).toDomainEvent()
)
async def _on_channel_recovered(self, event: DomainEvent) -> None:
"""处理渠道恢复事件。
@ -788,7 +885,7 @@ class TransportManager:
trace_id = event.trace_id or str(uuid.uuid4())
channel_type_raw = event.payload.get("channel_type")
account_id = event.payload.get("account_id")
await self._logger.warn(
await self._logger.warning(
"transport error occurred",
trace_id=trace_id,
channel_type=channel_type_raw,
@ -825,7 +922,7 @@ class TransportManager:
channel_type_raw = event.payload.get("channel_type")
account_id = event.payload.get("account_id")
if channel_type_raw is None or account_id is None:
await self._logger.warn(
await self._logger.warning(
"account config changed event missing required fields, skip",
trace_id=trace_id,
channel_type=channel_type_raw,
@ -838,7 +935,7 @@ class TransportManager:
try:
account = await self._persistence_port.getChannelAccount(channel_type, account_id)
except Exception as exc:
await self._logger.warn(
await self._logger.warning(
"failed to get channel account for config change, skip worker rebuild",
trace_id=trace_id,
channel_type=channel_type,
@ -863,9 +960,8 @@ class TransportManager:
account_id=account_id,
)
# 清除降级标记,给新配置一个重新尝试 Stream 的机会
account_key = self._make_account_key(channel_type, account_id)
self._degraded_accounts.discard(account_key)
# 清除降级标记,给新配置一个重新尝试 Stream 的机会M11 分布式)
await self._clear_degraded(channel_type, account_id)
# 停止现有 Workerno-op if not running
if self._puller_worker is not None:
@ -893,7 +989,7 @@ class TransportManager:
try:
await self._persistence_port.updatePluginStatus(channel_type, account_id, plugin_status)
except Exception as exc:
await self._logger.warn(
await self._logger.warning(
"failed to update plugin_status",
trace_id=trace_id,
channel_type=channel_type,
@ -926,7 +1022,7 @@ class TransportManager:
trace_id = event.trace_id or str(uuid.uuid4())
if key in _RESTART_REQUIRED_CONFIG_KEYS:
await self._logger.warn(
await self._logger.warning(
"transport config change requires restart to take effect",
trace_id=trace_id,
key=key,
@ -945,9 +1041,23 @@ class TransportManager:
key=key,
event_type=event.event_type,
)
# H-14与 _restoreOnlineAccounts 互斥,等待恢复扫描完成后再重载配置
async with self._restore_lock:
# H6+H8与 _restoreOnlineAccounts 共用分布式恢复锁,互斥配置热更新
# 与恢复扫描。锁被恢复扫描持有时跳过本次 reload非阻塞下一次
# 配置变更事件会再次触发。
config_lock_token = await self._cache_port.acquireAdvisoryLock(
"transport:restore", ttl_seconds=_RESTORE_LOCK_TTL_S
)
if config_lock_token is None:
await self._logger.info(
"transport config reload skipped, restore in progress",
trace_id=trace_id,
key=key,
)
return
try:
await self.reloadConfig()
finally:
await self._cache_port.releaseAdvisoryLock(config_lock_token)
async def reloadConfig(self) -> None:
"""重新加载传输配置并更新 WorkerFR-04 hot 模式)。

View File

@ -211,7 +211,7 @@ class StreamWorker(BaseTransportWorker):
except Exception as exc:
# H-3 修复:原 ``except: pass`` 静默吞异常违反硬约束,
# 心跳任务异常往往是适配器 bug 信号,需记录 warn 日志
await self._logger.warn(
await self._logger.warning(
"stream heartbeat task cleanup error",
trace_id=trace_id,
channel_type=channel_type,
@ -225,7 +225,7 @@ class StreamWorker(BaseTransportWorker):
except Exception as exc:
# H-3 修复:原 ``except: pass`` 静默吞异常违反硬约束,
# 连接关闭异常需记录 warn 日志便于排查适配器问题
await self._logger.warn(
await self._logger.warning(
"stream connection close error",
trace_id=trace_id,
channel_type=channel_type,
@ -300,7 +300,7 @@ class StreamWorker(BaseTransportWorker):
except Exception as exc:
# Task 10.1: 接收异常不 raise 逃逸,记录 warn 并退出循环,
# 让 _runAccountLoop 的退避重连逻辑Task 10.2)可达。
await self._logger.warn(
await self._logger.warning(
"stream receive error, will reconnect",
trace_id=trace_id,
channel_type=channel_type,
@ -339,7 +339,7 @@ class StreamWorker(BaseTransportWorker):
error_type=type(exc).__name__,
)
else:
await self._logger.warn(
await self._logger.warning(
"stream message delivery failed, retrying",
trace_id=trace_id,
channel_type=channel_type,
@ -568,7 +568,7 @@ class StreamWorker(BaseTransportWorker):
except asyncio.CancelledError:
raise
except Exception as exc:
await self._logger.warn(
await self._logger.warning(
"stream heartbeat failed",
trace_id=trace_id,
channel_type=channel_type,

View File

@ -11,6 +11,7 @@
from __future__ import annotations
import asyncio
import dataclasses
import os
from datetime import datetime
@ -94,6 +95,9 @@ _RATE_LIMIT_RETRY_AFTER_MS = int(os.environ.get("YUXI_RATE_LIMIT_RETRY_AFTER_MS"
# 避免幂等键被永久锁死导致客户端无法重试FR-19
# 从环境变量 YUXI_IDEMPOTENCY_TIMEOUT_SECONDS 读取(默认 300
_IDEMPOTENCY_TIMEOUT_SECONDS = int(os.environ.get("YUXI_IDEMPOTENCY_TIMEOUT_SECONDS", "300"))
# fan-out 有界并发度:限制 DB 并发(会话解析 / outbox 持久化 / 管道执行),
# 非 bridge 限流——发送段串行化由 ``_send_locks`` 保证H2
_FAN_OUT_CONCURRENCY = 20
class AdminMessageService:
@ -218,7 +222,7 @@ class AdminMessageService:
if existing.in_progress_started_at is not None and _isStaleInProgress(
existing.in_progress_started_at, _IDEMPOTENCY_TIMEOUT_SECONDS
):
await self._logger.warn(
await self._logger.warning(
"stale in_progress idempotency record detected, rebuilding",
idempotency_key=cmd.idempotency_key,
started_at=existing.in_progress_started_at.isoformat(),
@ -539,184 +543,51 @@ class AdminMessageService:
if cmd.content.format == MessageFormat.RICH:
rich_message = RichMessage(text=cmd.content.text)
# H2: fan-out 有界并发化。Semaphore 限制 DB 并发(会话解析 / outbox
# 持久化 / 管道执行),非 bridge 限流——发送段串行化由 ``_send_locks``
# 保证。per-target 容错通过 ``return_exceptions=True`` 保持:单目标
# 失败不中断整批,结果聚合到 AdminSendResult。
# M8: 为每个 target 填充 fan_out_batch_id / fan_out_seq供 outbox
# 重试保序按批次聚合查询前序状态。
batch_id = str(uuid4())
semaphore = asyncio.Semaphore(_FAN_OUT_CONCURRENCY)
async def _execute_single(seq: int, target: str) -> tuple[list[str], list[FailureDetail], list[SkipDetail]]:
async with semaphore:
return await self._executeSingleTarget(
target=target,
seq=seq,
batch_id=batch_id,
cmd=cmd,
trace_id=trace_id,
policy=policy,
delivery_mode=delivery_mode,
rich_message=rich_message,
)
raw_results = await asyncio.gather(
*[_execute_single(seq, target) for seq, target in enumerate(targets)],
return_exceptions=True,
)
message_ids: list[str] = []
failures: list[FailureDetail] = []
skipped: list[SkipDetail] = []
for target in targets:
if target.lower() in _BROADCAST_MARKERS:
skipped.append(
SkipDetail(
target=target,
reason="broadcast_not_supported",
policy="fan_out_delegation_required",
)
)
continue
channel_type, account_id, session_id = self._resolveTarget(target)
if channel_type is None:
failures.append(
FailureDetail(
target=target,
error_code="VALIDATION_ERROR",
message=f"invalid channel_type in target: {target}",
retryable=False,
)
)
continue
# 内容长度校验:优先取渠道 manifest 的 max_message_length
# 未声明 manifest 时兜底默认值FR19-P0-5。per-target 容错:
# 超限时归入 failures 而非中止整批 fan-out与其他 per-target
# 失败策略一致(会话创建失败、管道崩溃均为 per-target 容错)。
max_length = self._getMaxMessageLength(channel_type)
if len(cmd.content.text) > max_length:
failures.append(
FailureDetail(
target=target,
error_code="VALIDATION_ERROR",
message=(
f"message length {len(cmd.content.text)} exceeds max "
f"{max_length} for channel {channel_type}"
),
retryable=False,
)
)
continue
# 会话策略reuse 查不到则跳过reuse-or-create 查不到则创建;
# new 强制创建新会话
try:
session = await self._getOrCreateSession(
channel_type,
account_id,
session_id,
policy,
target_type=cmd.target_type,
)
except NotFoundError as exc:
# FR-19fan-out 单目标资源不存在时不应阻断整批请求,
# 转换为 per-target failure 返回给调用方。
failures.append(toFailureDetail(target, exc))
await self._logger.warn(
"admin_message_target_not_found",
target=target,
resource=exc.resource,
resource_id=exc.id,
trace_id=trace_id,
)
continue
except Exception as e:
await self._logger.error(
f"session create failed for target {target}: {e}",
trace_id=trace_id,
target=target,
)
for target, result in zip(targets, raw_results):
if isinstance(result, Exception):
failures.append(
FailureDetail(
target=target,
error_code="INTERNAL",
message=f"session create failed: {e}",
message=f"fan-out target crashed: {result}",
retryable=True,
)
)
continue
if session is None:
skipped.append(
SkipDetail(
target=target,
reason="target_not_found",
policy="session_lookup",
)
)
continue
# reuse 策略保留原有跳过语义:未关联内部会话则跳过;
# reuse-or-create / new 创建的新会话已保证 conversation_id 与 owner_peer_id。
if session.conversation_id is None and policy == "reuse":
skipped.append(
SkipDetail(
target=target,
reason="no_conversation",
policy="session_lookup",
)
)
continue
conversation_id = session.conversation_id
request_id = str(uuid4())
ctx = OutboundContext(
trace_id=trace_id,
request_id=request_id,
channel_type=channel_type,
account_id=account_id,
channel_session_id=session.session_id,
conversation_id=conversation_id,
peer_id=session.peer_id,
agent_run_id="",
delivery_mode=delivery_mode,
# Admin 消息由系统侧发起,不声明渠道客户端发送者身份,
# 使 trusted-inject 阶段跳过 FR-25 客户端声明一致性校验。
trusted_sender_id="",
stream_chunks=[cmd.content.text],
rich_message=rich_message,
attachments=cmd.content.attachments,
sender_role="admin",
)
# H-O5: 按会话串行化出站管道,避免并发出站互相覆盖
lock_key = f"outbound:{ctx.conversation_id}"
lock_token = await self._cache_port.acquireAdvisoryLock(lock_key, ttl_seconds=60)
if lock_token is None:
failures.append(
FailureDetail(
target=target,
error_code="CONFLICT",
message="outbound pipeline busy for conversation",
retryable=True,
)
)
continue
try:
ok, err = await self._outbound_pipeline.run(ctx)
except Exception as e:
await self._logger.error(
f"outbound pipeline crashed for target {target}: {e}",
trace_id=trace_id,
target=target,
)
failures.append(
FailureDetail(
target=target,
error_code="INTERNAL",
message=f"outbound pipeline crashed: {e}",
retryable=True,
)
)
continue
finally:
if lock_token is not None:
await self._cache_port.releaseAdvisoryLock(lock_token)
if not ok:
# 白名单拒绝归类为 skippedAC-68其余失败归 failures
if isinstance(err, DmDeniedError):
skipped.append(
SkipDetail(
target=target,
reason="in_denylist",
policy="whitelist_check",
)
)
else:
failures.append(self._toFailureDetail(target, err))
continue
if ctx.message_id is not None:
message_ids.append(ctx.message_id)
elif ctx.channel_msg_id is not None:
message_ids.append(ctx.channel_msg_id)
msg_ids, fails, skips = result
message_ids.extend(msg_ids)
failures.extend(fails)
skipped.extend(skips)
return AdminSendResult(
message_ids=tuple(message_ids),
@ -724,6 +595,215 @@ class AdminMessageService:
skipped=tuple(skipped),
)
async def _executeSingleTarget(
self,
target: str,
seq: int,
batch_id: str,
cmd: AdminSendCmd,
trace_id: str,
policy: str,
delivery_mode: str,
rich_message: RichMessage | None,
) -> tuple[list[str], list[FailureDetail], list[SkipDetail]]:
"""执行单个目标的 fan-out 投递,返回 (message_ids, failures, skipped)。
``_executeFanOut`` 提取的 per-target 逻辑广播跳过 / 格式校验 /
内容长度校验 / 会话解析 / 出站管道执行每个列表至多含 1 个元素
由调用方聚合per-target 容错任一失败/跳过均返回而非抛出单目标
失败不中断整批H2
参数
target: 单个目标字符串
seq: fan-out 批次内序号M8 保序
batch_id: fan-out 批次 IDM8 保序
cmd: 管理员发送命令
trace_id: 链路 ID
policy: 会话策略
delivery_mode: 投递模式
rich_message: 富消息源数据可选
"""
message_ids: list[str] = []
failures: list[FailureDetail] = []
skipped: list[SkipDetail] = []
if target.lower() in _BROADCAST_MARKERS:
skipped.append(
SkipDetail(
target=target,
reason="broadcast_not_supported",
policy="fan_out_delegation_required",
)
)
return message_ids, failures, skipped
channel_type, account_id, session_id = self._resolveTarget(target)
if channel_type is None:
failures.append(
FailureDetail(
target=target,
error_code="VALIDATION_ERROR",
message=f"invalid channel_type in target: {target}",
retryable=False,
)
)
return message_ids, failures, skipped
# 内容长度校验:优先取渠道 manifest 的 max_message_length
# 未声明 manifest 时兜底默认值FR19-P0-5。per-target 容错:
# 超限时归入 failures 而非中止整批 fan-out与其他 per-target
# 失败策略一致(会话创建失败、管道崩溃均为 per-target 容错)。
max_length = self._getMaxMessageLength(channel_type)
if len(cmd.content.text) > max_length:
failures.append(
FailureDetail(
target=target,
error_code="VALIDATION_ERROR",
message=(
f"message length {len(cmd.content.text)} exceeds max {max_length} for channel {channel_type}"
),
retryable=False,
)
)
return message_ids, failures, skipped
# 会话策略reuse 查不到则跳过reuse-or-create 查不到则创建;
# new 强制创建新会话
try:
session = await self._getOrCreateSession(
channel_type,
account_id,
session_id,
policy,
target_type=cmd.target_type,
)
except NotFoundError as exc:
# FR-19fan-out 单目标资源不存在时不应阻断整批请求,
# 转换为 per-target failure 返回给调用方。
failures.append(toFailureDetail(target, exc))
await self._logger.warning(
"admin_message_target_not_found",
target=target,
resource=exc.resource,
resource_id=exc.id,
trace_id=trace_id,
)
return message_ids, failures, skipped
except Exception as e:
await self._logger.error(
f"session create failed for target {target}: {e}",
trace_id=trace_id,
target=target,
)
failures.append(
FailureDetail(
target=target,
error_code="INTERNAL",
message=f"session create failed: {e}",
retryable=True,
)
)
return message_ids, failures, skipped
if session is None:
skipped.append(
SkipDetail(
target=target,
reason="target_not_found",
policy="session_lookup",
)
)
return message_ids, failures, skipped
# reuse 策略保留原有跳过语义:未关联内部会话则跳过;
# reuse-or-create / new 创建的新会话已保证 conversation_id 与 owner_peer_id。
if session.conversation_id is None and policy == "reuse":
skipped.append(
SkipDetail(
target=target,
reason="no_conversation",
policy="session_lookup",
)
)
return message_ids, failures, skipped
conversation_id = session.conversation_id
request_id = str(uuid4())
ctx = OutboundContext(
trace_id=trace_id,
request_id=request_id,
channel_type=channel_type,
account_id=account_id,
channel_session_id=session.session_id,
conversation_id=conversation_id,
peer_id=session.peer_id,
agent_run_id="",
delivery_mode=delivery_mode,
# Admin 消息由系统侧发起,不声明渠道客户端发送者身份,
# 使 trusted-inject 阶段跳过 FR-25 客户端声明一致性校验。
trusted_sender_id="",
stream_chunks=[cmd.content.text],
rich_message=rich_message,
attachments=cmd.content.attachments,
sender_role="admin",
fan_out_batch_id=batch_id,
fan_out_seq=seq,
)
# H-O5: 按会话串行化出站管道,避免并发出站互相覆盖
lock_key = f"outbound:{ctx.conversation_id}"
lock_token = await self._cache_port.acquireAdvisoryLock(lock_key, ttl_seconds=60)
if lock_token is None:
failures.append(
FailureDetail(
target=target,
error_code="CONFLICT",
message="outbound pipeline busy for conversation",
retryable=True,
)
)
return message_ids, failures, skipped
try:
ok, err = await self._outbound_pipeline.run(ctx)
except Exception as e:
await self._logger.error(
f"outbound pipeline crashed for target {target}: {e}",
trace_id=trace_id,
target=target,
)
failures.append(
FailureDetail(
target=target,
error_code="INTERNAL",
message=f"outbound pipeline crashed: {e}",
retryable=True,
)
)
return message_ids, failures, skipped
finally:
if lock_token is not None:
await self._cache_port.releaseAdvisoryLock(lock_token)
if not ok:
# 白名单拒绝归类为 skippedAC-68其余失败归 failures
if isinstance(err, DmDeniedError):
skipped.append(
SkipDetail(
target=target,
reason="in_denylist",
policy="whitelist_check",
)
)
else:
failures.append(self._toFailureDetail(target, err))
return message_ids, failures, skipped
if ctx.message_id is not None:
message_ids.append(ctx.message_id)
elif ctx.channel_msg_id is not None:
message_ids.append(ctx.channel_msg_id)
return message_ids, failures, skipped
async def _getOrCreateSession(
self,
channel_type: ChannelType,

View File

@ -315,7 +315,7 @@ class ChannelControlService:
# 502/504 状态码,不在此处降级为 failed 结果。
raise
except Error as e:
await self._logger.warn(
await self._logger.warning(
f"control plane pipeline failed: {e}",
trace_id=trace_id,
operation=cmd.operation,
@ -344,7 +344,7 @@ class ChannelControlService:
try:
await hook()
except Exception as hook_exc:
await self._logger.warn(
await self._logger.warning(
f"post-commit hook failed: {hook_exc}",
trace_id=trace_id,
operation=cmd.operation,
@ -353,7 +353,7 @@ class ChannelControlService:
# INDEPENDENT 审计策略下 audit 写入失败时,管道仍返回成功(外部
# 副作用已发生。记录告警日志运维通过告警发现审计缺失§10.1)。
if ctx.audit_error is not None:
await self._logger.warn(
await self._logger.warning(
f"audit log write failed (best-effort): {ctx.audit_error}",
trace_id=trace_id,
operation=cmd.operation,
@ -2073,7 +2073,7 @@ class ChannelControlService:
tx=tx,
)
if self._logger is not None:
await self._logger.warn(
await self._logger.warning(
"identity_merge_pending_review",
canonical_identity_id=canonical_identity_id,
sibling_identity_id=sibling.identity_id,
@ -3857,7 +3857,7 @@ class ChannelControlService:
await self._audit_builder.writeAuditLog(ctx=ctx, result="failed", tx=None)
except Exception as audit_err:
# best-effort审计日志写入失败不掩盖原始错误仅记录告警。
await self._logger.warn(
await self._logger.warning(
f"failure audit log write failed: {audit_err}",
trace_id=ctx.trace_id,
operation=ctx.operation,

View File

@ -306,7 +306,7 @@ class HealthCheckService:
try:
await self._account_repository.updateLastHealthCheckAt(channel_type, account_id)
except Exception as exc:
await self._logger.warn(
await self._logger.warning(
"failed to update last_health_check_at",
channel_type=channel_type,
account_id=account_id,
@ -358,7 +358,7 @@ class HealthCheckService:
except Exception as exc:
# 审计日志写入失败时仅记录告警,不中止探测结果返回
# (探测已执行,副作用已产生,审计失败不应丢弃探测结果)。
await self._logger.warn(
await self._logger.warning(
"probe audit log write failed",
channel_type=channel_type,
account_id=account_id,

View File

@ -331,7 +331,7 @@ class InboundMessageService:
updated_by=ctx.account_id,
)
except Exception as idempotency_exc:
await self._logger.warn(
await self._logger.warning(
"idempotency status update failed",
trace_id=trace_id,
record_id=ctx.idempotency_record_id,
@ -356,7 +356,7 @@ class InboundMessageService:
try:
await hook()
except Exception as e:
await self._logger.warn(
await self._logger.warning(
"post_commit hook failed",
trace_id=trace_id,
exc_info=e,
@ -813,7 +813,7 @@ class InboundMessageService:
if not already_acked:
await self._ack_decision_maker.recordAck(idempotency_key)
except Exception as e:
await self._logger.warn(
await self._logger.warning(
"ack decision failed after outbound success, rollback idempotency record",
trace_id=ctx.trace_id,
account_id=ctx.account_id,
@ -890,7 +890,7 @@ class InboundMessageService:
)
except Exception as e:
# 回退失败仅告警,不掩盖原始 outbound_error 传播C-I2 约束)。
await self._logger.warn(
await self._logger.warning(
"idempotency status rollback to failed after outbound failure failed",
trace_id=ctx.trace_id,
record_id=ctx.idempotency_record_id,
@ -1034,7 +1034,7 @@ class InboundMessageService:
)
timeout = config.value if isinstance(config.value, int) else _DEFAULT_ACK_FALLBACK_TIMEOUT_SECONDS
except Exception as e:
await self._logger.warn(
await self._logger.warning(
"failed to read ack_fallback_timeout_seconds, using default 60s",
trace_id=trace_id,
account_id=account_id,
@ -1060,7 +1060,7 @@ class InboundMessageService:
try:
await self._ack_decision_maker.recordAck(idempotency_key)
except Exception as e:
await self._logger.warn(
await self._logger.warning(
"manual fallback ack failed, channel may retry",
trace_id=trace_id,
error=str(e),

View File

@ -8,7 +8,7 @@
from __future__ import annotations
from dataclasses import dataclass
from dataclasses import dataclass, field
from enum import StrEnum
from yuxi.channels.contract.errors import ValidationError
@ -134,11 +134,13 @@ class ConnectivityResult:
reachable: 是否可达
latency_ms: 延迟毫秒不可达时为 None
error: 错误信息可达时为 None
warnings: 结构化告警信息默认空列表向后兼容
"""
reachable: bool
latency_ms: int | None = None
error: str | None = None
warnings: list[str] = field(default_factory=list)
@dataclass(frozen=True)

View File

@ -397,6 +397,104 @@ class ChannelMessageDeliveredEvent:
)
@dataclass(frozen=True)
class MediaDownloadFailedEvent:
"""媒体下载永久失败事件。
描述入站适配器在有限重试后仍无法下载媒体附件 bridge 404 在重试
窗口内未恢复由适配器发布以触发告警避免媒体静默丢失订阅者可
据此记录审计发出告警或触发补偿流程
字段
account_id: 渠道账户 ID
msg_id: bridge 全局唯一消息 ID
reason: 失败原因 ``not_found_after_retries``
occurred_at: 事件发生时间
"""
account_id: str
msg_id: str
reason: str
occurred_at: datetime
def __post_init__(self) -> None:
"""校验必填字段非空。"""
if not self.account_id:
raise ValidationError("account_id", "must not be empty")
if not self.msg_id:
raise ValidationError("msg_id", "must not be empty")
if not self.reason:
raise ValidationError("reason", "must not be empty")
if self.occurred_at is None:
raise ValidationError("occurred_at", "must not be None")
if not isinstance(self.occurred_at, datetime):
raise ValidationError("occurred_at", "must be a datetime")
def toDomainEvent(self) -> DomainEvent:
"""转换为契约层 ``DomainEvent``。"""
return DomainEvent(
event_id=str(uuid.uuid4()),
event_type="MediaDownloadFailed",
payload={
"account_id": self.account_id,
"msg_id": self.msg_id,
"reason": self.reason,
"occurred_at": self.occurred_at.isoformat(),
},
timestamp=self.occurred_at,
trace_id=None,
)
@dataclass(frozen=True)
class ChannelTransportFailedEvent:
"""渠道传输永久失败事件N-M1
描述 TransportManager 在降级重试耗尽后仍无法恢复账号传输任务
``_degradeToPuller`` 3 次重试均失败后发布触发告警订阅者
可据此记录审计发出告警或触发人工介入流程
字段
channel_type: 渠道类型
account_id: 渠道账户 ID
reason: 失败原因 ``degrade_failed_after_3_retries``
occurred_at: 事件发生时间
"""
channel_type: ChannelType
account_id: str
reason: str
occurred_at: datetime
def __post_init__(self) -> None:
"""校验必填字段非空。"""
if self.channel_type is None or not self.channel_type:
raise ValidationError("channel_type", "must not be empty")
if not self.account_id:
raise ValidationError("account_id", "must not be empty")
if not self.reason:
raise ValidationError("reason", "must not be empty")
if self.occurred_at is None:
raise ValidationError("occurred_at", "must not be None")
if not isinstance(self.occurred_at, datetime):
raise ValidationError("occurred_at", "must be a datetime")
def toDomainEvent(self) -> DomainEvent:
"""转换为契约层 ``DomainEvent``。"""
return DomainEvent(
event_id=str(uuid.uuid4()),
event_type="ChannelTransportFailed",
payload={
"channel_type": str(self.channel_type),
"account_id": self.account_id,
"reason": self.reason,
"occurred_at": self.occurred_at.isoformat(),
},
timestamp=self.occurred_at,
trace_id=None,
)
__all__ = [
"OutboxStateChangedEvent",
"OutboxEntryPurgedEvent",
@ -405,4 +503,6 @@ __all__ = [
"ChannelMessageSentEvent",
"ChannelMessagePersistedEvent",
"ChannelMessageDeliveredEvent",
"MediaDownloadFailedEvent",
"ChannelTransportFailedEvent",
]

View File

@ -294,6 +294,11 @@ class OutboxEntry:
与审计使用
delivered_parts: 已成功投递的分片序号列表默认空列表多分片
投递部分失败时记录已投递分片重试时据此仅发送未投递分片H-15
fan_out_batch_id: 扇出批次 ID可选标识同一次 fan-out 产生的
一组 outbox 条目 M8 outbox 重试保序按批次聚合查询使用默认
``None`` 保证向后兼容历史数据
fan_out_seq: 扇出批次内序号可选标识同批次内的投递顺序
M8 重试保序按序号恢复原始顺序默认 ``None`` 保证向后兼容历史数据
"""
outbox_id: str
@ -322,6 +327,8 @@ class OutboxEntry:
stream_aborted_at_chunk: int | None = None
degraded_reason: str | None = None
delivered_parts: list[int] = field(default_factory=list)
fan_out_batch_id: str | None = None
fan_out_seq: int | None = None
@dataclass(frozen=True)
@ -372,6 +379,8 @@ class OutboxQueryFilter:
channel_msg_id_like: 渠道侧消息 ID 模糊搜索可选SQL ``LIKE``
channel_account_id_like: 渠道账户 ID 模糊搜索可选SQL ``LIKE``
last_error_like: 最近错误关键词筛选可选SQL ``LIKE``
fan_out_batch_id: 扇出批次 ID可选 fan-out 批次过滤
M8 outbox 重试保序按批次聚合查询使用
"""
channel_type: ChannelType | None = None
@ -387,6 +396,7 @@ class OutboxQueryFilter:
channel_msg_id_like: str | None = None
channel_account_id_like: str | None = None
last_error_like: str | None = None
fan_out_batch_id: str | None = None
def __post_init__(self) -> None:
"""校验过滤条件合法性。
@ -437,6 +447,8 @@ class OutboxQueryFilter:
result["channel_account_id_like"] = self.channel_account_id_like
if self.last_error_like is not None:
result["last_error_like"] = self.last_error_like
if self.fan_out_batch_id is not None:
result["fan_out_batch_id"] = self.fan_out_batch_id
return result

View File

@ -372,6 +372,14 @@ class SaveOutboxEntryCmd:
由出站管道从 ``OutboundContext.stream_aborted_at_chunk`` 透传
流式投递被中断时记录已发送分片序号供重试 worker 调用
``sendMessageContinuation`` 从断点续发剩余内容
fan_out_batch_id: fan-out 批次 ID可选M8 保序标识同一次
fan-out 批次 ``AdminMessageService`` fan-out 时填充到
``OutboundContext.fan_out_batch_id``再由 outbox-persist 阶段
透传至 DB outbox 重试 worker 按批次保序拉取M8
fan_out_seq: fan-out 批次内序号可选M8 保序 outbox-persist
阶段在批量路径下按 ``fan_out_entries`` 列表位置 ``enumerate``
生成单条路径直接取 ``OutboundContext.fan_out_seq`` outbox
重试 worker 在同一批次内按 seq 排序投递M8
"""
channel_type: ChannelType
@ -381,6 +389,8 @@ class SaveOutboxEntryCmd:
trace_id: str | None = None
channel_session_id: str | None = None
stream_aborted_at_chunk: int | None = None
fan_out_batch_id: str | None = None
fan_out_seq: int | None = None
def __post_init__(self) -> None:
"""校验必填字段非空与持久化策略取值FR-22

View File

@ -15,8 +15,8 @@
``ServiceAccountCreationError``
- ``transport``传输层错误 ``TransportError``
- ``domain``领域错误按子领域拆分为 ``base`` / ``config`` / ``plugin`` /
``agent`` / ``content`` / ``credential`` / ``onboarding`` / ``state``详见
``domain/__init__.py``
``agent`` / ``content`` / ``credential`` / ``onboarding`` / ``state`` /
``media``详见 ``domain/__init__.py``
"""
from __future__ import annotations
@ -61,6 +61,7 @@ from yuxi.channels.contract.errors.domain import (
OnboardingConflictError,
OnboardingInvalidTransitionError,
PairingExpiredError,
PayloadTooLargeError,
PipelineConfigError,
PluginAlreadyRegisteredError,
PluginFailedError,
@ -139,4 +140,6 @@ __all__ = [
"StateTransitionError",
"IdempotencyConflictError",
"IllegalStateError",
# media媒体下载超限
"PayloadTooLargeError",
]

View File

@ -59,4 +59,6 @@ CHANNEL_ERROR_STATUS_MAP: dict[str, int] = {
"STATE_TRANSITION_ERROR": 409,
"IDEMPOTENCY_CONFLICT": 409,
"ILLEGAL_STATE": 409,
# 媒体下载大小限制相关错误N-M2
"PAYLOAD_TOO_LARGE": 413,
}

View File

@ -11,6 +11,7 @@
- ``credential``凭证管理相关错误
- ``onboarding``账号接入与身份绑定相关错误
- ``state``运行态状态机非法跃迁幂等冲突与非法状态调用相关错误
- ``media``媒体下载超限等媒体相关错误
``__init__`` 重新导出全部领域错误类外部可直接
``from yuxi.channels.contract.errors.domain import XxxError`` 导入
@ -54,6 +55,7 @@ from yuxi.channels.contract.errors.domain.credential import (
CredentialInvalidError,
CredentialNotFoundError,
)
from yuxi.channels.contract.errors.domain.media import PayloadTooLargeError
from yuxi.channels.contract.errors.domain.onboarding import (
ConnectivityVerifyFailedError,
IdentityAlreadyBoundError,
@ -116,4 +118,6 @@ __all__ = [
"StateTransitionError",
"IdempotencyConflictError",
"IllegalStateError",
# media媒体下载超限
"PayloadTooLargeError",
]

View File

@ -0,0 +1,50 @@
"""媒体子域领域错误。
定义媒体下载超限等媒体相关的领域错误继承 ``DomainError``
"""
from __future__ import annotations
from typing import Any
from yuxi.channels.contract.errors.domain.base import DomainError
class PayloadTooLargeError(DomainError):
"""媒体载荷超限错误。
媒体下载体积超过允许上限时抛出HTTP 413error_code=
``PAYLOAD_TOO_LARGE````details`` 包含 ``resource````id``
``size_bytes`` ``max_bytes``便于定位超限的资源与量化限制
触发场景``download_media`` 流式读取累计字节数超过 ``_MAX_MEDIA_SIZE_BYTES``
时抛出N-M2
"""
error_code = "PAYLOAD_TOO_LARGE"
def __init__(
self,
resource: str,
id: str,
size_bytes: int,
max_bytes: int,
*,
trace_id: str | None = None,
) -> None:
super().__init__(
f"Payload too large: {resource} {id} size {size_bytes} exceeds limit {max_bytes}",
trace_id=trace_id,
)
self.resource = resource
self.id = id
self.size_bytes = size_bytes
self.max_bytes = max_bytes
def to_dict(self) -> dict[str, Any]:
data = super().to_dict()
data["resource"] = self.resource
data["id"] = self.id
data["size_bytes"] = self.size_bytes
data["max_bytes"] = self.max_bytes
return data

View File

@ -84,6 +84,10 @@ class DependencyError(ServerError):
"""依赖故障错误。
外部依赖如数据库Redis第三方服务故障时抛出HTTP 502
``retry_after_ms`` 携带上游建议的重试等待时间毫秒用于 503
场景解析 ``Retry-After`` header 后透传给全局错误处理器M10
默认 ``None`` 表示无明确重试建议保持向后兼容
"""
error_code = "DEPENDENCY"
@ -96,6 +100,7 @@ class DependencyError(ServerError):
message: str | None = None,
trace_id: str | None = None,
category_hint: TransportErrorCategory | None = None,
retry_after_ms: int | None = None,
) -> None:
super().__init__(
message or f"Dependency {dep} failed",
@ -104,10 +109,13 @@ class DependencyError(ServerError):
category_hint=category_hint,
)
self.dep = dep
self.retry_after_ms = retry_after_ms
def to_dict(self) -> dict[str, Any]:
data = super().to_dict()
data["dep"] = self.dep
if self.retry_after_ms is not None:
data["retry_after_ms"] = self.retry_after_ms
return data

View File

@ -17,7 +17,7 @@ class LoggerPort(Protocol):
"""日志被驱动端口。
覆盖结构化日志的记录用例 application 层调用framework 层实现
端口方法遵循领域语义命名``log`` / ``debug`` / ``info`` / ``warn`` /
端口方法遵循领域语义命名``log`` / ``debug`` / ``info`` / ``warning`` /
``error``不暴露 HTTP / DB 等技术细节
关键约束
@ -105,21 +105,21 @@ class LoggerPort(Protocol):
"""
...
async def warn(
async def warning(
self,
message: str,
*,
trace_id: str | None = None,
**kwargs: Any,
) -> None:
"""记录 WARN 级别日志。
"""记录 WARNING 级别日志。
@pre
- message 非空
- kwargs 中的敏感字段已由调用方脱敏
@post
- WARN 级别日志已输出携带 trace_id若提供 kwargs 上下文
- WARNING 级别日志已输出携带 trace_id若提供 kwargs 上下文
@failure
- 日志故障不得阻断主流程

View File

@ -91,6 +91,23 @@ class OutboxRepositoryPort(Protocol):
"""
...
async def saveOutboxEntries(
self,
cmds: list[SaveOutboxEntryCmd],
tx: TransactionContext | None = None,
) -> list[OutboxId]:
"""批量保存 OutboxEntry单事务。默认实现循环调用单条可覆写优化
M13 批量化 DB 操作使用适配器可覆写为单次批量 INSERT 以减少
往返返回 OutboxId 列表顺序与 ``cmds`` 一致
@pre: cmds 非空tx 非空时加入应用层事务不自主提交 None 时单方法提交
@post: 返回 OutboxId 列表顺序与 cmds 一致各条目初始状态为 PENDING
@failure: ValidationError - cmd 字段非法DependencyError - 数据库故障
@consistency: Strong
"""
return [await self.saveOutboxEntry(cmd, tx=tx) for cmd in cmds]
async def getOutboxEntry(self, outbox_id: str) -> OutboxEntry | None:
"""按 outbox_id 查询发件箱条目;不存在返回 None。"""
...
@ -112,6 +129,27 @@ class OutboxRepositoryPort(Protocol):
"""
...
async def getOutboxEntriesByBatch(
self,
batch_id: str,
*,
status: OutboxStatus | None = None,
max_seq: int | None = None,
tx: TransactionContext | None = None,
) -> list[OutboxEntry]:
"""查询同批次 outbox 条目,用于 M8 重试保序检查。
``batch_id`` 查询同批次 outbox 条目支持 ``status``
``max_seq`` 过滤默认实现返回空列表可由适配器覆写
@pre: batch_id 非空
@post: 返回匹配批次 ID 的条目列表status 非空时仅返回该状态条目
max_seq 非空时仅返回 seq <= max_seq 的条目
@failure: DependencyError - 数据库故障
@consistency: Strong
"""
return []
async def updateOutboxEntry(
self,
entry: OutboxEntry,

View File

@ -1,146 +1,25 @@
"""事务被驱动端口
"""事务被驱动端口re-export 共享契约)
定义应用层对事务控制能力的依赖契约 application 层调用framework
实现事务边界 **必须** 由应用层管道或用例编排器显式开启与提交
被驱动适配器 **不得** 自主开启跨调用的事务§10.1
本模块原为 channels 限界上下文本地定义的事务端口契约已收敛到共享事务
基础设施 ``yuxi.storage.transactions``见三模块事务管理优化方案
本模块保留以维持 channels 调用方的导入路径稳定仅做 re-export
事务透传机制C-I1被驱动适配器 ``ChannelPersistenceAdapter`` /
``ContentReviewRepositoryAdapter`` / ``AgentRunAdapter``为无状态协议
转换器构造时仅注入 ``session_factory````Callable[[], AsyncSession]``
不持有 session 实例适配器通过 ``_session_scope(tx)`` 统一管理 session
``tx`` 非空时通过 ``tx.get_session()`` 复用应用层主事务 sessionC-I1
透传``commit=False``由应用层统一提交``tx`` ``None`` 时通过
``session_factory`` 创建独立 session 并自主提交``commit=True``
事务透传机制C-I1与事务边界由应用层独占的约束不变详见
``yuxi.storage.transactions.ports`` 模块 docstring
``yuxi.storage.transactions.ports`` 子模块直接导入而非包顶层
以避免循环导入``yuxi.storage.transactions.__init__`` 加载 ``exceptions``
时会触发 ``yuxi.channels.contract`` 初始化链最终回到本模块若从包顶层
导入``TransactionContext`` / ``TransactionPort`` 尚未绑定到包命名空间
``__init__`` 未执行完毕导致 ``ImportError````ports`` 子模块运行时
仅依赖 ``typing`` / ``collections.abc``可在 ``__init__`` 完成前独立加载
"""
from __future__ import annotations
from typing import TYPE_CHECKING, Any, Protocol, runtime_checkable
from yuxi.storage.transactions.ports import (
TransactionContext,
TransactionPort,
)
if TYPE_CHECKING:
from sqlalchemy.ext.asyncio import AsyncSession
@runtime_checkable
class TransactionPort(Protocol):
"""事务被驱动端口。
提供事务边界控制能力由应用层在管道或用例编排器中显式调用
被驱动适配器通过共享事务上下文``TransactionContext``加入同一
事务**不得** 自主提交
约束§10.1
- 事务边界 **必须** 定义在应用服务层
- 领域核心 **不得** 持有事务上下文**不得** 直接调用事务 API
- 被驱动适配器 **不得** 自主开启跨调用的事务事务范围 **必须**
由应用层控制
使用示例::
async with transaction.begin() as tx:
await persistence_port.saveChannelAccount(cmd, tx=tx)
await persistence_port.saveAuditLog(audit_cmd, tx=tx)
# 退出 with 块时自动提交,异常时自动回滚
"""
def begin(self) -> TransactionContext:
"""开启一个新事务,返回事务上下文。
@pre
- 当前无活动事务或适配器允许嵌套由实现决定
@post
- 返回事务上下文被驱动适配器通过该上下文加入同一事务
- 上下文管理器退出时自动提交无异常或回滚有异常
@failure
- DependencyError: 事务管理器故障
@consistency
- Strong事务边界由 ``TransactionContext`` 管理
"""
...
class TransactionContext(Protocol):
"""事务上下文。
``TransactionPort.begin()`` 创建上下文管理器退出时自动提交
无异常或回滚有异常
事务透传机制C-I1无状态适配器通过 ``get_session()`` 获取底层
共享会话复用主事务的 session避免独立提交产生孤儿记录适配器
``_session_scope(tx)`` 中判断 ``tx`` 非空且 ``get_session()``
返回 session 时使用该 session``commit=False``否则创建独立
session``commit=True``
被驱动适配器 **不得** 自主调用 ``commit`` / ``rollback``
说明 Protocol 未声明 ``@runtime_checkable``不参与 ``isinstance``
检查``TransactionContext`` 仅作为 ``tx`` 参数的类型注解运行时由
``TransactionPort.begin()`` 返回的具体实现承载无需运行时类型校验
"""
def get_session(self) -> AsyncSession | None:
"""返回底层共享会话,供无状态适配器复用主事务 session。
SQL 实现返回真实 ``AsyncSession`` SQL 实现返回 ``None``
由适配器回退到 ``session_factory`` 创建独立 session 的路径
@pre
- 事务已开启``__aenter__`` 已调用
@post
- 返回底层共享会话 SQL 实现返回 ``None``
@consistency
- 调用方通过此 session 写入的数据自动加入主事务
由应用层统一提交/回滚C-I1
"""
...
async def commit(self) -> None:
"""提交当前事务。
仅由应用层管道或用例编排器调用被驱动适配器 **不得** 调用
@pre
- 事务已开启且未提交/回滚
@post
- 事务内所有写操作持久化生效
@failure
- DependencyError: 提交失败事务自动回滚
@consistency
- Strong提交成功后写操作立即可见
"""
...
async def rollback(self) -> None:
"""回滚当前事务。
仅由应用层管道或用例编排器调用被驱动适配器 **不得** 调用
@pre
- 事务已开启且未提交/回滚
@post
- 事务内所有写操作被撤销
@failure
- DependencyError: 回滚失败连接异常需关闭会话
@consistency
- Strong回滚后数据库恢复到事务开启前状态
"""
...
async def __aenter__(self) -> TransactionContext:
"""进入事务上下文。"""
...
async def __aexit__(self, exc_type: Any, exc: Any, tb: Any) -> None:
"""退出事务上下文,自动提交(无异常)或回滚(有异常)。"""
...
__all__ = ["TransactionPort", "TransactionContext"]

View File

@ -117,7 +117,7 @@ class AccountLifecycleService:
# 适配器抛出未预期异常(非 ValidationError / ChannelDegradedError
# 视为适配器实现缺陷,翻译为 InternalError5xx而非
# ValidationError4xx避免将服务端缺陷误判为客户端入参错误。
await self._logger.warn(
await self._logger.warning(
f"resolveAccountId unexpected error: {type(exc).__name__}: {exc}",
trace_id=trace_id,
channel_type=channel_type,
@ -156,7 +156,7 @@ class AccountLifecycleService:
except Exception as exc:
# 适配器抛出未预期异常,翻译为 InternalError5xx避免将
# 服务端实现缺陷误判为客户端入参错误ValidationError
await self._logger.warn(
await self._logger.warning(
f"applyAccountConfig unexpected error: {type(exc).__name__}: {exc}",
trace_id=trace_id,
channel_type=channel_type,
@ -247,7 +247,7 @@ class AccountLifecycleService:
except DependencyError:
raise
except Exception as exc:
await self._logger.warn(
await self._logger.warning(
f"validateCredentials unexpected error: {type(exc).__name__}: {exc}",
trace_id=trace_id,
channel_type=channel_type,
@ -305,7 +305,7 @@ class AccountLifecycleService:
return LifecycleCallbackResult(status="ok", hook=hook)
except TimeoutError:
msg = f"timeout after {timeout}s"
await self._logger.warn(
await self._logger.warning(
f"lifecycle callback timeout: hook={hook}, "
f"channel_type={channel_type}, "
f"account_id={account.account_id}, {msg}",
@ -313,7 +313,7 @@ class AccountLifecycleService:
)
return LifecycleCallbackResult(status="warn", hook=hook, error_message=msg)
except LifecycleHookError as exc:
await self._logger.warn(
await self._logger.warning(
f"lifecycle callback failed: hook={hook}, "
f"channel_type={channel_type}, "
f"account_id={account.account_id}, reason={exc.reason}",
@ -322,7 +322,7 @@ class AccountLifecycleService:
return LifecycleCallbackResult(status="warn", hook=hook, error_message=exc.reason)
except (ChannelDegradedError, DependencyError) as exc:
msg = f"{type(exc).__name__}: {exc}"
await self._logger.warn(
await self._logger.warning(
f"lifecycle callback dependency error: hook={hook}, "
f"channel_type={channel_type}, "
f"account_id={account.account_id}, {msg}",

View File

@ -141,7 +141,7 @@ class AckDecisionMaker:
value = await self.cache_port.get(redis_key)
return value.is_some()
except DependencyError as exc:
await self._logger.warn(
await self._logger.warning(
"ack idempotency check failed, treating as not acked",
idempotency_key=idempotency_key,
error=str(exc),
@ -167,7 +167,7 @@ class AckDecisionMaker:
try:
await self.cache_port.set(redis_key, True, ttl_seconds=_ACK_IDEMPOTENCY_TTL)
except DependencyError as exc:
await self._logger.warn(
await self._logger.warning(
"ack idempotency record failed, duplicate ack possible on retry",
idempotency_key=idempotency_key,
error=str(exc),

View File

@ -107,7 +107,7 @@ class BotLoopBudgetGuard:
try:
cached = await self.cache_port.get(budget_key)
except DependencyError as exc:
await self._logger.warn(
await self._logger.warning(
"bot_loop_budget cache get failed, degrade to cache-miss",
budget_key=budget_key,
error=str(exc),
@ -123,7 +123,7 @@ class BotLoopBudgetGuard:
try:
await self.cache_port.set(budget_key, current_count - 1, cooldown)
except DependencyError as exc:
await self._logger.warn(
await self._logger.warning(
"bot_loop_budget cache set failed, skip writeback",
budget_key=budget_key,
error=str(exc),
@ -150,7 +150,7 @@ class BotLoopBudgetGuard:
try:
cached = await self.cache_port.get(budget_key)
except DependencyError as exc:
await self._logger.warn(
await self._logger.warning(
"bot_loop_budget cache get failed on release, skip",
budget_key=budget_key,
error=str(exc),
@ -166,7 +166,7 @@ class BotLoopBudgetGuard:
cooldown,
)
except DependencyError as exc:
await self._logger.warn(
await self._logger.warning(
"bot_loop_budget cache set failed on release, skip writeback",
budget_key=budget_key,
error=str(exc),
@ -202,7 +202,7 @@ class BotLoopBudgetGuard:
try:
await self.cache_port.set(budget_key, max_budget, cooldown)
except DependencyError as exc:
await self._logger.warn(
await self._logger.warning(
"bot_loop_budget cache set failed, skip writeback",
budget_key=budget_key,
error=str(exc),
@ -212,7 +212,7 @@ class BotLoopBudgetGuard:
try:
cached = await self.cache_port.get(budget_key)
except DependencyError as exc:
await self._logger.warn(
await self._logger.warning(
"bot_loop_budget cache get failed, degrade to cache-miss",
budget_key=budget_key,
error=str(exc),

View File

@ -91,7 +91,7 @@ class CapabilityVerifier:
try:
cached = await self.cache_port.get(cache_key)
except DependencyError as exc:
await self._logger.warn(
await self._logger.warning(
"capability cache get failed, skip cache",
plugin_id=plugin_id,
capability=capability,
@ -105,7 +105,7 @@ class CapabilityVerifier:
prover = self.registry.findProver(plugin_id)
if prover is None:
if self._logger is not None:
await self._logger.warn(
await self._logger.warning(
"capability proof: no prover registered, trusting declaration by default",
plugin_id=plugin_id,
capability=capability,
@ -116,7 +116,7 @@ class CapabilityVerifier:
try:
await self.cache_port.set(cache_key, proof.proven, 300)
except DependencyError as exc:
await self._logger.warn(
await self._logger.warning(
"capability cache set failed, skip writeback",
plugin_id=plugin_id,
capability=capability,

View File

@ -258,7 +258,7 @@ class CommonCommandAdapter:
sub = params[0] if params else ""
if self._whitelist_command_port is None:
await self._logger.warn(
await self._logger.warning(
"whitelist command port not injected, fallback to usage text",
command="allowlist",
action=sub,

View File

@ -326,7 +326,7 @@ class CredentialService:
try:
cached = await self._cache_port.get(cache_key)
except DependencyError as exc:
await self._logger_port.warn(
await self._logger_port.warning(
"credential cache get failed, fallback to ConfigPort",
account_id=account_id,
error=str(exc),
@ -365,7 +365,7 @@ class CredentialService:
ttl_seconds=_CREDENTIAL_CACHE_TTL,
)
except DependencyError as exc:
await self._logger_port.warn(
await self._logger_port.warning(
"credential cache backfill failed",
account_id=account_id,
error=str(exc),
@ -482,7 +482,7 @@ class CredentialService:
ttl_seconds=_CREDENTIAL_CACHE_TTL,
)
except DependencyError as exc:
await self._logger_port.warn(
await self._logger_port.warning(
"credential cache refresh failed",
account_id=account_id,
error=str(exc),

View File

@ -96,7 +96,7 @@ class DegradationManager:
try:
retry_count = await self.cache_port.incr(count_key)
except DependencyError as exc:
await self._logger.warn(
await self._logger.warning(
"degradation cache incr failed, degrade to retry_count=1",
plugin_id=plugin_id,
error=str(exc),
@ -108,7 +108,7 @@ class DegradationManager:
try:
await self.cache_port.expire(count_key, _DEGRADATION_RECORD_TTL)
except DependencyError as exc:
await self._logger.warn(
await self._logger.warning(
"degradation cache expire failed, count key may persist beyond window",
plugin_id=plugin_id,
error=str(exc),
@ -128,7 +128,7 @@ class DegradationManager:
try:
await self.cache_port.set(cache_key, payload, _DEGRADATION_RECORD_TTL)
except DependencyError as exc:
await self._logger.warn(
await self._logger.warning(
"degradation cache set failed, skip writeback",
plugin_id=plugin_id,
error=str(exc),
@ -177,7 +177,7 @@ class DegradationManager:
try:
cached = await self.cache_port.get(cache_key)
except DependencyError as exc:
await self._logger.warn(
await self._logger.warning(
"degradation cache get failed, skip plugin",
plugin_id=plugin_id,
error=str(exc),
@ -194,7 +194,7 @@ class DegradationManager:
try:
next_retry_at = datetime.fromisoformat(next_retry_str)
except ValueError as exc:
await self._logger.warn(
await self._logger.warning(
"degradation cache next_retry_at parse failed, skip plugin",
plugin_id=plugin_id,
next_retry_at=next_retry_str,
@ -231,7 +231,7 @@ class DegradationManager:
try:
cached = await self.cache_port.get(cache_key)
except DependencyError as exc:
await self._logger.warn(
await self._logger.warning(
"degradation cache get failed, treat as not failed",
plugin_id=plugin.manifest.id,
error=str(exc),

View File

@ -314,7 +314,7 @@ class DoctorService:
try:
repair_result = await adapter.autoFix(account_id, check_id)
except DependencyError as exc:
await self._logger.warn(
await self._logger.warning(
"doctor autoFix failed",
channel_type=channel_type,
account_id=account_id,
@ -362,7 +362,7 @@ class DoctorService:
except DependencyError:
raise
except Exception as exc:
await self._logger.warn(
await self._logger.warning(
"doctor runItem failed",
check_id=item.check_id,
error=str(exc),
@ -432,7 +432,7 @@ class DoctorService:
try:
result = await adapter.checkConnectivity(account_id)
except DependencyError as exc:
await self._logger.warn(
await self._logger.warning(
"doctor checkConnectivity failed",
check_id="credential_validity",
error=str(exc),
@ -485,7 +485,7 @@ class DoctorService:
try:
result = await adapter.checkPermissions(account_id)
except DependencyError as exc:
await self._logger.warn(
await self._logger.warning(
"doctor checkPermissions failed",
check_id="required_permissions",
error=str(exc),
@ -693,7 +693,7 @@ class DoctorService:
try:
value = await self._config_port.get(key, scope=scope, target=target)
except (ConfigValidationError, NotFoundError) as exc:
await self._logger.warn(
await self._logger.warning(
"config read failed, returning None",
key=key,
scope=scope.value,
@ -732,7 +732,7 @@ class DoctorService:
target=f"{channel_type}:{account_id}",
)
except (ConfigValidationError, NotFoundError) as exc:
await self._logger.warn(
await self._logger.warning(
"applied migrations read failed, returning empty",
channel_type=channel_type,
account_id=account_id,

View File

@ -143,7 +143,7 @@ class FenceGuard:
current_generation=0,
stale_generation=generation,
)
await self._logger.warn(
await self._logger.warning(
"fence TTL 过期,栅栏保护失效",
conversation_id=conversation_id,
)
@ -209,7 +209,7 @@ class FenceGuard:
if fence.revertGeneration(generation):
await self._saveFenceLocked(cache_key, fence, conversation_id)
except DependencyError as exc:
await self._logger.warn(
await self._logger.warning(
"fence rollback skipped due to cache dependency error",
conversation_id=conversation_id,
generation=generation,
@ -286,7 +286,7 @@ class FenceGuard:
error=str(exc),
)
raise
await self._logger.warn(
await self._logger.warning(
"fence cache get failed, fallback to local",
cache_key=cache_key,
error=str(exc),
@ -300,7 +300,7 @@ class FenceGuard:
if not fence.isExpired():
return fence
del self._local_fallback[cache_key]
await self._logger.warn(
await self._logger.warning(
"fence TTL expired, auto-released",
conversation_id=conversation_id,
)
@ -345,7 +345,7 @@ class FenceGuard:
error=str(exc),
)
raise
await self._logger.warn(
await self._logger.warning(
"fence cache set failed, fallback to local",
cache_key=cache_key,
error=str(exc),
@ -373,7 +373,7 @@ class FenceGuard:
仅在 fail-open 模式下调用并记 WARN 日志标注多实例保护弱化
fail-closed 模式在降级前已抛 ``DependencyError``ERROR 日志由
``_loadFenceLocked`` / ``_saveFenceLocked`` 调用方在抛出前记录
``logger.warn`` 在去重检查与标志置位之后调用保持与原有结构一致
``logger.warning`` 在去重检查与标志置位之后调用保持与原有结构一致
.. note:: 调用方必须已持有 ``self._lock``事务级锁语义本方法
内部不再加锁直接访问 ``_degrade_warned``
@ -384,7 +384,7 @@ class FenceGuard:
if self._degrade_warned:
return
self._degrade_warned = True
await self._logger.warn(
await self._logger.warning(
"fence cache unavailable, degrading to in-process memory counter; "
"多实例部署下栅栏保护弱化,建议恢复 Redis 后重启所有实例",
conversation_id=conversation_id,

View File

@ -179,7 +179,7 @@ class IdentityResolver:
try:
result = await self._cache_port.get(key)
except DependencyError as exc:
await self._logger.warn(
await self._logger.warning(
"identity cache get failed, fallback to no-cache",
cache_key=key,
error=str(exc),
@ -204,7 +204,7 @@ class IdentityResolver:
try:
await self._cache_port.set(key, self._toCacheValue(value), self._cache_ttl)
except DependencyError as exc:
await self._logger.warn(
await self._logger.warning(
"identity cache set failed",
cache_key=key,
error=str(exc),

Some files were not shown because too many files have changed in this diff Show More