"""DSH content-production worker. The workbench API owns configuration, validation, queuing, human review and delivery. This process leases ``campaign-*`` jobs from the shared SQLite queue and executes the existing content generation pipeline. Run: YAWAN_EXECUTION_BACKEND=dsh python -m app.dsh_worker The worker must share the same YAWAN_DATA_DIR as the API process (SQLite, knowledge bases, vector indexes and artifacts directory). """ from __future__ import annotations import logging import os import signal import socket import threading import time import uuid from sqlmodel import Session, select from .agents import runner from . import campaign_jobs from . import planner from .db import engine, init_db from .models import ( Campaign, CampaignRun, AgentSession, Notification, RunEvent, Segment, TaskQueueItem, now, ) from . import progress from . import task_queue logger = logging.getLogger("yawen.dsh") STOP = threading.Event() WORKER_ID = os.getenv("YAWAN_DSH_WORKER_ID") or f"dsh-{socket.gethostname()}-{os.getpid()}-{uuid.uuid4().hex[:6]}" POLL_SECONDS = float(os.getenv("YAWAN_DSH_POLL_SECONDS", "2.0")) HEARTBEAT_SECONDS = int(os.getenv("YAWAN_DSH_HEARTBEAT_SECONDS", "60")) def _record_event(session: Session, run: CampaignRun, event_type: str, status: str = "", payload: dict | None = None) -> None: session.add(RunEvent( run_id=run.id, campaign_id=run.campaign_id, trace_id=run.trace_id, event_type=event_type, status=status, payload=payload or {}, )) def _set_campaign_review(session: Session, campaign_id: int, has_failure: bool | None = None) -> None: campaign = session.get(Campaign, campaign_id) if not campaign or campaign.status == "done": return segments = session.exec(select(Segment).where(Segment.campaign_id == campaign_id)).all() campaign.has_failure = has_failure if has_failure is not None else any(x.status == "failed" for x in segments) if all(x.status in ("approved", "skipped") for x in segments): campaign.status = "composing_ready" else: campaign.status = "human_review" session.add(campaign) def _recover_orphan_run(session: Session, run: CampaignRun, payload: dict) -> None: """Reset stale runtime state before the leased worker owns the run. A lease is only stolen after heartbeat timeout, so any running session/segment here belongs to a dead worker. The same applies to an immediate in-process retry: the previous job thread is already gone. """ for runtime_session in session.exec(select(AgentSession).where( AgentSession.run_id == run.id, AgentSession.status == "running")).all(): runtime_session.status = "interrupted" runtime_session.updated_at = now() session.add(runtime_session) segment_id = int(payload.get("segment_id") or 0) if segment_id: seg = session.get(Segment, segment_id) if seg and seg.campaign_id == run.campaign_id and seg.status == "running": seg.status = "pending" session.add(seg) else: # 全量任务的上一次尝试可能已把 run 置为 failed;只要队列重新租约, # 所有遗留 running 段都属于上一个已结束 worker,应恢复为可执行状态。 for seg in session.exec(select(Segment).where( Segment.campaign_id == run.campaign_id, Segment.status == "running")).all(): seg.status = "pending" session.add(seg) session.commit() def _run_full(session: Session, run: CampaignRun) -> str: campaign = session.get(Campaign, run.campaign_id) if run.cancel_requested: run.status = "cancelled" run.finished_at = now() if campaign: campaign.status = "human_review" session.add(campaign) session.add(run) _record_event(session, run, "run_finished", "cancelled", {"reason": "cancel_before_start"}) session.commit() return run.status if run.pause_requested: run.status = "paused" if campaign: campaign.status = "human_review" session.add(campaign) session.add(run) _record_event(session, run, "run_finished", "paused", {"reason": "pause_before_start"}) session.commit() return run.status run.status = "running" run.started_at = run.started_at or now() run.trace_id = run.trace_id or uuid.uuid4().hex run.checkpoint = {**(run.checkpoint or {}), "phase": "running", "worker_id": WORKER_ID} session.add(run) _record_event(session, run, "run_started", "running", {"worker_id": WORKER_ID}) session.commit() result = runner.run_campaign(session, run.campaign_id, run_id=run.id) session.refresh(run) campaign = session.get(Campaign, run.campaign_id) if run.cancel_requested: run.status = "cancelled" run.finished_at = now() if campaign: campaign.status = "human_review" elif str(result.get("status")) == "paused" or run.pause_requested: run.status = "paused" elif str(result.get("status")) in ("failed", "error") or (campaign and campaign.has_failure): run.status = "failed" run.error = str(result.get("error") or "")[:500] run.finished_at = now() else: run.status = "awaiting_review" run.finished_at = now() run.checkpoint = { **(run.checkpoint or {}), "phase": "finished", "campaign_status": result.get("status"), "worker_id": WORKER_ID, "results": result.get("results", [])[-100:], } session.add(run) if campaign: session.add(campaign) _record_event(session, run, "run_finished", run.status, {"campaign_status": result.get("status")}) session.commit() return run.status def _run_step(session: Session, run: CampaignRun, segment_id: int) -> str: campaign = session.get(Campaign, run.campaign_id) if run.cancel_requested: run.status = "cancelled" run.finished_at = now() if campaign: campaign.status = "human_review" session.add(campaign) session.add(run) _record_event(session, run, "run_finished", "cancelled", {"reason": "cancel_before_start"}) session.commit() return run.status if run.pause_requested: run.status = "paused" if campaign: campaign.status = "human_review" session.add(campaign) session.add(run) _record_event(session, run, "run_finished", "paused", {"reason": "pause_before_start"}) session.commit() return run.status segment = session.get(Segment, segment_id) if not campaign or not segment or segment.campaign_id != campaign.id: raise ValueError(f"逐段任务无效:run={run.id}, segment={segment_id}") if segment.status not in ("pending", "rework", "failed"): run.status = "completed" run.error = "" run.finished_at = now() run.checkpoint = {**(run.checkpoint or {}), "phase": "skipped", "reason": segment.status} session.add(run) session.commit() return run.status run.status = "running" run.started_at = run.started_at or now() run.trace_id = run.trace_id or uuid.uuid4().hex run.checkpoint = {**(run.checkpoint or {}), "phase": "running", "mode": "step", "segment_ids": [segment.id], "worker_id": WORKER_ID} session.add(run) _record_event(session, run, "run_started", "running", {"mode": "step", "segment_id": segment.id}) session.commit() progress.publish(int(campaign.owner_user_id or 0), "run_step_start", campaign_id=campaign.id, segment_id=segment.id, title=segment.title, backend="dsh") try: result = runner.execute_segment(session, segment, campaign, run_id=run.id) if runner._supervisor_gate(session, segment, campaign, run.id) == "redo": segment = session.get(Segment, segment.id) result = runner.execute_segment(session, segment, campaign, run_id=run.id) status = str(result.get("status") or "failed") run.checkpoint = {**(run.checkpoint or {}), "last_result": status} if status == "waiting_approval": run.status = "awaiting_review" elif status in ("human_review", "rework", "approved"): run.status = "completed" run.finished_at = now() else: run.status = "failed" run.error = str(result.get("error") or "")[:500] run.finished_at = now() _set_campaign_review(session, campaign.id) session.add(run) _record_event(session, run, "run_finished", run.status, {"segment_status": status}) session.commit() return run.status except Exception as exc: session.rollback() segment = session.get(Segment, segment_id) run = session.get(CampaignRun, run.id) campaign = session.get(Campaign, run.campaign_id) if segment: segment.status = "failed" segment.failure = {"type": "dsh_step_failed", "detail": str(exc)[:300]} session.add(segment) run.status = "failed" run.error = str(exc)[:500] run.finished_at = now() if campaign: campaign.status = "human_review" session.add(campaign) session.add(run) session.commit() raise def _segment_redo(session: Session, payload: dict) -> None: segment_id = int(payload.get("segment_id") or 0) opinion = str(payload.get("opinion") or "") expected_agent = int(payload.get("expected_agent_id") or 0) expected_round = int(payload.get("expected_round") or 0) seg = session.get(Segment, segment_id) campaign = session.get(Campaign, seg.campaign_id) if seg else None if not seg or not campaign: raise ValueError("重做任务对应的内容段不存在") if seg.status == "running": # 队列租约过期后才可能重新领取到该确定性任务;旧 Worker 已被视为死亡。 seg.status = "rework" session.add(seg) session.commit() if seg.status not in ("rework", "failed"): return if expected_agent and int(seg.agent_id or 0) != expected_agent: return if expected_round and int(seg.round or 0) != expected_round: return seg.status = "running" session.add(seg) session.commit() progress.publish(int(campaign.owner_user_id or 0), "redo_start", campaign_id=campaign.id, segment_id=seg.id, round=seg.round, title=seg.title, reason="user", backend="dsh") try: runner.execute_segment(session, seg, campaign, auto_retry_qc=False, qc_issues=opinion) seg2 = session.get(Segment, segment_id) progress.publish(int(campaign.owner_user_id or 0), "redo_done", campaign_id=campaign.id, segment_id=segment_id, status=(seg2.status if seg2 else "?"), round=(seg2.round if seg2 else 0), title=(seg2.title if seg2 else "")) _set_campaign_review(session, campaign.id) session.commit() except Exception as exc: session.rollback() seg = session.get(Segment, segment_id) campaign = session.get(Campaign, campaign.id) if seg: seg.status = "failed" seg.failure = {"type": "dsh_redo_failed", "detail": str(exc)[:300]} session.add(seg) if campaign: campaign.status = "human_review" campaign.has_failure = True session.add(campaign) session.commit() raise def _segment_fix(session: Session, payload: dict) -> None: segment_id = int(payload.get("segment_id") or 0) seg = session.get(Segment, segment_id) if seg and seg.status in ("skipped", "approved"): # skip/disable 的管理动作已在 API 请求中完成;approved 说明人工已接管,旧任务作废。 return if seg and seg.status == "running": # 上一执行进程租约超时,遗留 running 段恢复为 pending 后重试。 seg.status = "pending" seg.failure = {} session.add(seg) session.commit() if seg and seg.status not in ("pending", "failed"): return fix = payload.get("fix") if isinstance(payload.get("fix"), dict) else {} try: result = runner.fix_segment(session, segment_id, fix, execute=True, apply_changes=False) if result.get("status") == "error": raise ValueError(str(result.get("error") or "修复执行失败")) seg = session.get(Segment, segment_id) if seg: _set_campaign_review(session, seg.campaign_id) session.commit() except Exception as exc: session.rollback() seg = session.get(Segment, segment_id) if seg: campaign = session.get(Campaign, seg.campaign_id) seg.status = "failed" seg.failure = {"type": "dsh_fix_failed", "detail": str(exc)[:300]} session.add(seg) if campaign: campaign.status = "human_review" campaign.has_failure = True session.add(campaign) session.commit() raise def _campaign_parse(session: Session, payload: dict) -> None: campaign_id = int(payload.get("campaign_id") or 0) campaign_jobs.run_parse_job(session, campaign_id, str(payload.get("advice") or "")) def _campaign_plan(session: Session, payload: dict) -> None: campaign_id = int(payload.get("campaign_id") or 0) template_id = int(payload.get("template_id") or 0) result = planner.run_plan_job( session, campaign_id, template_id=template_id, advice=str(payload.get("advice") or ""), created_by=int(payload.get("created_by") or 0), ) if result.get("status") == "failed": logger.warning("DSH plan business failure: %s", str(result.get("error"))[:200]) def _campaign_compose(session: Session, payload: dict) -> None: campaign_id = int(payload.get("campaign_id") or 0) result = campaign_jobs.run_compose_job(session, campaign_id) if result.get("status") == "error": # 与 inline 同步接口一致:业务校验失败不做队列重试,工作台保留错误并允许用户重试点。 logger.warning("DSH compose business failure: %s", str(result.get("error"))[:200]) def process_job(job: TaskQueueItem) -> None: with Session(engine) as session: payload = dict(job.payload or {}) key = job.task_key or "" logger.info("DSH lease job %s: %s", job.id, key) if key.startswith("campaign-run:"): run_id = int(payload.get("run_id") or key.rsplit(":", 1)[-1]) run = session.get(CampaignRun, run_id) if not run: raise ValueError(f"运行不存在:{key}") _recover_orphan_run(session, run, payload) session.refresh(run) try: if run.status in ("completed", "cancelled"): return if payload.get("mode") == "step" or payload.get("segment_id"): _run_step(session, run, int(payload.get("segment_id") or 0)) else: _run_full(session, run) finally: # 预算池是进程内对象;DSH 每次执行结束都必须释放,防止长驻 Worker 内存泄漏。 from .agents.runtime import release_run_budget release_run_budget(run_id) return if key.startswith("campaign-segment-redo:"): _segment_redo(session, payload) return if key.startswith("campaign-segment-fix:"): _segment_fix(session, payload) return if key.startswith("campaign-plan:"): _campaign_plan(session, payload) return if key.startswith("campaign-parse:"): _campaign_parse(session, payload) return if key.startswith("campaign-compose:"): _campaign_compose(session, payload) return raise ValueError(f"DSH 不识别的内容生产任务:{key}") def _mark_dead_job_state(session: Session, job: TaskQueueItem, error: str) -> None: """Keep business state visible when all queue retry attempts are exhausted.""" payload = dict(job.payload or {}) key = job.task_key or "" campaign_id = int(payload.get("campaign_id") or 0) run_id = int(payload.get("run_id") or 0) detail = str(error)[:300] if key.startswith("campaign-run:") or run_id: run = session.get(CampaignRun, run_id) if run_id else None if run: campaign_id = campaign_id or run.campaign_id run.status = "failed" run.error = detail run.finished_at = now() session.add(run) elif key.startswith("campaign-plan:") or key.startswith("campaign-parse:"): campaign = session.get(Campaign, campaign_id) if campaign: campaign.status = "plan_failed" if key.startswith("campaign-plan:") else "parse_failed" notes = list(campaign.notes or []) label = "规划" if key.startswith("campaign-plan:") else "拆解" notes.append(f"DSH {label}任务进入死信队列:{detail[:180]}") campaign.notes = notes[-20:] session.add(campaign) elif key.startswith("campaign-compose:"): campaign = session.get(Campaign, campaign_id) if campaign and campaign.status != "done": campaign.status = "composing_ready" session.add(campaign) segment_id = int(payload.get("segment_id") or 0) if segment_id: seg = session.get(Segment, segment_id) if seg and seg.status == "running": seg.status = "failed" seg.failure = {"type": "dsh_job_dead", "detail": detail} session.add(seg) campaign_id = campaign_id or seg.campaign_id campaign = session.get(Campaign, campaign_id) if campaign_id else None if campaign and campaign.status not in ("done", "parse_failed", "plan_failed", "composing_ready"): campaign.status = "human_review" campaign.has_failure = True session.add(campaign) session.commit() def _heartbeat(job_id: int) -> threading.Thread: def loop(): while not STOP.wait(HEARTBEAT_SECONDS): try: with Session(engine) as session: ok = task_queue.heartbeat(session, TaskQueueItem, job_id, WORKER_ID) if not ok: return except Exception: logger.exception("DSH heartbeat failed: job=%s", job_id) thread = threading.Thread(target=loop, name=f"dsh-heartbeat-{job_id}", daemon=True) thread.start() return thread def worker_loop() -> None: init_db() logger.info("DSH worker started: %s (poll=%ss, heartbeat=%ss)", WORKER_ID, POLL_SECONDS, HEARTBEAT_SECONDS) while not STOP.is_set(): job = None try: with Session(engine) as session: job = task_queue.lease_next(session, TaskQueueItem, WORKER_ID, task_key_prefix="campaign-%") if job is None: STOP.wait(POLL_SECONDS) continue beat = _heartbeat(job.id) process_job(job) beat.join(timeout=1) with Session(engine) as session: task_queue.complete(session, TaskQueueItem, job.id, WORKER_ID) logger.info("DSH job completed: %s", job.id) except Exception as exc: logger.exception("DSH job failed: %s", getattr(job, "id", "?")) try: with Session(engine) as session: if job is not None: status = task_queue.fail(session, TaskQueueItem, job.id, WORKER_ID, str(exc)) if status == "dead": _mark_dead_job_state(session, job, str(exc)) payload = dict(job.payload or {}) campaign_id = int(payload.get("campaign_id") or 0) if not campaign_id and payload.get("run_id"): run = session.get(CampaignRun, int(payload["run_id"])) campaign_id = run.campaign_id if run else 0 if campaign_id: session.add(Notification( type="failure", ref_id=campaign_id, message=f"DSH 内容生产任务进入死信队列:{str(exc)[:160]}", )) session.commit() except Exception: logger.exception("Failed to mark DSH job failed: %s", getattr(job, "id", "?")) STOP.wait(min(30.0, POLL_SECONDS * 3)) logger.info("DSH worker stopped: %s", WORKER_ID) def _signal_handler(signum, frame): # pragma: no cover logger.info("DSH received signal %s, stopping...", signum) STOP.set() def main() -> None: logging.basicConfig( level=os.getenv("YAWAN_DSH_LOG_LEVEL", "INFO").upper(), format="%(asctime)s %(levelname)s %(name)s: %(message)s", ) if hasattr(signal, "SIGTERM"): signal.signal(signal.SIGTERM, _signal_handler) signal.signal(signal.SIGINT, _signal_handler) worker_loop() if __name__ == "__main__": main()