Files

809 lines
40 KiB
Python
Raw Permalink 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.
"""持久化长任务服务。
本模块位于 API 生命周期与耗时业务处理之间。每个任务独立保存为 JSON,进程重启后
仍可查询终态,并通过任务类型处理器从可序列化 payload 重建待执行工作。
"""
from __future__ import annotations
import json
import os
import shutil
from concurrent.futures import ThreadPoolExecutor
from dataclasses import dataclass, field
from datetime import datetime, timezone
from pathlib import Path
from threading import Lock
from typing import Any, Callable
from uuid import uuid4
from backend.agent_resume import normalize_identifier, sanitize_error, sanitize_error_history
from backend.config import validate_agent_concurrency
from backend.image_stage_plan import (
IMAGE_PROCESSING_MAX_ATTEMPTS,
IMAGE_PROCESSING_TASK_TYPES,
image_task_requires_single_attempt,
)
from backend.opencode_workspace import SELECTOR_RE
from backend.persistence.models import GLOBAL_LANE_RESOURCE_KEY
from backend.gif_compression import GIF_ERROR_CODES
from backend.public_dto import (
PUBLIC_TASK_STATUSES,
normalize_public_code,
sanitize_visual_snapshot_summary,
normalize_public_identifier,
sanitize_public_message,
sanitize_public_timestamp,
sanitize_task_result,
)
TERMINAL = {"succeeded", "failed"}
STABLE_TASK_ERRORS = set(GIF_ERROR_CODES) | {
"upload_receipt_missing",
"upload_receiving_interrupted",
"upload_input_size_changed",
"upload_input_expired",
"upload_pending_capacity_exceeded",
"upload_disk_capacity_exceeded",
"upload_request_conflict",
"image_exists",
"invalid_image",
"unsupported_format",
"no_indexable_images",
"opencode_not_configured",
"agent_process_failed",
"agent_analysis_policy_missing",
"agent_analysis_policy_invalid",
"agent_analysis_usage_unavailable",
"agent_analysis_reminder_plugin_unavailable",
"agent_maximum_analysis_depth_exceeded",
"model_capability_invalid",
"model_capability_unavailable",
"model_broker_endpoint_invalid",
"agent_timeout",
"agent_event_invalid",
"agent_export_failed",
"opencode_slot_unavailable",
"agent_output_invalid_json",
"agent_output_schema_invalid",
"agent_result_file_missing",
"agent_result_file_unreadable",
"agent_result_file_too_large",
"agent_result_file_invalid_json",
"agent_result_file_schema_invalid",
"agent_runtime_unavailable",
"agent_executor_not_configured",
"agent_executor_unavailable",
"agent_executor_unauthorized",
"agent_executor_invalid_response",
"agent_backpressure",
"agent_fairness_unavailable",
"agent_lane_slot_inconsistent",
"agent_claim_owner_invalid",
"agent_claim_lane_invalid",
"agent_claim_config_invalid",
"task_exists",
"agent_timeout_limit_exceeded",
"agent_image_root_mismatch",
"agent_input_provider_unavailable",
"agent_image_path_forbidden",
"agent_result_path_invalid",
"target_changed",
"task_interrupted",
"service_shutdown",
"agent_attempt_metadata_write_failed",
"generation_policy_conflict",
"processing_options_conflict",
"invalid_auto_name",
"auto_rename_title_missing",
"auto_rename_invalid_filename",
"auto_rename_target_exists",
"auto_rename_target_changed",
"auto_rename_claim_expired",
"auto_rename_unknown_execution",
"reverse_image_forbidden",
"reverse_image_unavailable",
"invalid_task",
"invalid_reverse_image_policy",
"task_not_running",
"invalid_image_format",
"image_too_large",
"invalid_search_type",
"reverse_image_provider_unavailable",
"reverse_image_provider_invalid",
"cache_write_failed",
"usage_request_conflict",
"thumbnail_backpressure",
"thumbnail_profile_mismatch",
"thumbnail_source_changed",
"thumbnail_source_unavailable",
"thumbnail_temp_limit_exceeded",
"thumbnail_output_limit_exceeded",
"thumbnail_generation_unavailable",
"thumbnail_generation_failed",
"thumbnail_generation_timeout",
"thumbnail_output_invalid",
"thumbnail_task_unavailable",
"thumbnail_not_found",
"thumbnail_cleanup_failed",
"thumbnail_reconcile_unavailable",
"thumbnail_reconcile_failed",
"visual_model_not_configured",
"visual_model_migration_required",
"visual_model_identity_invalid",
"visual_model_source_not_configured",
"visual_model_source_unreadable",
"visual_weights_unreadable",
"visual_weights_checksum_mismatch",
"visual_checkpoint_format_invalid",
"visual_model_architecture_mismatch",
"visual_model_runtime_unavailable",
"visual_service_unavailable",
"visual_service_http_error",
"visual_service_invalid_response",
"visual_model_identity_mismatch",
"visual_image_decode_failed",
"visual_inference_failed",
"visual_embedding_invalid",
"visual_embedding_dimensions_mismatch",
"visual_embedding_non_finite",
"visual_embedding_zero_norm",
"visual_embedding_sha256_invalid",
"visual_embedding_sha256_mismatch",
"query_embedding_not_ready",
"visual_match_snapshot_invalid",
"visual_match_snapshot_conflict",
"visual_match_snapshot_unavailable",
"visual_candidate_materialization_failed",
"claim_expired",
"embedding_not_configured",
"embedding_dimensions_mismatch",
"embedding_non_finite",
"embedding_zero_norm",
"task_scope_invalid",
"task_scope_mismatch",
"task_scope_unavailable",
"unknown_execution",
"reverse_image_unknown_execution",
"operation_forbidden",
"operation_limit_exceeded",
"operation_policy_unavailable",
"operation_grant_invalid",
"agent_reservation_expired",
"agent_reservation_deadline_invalid",
"blocked",
"agent_provider_rate_limited",
"agent_provider_server_error",
"agent_connection_interrupted",
"session_binding_mismatch",
"session_not_resumable",
"resume_disabled",
"resume_budget_exhausted",
"opencode_workspace_invalid",
"opencode_workspace_mismatch",
"opencode_workspace_provider_missing",
"opencode_workspace_capability_invalid",
"opencode_workspace_capability_expired",
"opencode_workspace_capability_unavailable",
}
# 这四类任务由逐图图片处理 Worker 独占扫描、认领和执行;通用任务 Worker
# 只能处理其它系统任务,避免旧调度器把图片链误判为缺少 handler。
# 图片叶子任务的执行控制面和最大尝试次数由 stage plan 统一声明;旧模块继续
# re-export 这些名称,避免 API/脚本的历史 import 失效。
TaskHandler = Callable[[dict[str, Any], Callable[[float | None, str | None], None]], Any]
def now() -> str:
"""返回任务记录使用的 UTC ISO 时间字符串。"""
return datetime.now(timezone.utc).isoformat()
def _public_agent_concurrency(value: object) -> int | None:
"""清洗任务摘要中的并发值,复用公共正整数校验且不伪造截断后的值。"""
try:
return validate_agent_concurrency(value)
except ValueError:
return None
@dataclass
class TaskRecord:
"""一条可序列化任务记录,承载状态、图片来源事实和有限诊断。
图片阶段任务的来源字段由数据库控制面填充;前端只能读取这些安全摘要,不能
通过 ``payload`` 改写 Job 归属或执行阶段。
"""
task_id: str
task_type: str
submission_mode: str | None = None
image_stage: str | None = None
processing_job_id: str | None = None
target_meme_id: str | None = None
target_image_sha256: str | None = None
# 数据库任务的资源归属事实;公开 DTO 不暴露调度内部 key。
lane_resource_key: str = GLOBAL_LANE_RESOURCE_KEY
payload: dict[str, Any] = field(default_factory=dict)
# 视觉候选只以摘要出现在公开任务记录,完整 snapshot 不离开后端控制面。
visual_snapshot_sha256: str | None = None
visual_snapshot_protocol_version: int | None = None
visual_snapshot_matched_at: str | None = None
visual_snapshot_candidate_count: int | None = None
status: str = "queued"
progress: float | None = 0.0
message: str | None = None
created_at: str = field(default_factory=now)
updated_at: str = field(default_factory=now)
completed_at: str | None = None
attempts: int = 0
error: dict[str, Any] | None = None
# 续跑摘要只包含明确 session/attempt 标识和脱敏错误历史。
resume_available: bool = False
resume_reason: str | None = None
session_id: str | None = None
executor_attempt_id: str | None = None
# 公开任务摘要最多携带 opaque selector,不暴露 workspace 物理路径。
workspace_selector: str | None = None
resume_attempts: int = 0
resume_started_at: str | None = None
first_error: dict[str, Any] | None = None
error_history: list[dict[str, Any]] = field(default_factory=list)
result: Any = None
settings_version: str | None = None
agent_concurrency: int | None = None
slot_id: int | None = None
# 数据库任务服务内部使用;公共 ``as_dict`` 不暴露 scope 身份。
scope_id: str | None = None
def as_dict(self, *, include_payload: bool = False) -> dict[str, Any]:
"""返回显式公开任务 DTO;持久化调用方可单独请求内部 payload。"""
session_id = normalize_identifier(self.session_id, kind="session")
executor_attempt_id = normalize_identifier(self.executor_attempt_id, kind="attempt")
workspace_selector = self.workspace_selector if isinstance(self.workspace_selector, str) and SELECTOR_RE.fullmatch(self.workspace_selector) else None
resume_available = bool(self.resume_available and session_id and executor_attempt_id)
resume_reason = normalize_public_code(self.resume_reason)
if self.workspace_selector is not None and workspace_selector is None:
resume_available = False
resume_reason = "opencode_workspace_mismatch"
if self.resume_available and not resume_available:
# 旧磁盘记录可能携带损坏的恢复标识;公开摘要必须 fail-closed,不能
# 让客户端把一条无法绑定的 session 当成可续跑目标。
resume_reason = "session_not_resumable"
task_id = normalize_public_identifier(self.task_id)
task_type = normalize_public_code(self.task_type, fallback="task") or "task"
status = self.status if isinstance(self.status, str) and self.status in PUBLIC_TASK_STATUSES else "failed"
processing_job_id = normalize_public_identifier(self.processing_job_id)
image_stage = self.image_stage if isinstance(self.image_stage, str) and self.image_stage in {"visual", "agent", "auto_rename", "text_embedding"} else None
submission_mode = self.submission_mode if isinstance(self.submission_mode, str) and self.submission_mode in {"pipeline", "standalone"} else None
progress = self.progress if isinstance(self.progress, (int, float)) and not isinstance(self.progress, bool) and 0 <= self.progress <= 1 else None
attempts = self.attempts if isinstance(self.attempts, int) and not isinstance(self.attempts, bool) and 0 <= self.attempts <= 1_000_000 else 0
resume_attempts = self.resume_attempts if isinstance(self.resume_attempts, int) and not isinstance(self.resume_attempts, bool) and 0 <= self.resume_attempts <= 1_000_000 else 0
agent_concurrency = _public_agent_concurrency(self.agent_concurrency)
visual_snapshot = sanitize_visual_snapshot_summary(
{
"protocol_version": self.visual_snapshot_protocol_version,
"snapshot_sha256": self.visual_snapshot_sha256,
"matched_at": self.visual_snapshot_matched_at,
"candidate_count": self.visual_snapshot_candidate_count,
}
)
result = {
"task_id": task_id or "invalid-task",
"task_type": task_type,
"submission_mode": submission_mode,
"image_stage": image_stage,
"processing_job_id": processing_job_id,
"job_id": processing_job_id,
"status": status,
"progress": progress,
"message": sanitize_public_message(self.message),
"created_at": sanitize_public_timestamp(self.created_at),
"updated_at": sanitize_public_timestamp(self.updated_at),
"completed_at": sanitize_public_timestamp(self.completed_at),
"attempts": attempts,
"error": sanitize_error(self.error) if isinstance(self.error, dict) else None,
"resume_available": resume_available,
"resume_reason": resume_reason,
"session_id": session_id,
"executor_attempt_id": executor_attempt_id,
"workspace_selector": workspace_selector,
"resume_attempts": resume_attempts,
"resume_started_at": sanitize_public_timestamp(self.resume_started_at),
"first_error": sanitize_error(self.first_error) if isinstance(self.first_error, dict) else None,
"error_history": sanitize_error_history(self.error_history),
"result": sanitize_task_result(task_type, self.result),
"settings_version": normalize_public_code(self.settings_version),
"agent_concurrency": agent_concurrency,
"visual_match_snapshot": visual_snapshot,
}
# slot 是调度器内部事实,不属于任务公开契约;旧调用方仍可从内部对象读取。
if task_type == "image_upload":
result["upload"] = sanitize_task_result(task_type, self.payload)
if include_payload:
result["payload"] = self.payload
result["lane_resource_key"] = self.lane_resource_key
return result
@classmethod
def from_dict(cls, value: dict[str, Any]) -> "TaskRecord":
"""从磁盘 JSON 恢复并验证最小任务字段。"""
task_id = value.get("task_id")
task_type = value.get("task_type")
payload = value.get("payload", {})
if not isinstance(task_id, str) or not task_id or not isinstance(task_type, str) or not task_type or not isinstance(payload, dict):
raise ValueError("task_record_invalid")
session_id = normalize_identifier(value.get("session_id"), kind="session")
executor_attempt_id = normalize_identifier(value.get("executor_attempt_id"), kind="attempt")
raw_workspace_selector = value.get("workspace_selector")
workspace_selector = raw_workspace_selector if isinstance(raw_workspace_selector, str) and SELECTOR_RE.fullmatch(raw_workspace_selector) else None
stored_resume_available = bool(value.get("resume_available", False))
resume_reason = value.get("resume_reason") if isinstance(value.get("resume_reason"), str) else None
if raw_workspace_selector is not None and workspace_selector is None:
# 保留损坏 selector 的事实,避免清洗成 None 后把旧 session 误报为可恢复。
stored_resume_available = False
resume_reason = "opencode_workspace_mismatch"
if stored_resume_available and not (session_id and executor_attempt_id):
resume_reason = "session_not_resumable"
raw_status = value.get("status", "queued")
safe_status = raw_status if isinstance(raw_status, str) and raw_status in PUBLIC_TASK_STATUSES else "failed"
raw_submission_mode = value.get("submission_mode")
submission_mode = raw_submission_mode if isinstance(raw_submission_mode, str) and raw_submission_mode in {"pipeline", "standalone"} else None
raw_image_stage = value.get("image_stage")
image_stage = raw_image_stage if isinstance(raw_image_stage, str) and raw_image_stage in {"visual", "agent", "auto_rename", "text_embedding"} else None
raw_processing_job_id = value.get("processing_job_id")
if not isinstance(raw_processing_job_id, str) or not raw_processing_job_id:
raw_processing_job_id = value.get("job_id")
processing_job_id = normalize_public_identifier(raw_processing_job_id)
raw_attempts = value.get("attempts", 0)
attempts = raw_attempts if isinstance(raw_attempts, int) and not isinstance(raw_attempts, bool) and 0 <= raw_attempts <= 1_000_000 else 0
raw_resume_attempts = value.get("resume_attempts", 0)
resume_attempts = raw_resume_attempts if isinstance(raw_resume_attempts, int) and not isinstance(raw_resume_attempts, bool) and 0 <= raw_resume_attempts <= 1_000_000 else 0
agent_concurrency = _public_agent_concurrency(value.get("agent_concurrency"))
snapshot = sanitize_visual_snapshot_summary(value.get("visual_match_snapshot"))
snapshot_sha256 = snapshot.get("snapshot_sha256") if snapshot is not None else None
snapshot_version = snapshot.get("protocol_version") if snapshot is not None else None
snapshot_matched_at = snapshot.get("matched_at") if snapshot is not None else None
snapshot_count = snapshot.get("candidate_count") if snapshot is not None else None
return cls(
task_id=task_id,
task_type=task_type,
submission_mode=submission_mode,
image_stage=image_stage,
processing_job_id=processing_job_id,
lane_resource_key=value.get("lane_resource_key") if isinstance(value.get("lane_resource_key"), str) and value.get("lane_resource_key") else GLOBAL_LANE_RESOURCE_KEY,
payload=payload,
visual_snapshot_sha256=snapshot_sha256 if isinstance(snapshot_sha256, str) and len(snapshot_sha256) == 64 else None,
visual_snapshot_protocol_version=snapshot_version if isinstance(snapshot_version, int) and not isinstance(snapshot_version, bool) else None,
visual_snapshot_matched_at=snapshot_matched_at if isinstance(snapshot_matched_at, str) else None,
visual_snapshot_candidate_count=snapshot_count if isinstance(snapshot_count, int) and not isinstance(snapshot_count, bool) else None,
status=safe_status,
progress=value.get("progress") if isinstance(value.get("progress"), (int, float)) and not isinstance(value.get("progress"), bool) and 0 <= value.get("progress") <= 1 else None,
message=sanitize_public_message(value.get("message")),
created_at=sanitize_public_timestamp(value.get("created_at")) or now(),
updated_at=sanitize_public_timestamp(value.get("updated_at")) or sanitize_public_timestamp(value.get("created_at")) or now(),
completed_at=sanitize_public_timestamp(value.get("completed_at")),
attempts=attempts,
error=sanitize_error(value.get("error")) if isinstance(value.get("error"), dict) else None,
resume_available=bool(stored_resume_available and session_id and executor_attempt_id),
resume_reason=resume_reason,
session_id=session_id,
executor_attempt_id=executor_attempt_id,
workspace_selector=workspace_selector,
resume_attempts=resume_attempts,
resume_started_at=sanitize_public_timestamp(value.get("resume_started_at")),
first_error=sanitize_error(value.get("first_error")) if isinstance(value.get("first_error"), dict) else None,
error_history=sanitize_error_history(value.get("error_history", [])),
result=sanitize_task_result(task_type, value.get("result")),
settings_version=normalize_public_code(value.get("settings_version")),
agent_concurrency=agent_concurrency,
slot_id=value.get("slot_id"),
scope_id=value.get("scope_id") if isinstance(value.get("scope_id"), str) else None,
)
class PersistentTaskService:
"""统一的任务存储与执行器。
`task_root` 保存任务 JSON;`handlers` 由应用在启动期注册,避免把 Python closure
写进任务文件。所有更新在锁内持久化,保证并发读者只会看到完整记录。该服务
仅是单 scope 的本地兼容实现,不宣称提供 PostgreSQL 跨进程公平保证。
"""
def __init__(self, task_root: Path, max_workers: int = 2, *, agent_concurrency: int = 1, agent_backpressure: int | None = None, settings_version: str | None = None):
"""创建持久任务服务,并为 Agent 任务建立独立执行 lane。
``agent_backpressure`` 仅为旧调用方保留,不参与 Agent 运行槽位或队列判定。
"""
del agent_backpressure
self.task_root = task_root.expanduser()
self.task_root.mkdir(parents=True, exist_ok=True)
self._records: dict[str, TaskRecord] = {}
self._handlers: dict[str, TaskHandler] = {}
self._lock = Lock()
self._executor = ThreadPoolExecutor(max_workers=max_workers, thread_name_prefix="mememeow-task")
self.agent_concurrency = validate_agent_concurrency(agent_concurrency)
self.settings_version = settings_version
self._agent_executor = ThreadPoolExecutor(max_workers=self.agent_concurrency, thread_name_prefix="mememeow-agent")
self._agent_task_types = {"meme_context_generation"}
self._batch_finalizer: Callable[[str], Any] | None = None
self._finalized_batches: set[str] = set()
self._started = False
self._stopped = False
self._load_records()
self.fairness_mode = "single_scope_process_compat"
@property
def supports_cross_scope_fairness(self) -> bool:
"""明确本地 JSON 服务不支持跨进程、跨 scope 的公平 claim。"""
return False
def register(self, task_type: str, handler: TaskHandler) -> None:
"""注册能从 payload 重建任务的处理器,必须在 start 前完成。"""
if not task_type:
raise ValueError("task_type_required")
self._handlers[task_type] = handler
def set_batch_finalizer(self, callback: Callable[[str], Any] | None) -> None:
"""注册批次终态回调,供 API 在所有语境任务结束后合并生成一次缓存。"""
self._batch_finalizer = callback
def _path(self, task_id: str) -> Path:
"""返回任务 JSON 的受控存储位置。"""
return self.task_root / f"{task_id}.json"
def _write(self, record: TaskRecord) -> None:
"""以 fsync 加原子替换保存单条任务,防止半写入记录。"""
target = self._path(record.task_id)
temporary = target.with_name(f".{target.name}.tmp.{os.getpid()}.{id(self)}")
payload = json.dumps(record.as_dict(include_payload=True), ensure_ascii=False, separators=(",", ":"))
try:
with temporary.open("w", encoding="utf-8") as handle:
handle.write(payload)
os.fchmod(handle.fileno(), 0o600)
handle.flush()
os.fsync(handle.fileno())
os.replace(temporary, target)
try:
directory_fd = os.open(self.task_root, os.O_RDONLY)
try:
os.fsync(directory_fd)
finally:
os.close(directory_fd)
except OSError:
# 某些文件系统不支持目录 fsync;文件替换仍是可恢复的。
pass
except OSError:
temporary.unlink(missing_ok=True)
raise
def _load_records(self) -> None:
"""恢复任务文件并隔离损坏记录,避免单个文件阻断服务启动。"""
for path in sorted(self.task_root.glob("*.json")):
try:
record = TaskRecord.from_dict(json.loads(path.read_text(encoding="utf-8")))
if self._path(record.task_id) != path:
raise ValueError("task_filename_mismatch")
except (OSError, ValueError, TypeError, json.JSONDecodeError):
corrupt = path.with_suffix(".corrupt")
index = 1
while corrupt.exists():
corrupt = path.with_suffix(f".corrupt.{index}")
index += 1
try:
shutil.move(path, corrupt)
except OSError:
pass
continue
self._records[record.task_id] = record
@staticmethod
def _payload_key(payload: dict[str, Any]) -> str:
"""将 payload 规范化为活动任务去重键。"""
# 批次标识和并发提交 nonce 只用于协调,不应让同一图片阶段重复执行。
comparable = {
key: value
for key, value in payload.items()
if key not in {"batch_id", "batch_ids", "standalone_submission_nonce"}
}
return json.dumps(comparable, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
@staticmethod
def _batch_ids(record: TaskRecord) -> tuple[str, ...]:
"""读取任务关联的批次标识,兼容单值和去重合并后的列表格式。"""
values: list[str] = []
single = record.payload.get("batch_id")
if isinstance(single, str) and single:
values.append(single)
multiple = record.payload.get("batch_ids")
if isinstance(multiple, list):
values.extend(value for value in multiple if isinstance(value, str) and value)
return tuple(dict.fromkeys(values))
def start(self) -> None:
"""恢复排队任务,并把无法证明存活的旧运行任务标记为中断。"""
with self._lock:
if self._started:
return
self._started = True
queued: list[str] = []
for record in self._records.values():
if record.status == "running":
record.status = "failed"
record.message = "服务重启中断了任务"
record.error = {"error": "task_interrupted", "message": "服务重启导致执行状态无法确认"}
record.completed_at = now()
record.updated_at = record.completed_at
self._write(record)
elif record.status == "queued":
queued.append(record.task_id)
for task_id in sorted(queued, key=lambda value: (self._records[value].created_at, value)):
self._schedule(task_id)
def submit(self, task_type: str, payload: dict[str, Any] | None = None, *, schedule: bool = True) -> TaskRecord:
"""持久化新任务并按需调度;相同类型和规范化 payload 的活动任务会被复用。"""
payload = dict(payload or {})
key = self._payload_key(payload)
with self._lock:
if self._stopped:
raise RuntimeError("task_service_stopped")
for record in self._records.values():
same_context_target = (
task_type == "meme_context_generation"
and record.task_type == task_type
and isinstance(payload.get("image_relative_path"), str)
and isinstance(payload.get("image_sha256"), str)
and record.payload.get("image_relative_path") == payload.get("image_relative_path")
and record.payload.get("image_sha256") == payload.get("image_sha256")
)
if record.task_type == task_type and record.status not in TERMINAL and (same_context_target or self._payload_key(record.payload) == key):
if same_context_target and isinstance(payload.get("batch_id"), str) and payload["batch_id"]:
# 同一图片被不同批次复用时保留全部关联,确保每个批次都能触发终态缓存合并。
batch_ids = list(self._batch_ids(record))
if payload["batch_id"] not in batch_ids:
batch_ids.append(payload["batch_id"])
record.payload["batch_ids"] = batch_ids
self._write(record)
return TaskRecord.from_dict(record.as_dict(include_payload=True))
record = TaskRecord(
task_id=uuid4().hex,
task_type=task_type,
payload=payload,
message="等待 Agent 运行槽位" if task_type in self._agent_task_types and self._agent_load_locked() >= self.agent_concurrency else None,
settings_version=str(payload.get("settings_version") or self.settings_version) if task_type == "meme_context_generation" else self.settings_version,
agent_concurrency=self.agent_concurrency if task_type == "meme_context_generation" else None,
)
self._records[record.task_id] = record
self._write(record)
if schedule and self._started:
self._schedule(record.task_id)
return TaskRecord.from_dict(record.as_dict(include_payload=True))
def schedule(self, task_id: str) -> None:
"""显式唤醒一个已持久化任务,与 PostgreSQL 任务服务保持相同入口。"""
self._schedule(task_id)
def _schedule(self, task_id: str) -> None:
"""把已持久化的 queued 任务交给线程池,不重复提交运行记录。"""
with self._lock:
record = self._records.get(task_id)
if not record or record.status != "queued" or self._stopped:
return
executor = self._agent_executor if record.task_type in self._agent_task_types else self._executor
executor.submit(self._run, task_id)
def _queued_agent_count_locked(self) -> int:
"""统计当前尚未启动的 Agent 任务数量,调用方必须持有任务锁。"""
return sum(1 for item in self._records.values() if item.task_type in self._agent_task_types and item.status == "queued")
def _agent_load_locked(self) -> int:
"""统计 Agent lane 中排队或运行的任务数量,调用方必须持有任务锁。"""
return sum(1 for item in self._records.values() if item.task_type in self._agent_task_types and item.status not in TERMINAL)
def _maybe_finalize_batch(self, record: TaskRecord) -> None:
"""在批次所有语境任务进入终态后调用一次缓存合并回调。"""
batch_ids = self._batch_ids(record)
if record.task_type not in self._agent_task_types or not batch_ids:
return
callbacks: list[tuple[str, Callable[[str], Any]]] = []
with self._lock:
callback = self._batch_finalizer
if callback is None:
return
for batch_id in batch_ids:
if batch_id in self._finalized_batches:
continue
active = any(
item.task_type in self._agent_task_types
and batch_id in self._batch_ids(item)
and item.status not in TERMINAL
for item in self._records.values()
)
if active:
continue
self._finalized_batches.add(batch_id)
callbacks.append((batch_id, callback))
for batch_id, callback in callbacks:
try:
callback(batch_id)
except Exception:
# 缓存失败由其独立任务记录承载,不改变已完成的语境任务。
pass
def _run(self, task_id: str) -> None:
"""执行已注册 handler,并将全部状态转换持久化。"""
with self._lock:
record = self._records.get(task_id)
if not record or record.status != "queued" or self._stopped:
return
record.status = "running"
record.attempts += 1
record.message = "任务开始"
record.updated_at = now()
self._write(record)
handler = self._handlers.get(record.task_type)
if handler is None:
self.update(task_id, status="failed", message="任务处理器不可用", error={"error": "task_handler_missing", "message": "当前服务未注册此任务类型"})
return
def progress(value: float | None, message: str | None = None) -> None:
self.update(task_id, progress=value, message=message)
try:
result = handler(dict(record.payload), progress)
except Exception as exc: # noqa: BLE001
diagnostic = str(exc)[:500]
code = diagnostic.partition(":")[0]
if code not in STABLE_TASK_ERRORS:
code = "task_failed"
self.update(task_id, status="failed", message="任务执行失败", error={"error": code, "message": diagnostic})
else:
self.update(task_id, status="succeeded", progress=1.0, message="任务完成", result=result)
finally:
latest = self.get(task_id)
if latest:
self._maybe_finalize_batch(latest)
def update(self, task_id: str, **changes: Any) -> None:
"""更新任务状态或进度,终态记录不可被后续线程覆写。"""
with self._lock:
record = self._records.get(task_id)
if not record or record.status in TERMINAL:
return
for key, value in changes.items():
if hasattr(record, key):
setattr(record, key, value)
record.updated_at = now()
if record.status in TERMINAL:
record.completed_at = record.updated_at
self._write(record)
def get(self, task_id: str) -> TaskRecord | None:
"""返回单条任务的独立快照。"""
with self._lock:
record = self._records.get(task_id)
return TaskRecord.from_dict(record.as_dict(include_payload=True)) if record else None
def find_active(self, task_type: str, dedupe_key: str) -> TaskRecord | None:
"""按兼容任务服务的规范化 payload 查找活动任务。
本地 JSON 服务没有数据库 dedupe 列;该方法只为图片 Worker 的统一查询
协议保留,PostgreSQL 服务使用持久 dedupe_key 实现更严格的判断。
"""
if not isinstance(task_type, str) or not task_type or not isinstance(dedupe_key, str) or not dedupe_key:
return None
def comparable(record: TaskRecord) -> str:
"""按 PostgreSQL facade 的规则重建兼容服务去重键。"""
payload = record.payload
if task_type == "meme_context_generation":
return "context:{meme}:{sha}:{config}:{policy}:r{revision}".format(
meme=payload.get("meme_id"),
sha=payload.get("image_sha256"),
config=payload.get("processing_config_hash") or payload.get("skill_hash") or payload.get("model"),
policy=payload.get("reverse_image_policy") or "forbid",
revision=payload.get("job_revision") or "legacy",
)
return f"{task_type}:{json.dumps(payload, ensure_ascii=False, sort_keys=True, separators=(',', ':'))}"
with self._lock:
records = [record for record in self._records.values() if record.task_type == task_type and record.status not in TERMINAL and comparable(record) == dedupe_key]
if not records:
return None
record = min(records, key=lambda item: (item.created_at, item.task_id))
return TaskRecord.from_dict(record.as_dict(include_payload=True))
def list(self, *, statuses: set[str] | None = None, task_types: set[str] | None = None, cursor: str | None = None, limit: int = 50) -> tuple[list[TaskRecord], str | None]:
"""按创建时间和 ID 稳定倒序分页,返回受调用方控制的记录快照。"""
with self._lock:
records = list(self._records.values())
if statuses:
records = [record for record in records if record.status in statuses]
if task_types:
records = [record for record in records if record.task_type in task_types]
records.sort(key=lambda record: (record.created_at, record.task_id), reverse=True)
if cursor:
try:
position = next(index for index, record in enumerate(records) if record.task_id == cursor) + 1
except StopIteration:
position = 0
records = records[position:]
page = records[: max(1, min(limit, 100))]
next_cursor = page[-1].task_id if len(records) > len(page) else None
return [TaskRecord.from_dict(record.as_dict(include_payload=True)) for record in page], next_cursor
def shutdown(self) -> None:
"""收束未完成记录;子进程由相应 handler 的服务负责终止。"""
with self._lock:
self._stopped = True
for record in self._records.values():
if record.status not in TERMINAL:
record.status = "failed"
record.message = "服务关闭中断了任务"
record.error = {"error": "task_interrupted", "message": "服务关闭导致任务中断"}
record.completed_at = now()
record.updated_at = record.completed_at
self._write(record)
self._executor.shutdown(wait=False, cancel_futures=True)
self._agent_executor.shutdown(wait=False, cancel_futures=True)
class TaskManager:
"""兼容旧单元测试的进程内任务管理器,应用不再使用该类。"""
def __init__(self, max_workers: int = 2):
self._records: dict[str, TaskRecord] = {}
self._active: dict[str, str] = {}
self._lock = Lock()
self._executor = ThreadPoolExecutor(max_workers=max_workers, thread_name_prefix="mememeow-legacy-task")
def submit(self, task_type: str, fn: Callable[[Callable[[float | None, str | None], None]], Any]) -> TaskRecord:
"""提交旧式 closure 任务,仅为兼容外部直接调用保留。"""
with self._lock:
active = self._active.get(task_type)
if active and self._records[active].status not in TERMINAL:
return self._records[active]
record = TaskRecord(task_id=uuid4().hex, task_type=task_type)
self._records[record.task_id] = record
self._active[task_type] = record.task_id
self._executor.submit(self._run, record.task_id, fn)
return record
def _run(self, task_id: str, fn: Callable[[Callable[[float | None, str | None], None]], Any]) -> None:
"""执行兼容任务并持有原有任务状态机语义。"""
self.update(task_id, status="running", message="任务开始")
try:
result = fn(lambda value, message=None: self.update(task_id, progress=value, message=message))
except Exception as exc: # noqa: BLE001
self.update(task_id, status="failed", message="任务执行失败", error={"error": "task_failed", "message": str(exc)})
else:
self.update(task_id, status="succeeded", progress=1.0, message="任务完成", result=result)
def update(self, task_id: str, **changes: Any) -> None:
"""更新兼容任务记录。"""
with self._lock:
record = self._records.get(task_id)
if not record or record.status in TERMINAL:
return
for key, value in changes.items():
if hasattr(record, key):
setattr(record, key, value)
record.updated_at = now()
if record.status in TERMINAL:
record.completed_at = record.updated_at
def get(self, task_id: str) -> TaskRecord | None:
"""读取兼容任务快照。"""
with self._lock:
record = self._records.get(task_id)
return TaskRecord.from_dict(record.as_dict(include_payload=True)) if record else None
def shutdown(self) -> None:
"""结束兼容执行器。"""
with self._lock:
for record in self._records.values():
if record.status not in TERMINAL:
record.status = "failed"
record.error = {"error": "task_not_recoverable", "message": "服务重启导致任务失败"}
record.completed_at = now()
self._executor.shutdown(wait=False, cancel_futures=True)