Handle invalid UTF-8 with replacement decoding

This commit is contained in:
jxxghp
2026-06-21 09:56:34 +08:00
parent 99e369aaa4
commit 68f18db374
29 changed files with 139 additions and 121 deletions

View File

@@ -891,7 +891,7 @@ class LLMProviderManager(metaclass=Singleton):
if not self._models_dev_cache_path.exists():
payload = None
else:
payload = json.loads(self._models_dev_cache_path.read_text(encoding="utf-8"))
payload = json.loads(self._models_dev_cache_path.read_text(encoding="utf-8", errors="replace"))
except Exception as err:
logger.warning(f"读取 models.dev provider 缓存失败: {err}")
payload = None
@@ -1424,7 +1424,7 @@ class LLMProviderManager(metaclass=Singleton):
if not self._models_dev_cache_path.exists():
return None
async with aiofiles.open(
self._models_dev_cache_path, mode="r", encoding="utf-8"
self._models_dev_cache_path, mode="r", encoding="utf-8", errors="replace"
) as stream:
return json.loads(await stream.read())
except Exception as err:
@@ -1437,7 +1437,7 @@ class LLMProviderManager(metaclass=Singleton):
if not self._MODELS_DEV_BUNDLED_PATH.exists():
return None
payload = json.loads(
self._MODELS_DEV_BUNDLED_PATH.read_text(encoding="utf-8")
self._MODELS_DEV_BUNDLED_PATH.read_text(encoding="utf-8", errors="replace")
)
except Exception as err:
logger.warning(f"读取本地 models.dev 离线文件失败: {err}")

View File

@@ -133,7 +133,7 @@ def load_activity_log_index(activity_dir: str, days: int = PROMPT_LOAD_DAYS) ->
if not log_path.is_file():
continue
try:
content = log_path.read_text(encoding="utf-8")
content = log_path.read_text(encoding="utf-8", errors="replace")
except Exception as e:
logger.warning(f"读取活动日志索引失败 {log_path}: {e}")
continue
@@ -197,7 +197,7 @@ def query_activity_logs(
if not log_path.is_file():
continue
try:
content = log_path.read_text(encoding="utf-8")
content = log_path.read_text(encoding="utf-8", errors="replace")
except Exception as e:
logger.warning(f"读取活动日志失败 {log_path}: {e}")
continue
@@ -480,7 +480,7 @@ class ActivityLogMiddleware(AgentMiddleware[ActivityLogState, ContextT, Response
entry = f"- **{now_str}** {summary}\n"
try:
if await log_path.exists():
existing = await log_path.read_text(encoding="utf-8")
existing = await log_path.read_text(encoding="utf-8", errors="replace")
await log_path.write_text(existing + entry, encoding="utf-8")
else:
header = f"# {today_str} 活动日志\n\n"

View File

@@ -128,7 +128,7 @@ def _parse_job_metadata(
async def _alist_jobs(source_path: AsyncPath) -> list[JobMetadata]:
"""异步列出指定路径下的所有任务。
扫描包含 JOB.md 的目录并解析其元数据。
扫描包含 JOB.md 的目录并解析其元数据,遇到非法 UTF-8 字节时以替换字符兜底
"""
jobs: list[JobMetadata] = []
@@ -151,7 +151,10 @@ async def _alist_jobs(source_path: AsyncPath) -> list[JobMetadata]:
for job_path in job_dirs:
job_md_path = job_path / "JOB.md"
job_content = await job_md_path.read_text(encoding="utf-8")
job_content = await job_md_path.read_text(
encoding="utf-8",
errors="replace",
)
# 解析元数据
job_metadata = _parse_job_metadata(

View File

@@ -335,7 +335,7 @@ class MemoryMiddleware(AgentMiddleware[MemoryState, ContextT, ResponseT]): # no
MAX_MEMORY_FILE_SIZE,
)
continue
contents[path] = await file_path.read_text(encoding="utf-8")
contents[path] = await file_path.read_text(encoding="utf-8", errors="replace")
logger.debug("Loaded memory from: %s", path)
except Exception as e:
logger.warning("Failed to read memory file %s: %s", path, e)

View File

@@ -234,7 +234,7 @@ async def _alist_skills(source_path: AsyncPath) -> list[SkillMetadata]:
for skill_path in skill_dirs:
skill_md_path = skill_path / "SKILL.md"
skill_content = await skill_md_path.read_text(encoding="utf-8")
skill_content = await skill_md_path.read_text(encoding="utf-8", errors="replace")
# 解析元数据
skill_metadata = _parse_skill_metadata(
@@ -312,7 +312,7 @@ Remember: Skills make you more capable and consistent. When in doubt, check if a
def _extract_version(skill_md: Path) -> int:
"""从 SKILL.md 文件中快速提取 version 字段,无法提取时返回 0。"""
try:
content = skill_md.read_text(encoding="utf-8")
content = skill_md.read_text(encoding="utf-8", errors="replace")
except Exception as err:
print(err)
return 0

View File

@@ -102,7 +102,7 @@ class PromptManager:
prompt_file = self.prompts_dir / prompt_name
try:
with open(prompt_file, "r", encoding="utf-8") as f:
with open(prompt_file, "r", encoding="utf-8", errors="replace") as f:
content = f.read().strip()
# 缓存提示词
self.prompts_cache[prompt_name] = content
@@ -187,7 +187,7 @@ class PromptManager:
return self._system_tasks_cache
try:
content = system_tasks_path.read_text(encoding="utf-8")
content = system_tasks_path.read_text(encoding="utf-8", errors="replace")
except Exception as err: # noqa: BLE001
logger.error(f"读取系统任务定义失败: {system_tasks_path}, 错误: {err}")
raise PromptConfigError(

View File

@@ -702,7 +702,7 @@ class AgentRuntimeManager:
if not path.exists():
raise AgentRuntimeConfigError(f"缺少配置文件: {path}")
try:
content = path.read_text(encoding="utf-8")
content = path.read_text(encoding="utf-8", errors="replace")
except Exception as err: # noqa: BLE001
raise AgentRuntimeConfigError(f"读取配置文件失败 {path}: {err}") from err

View File

@@ -533,7 +533,7 @@ class _TerminalSessionManager:
if len(encoded) > remaining:
if remaining > 0:
output_parts.append(
encoded[:remaining].decode("utf-8", errors="ignore")
encoded[:remaining].decode("utf-8", errors="replace")
)
output_truncated = True
break

View File

@@ -59,7 +59,7 @@ class EditFileTool(MoviePilotTool):
return f"错误:{resolved_path} 不是一个文件"
if await path.exists():
content = await path.read_text(encoding="utf-8")
content = await path.read_text(encoding="utf-8", errors="replace")
if old_text not in content:
logger.warning(f"编辑文件 {resolved_path} 失败:未找到指定的旧文本块")
return f"错误:在文件 {resolved_path} 中未找到指定的旧文本。请确保包含所有的空格、缩进 and 换行符。"

View File

@@ -58,7 +58,7 @@ class _CommandOutput:
"""按 UTF-8 字节数截断文本,避免截断后出现非法字符。"""
if byte_limit <= 0:
return ""
return text.encode("utf-8")[:byte_limit].decode("utf-8", errors="ignore")
return text.encode("utf-8")[:byte_limit].decode("utf-8", errors="replace")
def _write_chunk(self, stream_name: str, text: str) -> None:
"""把输出分片按 stdout/stderr 分段写入临时文件。"""

View File

@@ -55,7 +55,7 @@ class ReadFileTool(MoviePilotTool):
if not await path.is_file():
return f"错误:{resolved_path} 不是一个文件"
content = await path.read_text(encoding="utf-8")
content = await path.read_text(encoding="utf-8", errors="replace")
truncated = False
if start_line is not None or end_line is not None:
@@ -75,7 +75,7 @@ class ReadFileTool(MoviePilotTool):
# 检查大小限制
content_bytes = content.encode("utf-8")
if len(content_bytes) > MAX_READ_SIZE:
content = content_bytes[:MAX_READ_SIZE].decode("utf-8", errors="ignore")
content = content_bytes[:MAX_READ_SIZE].decode("utf-8", errors="replace")
truncated = True
if truncated:

View File

@@ -44,7 +44,7 @@ async def user_message(
args = request.query_params
source = args.get("source")
content_type = request.headers.get("content-type", "")
body_text = body.decode("utf-8", errors="ignore")
body_text = body.decode("utf-8", errors="replace")
image_markers = [
marker
for marker in (

View File

@@ -1002,7 +1002,7 @@ async def get_logging(
# 读取历史日志
async with aiofiles.open(
log_path, mode="r", encoding="utf-8", errors="ignore"
log_path, mode="r", encoding="utf-8", errors="replace"
) as f:
# 优化大文件读取策略
if file_size > 100 * 1024:
@@ -1031,7 +1031,7 @@ async def get_logging(
# 实时监听新日志
async with aiofiles.open(
log_path, mode="r", encoding="utf-8", errors="ignore"
log_path, mode="r", encoding="utf-8", errors="replace"
) as f:
# 移动文件指针到文件末尾,继续监听新增内容
await f.seek(0, 2)
@@ -1070,7 +1070,7 @@ async def get_logging(
try:
# 使用 aiofiles 异步读取文件
async with aiofiles.open(
log_path, mode="r", encoding="utf-8", errors="ignore"
log_path, mode="r", encoding="utf-8", errors="replace"
) as file:
text = await file.read()
# 倒序输出

View File

@@ -70,7 +70,7 @@ async def update_cookie(req: schemas.CookieData):
content = json.dumps({"encrypted": req.encrypted})
async with aiofiles.open(file_path, encoding="utf-8", mode="w") as file:
await file.write(content)
async with aiofiles.open(file_path, encoding="utf-8", mode="r") as file:
async with aiofiles.open(file_path, encoding="utf-8", errors="replace", mode="r") as file:
read_content = await file.read()
if read_content == content:
return {"action": "done"}
@@ -89,7 +89,7 @@ async def load_encrypt_data(uuid: str) -> Dict[str, Any]:
raise HTTPException(status_code=404, detail="Item not found")
# 读取文件
async with aiofiles.open(file_path, encoding="utf-8", mode="r") as file:
async with aiofiles.open(file_path, encoding="utf-8", errors="replace", mode="r") as file:
read_content = await file.read()
data = json.loads(read_content.encode("utf-8"))
return data

View File

@@ -292,7 +292,7 @@ class SystemChain(ChainBase):
version_file = Path(settings.FRONTEND_PATH) / "version.txt"
if version_file.exists():
try:
with open(version_file, 'r') as f:
with open(version_file, 'r', encoding='utf-8', errors='replace') as f:
version = str(f.read()).strip()
return version
except Exception as err:

View File

@@ -53,7 +53,7 @@ def _read_json_file(path: Path) -> Optional[Dict[str, Any]]:
if not path.exists():
return None
try:
return json.loads(path.read_text(encoding="utf-8"))
return json.loads(path.read_text(encoding="utf-8", errors="replace"))
except (OSError, json.JSONDecodeError):
return None
@@ -158,7 +158,7 @@ def _http_request(
"text": raw,
}
except HTTPError as exc:
raw = exc.read().decode("utf-8", errors="ignore")
raw = exc.read().decode("utf-8", errors="replace")
try:
data = json.loads(raw) if raw else None
except json.JSONDecodeError:
@@ -198,7 +198,7 @@ def _frontend_health(runtime: Optional[Dict[str, Any]] = None, timeout: float =
request = Request(url=url, headers={"Accept": "text/plain"}, method="GET")
try:
with urlopen(request, timeout=timeout) as response:
raw = response.read().decode("utf-8", errors="ignore").strip()
raw = response.read().decode("utf-8", errors="replace").strip()
return response.status == 200, {"version": raw}
except (HTTPError, URLError):
return False, None
@@ -233,7 +233,7 @@ def _github_api_json(url: str, *, repo: str) -> Any:
with opener.open(request, timeout=10.0) as response:
return json.loads(response.read().decode("utf-8"))
except HTTPError as exc:
detail = exc.read().decode("utf-8", errors="ignore")
detail = exc.read().decode("utf-8", errors="replace")
raise RuntimeError(f"访问 GitHub API 失败HTTP {exc.code}: {detail or url}") from exc
except URLError as exc:
raise RuntimeError(f"访问 GitHub API 失败:{exc.reason}") from exc
@@ -342,7 +342,7 @@ def _best_effort_auto_update() -> None:
stderr=subprocess.STDOUT,
text=True,
encoding="utf-8",
errors="ignore",
errors="replace",
check=False,
)
if result.returncode == 0:
@@ -451,7 +451,7 @@ def _annotation_name(annotation: Any) -> str:
def _tail_lines(path: Path, count: int) -> list[str]:
if not path.exists():
raise click.ClickException(f"日志文件不存在:{path}")
with path.open("r", encoding="utf-8", errors="ignore") as handle:
with path.open("r", encoding="utf-8", errors="replace") as handle:
return [line.rstrip("\n") for line in deque(handle, maxlen=count)]
@@ -459,7 +459,7 @@ def _follow_file(path: Path) -> None:
if not path.exists():
raise click.ClickException(f"日志文件不存在:{path}")
with path.open("r", encoding="utf-8", errors="ignore") as handle:
with path.open("r", encoding="utf-8", errors="replace") as handle:
handle.seek(0, os.SEEK_END)
while True:
line = handle.readline()
@@ -826,7 +826,7 @@ def _installed_frontend_version() -> Optional[str]:
if not FRONTEND_VERSION_FILE.exists():
return None
try:
return FRONTEND_VERSION_FILE.read_text(encoding="utf-8").strip() or None
return FRONTEND_VERSION_FILE.read_text(encoding="utf-8", errors="replace").strip() or None
except OSError:
return None

View File

@@ -428,7 +428,7 @@ class PluginManager(ConfigReloadMixin, metaclass=Singleton):
return None
# 读取 __init__.py 文件,查找插件主类名
with open(init_file, "r", encoding="utf-8") as f:
with open(init_file, "r", encoding="utf-8", errors="replace") as f:
source_code = f.read()
tree = ast.parse(source_code)
@@ -1928,7 +1928,7 @@ class PluginManager(ConfigReloadMixin, metaclass=Singleton):
修改Python文件中的类名和插件信息
"""
try:
with open(file_path, 'r', encoding='utf-8') as f:
with open(file_path, 'r', encoding='utf-8', errors='replace') as f:
content = f.read()
# 替换类名
@@ -2017,7 +2017,7 @@ class PluginManager(ConfigReloadMixin, metaclass=Singleton):
# 处理JS文件
if file_path.suffix == '.js':
try:
with open(file_path, 'r', encoding='utf-8') as f:
with open(file_path, 'r', encoding='utf-8', errors='replace') as f:
content = f.read()
# 替换类名引用(精确匹配)
@@ -2042,7 +2042,7 @@ class PluginManager(ConfigReloadMixin, metaclass=Singleton):
# 处理CSS文件
elif file_path.suffix == '.css':
try:
with open(file_path, 'r', encoding='utf-8') as f:
with open(file_path, 'r', encoding='utf-8', errors='replace') as f:
content = f.read()
# 替换CSS中可能的类名引用

View File

@@ -131,7 +131,7 @@ def _read_json(path: Path) -> Optional[dict[str, Any]]:
if not path.exists():
return None
try:
data = json.loads(path.read_text(encoding="utf-8"))
data = json.loads(path.read_text(encoding="utf-8", errors="replace"))
except (OSError, json.JSONDecodeError):
return None
return data if isinstance(data, dict) else None
@@ -249,7 +249,7 @@ def _backend_health_payload(port: int, timeout: float = BACKEND_HEALTH_TIMEOUT)
with urlopen(request, timeout=timeout) as response:
if response.status != 200:
return None
raw = response.read().decode("utf-8", errors="ignore")
raw = response.read().decode("utf-8", errors="replace")
except (HTTPError, URLError):
return None
except OSError:
@@ -672,7 +672,7 @@ def _check_frontend_assets(runner: DoctorRunnerProtocol) -> None:
version = ""
try:
version = (frontend_dir / "version.txt").read_text(encoding="utf-8").strip()
version = (frontend_dir / "version.txt").read_text(encoding="utf-8", errors="replace").strip()
except OSError:
version = "unknown"
runner.add(

View File

@@ -126,7 +126,7 @@ class CookieCloudHelper:
return {}
# 读取文件
with open(file_path, encoding="utf-8", mode="r") as file:
with open(file_path, encoding="utf-8", errors="replace", mode="r") as file:
read_content = file.read()
data = json.loads(read_content.encode("utf-8"))
return data

View File

@@ -232,7 +232,7 @@ class PluginHelper(metaclass=WeakSingleton):
if not package_file.exists():
return {}
try:
content = package_file.read_text(encoding="utf-8")
content = package_file.read_text(encoding="utf-8", errors="replace")
payload = json.loads(content)
except Exception as e:
logger.warn(f"读取本地插件包 {package_file} 失败:{e}")
@@ -1087,7 +1087,7 @@ class PluginHelper(metaclass=WeakSingleton):
return roots
try:
with open(requirements_file, "r", encoding="utf-8") as f:
with open(requirements_file, "r", encoding="utf-8", errors="replace") as f:
for raw_line in f:
line = raw_line.strip()
if not line or line.startswith("#"):
@@ -1289,7 +1289,7 @@ class PluginHelper(metaclass=WeakSingleton):
"""
conflicts = []
try:
with open(requirements_file, "r", encoding="utf-8") as f:
with open(requirements_file, "r", encoding="utf-8", errors="replace") as f:
for raw_line in f:
line = raw_line.strip()
if not line or line.startswith("#"):
@@ -1890,7 +1890,7 @@ class PluginHelper(metaclass=WeakSingleton):
"""
dependencies = {}
try:
with open(requirements_file, "r", encoding="utf-8") as f:
with open(requirements_file, "r", encoding="utf-8", errors="replace") as f:
for line in f:
line = line.strip()
if line and not line.startswith('#'):
@@ -2440,7 +2440,7 @@ class PluginHelper(metaclass=WeakSingleton):
"""
dependencies = {}
try:
async with aiofiles.open(requirements_file, "r", encoding="utf-8") as f:
async with aiofiles.open(requirements_file, "r", encoding="utf-8", errors="replace") as f:
async for line in f:
line = str(line).strip()
if line and not line.startswith('#'):

View File

@@ -238,7 +238,7 @@ class MoviePilotServerHelper:
version_file = Path(settings.FRONTEND_PATH) / "version.txt"
if version_file.exists():
try:
with open(version_file, "r") as file:
with open(version_file, "r", encoding="utf-8", errors="replace") as file:
version = str(file.read()).strip()
return version or FRONTEND_VERSION
except Exception as err:

View File

@@ -343,7 +343,7 @@ class SkillHelper(metaclass=WeakSingleton):
if not meta_path.exists():
return {}
try:
payload = json.loads(meta_path.read_text(encoding="utf-8"))
payload = json.loads(meta_path.read_text(encoding="utf-8", errors="replace"))
return payload if isinstance(payload, dict) else {}
except Exception as e:
logger.warning("读取技能来源元数据失败:%s - %s", meta_path, e)
@@ -381,7 +381,7 @@ class SkillHelper(metaclass=WeakSingleton):
if not skill_md.exists():
continue
try:
content = skill_md.read_text(encoding="utf-8")
content = skill_md.read_text(encoding="utf-8", errors="replace")
except Exception as e:
logger.warning("读取技能文件失败:%s - %s", skill_md, e)
continue
@@ -979,7 +979,7 @@ class SkillHelper(metaclass=WeakSingleton):
content = getattr(response, "content", b"")
if isinstance(content, bytes):
return content.decode("utf-8", errors="ignore")
return content.decode("utf-8", errors="replace")
if isinstance(content, str):
return content
return ""

View File

@@ -55,7 +55,7 @@ class SystemHelper(ConfigReloadMixin):
if not path.exists():
return None
try:
payload = json.loads(path.read_text(encoding="utf-8"))
payload = json.loads(path.read_text(encoding="utf-8", errors="replace"))
except (OSError, json.JSONDecodeError):
return None
return payload if isinstance(payload, dict) else None
@@ -149,7 +149,7 @@ class SystemHelper(ConfigReloadMixin):
return None
try:
raw_mode = path.read_text(encoding="utf-8")
raw_mode = path.read_text(encoding="utf-8", errors="replace")
except OSError as err:
logger.warning(f"读取一次性升级标记失败: {err}")
raw_mode = ""
@@ -214,7 +214,7 @@ class SystemHelper(ConfigReloadMixin):
"""
container_id = None
try:
with open("/proc/self/mountinfo", "r") as f:
with open("/proc/self/mountinfo", "r", encoding="utf-8", errors="replace") as f:
data = f.read()
index_resolv_conf = data.find("resolv.conf")
if index_resolv_conf != -1:

View File

@@ -597,7 +597,7 @@ class Rclone(StorageBase):
if not file_path or not Path(file_path).exists():
return None
# 读取rclone文件检查是否有[MP]节点配置
with open(file_path, "r", encoding="utf-8") as f:
with open(file_path, "r", encoding="utf-8", errors="replace") as f:
lines = f.readlines()
if not lines:
return None

View File

@@ -287,7 +287,7 @@ class Rtorrent:
if isinstance(content, bytes):
# 检查是否为磁力链接bytes形式
if content.startswith(b"magnet:"):
content = content.decode("utf-8", errors="ignore")
content = content.decode("utf-8", errors="replace")
else:
# 种子文件内容使用load.raw
raw = xmlrpc.client.Binary(content)

View File

@@ -44,7 +44,7 @@ class CategoryHelper(metaclass=WeakSingleton):
try:
if not self._category_path.exists():
shutil.copy(settings.INNER_CONFIG_PATH / "category.yaml", self._category_path)
with open(self._category_path, mode='r', encoding='utf-8') as f:
with open(self._category_path, mode='r', encoding='utf-8', errors='replace') as f:
try:
yaml_loader = ruamel.yaml.YAML()
self._categorys = yaml_loader.load(f)
@@ -67,7 +67,7 @@ class CategoryHelper(metaclass=WeakSingleton):
if not self._category_path.exists():
return config
try:
with open(self._category_path, 'r', encoding='utf-8') as f:
with open(self._category_path, 'r', encoding='utf-8', errors='replace') as f:
yaml_loader = ruamel.yaml.YAML()
data = yaml_loader.load(f)
if data:

View File

@@ -78,7 +78,7 @@ class WechatClawBotModule(_ModuleBase, _MessageBase[WechatClawBot]):
if isinstance(body, dict):
payload = body
elif isinstance(body, bytes):
payload = json.loads(body.decode("utf-8", errors="ignore"))
payload = json.loads(body.decode("utf-8", errors="replace"))
else:
payload = json.loads(body)
while isinstance(payload, str):

View File

@@ -491,14 +491,14 @@ class Monitor(ConfigReloadMixin, metaclass=SingletonClass):
if system == 'Linux':
# 检查 inotify 限制
try:
with open('/proc/sys/fs/inotify/max_user_watches', 'r') as f:
with open('/proc/sys/fs/inotify/max_user_watches', 'r', encoding='utf-8', errors='replace') as f:
limits['max_user_watches'] = int(f.read().strip())
except Exception as e:
logger.debug(f"读取 inotify 限制失败: {e}")
limits['max_user_watches'] = 8192 # 默认值
try:
with open('/proc/sys/fs/inotify/max_user_instances', 'r') as f:
with open('/proc/sys/fs/inotify/max_user_instances', 'r', encoding='utf-8', errors='replace') as f:
limits['max_user_instances'] = int(f.read().strip())
except Exception as e:
logger.debug(f"读取 inotify 实例限制失败: {e}")

View File

@@ -1,68 +1,62 @@
import tempfile
import unittest
from pathlib import Path
import anyio
from anyio import Path as AsyncPath
from app.agent.middleware.jobs import _alist_jobs, filter_active_jobs
class JobsMiddlewareTest(unittest.TestCase):
def test_filter_active_jobs_only_keeps_pending_and_in_progress(self):
jobs_metadata = [
{
"id": "pending-job",
"name": "待执行任务",
"description": "desc",
"path": "/tmp/pending/JOB.md",
"schedule": "once",
"status": "pending",
"last_run": None,
},
{
"id": "running-job",
"name": "执行中任务",
"description": "desc",
"path": "/tmp/running/JOB.md",
"schedule": "recurring",
"status": "in_progress",
"last_run": "2026-05-10 10:00",
},
{
"id": "completed-recurring-job",
"name": "已完成循环任务",
"description": "desc",
"path": "/tmp/completed/JOB.md",
"schedule": "recurring",
"status": "completed",
"last_run": "2026-05-10 11:00",
},
{
"id": "cancelled-job",
"name": "已取消任务",
"description": "desc",
"path": "/tmp/cancelled/JOB.md",
"schedule": "once",
"status": "cancelled",
"last_run": None,
},
]
def test_filter_active_jobs_only_keeps_pending_and_in_progress():
"""筛选任务时只保留待执行和执行中的任务。"""
jobs_metadata = [
{
"id": "pending-job",
"name": "待执行任务",
"description": "desc",
"path": "/tmp/pending/JOB.md",
"schedule": "once",
"status": "pending",
"last_run": None,
},
{
"id": "running-job",
"name": "执行中任务",
"description": "desc",
"path": "/tmp/running/JOB.md",
"schedule": "recurring",
"status": "in_progress",
"last_run": "2026-05-10 10:00",
},
{
"id": "completed-recurring-job",
"name": "已完成循环任务",
"description": "desc",
"path": "/tmp/completed/JOB.md",
"schedule": "recurring",
"status": "completed",
"last_run": "2026-05-10 11:00",
},
{
"id": "cancelled-job",
"name": "已取消任务",
"description": "desc",
"path": "/tmp/cancelled/JOB.md",
"schedule": "once",
"status": "cancelled",
"last_run": None,
},
]
active_job_ids = [job["id"] for job in filter_active_jobs(jobs_metadata)]
active_job_ids = [job["id"] for job in filter_active_jobs(jobs_metadata)]
self.assertEqual(["pending-job", "running-job"], active_job_ids)
assert active_job_ids == ["pending-job", "running-job"]
class JobsMiddlewareAsyncTest(unittest.IsolatedAsyncioTestCase):
async def test_alist_jobs_sorts_job_directories_by_name(self):
with tempfile.TemporaryDirectory() as tempdir:
root = Path(tempdir)
for job_id in ("z-job", "a-job", "m-job"):
job_dir = root / job_id
job_dir.mkdir()
(job_dir / "JOB.md").write_text(
f"""---
def test_alist_jobs_sorts_job_directories_by_name(tmp_path):
"""加载任务元数据时按任务目录名稳定排序。"""
for job_id in ("z-job", "a-job", "m-job"):
job_dir = tmp_path / job_id
job_dir.mkdir()
(job_dir / "JOB.md").write_text(
f"""---
name: {job_id}
description: test
schedule: once
@@ -70,9 +64,30 @@ status: pending
---
# {job_id}
""",
encoding="utf-8",
)
encoding="utf-8",
)
jobs = await _alist_jobs(AsyncPath(str(root)))
jobs = anyio.run(_alist_jobs, AsyncPath(str(tmp_path)))
self.assertEqual(["a-job", "m-job", "z-job"], [job["id"] for job in jobs])
assert [job["id"] for job in jobs] == ["a-job", "m-job", "z-job"]
def test_alist_jobs_tolerates_invalid_utf8_bytes(tmp_path):
"""加载任务元数据时非法 UTF-8 字节不应中断整个 Agent。"""
job_dir = tmp_path / "broken-job"
job_dir.mkdir()
(job_dir / "JOB.md").write_bytes(
b"""---
name: broken-job
description: test
schedule: once
status: pending
---
# broken-job
\x90
"""
)
jobs = anyio.run(_alist_jobs, AsyncPath(str(tmp_path)))
assert [job["id"] for job in jobs] == ["broken-job"]