ForcePilot/backend/package/yuxi/channels/application/pipeline/base.py
Kris b10cb080de refactor: 多模块代码优化与功能增强
本次提交包含多项代码改进与功能增强:
1. 新增服务账号禁用错误类型与状态映射
2. 优化出站管道阶段顺序与上下文字段
3. 完善会话与事务管理逻辑,修复并发冲突处理
4. 调整钉钉与微信插件的适配逻辑
5. 优化内容校验与限流降级策略
6. 新增渠道降级投递事件与围栏回滚机制
7. 修正配置schema与权限校验逻辑
8. 完善审计与监控相关的日志与钩子处理
2026-07-09 13:51:58 +08:00

447 lines
19 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""管道基础设施。
定义应用层管道基类 ``Pipeline`` 与阶段协议 ``AppStage``,提供阶段顺序执行、
条件跳过、失败策略处理与补偿执行能力。``FailureStrategy`` 从契约层复用,
不重新定义,保证失败语义在全链路一致。
可观测性§11.2 / INV-10``Pipeline.run()`` 在每个阶段执行后输出结构化
日志,必填字段 ``trace_id`` / ``stage_id`` / ``duration_ms`` / ``status`` /
``error_type``(失败时),支持阶段延迟分布与成功率度量。
协程取消清理C-O2``Pipeline.run()`` 在阶段循环外包裹 ``try/finally``
finally 块执行带 ``cleanup=True`` 标记的阶段,确保协程被取消时流式会话与
输入指示资源被释放。``asyncio.CancelledError`` 不被 ``except Exception``
捕获Python 3.8+ 继承 ``BaseException``finally 是唯一可靠的清理点。
"""
from __future__ import annotations
import asyncio
import sys
import time
from collections.abc import Callable
from typing import Any, Protocol, runtime_checkable
from yuxi.channels.contract.dtos.trace import Span
from yuxi.channels.contract.errors import Error, InternalError, PipelineConfigError
from yuxi.channels.contract.plugin.extension_point import FailureStrategy
from yuxi.channels.contract.ports.driven.logger_port import LoggerPort
from yuxi.channels.contract.ports.driven.tracer_port import TracerPort
@runtime_checkable
class AppStage(Protocol):
"""应用层管道阶段协议。
对齐契约层 ``Stage`` Protocol 并扩展:
- ``compensate`` 由 ``bool`` 调整为 ``str | None``,携带补偿阶段名称
(如 "outbox-rollback"None 表示无补偿;
- 新增 ``condition`` 字段支持基于上下文的阶段执行条件None 表示
无条件执行;
- 新增 ``cleanup`` 字段C-O2标记为清理阶段的阶段会在 ``Pipeline.run``
的 finally 块中被重新执行,用于协程取消时释放资源(如流式会话)。
默认 False未标记的阶段不参与清理。
阶段处理方法 ``process`` 返回 ``bool``True 表示成功False 或抛出
异常表示失败。
"""
id: str
reads: tuple[str, ...]
writes: tuple[str, ...]
idempotent: bool
thread_safe: bool
failure: FailureStrategy
compensate: str | None
cleanup: bool
condition: Callable[[Any], bool] | None
async def process(self, context: Any) -> bool:
"""处理管道阶段。
参数:
context: 管道上下文。
返回:
True 表示成功False 或抛出异常表示失败。
"""
...
class Pipeline:
"""管道基类。
顺序执行阶段,按 ``AppStage.failure`` 策略处理失败。支持条件跳过、
异常翻译与补偿执行。
失败策略处理:
- TERMINATE终止管道返回失败
- SKIP跳过当前阶段继续下一阶段
- COMPENSATE执行补偿阶段后返回失败
- DEGRADE标记降级并继续下一阶段。
命名等价说明6-P2-01上下文 ``trace_id`` 与 Agent 层 ``turn_id``
为同一概念的不同命名,均标识一次请求/会话回合的链路追踪 ID。
``_logStageExecution`` 输出的 ``trace_id`` 字段即等价于 ``turn_id``。
"""
name: str
stages: list[AppStage]
logger: LoggerPort | None
tracer_port: TracerPort | None
def __init__(
self,
name: str,
stages: list[AppStage],
logger: LoggerPort | None = None,
tracer_port: TracerPort | None = None,
) -> None:
"""初始化管道。
参数:
name: 管道名称(如 "inbound" / "outbound" / "control-plane")。
stages: 阶段列表,按列表顺序执行。
logger: 可选日志端口用于记录补偿阶段异常None 时不记录。
tracer_port: 可选追踪端口,用于为每个 stage 包裹独立 span
H-4 / §11.2None 时不生成 span向后兼容
Raises:
PipelineConfigError: 任一阶段的 ``compensate`` 字段指向不存在
的阶段 ID。构造期 fail-fast避免运行期 ``_runCompensate``
按名称查找静默失败§3.1 补偿执行约束)。
"""
self.name = name
self.stages = stages
self.logger = logger
self.tracer_port = tracer_port
self._validateCompensateReferences()
def _validateCompensateReferences(self) -> None:
"""校验所有阶段的 ``compensate`` 引用指向已注册的阶段 ID。
``compensate`` 字段为字符串约定,``_runCompensate`` 按名称查找
阶段,未找到时**静默返回原错误**(不报错)。若因 typo 导致引用
不存在,补偿逻辑永远不会执行且无任何告警。
本方法在构造期校验所有 ``compensate`` 引用,发现不存在的引用
立即抛出 ``PipelineConfigError``fail-fast将运行期静默失败
转换为构造期显式失败。
"""
stage_ids = {stage.id for stage in self.stages}
for stage in self.stages:
if stage.compensate is not None and stage.compensate not in stage_ids:
raise PipelineConfigError(
pipeline=self.name,
stage_id=stage.id,
compensate=stage.compensate,
)
async def run(self, ctx: Any) -> tuple[bool, Error | None]:
"""执行管道。
按 ``stages`` 列表顺序执行每个阶段。阶段返回 True 表示成功,继续
下一阶段;返回 False 或抛出异常表示失败,按 ``failure`` 策略处理。
若 ``condition`` 返回 False 则跳过该阶段。
异常翻译:``Error`` 子类直接处理,原生 ``Exception`` 翻译为
``InternalError``(携带 ``trace_id`` 与异常信息),不得让原生异常
穿透管道边界INV-7 / FF-ERR-01
可观测性§11.2 / INV-10每个阶段执行后输出结构化日志必填
字段 ``trace_id`` / ``stage_id`` / ``duration_ms`` / ``status`` /
``error_type``(失败时),日志失败不影响管道执行。
协程取消清理C-O2阶段循环包裹在 ``try/finally`` 中。
``asyncio.CancelledError`` 继承 ``BaseException``Python 3.8+
不被 ``except Error`` / ``except Exception`` 捕获,直接触发 finally。
finally 块执行带 ``cleanup=True`` 标记的阶段(如 typing-stop
释放流式会话与输入指示资源。清理阶段异常仅 warn 日志,不掩盖原始
``CancelledError``;清理过程中再次被取消时捕获并继续剩余清理,
最终重新抛出 ``CancelledError`` 以保留取消语义。
参数:
ctx: 管道上下文。
返回:
元组 (success, error)。全部阶段成功时 success 为 True、
error 为 None否则 success 为 False、error 携带失败原因。
Raises:
asyncio.CancelledError: 协程被取消时,清理完成后重新抛出。
"""
try:
for stage in self.stages:
if stage.condition is not None and not stage.condition(ctx):
continue
ok = False
err: Error | None = None
trace_id = getattr(ctx, "trace_id", None)
start = time.monotonic()
# H-4 / §11.2:为每个 stage 包裹独立 spantracer_port 为 None 时
# 跳过向后兼容。span 名格式 {pipeline_name}.{stage_id},使运维
# 可定位慢调用发生在哪个 stage。
span: Span | None = None
if self.tracer_port is not None:
# M-11: tracer 失败不得中断管道主链路,降级为无 span 执行
try:
span = await self.tracer_port.startSpan(f"{self.name}.{stage.id}", trace_id=trace_id)
except Exception as exc:
span = None
try:
if self.logger is not None:
await self.logger.warn(
f"tracer startSpan failed: {stage.id}",
trace_id=trace_id,
stage_id=stage.id,
error=str(exc),
)
except Exception:
pass
try:
ok = await stage.process(ctx)
except Error as e:
err = e
except Exception as e:
err = InternalError(
trace_id=trace_id,
message=f"stage '{stage.id}' raised native exception: {type(e).__name__}: {e}",
cause=e,
)
finally:
if span is not None:
# M-11: endSpan 失败不得中断管道主链路,降级记录日志
try:
await self.tracer_port.endSpan(span, status="ok" if ok else "error")
except Exception as exc:
try:
if self.logger is not None:
await self.logger.warn(
f"tracer endSpan failed: {stage.id}",
trace_id=trace_id,
stage_id=stage.id,
error=str(exc),
)
except Exception:
pass
duration_ms = (time.monotonic() - start) * 1000
# 结构化可观测性日志§11.2 / INV-10
await self._logStageExecution(ctx, stage.id, duration_ms, ok, err)
if ok:
continue
match stage.failure:
case FailureStrategy.TERMINATE:
return False, err
case FailureStrategy.SKIP:
continue
case FailureStrategy.COMPENSATE:
return False, await self._runCompensate(ctx, stage.compensate, err)
case FailureStrategy.DEGRADE:
ctx.degraded = True
ctx.degraded_reason = err
continue
return True, None
finally:
# C-O2协程取消清理。CancelledError 不被上方 except 捕获,
# 直接触发本 finally。仅当流式资源已启动时执行清理阶段。
await self._runCleanup(ctx)
async def _runCleanup(self, ctx: Any) -> None:
"""执行清理阶段C-O2
在 ``Pipeline.run`` 的 finally 块中调用,查找并执行带
``cleanup=True`` 标记的阶段(如 typing-stop释放流式会话与
输入指示资源。
清理策略:
- 仅当上下文 ``streaming_started`` 或 ``typing_started`` 为 True
时执行(无流式资源启动时跳过,避免无谓工作);
- 清理阶段异常仅 warn 日志不掩盖原始异常CancelledError 或
管道返回的错误);
- 清理过程中若再次被取消(``CancelledError``),捕获并继续剩余
清理阶段,最终重新抛出 ``CancelledError`` 以保留取消语义。
参数:
ctx: 管道上下文。
"""
# 仅当流式资源已启动时执行清理,避免无谓工作
streaming_started = getattr(ctx, "streaming_started", False)
typing_started = getattr(ctx, "typing_started", False)
if not (streaming_started or typing_started):
return
trace_id = getattr(ctx, "trace_id", None)
cleanup_cancelled = False
for stage in self.stages:
# 严格匹配 True避免 MagicMock 桩的 truthy 属性误判为清理阶段
if getattr(stage, "cleanup", False) is not True:
continue
if stage.condition is not None and not stage.condition(ctx):
continue
try:
await stage.process(ctx)
except asyncio.CancelledError:
# 清理过程中被取消:记录并继续剩余清理阶段,最终重新抛出
cleanup_cancelled = True
except Exception as exc:
# 清理阶段异常仅 warn 日志,不掩盖原始异常
if self.logger is not None:
try:
await self.logger.warn(
f"cleanup stage {stage.id} failed: {exc}",
trace_id=trace_id,
stage_id=stage.id,
)
except Exception:
pass
if cleanup_cancelled:
raise asyncio.CancelledError()
async def _logStageExecution(
self,
ctx: Any,
stage_id: str,
duration_ms: float,
ok: bool,
err: Error | None,
) -> None:
"""输出阶段执行结构化日志§11.2 / INV-10
必填字段:``trace_id`` / ``stage_id`` / ``duration_ms`` / ``status`` /
``error_type``(失败时)。日志端口为 None 或日志写入失败时不影响
管道执行(降级至 stderr
渠道语义字段6-P0-04 / 6-P1-07 / 6-P2-02从 ctx 读取并输出
``channel_type`` / ``account_id`` / ``agent_run_id`` / ``plugin_id`` /
``pairing_id`` / ``dm_decision`` / ``channel_format_spec`` /
``channel_context_note``(仅当值非 None 时输出)。当 ``channel_type``
与 ``account_id`` 均非 None 时派生 ``channel_id`` 并输出,
``channel_id`` 等价于 ``${channel_type}:${account_id}``。
参数:
ctx: 管道上下文(用于提取 trace_id 与渠道语义字段)。
stage_id: 阶段唯一标识。
duration_ms: 阶段执行耗时(毫秒)。
ok: 阶段是否成功。
err: 失败时的错误对象,成功时为 None。
"""
if self.logger is None:
return
trace_id = getattr(ctx, "trace_id", None)
# 收集必填字段
fields: dict[str, Any] = {
"trace_id": trace_id,
"pipeline": self.name,
"stage_id": stage_id,
"duration_ms": duration_ms,
"status": "ok" if ok else "failed",
}
# 收集渠道语义字段(仅当值非 None 时输出6-P0-04 / 6-P1-07 / 6-P2-02
channel_type = getattr(ctx, "channel_type", None)
account_id = getattr(ctx, "account_id", None)
agent_run_id = getattr(ctx, "agent_run_id", None)
plugin_id = getattr(ctx, "plugin_id", None)
pairing_id = getattr(ctx, "pairing_id", None)
dm_decision = getattr(ctx, "dm_decision", None)
channel_format_spec = getattr(ctx, "channel_format_spec", None)
channel_context_note = getattr(ctx, "channel_context_note", None)
if channel_type is not None:
fields["channel_type"] = channel_type
if account_id is not None:
fields["account_id"] = account_id
if agent_run_id is not None:
fields["agent_run_id"] = agent_run_id
if plugin_id is not None:
fields["plugin_id"] = plugin_id
if pairing_id is not None:
fields["pairing_id"] = pairing_id
if dm_decision is not None:
fields["dm_decision"] = dm_decision.value
if channel_format_spec is not None:
fields["channel_format_spec"] = channel_format_spec
if channel_context_note is not None:
fields["channel_context_note"] = channel_context_note
# 派生 channel_id等价于 ${channel_type}:${account_id}
if channel_type is not None and account_id is not None:
fields["channel_id"] = f"{channel_type}:{account_id}"
if not ok:
fields["error_type"] = type(err).__name__ if err else None
# 记录异常 message 与 details便于排查 stage 失败根因。
# 此前仅记录 error_type类名导致日志中看不到具体异常原因。
if err is not None:
fields["error_message"] = err.message
if err.details:
fields["error_details"] = err.details
try:
if ok:
await self.logger.info(
f"stage executed: {stage_id}",
**fields,
)
else:
await self.logger.warn(
f"stage failed: {stage_id}",
**fields,
)
except Exception as e:
sys.stderr.write(f"[trace_id={trace_id}] stage logging failed: {stage_id}: {e}\n")
async def _runCompensate(
self,
ctx: Any,
compensate_name: str | None,
original_error: Error | None,
) -> Error | None:
"""执行补偿阶段。
查找补偿阶段并执行。补偿阶段自身的异常 **不** 覆盖原错误,仅通过
日志端口记录若注入保留原始失败原因§3.1 补偿执行约束)。
参数:
ctx: 管道上下文。
compensate_name: 补偿阶段名称None 表示无补偿。
original_error: 原始失败错误。
返回:
原始失败错误(补偿不改变管道失败状态)。
"""
if compensate_name is None:
return original_error
compensate_stage = self._findStage(compensate_name)
if compensate_stage is None:
return original_error
try:
await compensate_stage.process(ctx)
except Exception as e:
trace_id = getattr(ctx, "trace_id", None)
if self.logger is not None:
try:
await self.logger.error(
f"compensate stage {compensate_name} failed: {e}",
trace_id=trace_id,
compensate_stage=compensate_name,
)
except Exception:
sys.stderr.write(f"[trace_id={trace_id}] compensate stage {compensate_name} failed: {e}\n")
else:
sys.stderr.write(f"[trace_id={trace_id}] compensate stage {compensate_name} failed: {e}\n")
return original_error
def _findStage(self, stage_id: str) -> AppStage | None:
"""按 id 查找阶段。
参数:
stage_id: 阶段唯一标识。
返回:
匹配的阶段实例,未找到时返回 None。
"""
for stage in self.stages:
if stage.id == stage_id:
return stage
return None