Просмотр исходного кода

feat: 重构定时任务为持久化流水线

xueyiming 4 дней назад
Родитель
Сommit
c21b5e69c9
87 измененных файлов с 5003 добавлено и 703 удалено
  1. BIN
      .DS_Store
  2. 2 0
      .dockerignore
  3. 30 4
      .env.example
  4. 14 2
      .gitignore
  5. 1 1
      ARCHITECTURE.md
  6. 16 4
      Dockerfile
  7. 3 3
      PRD.md
  8. 39 0
      alembic.ini
  9. 58 0
      alembic/env.py
  10. 24 0
      alembic/script.py.mako
  11. 219 0
      alembic/versions/20260727_01_pipeline_control_plane.py
  12. 49 0
      alembic/versions/20260727_02_nullable_manual_deadline.py
  13. 29 8
      api/app.py
  14. 1 0
      api/routers/__init__.py
  15. 89 0
      api/routers/pipeline.py
  16. 1 0
      api/schemas/__init__.py
  17. 14 0
      api/schemas/pipeline.py
  18. 66 0
      api/services/pipeline.py
  19. 50 24
      api/services/scheduler.py
  20. 36 0
      deploy/README.md
  21. 67 0
      deploy/supervisord.pipeline.conf
  22. 4 0
      pyproject.toml
  23. 2 0
      requirements.txt
  24. 8 0
      scripts/container-entrypoint.sh
  25. 5 1
      scripts/docker-deploy.sh
  26. 9 3
      supply_infra/aigc/client.py
  27. 157 4
      supply_infra/config.py
  28. 2 2
      supply_infra/db/__init__.py
  29. 8 0
      supply_infra/db/models/__init__.py
  30. 32 0
      supply_infra/db/models/pipeline_lock.py
  31. 65 0
      supply_infra/db/models/pipeline_outbox.py
  32. 71 0
      supply_infra/db/models/pipeline_run.py
  33. 85 0
      supply_infra/db/models/pipeline_step_run.py
  34. 8 0
      supply_infra/db/repositories/__init__.py
  35. 122 0
      supply_infra/db/repositories/pipeline_lock_repo.py
  36. 54 0
      supply_infra/db/repositories/pipeline_outbox_repo.py
  37. 170 0
      supply_infra/db/repositories/pipeline_run_repo.py
  38. 225 0
      supply_infra/db/repositories/pipeline_step_run_repo.py
  39. 25 2
      supply_infra/db/session.py
  40. 5 0
      supply_infra/pipeline/__init__.py
  41. 58 0
      supply_infra/pipeline/cli.py
  42. 37 0
      supply_infra/pipeline/contracts.py
  43. 66 0
      supply_infra/pipeline/dag.py
  44. 102 0
      supply_infra/pipeline/dates.py
  45. 32 0
      supply_infra/pipeline/enums.py
  46. 48 0
      supply_infra/pipeline/gates.py
  47. 117 0
      supply_infra/pipeline/health.py
  48. 162 0
      supply_infra/pipeline/orchestrator.py
  49. 226 0
      supply_infra/pipeline/reconciler.py
  50. 194 0
      supply_infra/pipeline/registry.py
  51. 369 0
      supply_infra/pipeline/run_service.py
  52. 67 0
      supply_infra/pipeline/step_cli.py
  53. 151 0
      supply_infra/pipeline/step_runner.py
  54. 170 0
      supply_infra/pipeline/worker.py
  55. 46 0
      supply_infra/scheduler/__main__.py
  56. 17 15
      supply_infra/scheduler/app.py
  57. 10 6
      supply_infra/scheduler/jobs/demand_pool/videos.py
  58. 16 9
      supply_infra/scheduler/jobs/publish_videos_from_discovery.py
  59. 55 206
      supply_infra/scheduler/jobs/run_supply_pipeline.py
  60. 22 273
      supply_infra/scheduler/manual_jobs.py
  61. 6 120
      supply_infra/scheduler/step_runner.py
  62. 0 0
      tests/__init__.py
  63. 1 0
      tests/supply_infra/__init__.py
  64. 1 0
      tests/supply_infra/pipeline/__init__.py
  65. 34 0
      tests/supply_infra/pipeline/test_aigc_safety.py
  66. 52 0
      tests/supply_infra/pipeline/test_config_budget.py
  67. 37 0
      tests/supply_infra/pipeline/test_dag_and_gates.py
  68. 74 0
      tests/supply_infra/pipeline/test_dates.py
  69. 97 0
      tests/supply_infra/pipeline/test_health.py
  70. 151 0
      tests/supply_infra/pipeline/test_orchestrator.py
  71. 30 0
      tests/supply_infra/pipeline/test_run_serialization.py
  72. 129 0
      tests/supply_infra/pipeline/test_step_claim.py
  73. 1 0
      tests/supply_infra/scheduler/__init__.py
  74. 48 0
      tests/supply_infra/scheduler/test_grade_demand_pool.py
  75. 57 0
      tests/supply_infra/scheduler/test_run_supply_pipeline.py
  76. 88 0
      tests/supply_infra/scheduler/test_sync_multi_demand_pool.py
  77. 1 0
      web/src/App.vue
  78. 7 6
      web/src/api/dashboard.ts
  79. 53 0
      web/src/api/pipeline.ts
  80. 29 0
      web/src/components/pipeline/PipelineRunActions.vue
  81. 29 0
      web/src/components/pipeline/PipelineStatusCard.vue
  82. 26 0
      web/src/components/pipeline/PipelineStepTimeline.vue
  83. 7 0
      web/src/router.ts
  84. 74 0
      web/src/types/pipeline.ts
  85. 10 9
      web/src/views/OverviewView.vue
  86. 130 0
      web/src/views/PipelineRunsView.vue
  87. 1 1
      zhangbo.md

+ 2 - 0
.dockerignore

@@ -1,5 +1,6 @@
 .git
 .git
 .gitignore
 .gitignore
+.DS_Store
 .venv
 .venv
 __pycache__
 __pycache__
 *.py[cod]
 *.py[cod]
@@ -12,6 +13,7 @@ __pycache__
 .pytest_cache
 .pytest_cache
 .ruff_cache
 .ruff_cache
 *.log
 *.log
+*.result.json
 logs/
 logs/
 tests/
 tests/
 examples/
 examples/

+ 30 - 4
.env.example

@@ -26,7 +26,14 @@ MYSQL_PORT=3306
 MYSQL_USER=root
 MYSQL_USER=root
 MYSQL_PASSWORD=
 MYSQL_PASSWORD=
 MYSQL_DATABASE=supply_agent
 MYSQL_DATABASE=supply_agent
-MYSQL_POOL_SIZE=5
+MYSQL_CONNECTION_BUDGET=40
+MYSQL_POOL_SIZE=1
+MYSQL_POOL_SIZE_API=6
+MYSQL_POOL_SIZE_CONTROL=1
+MYSQL_MAX_OVERFLOW=0
+MYSQL_POOL_TIMEOUT_SECONDS=10
+MYSQL_POOL_RECYCLE_SECONDS=1800
+MYSQL_OPERATIONAL_RESERVE=4
 MYSQL_ECHO=false
 MYSQL_ECHO=false
 
 
 # ODPS (MaxCompute)
 # ODPS (MaxCompute)
@@ -35,9 +42,28 @@ ODPS_ACCESS_KEY=
 ODPS_PROJECT=
 ODPS_PROJECT=
 ODPS_ENDPOINT=https://service.cn.maxcompute.aliyun.com/api
 ODPS_ENDPOINT=https://service.cn.maxcompute.aliyun.com/api
 
 
-# Scheduler(供给流水线每天 14:30 Asia/Shanghai 触发)
-SCHEDULER_ENABLED=true
+# Scheduler(独立进程;供给流水线每天 15:00 Asia/Shanghai 触发)
+SCHEDULER_ENABLED=false
 SCHEDULER_TIMEZONE=Asia/Shanghai
 SCHEDULER_TIMEZONE=Asia/Shanghai
+SCHEDULER_CRON_HOUR=15
+SCHEDULER_CRON_MINUTE=0
+
+# Pipeline control plane
+PROCESS_ROLE=api
+DATABASE_AUTO_CREATE=false
+PIPELINE_WORKER_PROCESSES=4
+PIPELINE_MAX_ACTIVE_STEPS=4
+PIPELINE_WORKER_POLL_SECONDS=2
+PIPELINE_LEASE_SECONDS=120
+PIPELINE_HEARTBEAT_SECONDS=30
+PIPELINE_RECONCILE_SECONDS=60
+PIPELINE_MISSED_RUN_GRACE_SECONDS=600
+PIPELINE_SHUTDOWN_GRACE_SECONDS=300
+PIPELINE_WARN_AFTER_HOURS=12
+PIPELINE_CRITICAL_BEFORE_NEXT_MINUTES=120
+PIPELINE_FINAL_WARN_BEFORE_NEXT_MINUTES=30
+PIPELINE_LOG_DIR=logs/pipeline
+PIPELINE_EXTERNAL_EFFECTS_ENABLED=false
 # Aliyun OSS (agent 运行日志可视化上传;qwen 视频截断后片段也上传到此 bucket)
 # Aliyun OSS (agent 运行日志可视化上传;qwen 视频截断后片段也上传到此 bucket)
 ALIYUN_OSS_ACCESS_KEY_ID=
 ALIYUN_OSS_ACCESS_KEY_ID=
 ALIYUN_OSS_ACCESS_KEY_SECRET=
 ALIYUN_OSS_ACCESS_KEY_SECRET=
@@ -51,4 +77,4 @@ LOG_OSS_UPLOAD_ENABLED=true
 
 
 # AIGC platform (视频爬取/发布计划)
 # AIGC platform (视频爬取/发布计划)
 AIGC_API_TOKEN=
 AIGC_API_TOKEN=
-AIGC_DRY_RUN=false
+AIGC_DRY_RUN=true

+ 14 - 2
.gitignore

@@ -1,7 +1,13 @@
 __pycache__/
 __pycache__/
+.DS_Store
 *.py[cod]
 *.py[cod]
 *$py.class
 *$py.class
 *.egg-info/
 *.egg-info/
+.coverage
+coverage.xml
+htmlcov/
+test-results/
+*.result.json
 dist/
 dist/
 build/
 build/
 .venv/
 .venv/
@@ -11,8 +17,14 @@ build/
 .ruff_cache/
 .ruff_cache/
 *.log
 *.log
 logs/
 logs/
-tests/
+tests/*
+!tests/__init__.py
+!tests/supply_infra/
+tests/supply_infra/*
+!tests/supply_infra/__init__.py
+!tests/supply_infra/pipeline/
+!tests/supply_infra/scheduler/
 node_modules/
 node_modules/
 web/dist/
 web/dist/
 examples/
 examples/
-skills/
+skills/

+ 1 - 1
ARCHITECTURE.md

@@ -68,7 +68,7 @@ SupplyAgent/
                     ┌─────────────┐
                     ┌─────────────┐
                     │   ODPS      │
                     │   ODPS      │
                     └──────┬──────┘
                     └──────┬──────┘
-                           │ 定时任务 (每天 14:30)
+                           │ 定时任务 (每天 15:00)
 ┌──────────┐    ┌─────────────────────┐    ┌──────────┐
 ┌──────────┐    ┌─────────────────────┐    ┌──────────┐
 │  Agent   │───▶│  Repository (ORM)   │◀───│  Agent   │
 │  Agent   │───▶│  Repository (ORM)   │◀───│  Agent   │

+ 16 - 4
Dockerfile

@@ -25,13 +25,18 @@ ENV PYTHONUNBUFFERED=1 \
     PIP_NO_CACHE_DIR=1 \
     PIP_NO_CACHE_DIR=1 \
     PIP_DISABLE_PIP_VERSION_CHECK=1 \
     PIP_DISABLE_PIP_VERSION_CHECK=1 \
     TZ=Asia/Shanghai \
     TZ=Asia/Shanghai \
-    SCHEDULER_TIMEZONE=Asia/Shanghai
+    SCHEDULER_TIMEZONE=Asia/Shanghai \
+    SCHEDULER_CRON_HOUR=15 \
+    SCHEDULER_CRON_MINUTE=0 \
+    PIPELINE_WORKER_PROCESSES=4 \
+    PIPELINE_MAX_ACTIVE_STEPS=4 \
+    MYSQL_CONNECTION_BUDGET=40
 
 
 RUN sed -i 's|deb.debian.org|mirrors.aliyun.com|g; s|security.debian.org|mirrors.aliyun.com|g' /etc/apt/sources.list.d/debian.sources 2>/dev/null || \
 RUN sed -i 's|deb.debian.org|mirrors.aliyun.com|g; s|security.debian.org|mirrors.aliyun.com|g' /etc/apt/sources.list.d/debian.sources 2>/dev/null || \
     sed -i 's|deb.debian.org|mirrors.aliyun.com|g; s|security.debian.org|mirrors.aliyun.com|g' /etc/apt/sources.list 2>/dev/null || true
     sed -i 's|deb.debian.org|mirrors.aliyun.com|g; s|security.debian.org|mirrors.aliyun.com|g' /etc/apt/sources.list 2>/dev/null || true
 
 
 RUN apt-get update \
 RUN apt-get update \
-    && apt-get install -y --no-install-recommends gcc ffmpeg \
+    && apt-get install -y --no-install-recommends gcc ffmpeg supervisor \
     && ffmpeg -version >/dev/null \
     && ffmpeg -version >/dev/null \
     && ffprobe -version >/dev/null \
     && ffprobe -version >/dev/null \
     && ffmpeg -hide_banner -loglevel error \
     && ffmpeg -hide_banner -loglevel error \
@@ -48,13 +53,20 @@ COPY supply_agent/ supply_agent/
 COPY supply_infra/ supply_infra/
 COPY supply_infra/ supply_infra/
 COPY agents/ agents/
 COPY agents/ agents/
 COPY api/ api/
 COPY api/ api/
+COPY alembic.ini ./
+COPY alembic/ alembic/
+COPY deploy/ deploy/
 COPY scripts/verify_video_truncate_runtime.py scripts/
 COPY scripts/verify_video_truncate_runtime.py scripts/
+COPY scripts/container-entrypoint.sh scripts/
 
 
 RUN pip install -i https://mirrors.aliyun.com/pypi/simple/ --trusted-host mirrors.aliyun.com ".[odps]" \
 RUN pip install -i https://mirrors.aliyun.com/pypi/simple/ --trusted-host mirrors.aliyun.com ".[odps]" \
-    && python scripts/verify_video_truncate_runtime.py
+    && python scripts/verify_video_truncate_runtime.py \
+    && chmod +x scripts/container-entrypoint.sh
 
 
 COPY --from=web-builder /app/web/dist ./web/dist
 COPY --from=web-builder /app/web/dist ./web/dist
 
 
 EXPOSE 8080
 EXPOSE 8080
 
 
-CMD ["python", "-m", "api"]
+STOPSIGNAL SIGTERM
+
+ENTRYPOINT ["/app/scripts/container-entrypoint.sh"]

+ 3 - 3
PRD.md

@@ -106,7 +106,7 @@ SupplyAgent 是一套面向内容供给的每日需求处理系统。它将多
 
 
 ```mermaid
 ```mermaid
 flowchart LR
 flowchart LR
-    START["API/CLI 启动 Scheduler"] --> CRON["每日 14:30<br/>Asia/Shanghai"]
+    START["API/CLI 启动 Scheduler"] --> CRON["每日 15:00<br/>Asia/Shanghai"]
     CRON --> T1["① 同步 T-1 全局分类树"]
     CRON --> T1["① 同步 T-1 全局分类树"]
     T1 --> POOL["② 同步当日策略需求池"]
     T1 --> POOL["② 同步当日策略需求池"]
     POOL --> CLASSIFY["归类新词并建立匹配边"]
     POOL --> CLASSIFY["归类新词并建立匹配边"]
@@ -141,7 +141,7 @@ flowchart LR
 
 
 | 参数 | 当前值 |
 | 参数 | 当前值 |
 |---|---|
 |---|---|
-| 触发时间 | 每日 14:30 |
+| 触发时间 | 每日 15:00 |
 | 时区 | 默认 `Asia/Shanghai` |
 | 时区 | 默认 `Asia/Shanghai` |
 | Job ID | `run_supply_pipeline` |
 | Job ID | `run_supply_pipeline` |
 | 同一 Scheduler 最大实例 | 1 |
 | 同一 Scheduler 最大实例 | 1 |
@@ -811,7 +811,7 @@ flowchart TB
 
 
 ## 23. 待业务确认
 ## 23. 待业务确认
 
 
-1. 每日 `biz_dt` 应使用当天还是 T-1,14:30 时上游当天分区是否已稳定;
+1. 每日 `biz_dt` 应使用当天还是 T-1,15:00 时上游当天分区是否已稳定;
 2. S 与 A 的资源排序是否必须严格 S 优先;
 2. S 与 A 的资源排序是否必须严格 S 优先;
 3. `backup` 是否允许无人工确认直接进入 AIGC;
 3. `backup` 是否允许无人工确认直接进入 AIGC;
 4. 每天 Top 200 是固定预算,还是应按分类、等级、探索比例动态分配;
 4. 每天 Top 200 是固定预算,还是应按分类、等级、探索比例动态分配;

+ 39 - 0
alembic.ini

@@ -0,0 +1,39 @@
+[alembic]
+script_location = alembic
+prepend_sys_path = .
+path_separator = os
+sqlalchemy.url = driver://unused
+
+[loggers]
+keys = root,sqlalchemy,alembic
+
+[handlers]
+keys = console
+
+[formatters]
+keys = generic
+
+[logger_root]
+level = WARN
+handlers = console
+qualname =
+
+[logger_sqlalchemy]
+level = WARN
+handlers =
+qualname = sqlalchemy.engine
+
+[logger_alembic]
+level = INFO
+handlers =
+qualname = alembic
+
+[handler_console]
+class = StreamHandler
+args = (sys.stderr,)
+level = NOTSET
+formatter = generic
+
+[formatter_generic]
+format = %(levelname)-5.5s [%(name)s] %(message)s
+datefmt = %H:%M:%S

+ 58 - 0
alembic/env.py

@@ -0,0 +1,58 @@
+from __future__ import annotations
+
+from logging.config import fileConfig
+
+from alembic import context
+from sqlalchemy import engine_from_config, pool
+
+from supply_infra.config import get_infra_settings
+from supply_infra.db.base import Base
+import supply_infra.db.models  # noqa: F401
+
+config = context.config
+if config.config_file_name is not None:
+    fileConfig(config.config_file_name)
+
+# Alembic uses ConfigParser interpolation; escaped credentials may contain "%".
+config.set_main_option(
+    "sqlalchemy.url",
+    get_infra_settings().mysql_url.replace("%", "%%"),
+)
+target_metadata = Base.metadata
+
+
+def run_migrations_offline() -> None:
+    context.configure(
+        url=config.get_main_option("sqlalchemy.url"),
+        target_metadata=target_metadata,
+        literal_binds=True,
+        dialect_opts={"paramstyle": "named"},
+        compare_type=True,
+    )
+    with context.begin_transaction():
+        context.run_migrations()
+
+
+def run_migrations_online() -> None:
+    connectable = engine_from_config(
+        config.get_section(config.config_ini_section, {}),
+        prefix="sqlalchemy.",
+        poolclass=pool.NullPool,
+    )
+    with connectable.connect() as connection:
+        if connection.dialect.name == "mysql":
+            connection.exec_driver_sql("SET time_zone = '+00:00'")
+            connection.commit()
+        context.configure(
+            connection=connection,
+            target_metadata=target_metadata,
+            compare_type=True,
+        )
+        with context.begin_transaction():
+            context.run_migrations()
+
+
+if context.is_offline_mode():
+    run_migrations_offline()
+else:
+    run_migrations_online()

+ 24 - 0
alembic/script.py.mako

@@ -0,0 +1,24 @@
+"""${message}
+
+Revision ID: ${up_revision}
+Revises: ${down_revision | comma,n}
+Create Date: ${create_date}
+"""
+from typing import Sequence, Union
+
+from alembic import op
+import sqlalchemy as sa
+${imports if imports else ""}
+
+revision: str = ${repr(up_revision)}
+down_revision: Union[str, None] = ${repr(down_revision)}
+branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
+depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
+
+
+def upgrade() -> None:
+    ${upgrades if upgrades else "pass"}
+
+
+def downgrade() -> None:
+    ${downgrades if downgrades else "pass"}

+ 219 - 0
alembic/versions/20260727_01_pipeline_control_plane.py

@@ -0,0 +1,219 @@
+"""create durable pipeline control plane
+
+Revision ID: 20260727_01
+Revises:
+Create Date: 2026-07-27
+"""
+from __future__ import annotations
+
+from collections.abc import Sequence
+from datetime import datetime
+
+import sqlalchemy as sa
+from alembic import op
+
+revision: str = "20260727_01"
+down_revision: str | None = None
+branch_labels: str | Sequence[str] | None = None
+depends_on: str | Sequence[str] | None = None
+
+
+def upgrade() -> None:
+    op.create_table(
+        "pipeline_run",
+        sa.Column("run_id", sa.String(36), primary_key=True),
+        sa.Column("dedupe_key", sa.String(191), nullable=False),
+        sa.Column("pipeline_key", sa.String(64), nullable=False),
+        sa.Column("biz_dt", sa.String(8), nullable=False),
+        sa.Column("trigger_type", sa.String(24), nullable=False),
+        sa.Column("trigger_source", sa.String(64), nullable=True),
+        sa.Column("triggered_by", sa.String(128), nullable=True),
+        sa.Column("trigger_reason", sa.Text(), nullable=True),
+        sa.Column("parent_run_id", sa.String(36), nullable=True),
+        sa.Column("run_mode", sa.String(24), nullable=False),
+        sa.Column("dry_run", sa.Boolean(), nullable=False),
+        sa.Column("status", sa.String(32), nullable=False),
+        sa.Column("current_step", sa.String(64), nullable=True),
+        sa.Column("scheduled_for", sa.DateTime(), nullable=True),
+        sa.Column("deadline_at", sa.DateTime(), nullable=False),
+        sa.Column("started_at", sa.DateTime(), nullable=True),
+        sa.Column("finished_at", sa.DateTime(), nullable=True),
+        sa.Column("heartbeat_at", sa.DateTime(), nullable=True),
+        sa.Column("lease_owner", sa.String(191), nullable=True),
+        sa.Column("lease_until", sa.DateTime(), nullable=True),
+        sa.Column("code_version", sa.String(128), nullable=True),
+        sa.Column("config_snapshot_json", sa.JSON(), nullable=False),
+        sa.Column("date_snapshot_json", sa.JSON(), nullable=False),
+        sa.Column("summary_json", sa.JSON(), nullable=True),
+        sa.Column("error_code", sa.String(64), nullable=True),
+        sa.Column("error_message", sa.Text(), nullable=True),
+        sa.Column("created_at", sa.DateTime(), server_default=sa.func.now(), nullable=False),
+        sa.Column("updated_at", sa.DateTime(), server_default=sa.func.now(), nullable=False),
+        sa.UniqueConstraint("dedupe_key", name="uk_pipeline_run_dedupe_key"),
+        mysql_charset="utf8mb4",
+    )
+    op.create_index(
+        "idx_pipeline_run_biz",
+        "pipeline_run",
+        ["pipeline_key", "biz_dt", "created_at"],
+    )
+    op.create_index(
+        "idx_pipeline_run_status",
+        "pipeline_run",
+        ["status", "lease_until"],
+    )
+    op.create_index(
+        "idx_pipeline_run_deadline",
+        "pipeline_run",
+        ["status", "deadline_at"],
+    )
+
+    op.create_table(
+        "pipeline_step_run",
+        sa.Column("step_run_id", sa.String(36), primary_key=True),
+        sa.Column(
+            "run_id",
+            sa.String(36),
+            sa.ForeignKey("pipeline_run.run_id", ondelete="CASCADE"),
+            nullable=False,
+        ),
+        sa.Column("step_key", sa.String(64), nullable=False),
+        sa.Column("step_order", sa.Integer(), nullable=False),
+        sa.Column("attempt", sa.Integer(), nullable=False),
+        sa.Column("status", sa.String(32), nullable=False),
+        sa.Column("critical", sa.Boolean(), nullable=False),
+        sa.Column("dependency_snapshot_json", sa.JSON(), nullable=False),
+        sa.Column("input_snapshot_json", sa.JSON(), nullable=False),
+        sa.Column("timeout_seconds", sa.Integer(), nullable=False),
+        sa.Column("max_attempts", sa.Integer(), nullable=False),
+        sa.Column("retryable", sa.Boolean(), nullable=False),
+        sa.Column("next_retry_at", sa.DateTime(), nullable=True),
+        sa.Column("started_at", sa.DateTime(), nullable=True),
+        sa.Column("finished_at", sa.DateTime(), nullable=True),
+        sa.Column("heartbeat_at", sa.DateTime(), nullable=True),
+        sa.Column("lease_owner", sa.String(191), nullable=True),
+        sa.Column("lease_until", sa.DateTime(), nullable=True),
+        sa.Column("exit_code", sa.Integer(), nullable=True),
+        sa.Column("result_summary_json", sa.JSON(), nullable=True),
+        sa.Column("metrics_json", sa.JSON(), nullable=True),
+        sa.Column("error_code", sa.String(64), nullable=True),
+        sa.Column("error_message", sa.Text(), nullable=True),
+        sa.Column("log_uri", sa.String(1024), nullable=True),
+        sa.Column("created_at", sa.DateTime(), server_default=sa.func.now(), nullable=False),
+        sa.Column("updated_at", sa.DateTime(), server_default=sa.func.now(), nullable=False),
+        sa.UniqueConstraint(
+            "run_id",
+            "step_key",
+            "attempt",
+            name="uk_pipeline_step_run_attempt",
+        ),
+        mysql_charset="utf8mb4",
+    )
+    op.create_index(
+        "idx_pipeline_step_claim",
+        "pipeline_step_run",
+        ["status", "next_retry_at", "step_order"],
+    )
+    op.create_index(
+        "idx_pipeline_step_run",
+        "pipeline_step_run",
+        ["run_id", "step_order", "attempt"],
+    )
+    op.create_index(
+        "idx_pipeline_step_lease",
+        "pipeline_step_run",
+        ["status", "lease_until"],
+    )
+
+    op.create_table(
+        "pipeline_lock",
+        sa.Column("lock_key", sa.String(191), primary_key=True),
+        sa.Column("owner_run_id", sa.String(36), nullable=False),
+        sa.Column("owner_instance", sa.String(191), nullable=False),
+        sa.Column("lease_until", sa.DateTime(), nullable=False),
+        sa.Column("heartbeat_at", sa.DateTime(), nullable=False),
+        sa.Column("version", sa.BigInteger(), nullable=False),
+        sa.Column("created_at", sa.DateTime(), server_default=sa.func.now(), nullable=False),
+        sa.Column("updated_at", sa.DateTime(), server_default=sa.func.now(), nullable=False),
+        mysql_charset="utf8mb4",
+    )
+    pipeline_lock = sa.table(
+        "pipeline_lock",
+        sa.column("lock_key", sa.String()),
+        sa.column("owner_run_id", sa.String()),
+        sa.column("owner_instance", sa.String()),
+        sa.column("lease_until", sa.DateTime()),
+        sa.column("heartbeat_at", sa.DateTime()),
+        sa.column("version", sa.BigInteger()),
+    )
+    epoch = datetime(1970, 1, 1)
+    op.bulk_insert(
+        pipeline_lock,
+        [
+            {
+                "lock_key": "pipeline:claim_guard",
+                "owner_run_id": "control-plane",
+                "owner_instance": "claim-coordinator",
+                "lease_until": epoch,
+                "heartbeat_at": epoch,
+                "version": 1,
+            }
+        ],
+    )
+
+    op.create_table(
+        "pipeline_outbox",
+        sa.Column("outbox_id", sa.String(36), primary_key=True),
+        sa.Column(
+            "run_id",
+            sa.String(36),
+            sa.ForeignKey("pipeline_run.run_id", ondelete="CASCADE"),
+            nullable=False,
+        ),
+        sa.Column(
+            "step_run_id",
+            sa.String(36),
+            sa.ForeignKey("pipeline_step_run.step_run_id", ondelete="CASCADE"),
+            nullable=False,
+        ),
+        sa.Column("effect_type", sa.String(64), nullable=False),
+        sa.Column("idempotency_key", sa.String(191), nullable=False),
+        sa.Column("payload_hash", sa.String(64), nullable=False),
+        sa.Column("payload_json", sa.JSON(), nullable=True),
+        sa.Column("payload_uri", sa.String(1024), nullable=True),
+        sa.Column("status", sa.String(32), nullable=False),
+        sa.Column("dry_run", sa.Boolean(), nullable=False),
+        sa.Column("record_error", sa.Text(), nullable=True),
+        sa.Column("created_at", sa.DateTime(), server_default=sa.func.now(), nullable=False),
+        sa.Column("updated_at", sa.DateTime(), server_default=sa.func.now(), nullable=False),
+        sa.UniqueConstraint(
+            "idempotency_key",
+            name="uk_pipeline_outbox_idempotency",
+        ),
+        mysql_charset="utf8mb4",
+    )
+    op.create_index(
+        "idx_pipeline_outbox_run",
+        "pipeline_outbox",
+        ["run_id", "step_run_id"],
+    )
+    op.create_index(
+        "idx_pipeline_outbox_status",
+        "pipeline_outbox",
+        ["status", "created_at"],
+    )
+
+
+def downgrade() -> None:
+    op.drop_index("idx_pipeline_outbox_status", table_name="pipeline_outbox")
+    op.drop_index("idx_pipeline_outbox_run", table_name="pipeline_outbox")
+    op.drop_table("pipeline_outbox")
+    op.drop_table("pipeline_lock")
+    op.drop_index("idx_pipeline_step_lease", table_name="pipeline_step_run")
+    op.drop_index("idx_pipeline_step_run", table_name="pipeline_step_run")
+    op.drop_index("idx_pipeline_step_claim", table_name="pipeline_step_run")
+    op.drop_table("pipeline_step_run")
+    op.drop_index("idx_pipeline_run_deadline", table_name="pipeline_run")
+    op.drop_index("idx_pipeline_run_status", table_name="pipeline_run")
+    op.drop_index("idx_pipeline_run_biz", table_name="pipeline_run")
+    op.drop_table("pipeline_run")

+ 49 - 0
alembic/versions/20260727_02_nullable_manual_deadline.py

@@ -0,0 +1,49 @@
+"""allow manual pipeline runs without a deadline
+
+Revision ID: 20260727_02
+Revises: 20260727_01
+Create Date: 2026-07-27
+"""
+from __future__ import annotations
+
+from collections.abc import Sequence
+
+import sqlalchemy as sa
+from alembic import op
+
+revision: str = "20260727_02"
+down_revision: str | None = "20260727_01"
+branch_labels: str | Sequence[str] | None = None
+depends_on: str | Sequence[str] | None = None
+
+
+def upgrade() -> None:
+    op.alter_column(
+        "pipeline_run",
+        "deadline_at",
+        existing_type=sa.DateTime(),
+        nullable=True,
+    )
+    op.execute(
+        sa.text(
+            "UPDATE pipeline_run "
+            "SET deadline_at = NULL "
+            "WHERE trigger_type NOT IN ('cron', 'reconcile')"
+        )
+    )
+
+
+def downgrade() -> None:
+    op.execute(
+        sa.text(
+            "UPDATE pipeline_run "
+            "SET deadline_at = UTC_TIMESTAMP() "
+            "WHERE deadline_at IS NULL"
+        )
+    )
+    op.alter_column(
+        "pipeline_run",
+        "deadline_at",
+        existing_type=sa.DateTime(),
+        nullable=False,
+    )

+ 29 - 8
api/app.py

@@ -8,7 +8,9 @@ from fastapi import FastAPI, HTTPException, Query
 from fastapi.middleware.cors import CORSMiddleware
 from fastapi.middleware.cors import CORSMiddleware
 from fastapi.staticfiles import StaticFiles
 from fastapi.staticfiles import StaticFiles
 from pydantic import BaseModel, Field
 from pydantic import BaseModel, Field
+from sqlalchemy import text
 
 
+from api.routers.pipeline import router as pipeline_router
 from api.services.agent_catalog import (
 from api.services.agent_catalog import (
     get_agent_detail,
     get_agent_detail,
     update_agent_document_injection,
     update_agent_document_injection,
@@ -24,24 +26,26 @@ from api.services.scheduler import (
     list_triggerable_jobs,
     list_triggerable_jobs,
     run_scheduler_job,
     run_scheduler_job,
     run_supply_pipeline,
     run_supply_pipeline,
+    scheduler_status as get_persistent_scheduler_status,
 )
 )
 from api.services.video_discovery import (
 from api.services.video_discovery import (
     get_video_discovery_demand,
     get_video_discovery_demand,
     list_video_discovery_demands,
     list_video_discovery_demands,
 )
 )
-from supply_infra.db import init_db
-from supply_infra.scheduler.app import get_scheduler_status, start_scheduler, stop_scheduler
+from supply_infra.config import get_infra_settings
+from supply_infra.db import dispose_engine, get_session, init_db
 
 
 
 
 @asynccontextmanager
 @asynccontextmanager
 async def lifespan(_app: FastAPI):
 async def lifespan(_app: FastAPI):
-    init_db()
-    start_scheduler()
+    if get_infra_settings().database_auto_create:
+        init_db()
     yield
     yield
-    stop_scheduler()
+    dispose_engine()
 
 
 
 
 app = FastAPI(title="SupplyAgent API", version="0.1.0", lifespan=lifespan)
 app = FastAPI(title="SupplyAgent API", version="0.1.0", lifespan=lifespan)
+app.include_router(pipeline_router)
 
 
 app.add_middleware(
 app.add_middleware(
     CORSMiddleware,
     CORSMiddleware,
@@ -62,10 +66,25 @@ def health() -> dict[str, str]:
     return {"status": "ok"}
     return {"status": "ok"}
 
 
 
 
+@app.get("/health/live")
+def health_live() -> dict[str, str]:
+    return {"status": "ok"}
+
+
+@app.get("/health/ready")
+def health_ready() -> dict[str, str]:
+    try:
+        with get_session() as session:
+            session.execute(text("SELECT 1"))
+    except Exception as exc:
+        raise HTTPException(status_code=503, detail="database unavailable") from exc
+    return {"status": "ready"}
+
+
 @app.get("/api/scheduler/status")
 @app.get("/api/scheduler/status")
 def scheduler_status() -> dict:
 def scheduler_status() -> dict:
-    """Return scheduler enabled/running state and next run times."""
-    return get_scheduler_status()
+    """Deprecated compatibility view backed by MySQL pipeline state."""
+    return get_persistent_scheduler_status()
 
 
 
 
 class RunPipelineBody(BaseModel):
 class RunPipelineBody(BaseModel):
@@ -164,11 +183,13 @@ def trigger_scheduler_job(
         )
         )
     except KeyError:
     except KeyError:
         raise HTTPException(status_code=404, detail=f"scheduler job not found: {job_id}") from None
         raise HTTPException(status_code=404, detail=f"scheduler job not found: {job_id}") from None
+    except ValueError as exc:
+        raise HTTPException(status_code=409, detail=str(exc)) from None
 
 
 
 
 @app.get("/api/scheduler/runs/{run_id}")
 @app.get("/api/scheduler/runs/{run_id}")
 def scheduler_job_run(run_id: str) -> dict:
 def scheduler_job_run(run_id: str) -> dict:
-    """Return manual job run status (in-memory; cleared on process restart)."""
+    """Return durable pipeline run status."""
     result = get_scheduler_job_run(run_id)
     result = get_scheduler_job_run(run_id)
     if result is None:
     if result is None:
         raise HTTPException(status_code=404, detail="scheduler run not found")
         raise HTTPException(status_code=404, detail="scheduler run not found")

+ 1 - 0
api/routers/__init__.py

@@ -0,0 +1 @@
+"""FastAPI routers."""

+ 89 - 0
api/routers/pipeline.py

@@ -0,0 +1,89 @@
+from __future__ import annotations
+
+from fastapi import APIRouter, HTTPException, Query
+
+from api.schemas.pipeline import CreatePipelineRunBody
+from api.services.pipeline import (
+    cancel_run,
+    create_run,
+    get_run,
+    list_runs,
+    pipeline_health,
+    resume_run,
+    retry_step,
+)
+
+router = APIRouter(prefix="/api/pipeline", tags=["pipeline"])
+
+
+@router.get("/runs")
+def pipeline_runs(
+    limit: int = Query(default=50, ge=1, le=200),
+    status: str | None = None,
+    biz_dt: str | None = Query(default=None, pattern=r"^\d{8}$"),
+) -> dict:
+    return {"items": list_runs(limit=limit, status=status, biz_dt=biz_dt)}
+
+
+@router.post("/runs", status_code=202)
+def create_pipeline_run(body: CreatePipelineRunBody | None = None) -> dict:
+    payload = body or CreatePipelineRunBody()
+    return create_run(
+        biz_dt=payload.biz_dt,
+        source="api",
+        reason=payload.reason,
+    )
+
+
+@router.get("/runs/{run_id}")
+def pipeline_run_detail(run_id: str) -> dict:
+    result = get_run(run_id)
+    if result is None:
+        raise HTTPException(status_code=404, detail="pipeline run not found")
+    return result
+
+
+@router.get("/runs/{run_id}/steps")
+def pipeline_run_steps(run_id: str) -> dict:
+    result = get_run(run_id)
+    if result is None:
+        raise HTTPException(status_code=404, detail="pipeline run not found")
+    return {"items": result["steps"]}
+
+
+@router.post("/runs/{run_id}/resume", status_code=202)
+def resume_pipeline_run(run_id: str) -> dict:
+    if not resume_run(run_id):
+        raise HTTPException(status_code=404, detail="pipeline run not found or not resumable")
+    result = get_run(run_id)
+    assert result is not None
+    return {"accepted": True, "run_id": run_id, "status": result["status"]}
+
+
+@router.post("/runs/{run_id}/cancel", status_code=202)
+def cancel_pipeline_run(run_id: str) -> dict:
+    if not cancel_run(run_id):
+        raise HTTPException(status_code=404, detail="pipeline run not found")
+    result = get_run(run_id)
+    assert result is not None
+    return {"accepted": True, "run_id": run_id, "status": result["status"]}
+
+
+@router.post("/runs/{run_id}/steps/{step_key}/retry", status_code=202)
+def retry_pipeline_run_step(run_id: str, step_key: str) -> dict:
+    if not retry_step(run_id, step_key):
+        raise HTTPException(
+            status_code=409,
+            detail="step is not the first failed step or run is not resumable",
+        )
+    return {
+        "accepted": True,
+        "run_id": run_id,
+        "step_key": step_key,
+        "status": "queued",
+    }
+
+
+@router.get("/health")
+def health() -> dict:
+    return pipeline_health()

+ 1 - 0
api/schemas/__init__.py

@@ -0,0 +1 @@
+"""API request/response schemas."""

+ 14 - 0
api/schemas/pipeline.py

@@ -0,0 +1,14 @@
+from __future__ import annotations
+
+from pydantic import BaseModel, Field
+
+
+class CreatePipelineRunBody(BaseModel):
+    biz_dt: str | None = Field(default=None, pattern=r"^\d{8}$")
+    reason: str | None = Field(default=None, max_length=2000)
+
+
+class PipelineRunActionResponse(BaseModel):
+    accepted: bool
+    run_id: str
+    status: str

+ 66 - 0
api/services/pipeline.py

@@ -0,0 +1,66 @@
+from __future__ import annotations
+
+from typing import Any
+
+from supply_infra.pipeline.dag import PIPELINE_STEPS
+from supply_infra.pipeline.health import pipeline_health_snapshot
+from supply_infra.pipeline.run_service import (
+    cancel_pipeline_run,
+    get_pipeline_run,
+    list_pipeline_runs,
+    resume_pipeline_run,
+    retry_pipeline_step,
+    submit_pipeline_run,
+)
+
+
+def create_run(
+    *,
+    biz_dt: str | None,
+    source: str,
+    reason: str | None = None,
+) -> dict[str, Any]:
+    return submit_pipeline_run(
+        biz_dt=biz_dt,
+        trigger_type="api",
+        trigger_source=source,
+        trigger_reason=reason,
+    ).to_dict()
+
+
+def list_runs(
+    *,
+    limit: int,
+    status: str | None,
+    biz_dt: str | None,
+) -> list[dict[str, Any]]:
+    return list_pipeline_runs(limit=limit, status=status, biz_dt=biz_dt)
+
+
+def get_run(run_id: str) -> dict[str, Any] | None:
+    return get_pipeline_run(run_id)
+
+
+def resume_run(run_id: str) -> bool:
+    return resume_pipeline_run(run_id)
+
+
+def cancel_run(run_id: str) -> bool:
+    return cancel_pipeline_run(run_id)
+
+
+def retry_step(run_id: str, step_key: str) -> bool:
+    return retry_pipeline_step(run_id, step_key)
+
+
+def pipeline_health() -> dict[str, Any]:
+    recent = list_pipeline_runs(limit=1)
+    latest = recent[0] if recent else None
+    return {
+        "scheduler_embedded_in_api": False,
+        "pipeline_key": "supply_pipeline",
+        "dry_run": True,
+        "steps": len(PIPELINE_STEPS),
+        "latest_run": latest,
+        **pipeline_health_snapshot(),
+    }

+ 50 - 24
api/services/scheduler.py

@@ -1,18 +1,28 @@
-"""Scheduler API helpers."""
+"""Compatibility facade over the durable pipeline control plane."""
 from __future__ import annotations
 from __future__ import annotations
 
 
 from typing import Any
 from typing import Any
 
 
-from supply_infra.scheduler.constants import SUPPLY_PIPELINE_JOB_ID
-from supply_infra.scheduler.manual_jobs import (
-    get_manual_run,
-    list_manual_jobs,
-    trigger_manual_job,
-)
+from api.services.pipeline import get_run, pipeline_health
+from supply_infra.config import get_infra_settings
+from supply_infra.pipeline.dag import PIPELINE_STEPS
+from supply_infra.pipeline.dates import next_schedule_utc
+from supply_infra.pipeline.run_service import submit_pipeline_run
+from supply_infra.scheduler.constants import SUPPLY_PIPELINE_JOB_ID, SUPPLY_PIPELINE_JOB_NAME
 
 
 
 
 def list_triggerable_jobs() -> list[dict[str, Any]]:
 def list_triggerable_jobs() -> list[dict[str, Any]]:
-    return list_manual_jobs()
+    return [
+        {
+            "id": SUPPLY_PIPELINE_JOB_ID,
+            "name": SUPPLY_PIPELINE_JOB_NAME,
+            "description": "提交严格门禁的 12 步持久化供给流水线",
+            "accepts_biz_dt": True,
+            "accepts_partition_date": False,
+            "deprecated": False,
+            "steps": [step.key for step in PIPELINE_STEPS],
+        }
+    ]
 
 
 
 
 def run_scheduler_job(
 def run_scheduler_job(
@@ -22,25 +32,41 @@ def run_scheduler_job(
     partition_date: str | None = None,
     partition_date: str | None = None,
     wait: bool = False,
     wait: bool = False,
 ) -> dict[str, Any]:
 ) -> dict[str, Any]:
-    return trigger_manual_job(
-        job_id,
+    del partition_date
+    if job_id != SUPPLY_PIPELINE_JOB_ID:
+        raise KeyError(job_id)
+    if wait:
+        raise ValueError("wait=true is disabled for the durable API; poll by run_id")
+    return submit_pipeline_run(
         biz_dt=biz_dt,
         biz_dt=biz_dt,
-        partition_date=partition_date,
-        wait=wait,
-    )
+        trigger_type="api",
+        trigger_source="legacy_scheduler_api",
+    ).to_dict()
 
 
 
 
 def get_scheduler_job_run(run_id: str) -> dict[str, Any] | None:
 def get_scheduler_job_run(run_id: str) -> dict[str, Any] | None:
-    return get_manual_run(run_id)
+    return get_run(run_id)
 
 
 
 
-def run_supply_pipeline(
-    *,
-    biz_dt: str | None = None,
-) -> dict[str, Any]:
-    """异步执行供给数据全流程,立即返回 run_id。"""
-    return run_scheduler_job(
-        SUPPLY_PIPELINE_JOB_ID,
-        biz_dt=biz_dt,
-        wait=False,
-    )
+def run_supply_pipeline(*, biz_dt: str | None = None) -> dict[str, Any]:
+    return run_scheduler_job(SUPPLY_PIPELINE_JOB_ID, biz_dt=biz_dt)
+
+
+def scheduler_status() -> dict[str, Any]:
+    settings = get_infra_settings()
+    health = pipeline_health()
+    return {
+        "enabled": settings.scheduler_enabled,
+        "running": health["scheduler_running"],
+        "embedded": False,
+        "deprecated": True,
+        "timezone": settings.scheduler_timezone,
+        "jobs": [
+            {
+                "id": SUPPLY_PIPELINE_JOB_ID,
+                "name": SUPPLY_PIPELINE_JOB_NAME,
+                "next_run_time": next_schedule_utc(settings=settings).isoformat(),
+            }
+        ],
+        **health,
+    }

+ 36 - 0
deploy/README.md

@@ -0,0 +1,36 @@
+# Pipeline 单 Docker 部署
+
+当前生产保持一个镜像、一个容器。Supervisor 在容器内分别托管 API、独立
+Scheduler、多个 Pipeline Worker 和 Reconciler。
+
+## 启动前
+
+1. MySQL 必须是 8.0.36,并为本应用保留 40 个连接;
+2. 挂载 `/app/logs/pipeline` 到持久化目录;
+3. `SCHEDULER_ENABLED=true` 只影响独立 Scheduler 进程,API 不再内嵌调度器;
+   默认调度时间为 `Asia/Shanghai` 每日 15:00;
+4. `AIGC_DRY_RUN=true` 且 `PIPELINE_EXTERNAL_EFFECTS_ENABLED=false`;
+5. Docker 停止窗口至少 300 秒。
+6. 容器和 Alembic 连接 MySQL 后会将会话时区固定为 UTC;定时日批有次日
+   调度 deadline,手工/API/CLI 补数不设置 deadline。
+
+示例:
+
+```bash
+docker run \
+  --stop-timeout 300 \
+  --env-file /path/to/supply-agent.env \
+  -v /host/supply-agent-pipeline-logs:/app/logs/pipeline \
+  -p 8080:8080 \
+  registry.example/supply-agent:tag
+```
+
+标准档使用 40 个连接、4 个 Worker、4 个最大活跃步骤。高并发补跑档使用
+50 个连接、20 个 Worker,但必须将最大活跃步骤限制为 10。
+
+容器启动时默认运行 `alembic upgrade head`。紧急回滚代码时不自动 downgrade
+数据库;四张控制面表保留审计数据。
+
+Reconciler 会把失败、失联、漏跑恢复以及 12 小时/2 小时/30 分钟分级预警以
+`pipeline_alert` 结构化日志写到容器 stdout。第一阶段不上传到现有公共 CDN OSS;
+生产应由 Docker 日志采集器接入现有告警平台,或至少保留容器日志。

+ 67 - 0
deploy/supervisord.pipeline.conf

@@ -0,0 +1,67 @@
+[supervisord]
+nodaemon=true
+logfile=/dev/null
+logfile_maxbytes=0
+pidfile=/tmp/supervisord.pid
+
+[program:api]
+command=python -m api
+directory=/app
+environment=PROCESS_ROLE="api"
+autostart=true
+autorestart=unexpected
+startsecs=3
+stopasgroup=true
+killasgroup=true
+stopwaitsecs=300
+stdout_logfile=/dev/stdout
+stdout_logfile_maxbytes=0
+stderr_logfile=/dev/stderr
+stderr_logfile_maxbytes=0
+
+[program:scheduler]
+command=python -m supply_infra.scheduler
+directory=/app
+environment=PROCESS_ROLE="scheduler"
+autostart=true
+autorestart=unexpected
+startsecs=3
+stopasgroup=true
+killasgroup=true
+stopwaitsecs=300
+stdout_logfile=/dev/stdout
+stdout_logfile_maxbytes=0
+stderr_logfile=/dev/stderr
+stderr_logfile_maxbytes=0
+
+[program:pipeline-worker]
+command=python -m supply_infra.pipeline.cli worker
+directory=/app
+environment=PROCESS_ROLE="worker"
+numprocs=%(ENV_PIPELINE_WORKER_PROCESSES)s
+process_name=%(program_name)s-%(process_num)02d
+autostart=true
+autorestart=unexpected
+startsecs=3
+stopasgroup=true
+killasgroup=true
+stopwaitsecs=300
+stdout_logfile=/dev/stdout
+stdout_logfile_maxbytes=0
+stderr_logfile=/dev/stderr
+stderr_logfile_maxbytes=0
+
+[program:pipeline-reconciler]
+command=python -m supply_infra.pipeline.cli reconciler
+directory=/app
+environment=PROCESS_ROLE="reconciler"
+autostart=true
+autorestart=unexpected
+startsecs=3
+stopasgroup=true
+killasgroup=true
+stopwaitsecs=300
+stdout_logfile=/dev/stdout
+stdout_logfile_maxbytes=0
+stderr_logfile=/dev/stderr
+stderr_logfile_maxbytes=0

+ 4 - 0
pyproject.toml

@@ -16,6 +16,8 @@ dependencies = [
     "rich>=13.0",
     "rich>=13.0",
     "sqlalchemy>=2.0",
     "sqlalchemy>=2.0",
     "pymysql>=1.1",
     "pymysql>=1.1",
+    "alembic>=1.13",
+    "requests>=2.32",
     "apscheduler>=3.10",
     "apscheduler>=3.10",
     "python-dotenv>=1.0",
     "python-dotenv>=1.0",
     "oss2>=2.18.0",
     "oss2>=2.18.0",
@@ -39,6 +41,8 @@ packages = ["supply_agent", "supply_infra", "agents", "api"]
 [project.scripts]
 [project.scripts]
 supply-visualize = "supply_agent.logging.cli:main"
 supply-visualize = "supply_agent.logging.cli:main"
 supply-api = "api.run:main"
 supply-api = "api.run:main"
+supply-scheduler = "supply_infra.scheduler.__main__:main"
+supply-pipeline = "supply_infra.pipeline.cli:main"
 
 
 [tool.ruff]
 [tool.ruff]
 line-length = 100
 line-length = 100

+ 2 - 0
requirements.txt

@@ -13,6 +13,8 @@ markdown>=3.6
 # Database
 # Database
 sqlalchemy>=2.0
 sqlalchemy>=2.0
 pymysql>=1.1
 pymysql>=1.1
+alembic>=1.13
+requests>=2.32
 
 
 # Scheduler
 # Scheduler
 apscheduler>=3.10
 apscheduler>=3.10

+ 8 - 0
scripts/container-entrypoint.sh

@@ -0,0 +1,8 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+if [[ "${RUN_DB_MIGRATIONS:-true}" == "true" ]]; then
+  alembic upgrade head
+fi
+
+exec /usr/bin/supervisord -c /app/deploy/supervisord.pipeline.conf

+ 5 - 1
scripts/docker-deploy.sh

@@ -68,7 +68,11 @@ cd "$BUILD_DIR"
 docker build -t "$IMAGE_TAG" -t "$LATEST_TAG" .
 docker build -t "$IMAGE_TAG" -t "$LATEST_TAG" .
 
 
 echo "==> Verifying video truncate runtime in image"
 echo "==> Verifying video truncate runtime in image"
-docker run --rm "$IMAGE_TAG" python scripts/verify_video_truncate_runtime.py
+docker run --rm --entrypoint python "$IMAGE_TAG" scripts/verify_video_truncate_runtime.py
+
+echo "==> Verifying pipeline imports and migration head"
+docker run --rm --entrypoint python "$IMAGE_TAG" -m compileall -q supply_infra api alembic
+docker run --rm --entrypoint alembic "$IMAGE_TAG" heads
 
 
 if [[ "$PUSH" == "true" ]]; then
 if [[ "$PUSH" == "true" ]]; then
   echo "==> Pushing: ${IMAGE_TAG}"
   echo "==> Pushing: ${IMAGE_TAG}"

+ 9 - 3
supply_infra/aigc/client.py

@@ -40,7 +40,13 @@ class AigcClient:
                 dry_run = env_dry_run in {"1", "true", "yes", "on"}
                 dry_run = env_dry_run in {"1", "true", "yes", "on"}
             else:
             else:
                 dry_run = settings.aigc_dry_run
                 dry_run = settings.aigc_dry_run
-        self.dry_run = bool(dry_run)
+        # Phase one safety barrier: callers cannot enable real side effects unless
+        # the global control-plane switch is also explicitly enabled.
+        self.dry_run = (
+            bool(dry_run)
+            or settings.aigc_dry_run
+            or not settings.pipeline_external_effects_enabled
+        )
 
 
     def create_video_crawler_plan(
     def create_video_crawler_plan(
         self,
         self,
@@ -194,9 +200,9 @@ class AigcClient:
             return response.json()
             return response.json()
         except Exception as exc:
         except Exception as exc:
             logger.error(
             logger.error(
-                "invoke aigc platform error. url=%s request=%s error=%s",
+                "invoke aigc platform error. url=%s params=%s error=%s",
                 url,
                 url,
-                json.dumps(request, ensure_ascii=False),
+                json.dumps(params, ensure_ascii=False),
                 exc,
                 exc,
             )
             )
             return {}
             return {}

+ 157 - 4
supply_infra/config.py

@@ -4,7 +4,7 @@ from functools import lru_cache
 from pathlib import Path
 from pathlib import Path
 from urllib.parse import quote_plus
 from urllib.parse import quote_plus
 
 
-from pydantic import Field
+from pydantic import Field, model_validator
 from pydantic_settings import BaseSettings, SettingsConfigDict
 from pydantic_settings import BaseSettings, SettingsConfigDict
 
 
 # supply_infra/config.py -> project root; avoid depending on process cwd
 # supply_infra/config.py -> project root; avoid depending on process cwd
@@ -27,7 +27,35 @@ class InfraSettings(BaseSettings):
     mysql_user: str = Field(default="root", alias="MYSQL_USER")
     mysql_user: str = Field(default="root", alias="MYSQL_USER")
     mysql_password: str = Field(default="", alias="MYSQL_PASSWORD")
     mysql_password: str = Field(default="", alias="MYSQL_PASSWORD")
     mysql_database: str = Field(default="supply_agent", alias="MYSQL_DATABASE")
     mysql_database: str = Field(default="supply_agent", alias="MYSQL_DATABASE")
-    mysql_pool_size: int = Field(default=5, alias="MYSQL_POOL_SIZE")
+    mysql_pool_size: int = Field(default=1, ge=1, alias="MYSQL_POOL_SIZE")
+    mysql_connection_budget: int = Field(
+        default=40,
+        ge=30,
+        le=50,
+        alias="MYSQL_CONNECTION_BUDGET",
+    )
+    mysql_pool_size_api: int = Field(default=6, ge=1, alias="MYSQL_POOL_SIZE_API")
+    mysql_pool_size_control: int = Field(
+        default=1,
+        ge=1,
+        alias="MYSQL_POOL_SIZE_CONTROL",
+    )
+    mysql_max_overflow: int = Field(default=0, ge=0, alias="MYSQL_MAX_OVERFLOW")
+    mysql_pool_timeout_seconds: int = Field(
+        default=10,
+        ge=1,
+        alias="MYSQL_POOL_TIMEOUT_SECONDS",
+    )
+    mysql_pool_recycle_seconds: int = Field(
+        default=1800,
+        ge=60,
+        alias="MYSQL_POOL_RECYCLE_SECONDS",
+    )
+    mysql_operational_reserve: int = Field(
+        default=4,
+        ge=2,
+        alias="MYSQL_OPERATIONAL_RESERVE",
+    )
     mysql_echo: bool = Field(default=False, alias="MYSQL_ECHO")
     mysql_echo: bool = Field(default=False, alias="MYSQL_ECHO")
 
 
     # ODPS (MaxCompute)
     # ODPS (MaxCompute)
@@ -41,7 +69,80 @@ class InfraSettings(BaseSettings):
 
 
     # Scheduler
     # Scheduler
     scheduler_timezone: str = Field(default="Asia/Shanghai", alias="SCHEDULER_TIMEZONE")
     scheduler_timezone: str = Field(default="Asia/Shanghai", alias="SCHEDULER_TIMEZONE")
-    scheduler_enabled: bool = Field(default=True, alias="SCHEDULER_ENABLED")
+    scheduler_enabled: bool = Field(default=False, alias="SCHEDULER_ENABLED")
+    scheduler_cron_hour: int = Field(default=15, ge=0, le=23, alias="SCHEDULER_CRON_HOUR")
+    scheduler_cron_minute: int = Field(
+        default=0,
+        ge=0,
+        le=59,
+        alias="SCHEDULER_CRON_MINUTE",
+    )
+
+    # Pipeline process/control plane
+    process_role: str = Field(default="api", alias="PROCESS_ROLE")
+    database_auto_create: bool = Field(default=False, alias="DATABASE_AUTO_CREATE")
+    pipeline_worker_processes: int = Field(
+        default=4,
+        ge=1,
+        le=20,
+        alias="PIPELINE_WORKER_PROCESSES",
+    )
+    pipeline_max_active_steps: int = Field(
+        default=4,
+        ge=1,
+        le=20,
+        alias="PIPELINE_MAX_ACTIVE_STEPS",
+    )
+    pipeline_worker_poll_seconds: float = Field(
+        default=2.0,
+        gt=0,
+        alias="PIPELINE_WORKER_POLL_SECONDS",
+    )
+    pipeline_lease_seconds: int = Field(
+        default=120,
+        ge=30,
+        alias="PIPELINE_LEASE_SECONDS",
+    )
+    pipeline_heartbeat_seconds: int = Field(
+        default=30,
+        ge=5,
+        alias="PIPELINE_HEARTBEAT_SECONDS",
+    )
+    pipeline_reconcile_seconds: int = Field(
+        default=60,
+        ge=10,
+        alias="PIPELINE_RECONCILE_SECONDS",
+    )
+    pipeline_missed_run_grace_seconds: int = Field(
+        default=600,
+        ge=60,
+        alias="PIPELINE_MISSED_RUN_GRACE_SECONDS",
+    )
+    pipeline_shutdown_grace_seconds: int = Field(
+        default=300,
+        ge=10,
+        alias="PIPELINE_SHUTDOWN_GRACE_SECONDS",
+    )
+    pipeline_warn_after_hours: int = Field(
+        default=12,
+        ge=1,
+        alias="PIPELINE_WARN_AFTER_HOURS",
+    )
+    pipeline_critical_before_next_minutes: int = Field(
+        default=120,
+        ge=1,
+        alias="PIPELINE_CRITICAL_BEFORE_NEXT_MINUTES",
+    )
+    pipeline_final_warn_before_next_minutes: int = Field(
+        default=30,
+        ge=1,
+        alias="PIPELINE_FINAL_WARN_BEFORE_NEXT_MINUTES",
+    )
+    pipeline_log_dir: str = Field(default="logs/pipeline", alias="PIPELINE_LOG_DIR")
+    pipeline_external_effects_enabled: bool = Field(
+        default=False,
+        alias="PIPELINE_EXTERNAL_EFFECTS_ENABLED",
+    )
 
 
     # Aliyun OSS (agent run log publishing)
     # Aliyun OSS (agent run log publishing)
     aliyun_oss_access_key_id: str = Field(default="", alias="ALIYUN_OSS_ACCESS_KEY_ID")
     aliyun_oss_access_key_id: str = Field(default="", alias="ALIYUN_OSS_ACCESS_KEY_ID")
@@ -61,7 +162,40 @@ class InfraSettings(BaseSettings):
 
 
     # AIGC platform
     # AIGC platform
     aigc_api_token: str = Field(default="", alias="AIGC_API_TOKEN")
     aigc_api_token: str = Field(default="", alias="AIGC_API_TOKEN")
-    aigc_dry_run: bool = Field(default=False, alias="AIGC_DRY_RUN")
+    aigc_dry_run: bool = Field(default=True, alias="AIGC_DRY_RUN")
+
+    @model_validator(mode="after")
+    def validate_pipeline_runtime(self) -> "InfraSettings":
+        roles = {"api", "scheduler", "worker", "reconciler", "step", "cli"}
+        if self.process_role not in roles:
+            raise ValueError(
+                f"PROCESS_ROLE must be one of {sorted(roles)}, got {self.process_role!r}"
+            )
+        if self.pipeline_heartbeat_seconds >= self.pipeline_lease_seconds:
+            raise ValueError(
+                "PIPELINE_HEARTBEAT_SECONDS must be smaller than PIPELINE_LEASE_SECONDS"
+            )
+        if self.pipeline_max_active_steps > self.pipeline_worker_processes:
+            raise ValueError(
+                "PIPELINE_MAX_ACTIVE_STEPS cannot exceed PIPELINE_WORKER_PROCESSES"
+            )
+        if self.mysql_max_overflow != 0:
+            raise ValueError("MYSQL_MAX_OVERFLOW must remain 0 for connection budgeting")
+        required = self.pipeline_required_connections
+        if required > self.mysql_connection_budget:
+            raise ValueError(
+                "Pipeline connection budget exceeded: "
+                f"required={required} budget={self.mysql_connection_budget}"
+            )
+        if (
+            self.pipeline_external_effects_enabled
+            and not self.aigc_dry_run
+        ):
+            raise ValueError(
+                "Real AIGC effects are disabled in the current migration phase; "
+                "set AIGC_DRY_RUN=true"
+            )
+        return self
 
 
     @property
     @property
     def mysql_url(self) -> str:
     def mysql_url(self) -> str:
@@ -73,6 +207,25 @@ class InfraSettings(BaseSettings):
             f"?charset=utf8mb4"
             f"?charset=utf8mb4"
         )
         )
 
 
+    @property
+    def selected_mysql_pool_size(self) -> int:
+        if self.process_role == "api":
+            return self.mysql_pool_size_api
+        if self.process_role in {"scheduler", "worker", "reconciler", "step"}:
+            return self.mysql_pool_size_control
+        return self.mysql_pool_size
+
+    @property
+    def pipeline_required_connections(self) -> int:
+        control_processes = 2  # scheduler + reconciler
+        return (
+            self.mysql_pool_size_api
+            + control_processes * self.mysql_pool_size_control
+            + self.pipeline_worker_processes * self.mysql_pool_size_control
+            + self.pipeline_max_active_steps * self.mysql_pool_size_control
+            + self.mysql_operational_reserve
+        )
+
 
 
 @lru_cache
 @lru_cache
 def get_infra_settings() -> InfraSettings:
 def get_infra_settings() -> InfraSettings:

+ 2 - 2
supply_infra/db/__init__.py

@@ -1,6 +1,6 @@
 """Database layer."""
 """Database layer."""
 
 
 from supply_infra.db.base import Base
 from supply_infra.db.base import Base
-from supply_infra.db.session import get_session, init_db
+from supply_infra.db.session import dispose_engine, get_session, init_db
 
 
-__all__ = ["Base", "get_session", "init_db"]
+__all__ = ["Base", "dispose_engine", "get_session", "init_db"]

+ 8 - 0
supply_infra/db/models/__init__.py

@@ -23,6 +23,10 @@ from supply_infra.db.models.multi_demand_pool_di import MultiDemandPoolDi
 from supply_infra.db.models.multi_demand_video_detail import MultiDemandVideoDetail
 from supply_infra.db.models.multi_demand_video_detail import MultiDemandVideoDetail
 from supply_infra.db.models.multi_demand_video_point import MultiDemandVideoPoint
 from supply_infra.db.models.multi_demand_video_point import MultiDemandVideoPoint
 from supply_infra.db.models.oss_log import OssLog
 from supply_infra.db.models.oss_log import OssLog
+from supply_infra.db.models.pipeline_lock import PipelineLock
+from supply_infra.db.models.pipeline_outbox import PipelineOutbox
+from supply_infra.db.models.pipeline_run import PipelineRun
+from supply_infra.db.models.pipeline_step_run import PipelineStepRun
 from supply_infra.db.models.scheduler_job_execution import SchedulerJobExecution
 from supply_infra.db.models.scheduler_job_execution import SchedulerJobExecution
 from supply_infra.db.models.video_discovery import (
 from supply_infra.db.models.video_discovery import (
     VideoDiscoveryCandidate,
     VideoDiscoveryCandidate,
@@ -50,6 +54,10 @@ __all__ = [
     "MultiDemandVideoDetail",
     "MultiDemandVideoDetail",
     "MultiDemandVideoPoint",
     "MultiDemandVideoPoint",
     "OssLog",
     "OssLog",
+    "PipelineLock",
+    "PipelineOutbox",
+    "PipelineRun",
+    "PipelineStepRun",
     "SchedulerJobExecution",
     "SchedulerJobExecution",
     "VideoDiscoveryCandidate",
     "VideoDiscoveryCandidate",
     "VideoDiscoveryRun",
     "VideoDiscoveryRun",

+ 32 - 0
supply_infra/db/models/pipeline_lock.py

@@ -0,0 +1,32 @@
+from __future__ import annotations
+
+from datetime import datetime
+
+from sqlalchemy import BigInteger, DateTime, String, func
+from sqlalchemy.orm import Mapped, mapped_column
+
+from supply_infra.db.base import Base
+
+
+class PipelineLock(Base):
+    """Auditable lease used for cross-process pipeline coordination."""
+
+    __tablename__ = "pipeline_lock"
+
+    lock_key: Mapped[str] = mapped_column(String(191), primary_key=True)
+    owner_run_id: Mapped[str] = mapped_column(String(36), nullable=False)
+    owner_instance: Mapped[str] = mapped_column(String(191), nullable=False)
+    lease_until: Mapped[datetime] = mapped_column(DateTime, nullable=False)
+    heartbeat_at: Mapped[datetime] = mapped_column(DateTime, nullable=False)
+    version: Mapped[int] = mapped_column(BigInteger, nullable=False, default=1)
+    created_at: Mapped[datetime] = mapped_column(
+        DateTime,
+        nullable=False,
+        server_default=func.now(),
+    )
+    updated_at: Mapped[datetime] = mapped_column(
+        DateTime,
+        nullable=False,
+        server_default=func.now(),
+        onupdate=func.now(),
+    )

+ 65 - 0
supply_infra/db/models/pipeline_outbox.py

@@ -0,0 +1,65 @@
+from __future__ import annotations
+
+from datetime import datetime
+from typing import Any
+
+from sqlalchemy import (
+    Boolean,
+    DateTime,
+    ForeignKey,
+    Index,
+    JSON,
+    String,
+    Text,
+    UniqueConstraint,
+    func,
+)
+from sqlalchemy.orm import Mapped, mapped_column
+
+from supply_infra.db.base import Base
+
+
+class PipelineOutbox(Base):
+    """Dry-run ledger of intended AIGC writes; it is not dispatched in phase one."""
+
+    __tablename__ = "pipeline_outbox"
+    __table_args__ = (
+        UniqueConstraint("idempotency_key", name="uk_pipeline_outbox_idempotency"),
+        Index("idx_pipeline_outbox_run", "run_id", "step_run_id"),
+        Index("idx_pipeline_outbox_status", "status", "created_at"),
+    )
+
+    outbox_id: Mapped[str] = mapped_column(String(36), primary_key=True)
+    run_id: Mapped[str] = mapped_column(
+        String(36),
+        ForeignKey("pipeline_run.run_id", ondelete="CASCADE"),
+        nullable=False,
+    )
+    step_run_id: Mapped[str] = mapped_column(
+        String(36),
+        ForeignKey("pipeline_step_run.step_run_id", ondelete="CASCADE"),
+        nullable=False,
+    )
+    effect_type: Mapped[str] = mapped_column(String(64), nullable=False)
+    idempotency_key: Mapped[str] = mapped_column(String(191), nullable=False)
+    payload_hash: Mapped[str] = mapped_column(String(64), nullable=False)
+    payload_json: Mapped[dict[str, Any] | None] = mapped_column(JSON, nullable=True)
+    payload_uri: Mapped[str | None] = mapped_column(String(1024), nullable=True)
+    status: Mapped[str] = mapped_column(
+        String(32),
+        nullable=False,
+        default="dry_run_recorded",
+    )
+    dry_run: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
+    record_error: Mapped[str | None] = mapped_column(Text, nullable=True)
+    created_at: Mapped[datetime] = mapped_column(
+        DateTime,
+        nullable=False,
+        server_default=func.now(),
+    )
+    updated_at: Mapped[datetime] = mapped_column(
+        DateTime,
+        nullable=False,
+        server_default=func.now(),
+        onupdate=func.now(),
+    )

+ 71 - 0
supply_infra/db/models/pipeline_run.py

@@ -0,0 +1,71 @@
+from __future__ import annotations
+
+from datetime import datetime
+from typing import Any
+
+from sqlalchemy import Boolean, DateTime, Index, JSON, String, Text, UniqueConstraint, func
+from sqlalchemy.orm import Mapped, mapped_column
+
+from supply_infra.db.base import Base
+
+
+class PipelineRun(Base):
+    """One durable execution request for the complete supply pipeline."""
+
+    __tablename__ = "pipeline_run"
+    __table_args__ = (
+        UniqueConstraint("dedupe_key", name="uk_pipeline_run_dedupe_key"),
+        Index("idx_pipeline_run_biz", "pipeline_key", "biz_dt", "created_at"),
+        Index("idx_pipeline_run_status", "status", "lease_until"),
+        Index("idx_pipeline_run_deadline", "status", "deadline_at"),
+    )
+
+    run_id: Mapped[str] = mapped_column(String(36), primary_key=True)
+    dedupe_key: Mapped[str] = mapped_column(String(191), nullable=False)
+    pipeline_key: Mapped[str] = mapped_column(
+        String(64),
+        nullable=False,
+        default="supply_pipeline",
+    )
+    biz_dt: Mapped[str] = mapped_column(String(8), nullable=False)
+    trigger_type: Mapped[str] = mapped_column(String(24), nullable=False)
+    trigger_source: Mapped[str | None] = mapped_column(String(64), nullable=True)
+    triggered_by: Mapped[str | None] = mapped_column(String(128), nullable=True)
+    trigger_reason: Mapped[str | None] = mapped_column(Text, nullable=True)
+    parent_run_id: Mapped[str | None] = mapped_column(String(36), nullable=True)
+    run_mode: Mapped[str] = mapped_column(String(24), nullable=False, default="full")
+    dry_run: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
+    status: Mapped[str] = mapped_column(String(32), nullable=False, default="queued")
+    current_step: Mapped[str | None] = mapped_column(String(64), nullable=True)
+    scheduled_for: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
+    deadline_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
+    started_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
+    finished_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
+    heartbeat_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
+    lease_owner: Mapped[str | None] = mapped_column(String(191), nullable=True)
+    lease_until: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
+    code_version: Mapped[str | None] = mapped_column(String(128), nullable=True)
+    config_snapshot_json: Mapped[dict[str, Any]] = mapped_column(
+        JSON,
+        nullable=False,
+        default=dict,
+    )
+    date_snapshot_json: Mapped[dict[str, Any]] = mapped_column(
+        JSON,
+        nullable=False,
+        default=dict,
+    )
+    summary_json: Mapped[dict[str, Any] | None] = mapped_column(JSON, nullable=True)
+    error_code: Mapped[str | None] = mapped_column(String(64), nullable=True)
+    error_message: Mapped[str | None] = mapped_column(Text, nullable=True)
+    created_at: Mapped[datetime] = mapped_column(
+        DateTime,
+        nullable=False,
+        server_default=func.now(),
+    )
+    updated_at: Mapped[datetime] = mapped_column(
+        DateTime,
+        nullable=False,
+        server_default=func.now(),
+        onupdate=func.now(),
+    )

+ 85 - 0
supply_infra/db/models/pipeline_step_run.py

@@ -0,0 +1,85 @@
+from __future__ import annotations
+
+from datetime import datetime
+from typing import Any
+
+from sqlalchemy import (
+    Boolean,
+    DateTime,
+    ForeignKey,
+    Index,
+    Integer,
+    JSON,
+    String,
+    Text,
+    UniqueConstraint,
+    func,
+)
+from sqlalchemy.orm import Mapped, mapped_column
+
+from supply_infra.db.base import Base
+
+
+class PipelineStepRun(Base):
+    """One durable attempt of a pipeline step."""
+
+    __tablename__ = "pipeline_step_run"
+    __table_args__ = (
+        UniqueConstraint(
+            "run_id",
+            "step_key",
+            "attempt",
+            name="uk_pipeline_step_run_attempt",
+        ),
+        Index("idx_pipeline_step_claim", "status", "next_retry_at", "step_order"),
+        Index("idx_pipeline_step_run", "run_id", "step_order", "attempt"),
+        Index("idx_pipeline_step_lease", "status", "lease_until"),
+    )
+
+    step_run_id: Mapped[str] = mapped_column(String(36), primary_key=True)
+    run_id: Mapped[str] = mapped_column(
+        String(36),
+        ForeignKey("pipeline_run.run_id", ondelete="CASCADE"),
+        nullable=False,
+    )
+    step_key: Mapped[str] = mapped_column(String(64), nullable=False)
+    step_order: Mapped[int] = mapped_column(Integer, nullable=False)
+    attempt: Mapped[int] = mapped_column(Integer, nullable=False, default=1)
+    status: Mapped[str] = mapped_column(String(32), nullable=False, default="pending")
+    critical: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
+    dependency_snapshot_json: Mapped[list[str]] = mapped_column(
+        JSON,
+        nullable=False,
+        default=list,
+    )
+    input_snapshot_json: Mapped[dict[str, Any]] = mapped_column(
+        JSON,
+        nullable=False,
+        default=dict,
+    )
+    timeout_seconds: Mapped[int] = mapped_column(Integer, nullable=False)
+    max_attempts: Mapped[int] = mapped_column(Integer, nullable=False, default=1)
+    retryable: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
+    next_retry_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
+    started_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
+    finished_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
+    heartbeat_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
+    lease_owner: Mapped[str | None] = mapped_column(String(191), nullable=True)
+    lease_until: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
+    exit_code: Mapped[int | None] = mapped_column(Integer, nullable=True)
+    result_summary_json: Mapped[dict[str, Any] | None] = mapped_column(JSON, nullable=True)
+    metrics_json: Mapped[dict[str, Any] | None] = mapped_column(JSON, nullable=True)
+    error_code: Mapped[str | None] = mapped_column(String(64), nullable=True)
+    error_message: Mapped[str | None] = mapped_column(Text, nullable=True)
+    log_uri: Mapped[str | None] = mapped_column(String(1024), nullable=True)
+    created_at: Mapped[datetime] = mapped_column(
+        DateTime,
+        nullable=False,
+        server_default=func.now(),
+    )
+    updated_at: Mapped[datetime] = mapped_column(
+        DateTime,
+        nullable=False,
+        server_default=func.now(),
+        onupdate=func.now(),
+    )

+ 8 - 0
supply_infra/db/repositories/__init__.py

@@ -36,6 +36,10 @@ from supply_infra.db.repositories.multi_demand_video_point_repo import (
     MultiDemandVideoPointRepository,
     MultiDemandVideoPointRepository,
 )
 )
 from supply_infra.db.repositories.oss_log_repo import OssLogRepository
 from supply_infra.db.repositories.oss_log_repo import OssLogRepository
+from supply_infra.db.repositories.pipeline_lock_repo import PipelineLockRepository
+from supply_infra.db.repositories.pipeline_outbox_repo import PipelineOutboxRepository
+from supply_infra.db.repositories.pipeline_run_repo import PipelineRunRepository
+from supply_infra.db.repositories.pipeline_step_run_repo import PipelineStepRunRepository
 from supply_infra.db.repositories.scheduler_job_execution_repo import (
 from supply_infra.db.repositories.scheduler_job_execution_repo import (
     SchedulerJobExecutionRepository,
     SchedulerJobExecutionRepository,
 )
 )
@@ -60,6 +64,10 @@ __all__ = [
     "MultiDemandVideoDetailRepository",
     "MultiDemandVideoDetailRepository",
     "MultiDemandVideoPointRepository",
     "MultiDemandVideoPointRepository",
     "OssLogRepository",
     "OssLogRepository",
+    "PipelineLockRepository",
+    "PipelineOutboxRepository",
+    "PipelineRunRepository",
+    "PipelineStepRunRepository",
     "SchedulerJobExecutionRepository",
     "SchedulerJobExecutionRepository",
     "VideoDiscoveryRepository",
     "VideoDiscoveryRepository",
 ]
 ]

+ 122 - 0
supply_infra/db/repositories/pipeline_lock_repo.py

@@ -0,0 +1,122 @@
+from __future__ import annotations
+
+from datetime import datetime, timedelta
+
+from sqlalchemy import select
+
+from supply_infra.db.models.pipeline_lock import PipelineLock
+from supply_infra.db.repositories.base import BaseRepository
+
+CONTROL_PLANE_GUARD_KEY = "pipeline:claim_guard"
+
+
+class PipelineLockRepository(BaseRepository[PipelineLock]):
+    model = PipelineLock
+
+    def get(self, lock_key: str) -> PipelineLock | None:
+        return self.session.get(PipelineLock, lock_key)
+
+    def lock_control_plane(self, *, now: datetime) -> PipelineLock:
+        """Lock the singleton row used for atomic scheduling decisions."""
+        lock = self.session.scalar(
+            select(PipelineLock)
+            .where(PipelineLock.lock_key == CONTROL_PLANE_GUARD_KEY)
+            .with_for_update()
+        )
+        if lock is None:
+            # Production gets this row from Alembic. This fallback supports
+            # local databases created from SQLAlchemy metadata.
+            lock = PipelineLock(
+                lock_key=CONTROL_PLANE_GUARD_KEY,
+                owner_run_id="control-plane",
+                owner_instance="claim-coordinator",
+                lease_until=now,
+                heartbeat_at=now,
+                version=1,
+            )
+            self.session.add(lock)
+            self.session.flush()
+        return lock
+
+    def touch(
+        self,
+        *,
+        lock_key: str,
+        owner: str,
+        lease_seconds: int,
+        now: datetime,
+    ) -> PipelineLock:
+        lock = self.session.scalar(
+            select(PipelineLock)
+            .where(PipelineLock.lock_key == lock_key)
+            .with_for_update()
+        )
+        lease_until = now + timedelta(seconds=lease_seconds)
+        if lock is None:
+            lock = PipelineLock(
+                lock_key=lock_key,
+                owner_run_id="control-plane",
+                owner_instance=owner,
+                lease_until=lease_until,
+                heartbeat_at=now,
+                version=1,
+            )
+            self.session.add(lock)
+        else:
+            lock.owner_instance = owner
+            lock.lease_until = lease_until
+            lock.heartbeat_at = now
+            lock.version += 1
+        self.session.flush()
+        return lock
+
+    def acquire(
+        self,
+        *,
+        lock_key: str,
+        run_id: str,
+        owner: str,
+        lease_seconds: int,
+        now: datetime,
+    ) -> bool:
+        stmt = (
+            select(PipelineLock)
+            .where(PipelineLock.lock_key == lock_key)
+            .with_for_update()
+        )
+        lock = self.session.scalar(stmt)
+        lease_until = now + timedelta(seconds=lease_seconds)
+        if lock is None:
+            self.add(
+                PipelineLock(
+                    lock_key=lock_key,
+                    owner_run_id=run_id,
+                    owner_instance=owner,
+                    lease_until=lease_until,
+                    heartbeat_at=now,
+                    version=1,
+                )
+            )
+            return True
+        if lock.lease_until >= now and lock.owner_run_id != run_id:
+            return False
+        lock.owner_run_id = run_id
+        lock.owner_instance = owner
+        lock.lease_until = lease_until
+        lock.heartbeat_at = now
+        lock.version += 1
+        self.session.flush()
+        return True
+
+    def release(self, lock_key: str, *, run_id: str) -> bool:
+        stmt = (
+            select(PipelineLock)
+            .where(PipelineLock.lock_key == lock_key)
+            .with_for_update()
+        )
+        lock = self.session.scalar(stmt)
+        if lock is None or lock.owner_run_id != run_id:
+            return False
+        self.session.delete(lock)
+        self.session.flush()
+        return True

+ 54 - 0
supply_infra/db/repositories/pipeline_outbox_repo.py

@@ -0,0 +1,54 @@
+from __future__ import annotations
+
+import uuid
+from typing import Any
+
+from sqlalchemy import select
+
+from supply_infra.db.models.pipeline_outbox import PipelineOutbox
+from supply_infra.db.repositories.base import BaseRepository
+
+
+class PipelineOutboxRepository(BaseRepository[PipelineOutbox]):
+    model = PipelineOutbox
+
+    def get_by_idempotency_key(self, key: str) -> PipelineOutbox | None:
+        stmt = select(PipelineOutbox).where(PipelineOutbox.idempotency_key == key)
+        return self.session.scalar(stmt)
+
+    def record_dry_run(
+        self,
+        *,
+        run_id: str,
+        step_run_id: str,
+        effect_type: str,
+        idempotency_key: str,
+        payload_hash: str,
+        payload: dict[str, Any] | None,
+        payload_uri: str | None = None,
+    ) -> PipelineOutbox:
+        existing = self.get_by_idempotency_key(idempotency_key)
+        if existing is not None:
+            return existing
+        return self.add(
+            PipelineOutbox(
+                outbox_id=str(uuid.uuid4()),
+                run_id=run_id,
+                step_run_id=step_run_id,
+                effect_type=effect_type,
+                idempotency_key=idempotency_key,
+                payload_hash=payload_hash,
+                payload_json=payload,
+                payload_uri=payload_uri,
+                status="dry_run_recorded",
+                dry_run=True,
+            )
+        )
+
+    def list_for_run(self, run_id: str) -> list[PipelineOutbox]:
+        stmt = (
+            select(PipelineOutbox)
+            .where(PipelineOutbox.run_id == run_id)
+            .order_by(PipelineOutbox.created_at)
+        )
+        return list(self.session.scalars(stmt).all())

+ 170 - 0
supply_infra/db/repositories/pipeline_run_repo.py

@@ -0,0 +1,170 @@
+from __future__ import annotations
+
+from datetime import datetime
+from typing import Any
+
+from sqlalchemy import exists, select
+
+from supply_infra.db.models.pipeline_run import PipelineRun
+from supply_infra.db.models.pipeline_step_run import PipelineStepRun
+from supply_infra.db.repositories.base import BaseRepository
+
+
+class PipelineRunRepository(BaseRepository[PipelineRun]):
+    model = PipelineRun
+
+    def get(self, run_id: str, *, for_update: bool = False) -> PipelineRun | None:
+        stmt = select(PipelineRun).where(PipelineRun.run_id == run_id)
+        if for_update:
+            stmt = stmt.with_for_update()
+        return self.session.scalar(stmt)
+
+    def get_by_dedupe_key(self, dedupe_key: str) -> PipelineRun | None:
+        stmt = select(PipelineRun).where(PipelineRun.dedupe_key == dedupe_key)
+        return self.session.scalar(stmt)
+
+    def get_for_business_day(
+        self,
+        *,
+        pipeline_key: str,
+        biz_dt: str,
+    ) -> PipelineRun | None:
+        stmt = (
+            select(PipelineRun)
+            .where(PipelineRun.pipeline_key == pipeline_key)
+            .where(PipelineRun.biz_dt == biz_dt)
+            .order_by(PipelineRun.created_at.desc())
+            .limit(1)
+        )
+        return self.session.scalar(stmt)
+
+    def create(self, values: dict[str, Any]) -> PipelineRun:
+        return self.add(PipelineRun(**values))
+
+    def list_recent(
+        self,
+        *,
+        limit: int = 50,
+        status: str | None = None,
+        biz_dt: str | None = None,
+    ) -> list[PipelineRun]:
+        stmt = select(PipelineRun)
+        if status:
+            stmt = stmt.where(PipelineRun.status == status)
+        if biz_dt:
+            stmt = stmt.where(PipelineRun.biz_dt == biz_dt)
+        stmt = stmt.order_by(PipelineRun.created_at.desc()).limit(limit)
+        return list(self.session.scalars(stmt).all())
+
+    def get_latest_nonterminal(self) -> PipelineRun | None:
+        has_running_step = exists(
+            select(PipelineStepRun.step_run_id)
+            .where(PipelineStepRun.run_id == PipelineRun.run_id)
+            .where(PipelineStepRun.status == "running")
+        )
+        stmt = (
+            select(PipelineRun)
+            .where(
+                PipelineRun.status.in_(
+                    ("queued", "running", "cancelling", "blocked_previous_run")
+                )
+                | has_running_step
+            )
+            .order_by(PipelineRun.created_at.desc())
+            .limit(1)
+        )
+        return self.session.scalar(stmt)
+
+    def list_nonterminal(self, *, limit: int = 100) -> list[PipelineRun]:
+        stmt = (
+            select(PipelineRun)
+            .where(
+                PipelineRun.status.in_(
+                    ("queued", "running", "cancelling", "blocked_previous_run")
+                )
+            )
+            .order_by(PipelineRun.created_at)
+            .limit(limit)
+        )
+        return list(self.session.scalars(stmt).all())
+
+    def list_past_deadline(
+        self,
+        now: datetime,
+        *,
+        limit: int = 100,
+    ) -> list[PipelineRun]:
+        stmt = (
+            select(PipelineRun)
+            .where(PipelineRun.status.in_(("queued", "running")))
+            .where(PipelineRun.deadline_at <= now)
+            .order_by(PipelineRun.deadline_at)
+            .with_for_update(skip_locked=True)
+            .limit(limit)
+        )
+        return list(self.session.scalars(stmt).all())
+
+    def list_blocked_previous(self, *, limit: int = 100) -> list[PipelineRun]:
+        stmt = (
+            select(PipelineRun)
+            .where(PipelineRun.status == "blocked_previous_run")
+            .order_by(PipelineRun.biz_dt, PipelineRun.created_at)
+            .with_for_update(skip_locked=True)
+            .limit(limit)
+        )
+        return list(self.session.scalars(stmt).all())
+
+    def mark_running(
+        self,
+        run: PipelineRun,
+        *,
+        step_key: str,
+        owner: str,
+        now: datetime,
+        lease_until: datetime,
+    ) -> None:
+        if run.started_at is None:
+            run.started_at = now
+        run.status = "running"
+        run.current_step = step_key
+        run.heartbeat_at = now
+        run.lease_owner = owner
+        run.lease_until = lease_until
+        self.session.flush()
+
+    def finish(
+        self,
+        run: PipelineRun,
+        *,
+        status: str,
+        now: datetime,
+        summary: dict[str, Any] | None = None,
+        error_code: str | None = None,
+        error_message: str | None = None,
+    ) -> None:
+        run.status = status
+        run.finished_at = now
+        run.heartbeat_at = now
+        run.lease_owner = None
+        run.lease_until = None
+        run.current_step = None
+        run.summary_json = summary
+        run.error_code = error_code
+        run.error_message = error_message
+        self.session.flush()
+
+    def heartbeat(
+        self,
+        run_id: str,
+        *,
+        owner: str,
+        now: datetime,
+        lease_until: datetime,
+    ) -> bool:
+        run = self.get(run_id, for_update=True)
+        if run is None or run.lease_owner != owner or run.status != "running":
+            return False
+        run.heartbeat_at = now
+        run.lease_until = lease_until
+        self.session.flush()
+        return True

+ 225 - 0
supply_infra/db/repositories/pipeline_step_run_repo.py

@@ -0,0 +1,225 @@
+from __future__ import annotations
+
+import uuid
+from datetime import datetime, timedelta
+from typing import Any
+
+from sqlalchemy import func, select
+
+from supply_infra.db.models.pipeline_step_run import PipelineStepRun
+from supply_infra.db.repositories.base import BaseRepository
+from supply_infra.db.repositories.pipeline_lock_repo import PipelineLockRepository
+
+
+class PipelineStepRunRepository(BaseRepository[PipelineStepRun]):
+    model = PipelineStepRun
+
+    def get(
+        self,
+        step_run_id: str,
+        *,
+        for_update: bool = False,
+    ) -> PipelineStepRun | None:
+        stmt = select(PipelineStepRun).where(
+            PipelineStepRun.step_run_id == step_run_id
+        )
+        if for_update:
+            stmt = stmt.with_for_update()
+        return self.session.scalar(stmt)
+
+    def create_steps(self, rows: list[dict[str, Any]]) -> list[PipelineStepRun]:
+        entities = [PipelineStepRun(**row) for row in rows]
+        self.session.add_all(entities)
+        self.session.flush()
+        return entities
+
+    def list_for_run(self, run_id: str) -> list[PipelineStepRun]:
+        stmt = (
+            select(PipelineStepRun)
+            .where(PipelineStepRun.run_id == run_id)
+            .order_by(PipelineStepRun.step_order, PipelineStepRun.attempt)
+        )
+        return list(self.session.scalars(stmt).all())
+
+    def latest_attempts(self, run_id: str) -> dict[str, PipelineStepRun]:
+        latest: dict[str, PipelineStepRun] = {}
+        for item in self.list_for_run(run_id):
+            latest[item.step_key] = item
+        return latest
+
+    def claim_next_ready(
+        self,
+        *,
+        owner: str,
+        lease_seconds: int,
+        max_active_steps: int,
+        now: datetime,
+    ) -> PipelineStepRun | None:
+        # Serialize count-and-claim so concurrent workers cannot oversubscribe
+        # the configured global active-step cap.
+        PipelineLockRepository(self.session).lock_control_plane(now=now)
+
+        active_steps = self.session.scalar(
+            select(func.count())
+            .select_from(PipelineStepRun)
+            .where(PipelineStepRun.status == "running")
+        )
+        if int(active_steps or 0) >= max_active_steps:
+            return None
+        running_run_ids = list(
+            self.session.scalars(
+                select(PipelineStepRun.run_id)
+                .where(PipelineStepRun.status == "running")
+                .distinct()
+            ).all()
+        )
+
+        stmt = (
+            select(PipelineStepRun)
+            .where(PipelineStepRun.status == "ready")
+            .where(
+                (PipelineStepRun.next_retry_at.is_(None))
+                | (PipelineStepRun.next_retry_at <= now)
+            )
+            .order_by(PipelineStepRun.created_at, PipelineStepRun.step_order)
+            .with_for_update(skip_locked=True)
+            .limit(1)
+        )
+        if running_run_ids:
+            # A daily run may contain parallel steps in the future, but a
+            # different run must never overlap while an old process is alive.
+            stmt = stmt.where(PipelineStepRun.run_id.in_(running_run_ids))
+        step = self.session.scalar(stmt)
+        if step is None:
+            return None
+        step.status = "running"
+        step.started_at = step.started_at or now
+        step.heartbeat_at = now
+        step.lease_owner = owner
+        step.lease_until = now + timedelta(seconds=lease_seconds)
+        self.session.flush()
+        return step
+
+    def has_running_for_run(self, run_id: str) -> bool:
+        count = self.session.scalar(
+            select(func.count())
+            .select_from(PipelineStepRun)
+            .where(PipelineStepRun.run_id == run_id)
+            .where(PipelineStepRun.status == "running")
+        )
+        return bool(count)
+
+    def heartbeat(
+        self,
+        step_run_id: str,
+        *,
+        owner: str,
+        now: datetime,
+        lease_until: datetime,
+    ) -> bool:
+        step = self.get(step_run_id, for_update=True)
+        if step is None or step.status != "running" or step.lease_owner != owner:
+            return False
+        step.heartbeat_at = now
+        step.lease_until = lease_until
+        self.session.flush()
+        return True
+
+    def finish(
+        self,
+        step: PipelineStepRun,
+        *,
+        status: str,
+        now: datetime,
+        exit_code: int | None,
+        result: dict[str, Any] | None,
+        error_code: str | None,
+        error_message: str | None,
+        log_uri: str | None,
+    ) -> None:
+        step.status = status
+        step.finished_at = now
+        step.heartbeat_at = now
+        step.lease_owner = None
+        step.lease_until = None
+        step.exit_code = exit_code
+        step.result_summary_json = result
+        step.error_code = error_code
+        step.error_message = error_message
+        step.log_uri = log_uri
+        self.session.flush()
+
+    def create_retry_attempt(
+        self,
+        previous: PipelineStepRun,
+        *,
+        status: str = "ready",
+        next_retry_at: datetime | None = None,
+    ) -> PipelineStepRun:
+        entity = PipelineStepRun(
+            step_run_id=str(uuid.uuid4()),
+            run_id=previous.run_id,
+            step_key=previous.step_key,
+            step_order=previous.step_order,
+            attempt=previous.attempt + 1,
+            status=status,
+            critical=previous.critical,
+            dependency_snapshot_json=list(previous.dependency_snapshot_json or []),
+            input_snapshot_json=dict(previous.input_snapshot_json or {}),
+            timeout_seconds=previous.timeout_seconds,
+            max_attempts=previous.max_attempts,
+            retryable=previous.retryable,
+            next_retry_at=next_retry_at,
+        )
+        return self.add(entity)
+
+    def list_expired_running(self, now: datetime, *, limit: int = 100) -> list[PipelineStepRun]:
+        stmt = (
+            select(PipelineStepRun)
+            .where(PipelineStepRun.status == "running")
+            .where(PipelineStepRun.lease_until.is_not(None))
+            .where(PipelineStepRun.lease_until < now)
+            .order_by(PipelineStepRun.lease_until)
+            .with_for_update(skip_locked=True)
+            .limit(limit)
+        )
+        return list(self.session.scalars(stmt).all())
+
+    def promote_due_retries(self, now: datetime, *, limit: int = 100) -> int:
+        stmt = (
+            select(PipelineStepRun)
+            .where(PipelineStepRun.status == "retry_wait")
+            .where(PipelineStepRun.next_retry_at <= now)
+            .order_by(PipelineStepRun.next_retry_at)
+            .with_for_update(skip_locked=True)
+            .limit(limit)
+        )
+        count = 0
+        for step in self.session.scalars(stmt).all():
+            step.status = "ready"
+            count += 1
+        self.session.flush()
+        return count
+
+    def block_unfinished_downstream(
+        self,
+        *,
+        run_id: str,
+        after_order: int,
+        reason: str,
+    ) -> int:
+        count = 0
+        for step in self.list_for_run(run_id):
+            if step.step_order <= after_order or step.status in {
+                "succeeded",
+                "failed",
+                "timed_out",
+                "cancelled",
+            }:
+                continue
+            step.status = "blocked"
+            step.error_code = "upstream_failed"
+            step.error_message = reason
+            count += 1
+        self.session.flush()
+        return count

+ 25 - 2
supply_infra/db/session.py

@@ -4,7 +4,7 @@ from collections.abc import Generator
 from contextlib import contextmanager
 from contextlib import contextmanager
 from typing import Any
 from typing import Any
 
 
-from sqlalchemy import create_engine, inspect
+from sqlalchemy import create_engine, event, inspect
 from sqlalchemy.orm import Session, sessionmaker
 from sqlalchemy.orm import Session, sessionmaker
 
 
 from supply_infra.config import get_infra_settings
 from supply_infra.config import get_infra_settings
@@ -14,6 +14,15 @@ _engine: Any = None
 _SessionLocal: sessionmaker[Session] | None = None
 _SessionLocal: sessionmaker[Session] | None = None
 
 
 
 
+def _set_mysql_session_utc(dbapi_connection: Any, _connection_record: Any) -> None:
+    """Keep MySQL-generated timestamps aligned with application UTC values."""
+    cursor = dbapi_connection.cursor()
+    try:
+        cursor.execute("SET time_zone = '+00:00'")
+    finally:
+        cursor.close()
+
+
 def get_engine():
 def get_engine():
     """Lazy-init SQLAlchemy engine (singleton)."""
     """Lazy-init SQLAlchemy engine (singleton)."""
     global _engine, _SessionLocal
     global _engine, _SessionLocal
@@ -21,14 +30,28 @@ def get_engine():
         settings = get_infra_settings()
         settings = get_infra_settings()
         _engine = create_engine(
         _engine = create_engine(
             settings.mysql_url,
             settings.mysql_url,
-            pool_size=settings.mysql_pool_size,
+            pool_size=settings.selected_mysql_pool_size,
+            max_overflow=settings.mysql_max_overflow,
+            pool_timeout=settings.mysql_pool_timeout_seconds,
+            pool_recycle=settings.mysql_pool_recycle_seconds,
             pool_pre_ping=True,
             pool_pre_ping=True,
             echo=settings.mysql_echo,
             echo=settings.mysql_echo,
         )
         )
+        if _engine.dialect.name == "mysql":
+            event.listen(_engine, "connect", _set_mysql_session_utc)
         _SessionLocal = sessionmaker(bind=_engine, autoflush=False, autocommit=False)
         _SessionLocal = sessionmaker(bind=_engine, autoflush=False, autocommit=False)
     return _engine
     return _engine
 
 
 
 
+def dispose_engine() -> None:
+    """Dispose process-local connections, mainly for graceful shutdown and tests."""
+    global _engine, _SessionLocal
+    if _engine is not None:
+        _engine.dispose()
+    _engine = None
+    _SessionLocal = None
+
+
 def init_db() -> dict[str, list[str]]:
 def init_db() -> dict[str, list[str]]:
     """Create all tables (dev / first-run). Import models before calling."""
     """Create all tables (dev / first-run). Import models before calling."""
     import supply_infra.db.models  # noqa: F401 — register all models
     import supply_infra.db.models  # noqa: F401 — register all models

+ 5 - 0
supply_infra/pipeline/__init__.py

@@ -0,0 +1,5 @@
+"""Durable supply pipeline control plane."""
+
+from supply_infra.pipeline.run_service import RunSubmission, submit_pipeline_run
+
+__all__ = ["RunSubmission", "submit_pipeline_run"]

+ 58 - 0
supply_infra/pipeline/cli.py

@@ -0,0 +1,58 @@
+from __future__ import annotations
+
+import argparse
+import json
+import logging
+
+from supply_infra.pipeline.reconciler import PipelineReconciler, reconcile_once
+from supply_infra.pipeline.run_service import (
+    get_pipeline_run,
+    submit_pipeline_run,
+)
+from supply_infra.pipeline.worker import PipelineWorker
+
+
+def _parser() -> argparse.ArgumentParser:
+    parser = argparse.ArgumentParser(description="SupplyAgent durable pipeline control")
+    sub = parser.add_subparsers(dest="command", required=True)
+    submit = sub.add_parser("submit")
+    submit.add_argument("--biz-dt")
+    submit.add_argument("--reason")
+    inspect = sub.add_parser("inspect")
+    inspect.add_argument("--run-id", required=True)
+    sub.add_parser("worker")
+    sub.add_parser("reconciler")
+    sub.add_parser("reconcile-once")
+    return parser
+
+
+def main() -> None:
+    logging.basicConfig(
+        level=logging.INFO,
+        format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
+    )
+    args = _parser().parse_args()
+    if args.command == "submit":
+        result = submit_pipeline_run(
+            biz_dt=args.biz_dt,
+            trigger_type="cli",
+            trigger_source="cli",
+            trigger_reason=args.reason,
+        ).to_dict()
+        print(json.dumps(result, ensure_ascii=False))
+        return
+    if args.command == "inspect":
+        result = get_pipeline_run(args.run_id)
+        print(json.dumps(result, ensure_ascii=False, default=str))
+        raise SystemExit(0 if result is not None else 1)
+    if args.command == "worker":
+        PipelineWorker().run_forever()
+        return
+    if args.command == "reconciler":
+        PipelineReconciler().run_forever()
+        return
+    print(json.dumps(reconcile_once(), ensure_ascii=False))
+
+
+if __name__ == "__main__":
+    main()

+ 37 - 0
supply_infra/pipeline/contracts.py

@@ -0,0 +1,37 @@
+from __future__ import annotations
+
+from dataclasses import dataclass, field
+from typing import Any
+
+
+@dataclass(frozen=True)
+class StepContext:
+    run_id: str
+    step_run_id: str
+    step_key: str
+    biz_dt: str
+    date_snapshot: dict[str, Any]
+    config_snapshot: dict[str, Any]
+    input_snapshot: dict[str, Any] = field(default_factory=dict)
+
+
+@dataclass(frozen=True)
+class StepResult:
+    success: bool
+    payload: dict[str, Any]
+    error_code: str | None = None
+    error_message: str | None = None
+    exit_code: int | None = None
+    log_uri: str | None = None
+    timed_out: bool = False
+
+    def to_dict(self) -> dict[str, Any]:
+        return {
+            "success": self.success,
+            "payload": self.payload,
+            "error_code": self.error_code,
+            "error_message": self.error_message,
+            "exit_code": self.exit_code,
+            "log_uri": self.log_uri,
+            "timed_out": self.timed_out,
+        }

+ 66 - 0
supply_infra/pipeline/dag.py

@@ -0,0 +1,66 @@
+from __future__ import annotations
+
+from dataclasses import dataclass
+
+
+@dataclass(frozen=True)
+class StepDefinition:
+    key: str
+    order: int
+    dependencies: tuple[str, ...]
+    timeout_seconds: int
+    max_attempts: int = 1
+    retryable: bool = False
+    critical: bool = True
+
+
+_RAW_STEPS: tuple[tuple[str, int, bool], ...] = (
+    ("global_tree_sync", 3600, True),
+    ("demand_pool_source_sync", 3600, True),
+    ("demand_classify", 21600, False),
+    ("demand_belong_rel_sync", 3600, True),
+    ("real_metrics_sync", 3600, True),
+    ("popularity_stats", 3600, True),
+    ("category_tree_weight", 3600, True),
+    ("source_video_sync", 7200, True),
+    ("demand_grade", 21600, False),
+    ("demand_expand", 21600, False),
+    ("video_discovery", 21600, False),
+    ("aigc_write_record", 1800, True),
+)
+
+
+def _build_steps() -> tuple[StepDefinition, ...]:
+    result: list[StepDefinition] = []
+    previous: str | None = None
+    for order, (key, timeout_seconds, retryable) in enumerate(_RAW_STEPS, start=1):
+        result.append(
+            StepDefinition(
+                key=key,
+                order=order,
+                dependencies=(previous,) if previous else (),
+                timeout_seconds=timeout_seconds,
+                max_attempts=2 if retryable else 1,
+                retryable=retryable,
+            )
+        )
+        previous = key
+    return tuple(result)
+
+
+PIPELINE_STEPS = _build_steps()
+STEP_BY_KEY = {step.key: step for step in PIPELINE_STEPS}
+
+
+def validate_dag() -> None:
+    seen: set[str] = set()
+    for step in PIPELINE_STEPS:
+        if step.key in seen:
+            raise ValueError(f"Duplicate step key: {step.key}")
+        missing = set(step.dependencies) - seen
+        if missing:
+            raise ValueError(f"Step {step.key} has unresolved dependencies: {sorted(missing)}")
+        seen.add(step.key)
+
+
+validate_dag()

+ 102 - 0
supply_infra/pipeline/dates.py

@@ -0,0 +1,102 @@
+from __future__ import annotations
+
+from datetime import datetime, timedelta, timezone
+from zoneinfo import ZoneInfo
+
+from supply_infra.config import InfraSettings, get_infra_settings
+
+
+def validate_biz_dt(value: str) -> str:
+    text = str(value).strip()
+    parsed = datetime.strptime(text, "%Y%m%d")
+    if parsed.strftime("%Y%m%d") != text:
+        raise ValueError(f"Invalid biz_dt: {value!r}")
+    return text
+
+
+def resolve_biz_dt(
+    biz_dt: str | None,
+    *,
+    settings: InfraSettings | None = None,
+    now: datetime | None = None,
+) -> str:
+    if biz_dt:
+        return validate_biz_dt(biz_dt)
+    active = settings or get_infra_settings()
+    local_now = now or datetime.now(ZoneInfo(active.scheduler_timezone))
+    if local_now.tzinfo is None:
+        local_now = local_now.replace(tzinfo=ZoneInfo(active.scheduler_timezone))
+    return local_now.astimezone(ZoneInfo(active.scheduler_timezone)).strftime("%Y%m%d")
+
+
+def build_date_snapshot(biz_dt: str) -> dict[str, str]:
+    resolved = validate_biz_dt(biz_dt)
+    day = datetime.strptime(resolved, "%Y%m%d")
+    previous = (day - timedelta(days=1)).strftime("%Y%m%d")
+    return {
+        "biz_dt": resolved,
+        "global_tree_partition": previous,
+        "demand_pool_partition": resolved,
+        "video_decode_partition": previous,
+    }
+
+
+def utc_now() -> datetime:
+    return datetime.now(timezone.utc).replace(tzinfo=None)
+
+
+def next_schedule_utc(
+    *,
+    settings: InfraSettings | None = None,
+    now: datetime | None = None,
+) -> datetime:
+    active = settings or get_infra_settings()
+    zone = ZoneInfo(active.scheduler_timezone)
+    local_now = now or datetime.now(zone)
+    if local_now.tzinfo is None:
+        local_now = local_now.replace(tzinfo=zone)
+    else:
+        local_now = local_now.astimezone(zone)
+    candidate = local_now.replace(
+        hour=active.scheduler_cron_hour,
+        minute=active.scheduler_cron_minute,
+        second=0,
+        microsecond=0,
+    )
+    if candidate <= local_now:
+        candidate += timedelta(days=1)
+    return candidate.astimezone(timezone.utc).replace(tzinfo=None)
+
+
+def next_daily_deadline_utc(
+    *,
+    settings: InfraSettings | None = None,
+    now: datetime | None = None,
+) -> datetime:
+    """Return the next local calendar day's scheduler start as UTC-naive."""
+    active = settings or get_infra_settings()
+    zone = ZoneInfo(active.scheduler_timezone)
+    local_now = now or datetime.now(zone)
+    if local_now.tzinfo is None:
+        local_now = local_now.replace(tzinfo=zone)
+    else:
+        local_now = local_now.astimezone(zone)
+    candidate = (local_now + timedelta(days=1)).replace(
+        hour=active.scheduler_cron_hour,
+        minute=active.scheduler_cron_minute,
+        second=0,
+        microsecond=0,
+    )
+    return candidate.astimezone(timezone.utc).replace(tzinfo=None)
+
+
+def deadline_for_trigger(
+    trigger_type: str,
+    *,
+    settings: InfraSettings | None = None,
+    now: datetime | None = None,
+) -> datetime | None:
+    """Scheduled daily runs have a next-day deadline; manual runs do not."""
+    if trigger_type not in {"cron", "reconcile"}:
+        return None
+    return next_daily_deadline_utc(settings=settings, now=now)

+ 32 - 0
supply_infra/pipeline/enums.py

@@ -0,0 +1,32 @@
+from __future__ import annotations
+
+from enum import StrEnum
+
+
+class RunStatus(StrEnum):
+    QUEUED = "queued"
+    RUNNING = "running"
+    SUCCEEDED = "succeeded"
+    FAILED = "failed"
+    BLOCKED = "blocked"
+    CANCELLING = "cancelling"
+    CANCELLED = "cancelled"
+    DEADLINE_EXCEEDED = "deadline_exceeded"
+    BLOCKED_PREVIOUS_RUN = "blocked_previous_run"
+
+
+class StepStatus(StrEnum):
+    PENDING = "pending"
+    READY = "ready"
+    RUNNING = "running"
+    RETRY_WAIT = "retry_wait"
+    SUCCEEDED = "succeeded"
+    FAILED = "failed"
+    BLOCKED = "blocked"
+    TIMED_OUT = "timed_out"
+    CANCELLED = "cancelled"
+
+
+class OutboxStatus(StrEnum):
+    DRY_RUN_RECORDED = "dry_run_recorded"
+    RECORD_FAILED = "record_failed"

+ 48 - 0
supply_infra/pipeline/gates.py

@@ -0,0 +1,48 @@
+from __future__ import annotations
+
+from dataclasses import dataclass
+from typing import Any
+
+
+@dataclass(frozen=True)
+class GateDecision:
+    passed: bool
+    error_code: str | None = None
+    error_message: str | None = None
+
+
+def evaluate_step_gate(step_key: str, payload: dict[str, Any]) -> GateDecision:
+    if payload.get("success") is False:
+        return GateDecision(
+            passed=False,
+            error_code=str(payload.get("error_code") or "step_reported_failure"),
+            error_message=str(payload.get("error") or "Step returned success=false"),
+        )
+
+    if step_key == "demand_classify" and payload.get("failed_batches"):
+        return GateDecision(False, "classification_failed_batches", "Classification has failed batches")
+
+    if step_key in {"demand_grade", "demand_expand", "video_discovery"}:
+        failed = int(payload.get("failed", payload.get("failed_count", 0)) or 0)
+        if failed > 0:
+            return GateDecision(
+                False,
+                "business_failures",
+                f"{step_key} reported {failed} failed item(s)",
+            )
+
+    if step_key == "aigc_write_record":
+        if payload.get("dry_run_recorded") is not True or not payload.get("payload_hash"):
+            return GateDecision(
+                False,
+                "aigc_record_incomplete",
+                "AIGC dry-run payload/hash was not durably recorded",
+            )
+        if payload.get("external_request_made") is True:
+            return GateDecision(
+                False,
+                "external_effect_detected",
+                "AIGC external request is forbidden in phase one",
+            )
+
+    return GateDecision(True)

+ 117 - 0
supply_infra/pipeline/health.py

@@ -0,0 +1,117 @@
+from __future__ import annotations
+
+from datetime import datetime, timedelta
+from typing import Any
+
+from supply_infra.config import InfraSettings, get_infra_settings
+from supply_infra.db.repositories.pipeline_lock_repo import PipelineLockRepository
+from supply_infra.db.repositories.pipeline_run_repo import PipelineRunRepository
+from supply_infra.db.session import get_session
+from supply_infra.pipeline.dates import utc_now
+
+SCHEDULER_HEARTBEAT_KEY = "pipeline:scheduler"
+_ACTIVE_STATUSES = {
+    "queued",
+    "running",
+    "cancelling",
+    "blocked_previous_run",
+}
+
+
+def touch_scheduler_heartbeat(
+    *,
+    owner: str,
+    settings: InfraSettings | None = None,
+) -> None:
+    active = settings or get_infra_settings()
+    with get_session() as session:
+        PipelineLockRepository(session).touch(
+            lock_key=SCHEDULER_HEARTBEAT_KEY,
+            owner=owner,
+            lease_seconds=max(
+                active.pipeline_lease_seconds,
+                active.pipeline_heartbeat_seconds * 3,
+            ),
+            now=utc_now(),
+        )
+
+
+def run_alert_level(
+    *,
+    status: str,
+    started_at: datetime | None,
+    created_at: datetime,
+    deadline_at: datetime | None,
+    now: datetime,
+    settings: InfraSettings,
+) -> str | None:
+    if status not in _ACTIVE_STATUSES:
+        return None
+    if deadline_at is not None:
+        remaining = deadline_at - now
+        if remaining <= timedelta(minutes=settings.pipeline_final_warn_before_next_minutes):
+            return "final_warning"
+        if remaining <= timedelta(minutes=settings.pipeline_critical_before_next_minutes):
+            return "critical"
+    baseline = started_at or created_at
+    if now - baseline >= timedelta(hours=settings.pipeline_warn_after_hours):
+        return "warning"
+    return None
+
+
+def pipeline_health_snapshot(
+    settings: InfraSettings | None = None,
+) -> dict[str, Any]:
+    active = settings or get_infra_settings()
+    now = utc_now()
+    with get_session() as session:
+        scheduler_lock = PipelineLockRepository(session).get(SCHEDULER_HEARTBEAT_KEY)
+        latest = PipelineRunRepository(session).list_recent(limit=1)
+        latest_run = latest[0] if latest else None
+        scheduler_heartbeat_at = (
+            scheduler_lock.heartbeat_at if scheduler_lock is not None else None
+        )
+        scheduler_lease_until = (
+            scheduler_lock.lease_until if scheduler_lock is not None else None
+        )
+        latest_snapshot = (
+            {
+                "run_id": latest_run.run_id,
+                "status": latest_run.status,
+                "started_at": latest_run.started_at,
+                "created_at": latest_run.created_at,
+                "deadline_at": latest_run.deadline_at,
+            }
+            if latest_run is not None
+            else None
+        )
+
+    scheduler_heartbeat = (
+        scheduler_heartbeat_at.isoformat()
+        if scheduler_heartbeat_at is not None
+        else None
+    )
+    scheduler_running = bool(
+        active.scheduler_enabled
+        and scheduler_lease_until is not None
+        and scheduler_lease_until > now
+    )
+    alert_level = None
+    if latest_snapshot is not None:
+        alert_level = run_alert_level(
+            status=latest_snapshot["status"],
+            started_at=latest_snapshot["started_at"],
+            created_at=latest_snapshot["created_at"],
+            deadline_at=latest_snapshot["deadline_at"],
+            now=now,
+            settings=active,
+        )
+    return {
+        "scheduler_enabled": active.scheduler_enabled,
+        "scheduler_running": scheduler_running,
+        "scheduler_heartbeat_at": scheduler_heartbeat,
+        "latest_run_id": latest_snapshot["run_id"] if latest_snapshot else None,
+        "latest_run_status": latest_snapshot["status"] if latest_snapshot else None,
+        "latest_run_alert_level": alert_level,
+        "checked_at": now.isoformat(),
+    }

+ 162 - 0
supply_infra/pipeline/orchestrator.py

@@ -0,0 +1,162 @@
+from __future__ import annotations
+
+from datetime import timedelta
+
+from sqlalchemy.orm import Session
+
+from supply_infra.db.repositories.pipeline_run_repo import PipelineRunRepository
+from supply_infra.db.repositories.pipeline_step_run_repo import PipelineStepRunRepository
+from supply_infra.pipeline.contracts import StepResult
+from supply_infra.pipeline.dates import utc_now
+from supply_infra.pipeline.enums import RunStatus, StepStatus
+from supply_infra.pipeline.gates import evaluate_step_gate
+
+_TRANSIENT_ERRORS = {
+    "step_timed_out",
+    "step_launch_failed",
+    "db_pool_exhausted",
+}
+
+
+def complete_step(
+    session: Session,
+    *,
+    step_run_id: str,
+    owner: str,
+    result: StepResult,
+) -> str:
+    now = utc_now()
+    step_repo = PipelineStepRunRepository(session)
+    run_repo = PipelineRunRepository(session)
+    step = step_repo.get(step_run_id, for_update=True)
+    if step is None:
+        raise ValueError(f"step_run_id not found: {step_run_id}")
+    if step.status != StepStatus.RUNNING.value or step.lease_owner != owner:
+        raise RuntimeError(f"Step lease lost: {step_run_id}")
+    run = run_repo.get(step.run_id, for_update=True)
+    if run is None:
+        raise ValueError(f"pipeline run not found: {step.run_id}")
+    if run.status in {
+        RunStatus.CANCELLING.value,
+        RunStatus.CANCELLED.value,
+    }:
+        step_repo.finish(
+            step,
+            status=StepStatus.CANCELLED.value,
+            now=now,
+            exit_code=result.exit_code,
+            result=result.payload,
+            error_code="cancelled",
+            error_message="Step result discarded after pipeline cancellation",
+            log_uri=result.log_uri,
+        )
+        if run.status == RunStatus.CANCELLING.value:
+            run_repo.finish(run, status=RunStatus.CANCELLED.value, now=now)
+        return RunStatus.CANCELLED.value
+    if run.status == RunStatus.DEADLINE_EXCEEDED.value:
+        step_repo.finish(
+            step,
+            status=StepStatus.TIMED_OUT.value,
+            now=now,
+            exit_code=result.exit_code,
+            result=result.payload,
+            error_code="deadline_exceeded",
+            error_message="Step result discarded after pipeline deadline",
+            log_uri=result.log_uri,
+        )
+        return RunStatus.DEADLINE_EXCEEDED.value
+
+    gate = evaluate_step_gate(step.step_key, result.payload) if result.success else None
+    passed = bool(result.success and gate and gate.passed)
+    error_code = result.error_code
+    error_message = result.error_message
+    if result.success and gate is not None and not gate.passed:
+        error_code = gate.error_code
+        error_message = gate.error_message
+
+    if passed:
+        step_repo.finish(
+            step,
+            status=StepStatus.SUCCEEDED.value,
+            now=now,
+            exit_code=result.exit_code,
+            result=result.payload,
+            error_code=None,
+            error_message=None,
+            log_uri=result.log_uri,
+        )
+        latest = step_repo.latest_attempts(run.run_id)
+        incomplete = [
+            item
+            for item in sorted(latest.values(), key=lambda value: value.step_order)
+            if item.status != StepStatus.SUCCEEDED.value
+        ]
+        if not incomplete:
+            run_repo.finish(
+                run,
+                status=RunStatus.SUCCEEDED.value,
+                now=now,
+                summary={"steps": len(latest), "dry_run": run.dry_run},
+            )
+            return RunStatus.SUCCEEDED.value
+        next_step = incomplete[0]
+        if next_step.status == StepStatus.PENDING.value:
+            next_step.status = StepStatus.READY.value
+            next_step.error_code = None
+            next_step.error_message = None
+        run.status = RunStatus.QUEUED.value
+        run.current_step = next_step.step_key
+        run.heartbeat_at = now
+        run.lease_owner = None
+        run.lease_until = None
+        session.flush()
+        return RunStatus.QUEUED.value
+
+    failed_status = (
+        StepStatus.TIMED_OUT.value if result.timed_out else StepStatus.FAILED.value
+    )
+    step_repo.finish(
+        step,
+        status=failed_status,
+        now=now,
+        exit_code=result.exit_code,
+        result=result.payload,
+        error_code=error_code or "step_failed",
+        error_message=error_message,
+        log_uri=result.log_uri,
+    )
+    should_retry = (
+        step.retryable
+        and step.attempt < step.max_attempts
+        and (error_code or "") in _TRANSIENT_ERRORS
+    )
+    if should_retry:
+        retry_at = now + timedelta(seconds=min(300, 30 * (2 ** (step.attempt - 1))))
+        step_repo.create_retry_attempt(
+            step,
+            status=StepStatus.RETRY_WAIT.value,
+            next_retry_at=retry_at,
+        )
+        run.status = RunStatus.QUEUED.value
+        run.current_step = step.step_key
+        run.heartbeat_at = now
+        run.lease_owner = None
+        run.lease_until = None
+        session.flush()
+        return StepStatus.RETRY_WAIT.value
+
+    reason = error_message or error_code or "critical step failed"
+    step_repo.block_unfinished_downstream(
+        run_id=run.run_id,
+        after_order=step.step_order,
+        reason=reason,
+    )
+    run_repo.finish(
+        run,
+        status=RunStatus.FAILED.value,
+        now=now,
+        error_code=error_code or "critical_step_failed",
+        error_message=reason,
+        summary={"failed_step": step.step_key, "attempt": step.attempt},
+    )
+    return RunStatus.FAILED.value

+ 226 - 0
supply_infra/pipeline/reconciler.py

@@ -0,0 +1,226 @@
+from __future__ import annotations
+
+import logging
+import signal
+import threading
+from datetime import timedelta, timezone
+from zoneinfo import ZoneInfo
+
+from supply_infra.config import InfraSettings, get_infra_settings
+from supply_infra.db.repositories.pipeline_run_repo import PipelineRunRepository
+from supply_infra.db.repositories.pipeline_step_run_repo import PipelineStepRunRepository
+from supply_infra.db.session import dispose_engine, get_session
+from supply_infra.pipeline.dates import utc_now
+from supply_infra.pipeline.enums import RunStatus, StepStatus
+from supply_infra.pipeline.health import run_alert_level
+from supply_infra.pipeline.run_service import PIPELINE_KEY, submit_pipeline_run
+
+logger = logging.getLogger(__name__)
+
+
+def reconcile_once() -> dict[str, int]:
+    settings = get_infra_settings()
+    now = utc_now()
+    stats = {
+        "retries_promoted": 0,
+        "expired_steps": 0,
+        "deadline_exceeded": 0,
+        "previous_run_unblocked": 0,
+        "alerts_emitted": 0,
+        "missed_runs_submitted": 0,
+    }
+    with get_session() as session:
+        run_repo = PipelineRunRepository(session)
+        step_repo = PipelineStepRunRepository(session)
+        stats["retries_promoted"] = step_repo.promote_due_retries(now)
+
+        for step in step_repo.list_expired_running(now):
+            run = run_repo.get(step.run_id, for_update=True)
+            if run is not None and run.status in {
+                RunStatus.CANCELLING.value,
+                RunStatus.CANCELLED.value,
+            }:
+                step.status = StepStatus.CANCELLED.value
+                step.finished_at = now
+                step.lease_owner = None
+                step.lease_until = None
+                step.error_code = "cancelled"
+                step.error_message = "Cancellation completed after worker lease expired"
+                if run.status == RunStatus.CANCELLING.value:
+                    run_repo.finish(
+                        run,
+                        status=RunStatus.CANCELLED.value,
+                        now=now,
+                    )
+                stats["expired_steps"] += 1
+                continue
+            if run is not None and run.status == RunStatus.DEADLINE_EXCEEDED.value:
+                step.status = StepStatus.TIMED_OUT.value
+                step.finished_at = now
+                step.lease_owner = None
+                step.lease_until = None
+                step.error_code = "deadline_exceeded"
+                step.error_message = "Worker lease expired after pipeline deadline"
+                stats["expired_steps"] += 1
+                continue
+            step.status = StepStatus.TIMED_OUT.value
+            step.finished_at = now
+            step.lease_owner = None
+            step.lease_until = None
+            step.error_code = "lease_expired"
+            step.error_message = "Worker heartbeat lease expired"
+            if step.retryable and step.attempt < step.max_attempts:
+                step_repo.create_retry_attempt(step, status=StepStatus.READY.value)
+                if run is not None:
+                    run.status = RunStatus.QUEUED.value
+                    run.current_step = step.step_key
+                    run.lease_owner = None
+                    run.lease_until = None
+            else:
+                step_repo.block_unfinished_downstream(
+                    run_id=step.run_id,
+                    after_order=step.step_order,
+                    reason="Worker heartbeat lease expired",
+                )
+                if run is not None:
+                    run_repo.finish(
+                        run,
+                        status=RunStatus.FAILED.value,
+                        now=now,
+                        error_code="lease_expired",
+                        error_message=f"Lease expired at step {step.step_key}",
+                    )
+            stats["expired_steps"] += 1
+
+        for run in run_repo.list_past_deadline(now):
+            for step in step_repo.list_for_run(run.run_id):
+                if step.status == StepStatus.RUNNING.value:
+                    step.error_code = "deadline_exceeded"
+                    step.error_message = (
+                        "Pipeline exceeded next scheduled start; waiting for "
+                        "the worker or lease expiry"
+                    )
+                    continue
+                if step.status in {
+                    StepStatus.PENDING.value,
+                    StepStatus.READY.value,
+                    StepStatus.RETRY_WAIT.value,
+                    StepStatus.BLOCKED.value,
+                }:
+                    step.status = StepStatus.BLOCKED.value
+                    step.error_code = "deadline_exceeded"
+                    step.error_message = "Pipeline exceeded next scheduled start"
+            run_repo.finish(
+                run,
+                status=RunStatus.DEADLINE_EXCEEDED.value,
+                now=now,
+                error_code="deadline_exceeded",
+                error_message="Pipeline exceeded next scheduled start",
+            )
+            stats["deadline_exceeded"] += 1
+
+        for run in run_repo.list_blocked_previous():
+            blocked_by = (run.summary_json or {}).get("blocked_by_run_id")
+            previous = run_repo.get(str(blocked_by)) if blocked_by else None
+            if previous is not None and previous.status in {
+                RunStatus.QUEUED.value,
+                RunStatus.RUNNING.value,
+                RunStatus.CANCELLING.value,
+                RunStatus.BLOCKED_PREVIOUS_RUN.value,
+            }:
+                continue
+            if previous is not None and step_repo.has_running_for_run(previous.run_id):
+                continue
+            latest = step_repo.latest_attempts(run.run_id)
+            first = min(latest.values(), key=lambda item: item.step_order, default=None)
+            if first is None:
+                continue
+            first.status = StepStatus.READY.value
+            first.error_code = None
+            first.error_message = None
+            for step in latest.values():
+                if step.step_run_id != first.step_run_id:
+                    step.status = StepStatus.PENDING.value
+                    step.error_code = None
+                    step.error_message = None
+            run.status = RunStatus.QUEUED.value
+            run.summary_json = None
+            stats["previous_run_unblocked"] += 1
+
+        for run in run_repo.list_nonterminal():
+            alert_level = run_alert_level(
+                status=run.status,
+                started_at=run.started_at,
+                created_at=run.created_at,
+                deadline_at=run.deadline_at,
+                now=now,
+                settings=settings,
+            )
+            if alert_level is not None:
+                logger.warning(
+                    "pipeline_alert level=%s run_id=%s biz_dt=%s status=%s deadline=%s",
+                    alert_level,
+                    run.run_id,
+                    run.biz_dt,
+                    run.status,
+                    run.deadline_at.isoformat() if run.deadline_at else None,
+                )
+                stats["alerts_emitted"] += 1
+
+    local_now = now.replace(tzinfo=timezone.utc).astimezone(
+        ZoneInfo(settings.scheduler_timezone)
+    )
+    missed_cutoff = local_now.replace(
+        hour=settings.scheduler_cron_hour,
+        minute=settings.scheduler_cron_minute,
+        second=0,
+        microsecond=0,
+    ) + timedelta(seconds=settings.pipeline_missed_run_grace_seconds)
+    if settings.scheduler_enabled and local_now >= missed_cutoff:
+        biz_dt = local_now.strftime("%Y%m%d")
+        with get_session() as session:
+            existing = PipelineRunRepository(session).get_for_business_day(
+                pipeline_key=PIPELINE_KEY,
+                biz_dt=biz_dt,
+            )
+        if existing is None:
+            submission = submit_pipeline_run(
+                biz_dt=biz_dt,
+                trigger_type="reconcile",
+                trigger_source="missed_run_reconciler",
+                trigger_reason="Scheduled batch was absent after the grace window",
+                settings=settings,
+            )
+            if submission.created:
+                logger.error(
+                    "pipeline_alert level=critical type=missed_run_recovered "
+                    "run_id=%s biz_dt=%s",
+                    submission.run_id,
+                    biz_dt,
+                )
+                stats["missed_runs_submitted"] = 1
+    return stats
+
+
+class PipelineReconciler:
+    def __init__(self, settings: InfraSettings | None = None) -> None:
+        self.settings = settings or get_infra_settings()
+        self._stop = threading.Event()
+
+    def stop(self, *_args: object) -> None:
+        self._stop.set()
+
+    def run_forever(self) -> None:
+        signal.signal(signal.SIGTERM, self.stop)
+        signal.signal(signal.SIGINT, self.stop)
+        logger.info("Pipeline reconciler started")
+        try:
+            while not self._stop.is_set():
+                try:
+                    logger.info("Pipeline reconcile result: %s", reconcile_once())
+                except Exception:
+                    logger.exception("Pipeline reconcile failed")
+                self._stop.wait(self.settings.pipeline_reconcile_seconds)
+        finally:
+            dispose_engine()
+            logger.info("Pipeline reconciler stopped")

+ 194 - 0
supply_infra/pipeline/registry.py

@@ -0,0 +1,194 @@
+from __future__ import annotations
+
+import hashlib
+import json
+from collections.abc import Callable
+from pathlib import Path
+from typing import Any
+
+from supply_infra.config import get_infra_settings
+from supply_infra.db.repositories.pipeline_outbox_repo import PipelineOutboxRepository
+from supply_infra.db.session import get_session
+from supply_infra.pipeline.contracts import StepContext
+
+StepHandler = Callable[[StepContext], dict[str, Any]]
+_REPO_ROOT = Path(__file__).resolve().parents[2]
+_MAX_INLINE_EFFECT_BYTES = 512_000
+
+
+def _global_tree(context: StepContext) -> dict[str, Any]:
+    from supply_infra.scheduler.jobs.sync_global_tree_odps_to_mysql import (
+        sync_global_tree_odps_to_mysql,
+    )
+
+    return sync_global_tree_odps_to_mysql(
+        partition_date=str(context.date_snapshot["global_tree_partition"])
+    )
+
+
+def _demand_source(context: StepContext) -> dict[str, Any]:
+    from supply_infra.scheduler.jobs.demand_pool.sync import _sync_pool_rows
+
+    return _sync_pool_rows(str(context.date_snapshot["demand_pool_partition"]))
+
+
+def _demand_classify(context: StepContext) -> dict[str, Any]:
+    from supply_infra.scheduler.jobs.demand_pool.sync import _classify_words
+
+    return _classify_words(context.biz_dt)
+
+
+def _demand_rel(_context: StepContext) -> dict[str, Any]:
+    from supply_infra.scheduler.jobs.demand_pool.belong_rel import (
+        sync_demand_belong_pool_rel,
+    )
+
+    return sync_demand_belong_pool_rel()
+
+
+def _real_metrics(context: StepContext) -> dict[str, Any]:
+    from supply_infra.scheduler.jobs.demand_pool.sync import enrich_real_rov_vov_7d
+
+    return enrich_real_rov_vov_7d(context.biz_dt)
+
+
+def _popularity(context: StepContext) -> dict[str, Any]:
+    from supply_infra.scheduler.jobs.demand_pool.sync import compute_popularity_stats
+
+    return compute_popularity_stats(context.biz_dt)
+
+
+def _tree_weight(context: StepContext) -> dict[str, Any]:
+    from supply_infra.scheduler.jobs.demand_pool.tree_weight import (
+        compute_category_tree_weight,
+    )
+
+    return compute_category_tree_weight(context.biz_dt)
+
+
+def _source_videos(context: StepContext) -> dict[str, Any]:
+    from supply_infra.scheduler.jobs.demand_pool.videos import sync_multi_demand_videos
+
+    return sync_multi_demand_videos(
+        limit=None,
+        decode_dt=str(context.date_snapshot["video_decode_partition"]),
+    )
+
+
+def _grade(context: StepContext) -> dict[str, Any]:
+    from supply_infra.scheduler.jobs.grade_demand_pool import grade_demand_pool
+
+    return grade_demand_pool(context.biz_dt)
+
+
+def _expand(context: StepContext) -> dict[str, Any]:
+    from supply_infra.scheduler.jobs.expand_demand_from_video_points import (
+        expand_demand_from_video_points,
+    )
+
+    return expand_demand_from_video_points(
+        context.biz_dt,
+        workers=5,
+    )
+
+
+def _discover(context: StepContext) -> dict[str, Any]:
+    from supply_infra.scheduler.constants import (
+        PIPELINE_FIND_AGENT_TOP_DEMANDS,
+        PIPELINE_FIND_AGENT_WORKERS,
+    )
+    from supply_infra.scheduler.jobs.discover_videos_from_demands import (
+        discover_videos_from_demands,
+    )
+
+    return discover_videos_from_demands(
+        context.biz_dt,
+        workers=PIPELINE_FIND_AGENT_WORKERS,
+        top_limit=PIPELINE_FIND_AGENT_TOP_DEMANDS,
+    )
+
+
+def _aigc_write_record(context: StepContext) -> dict[str, Any]:
+    from supply_infra.scheduler.jobs.publish_videos_from_discovery import (
+        publish_videos_from_discovery,
+    )
+
+    payload = publish_videos_from_discovery(
+        biz_dt=context.biz_dt,
+        dry_run=True,
+    )
+    canonical = json.dumps(
+        payload,
+        ensure_ascii=False,
+        sort_keys=True,
+        separators=(",", ":"),
+        default=str,
+    )
+    encoded = canonical.encode("utf-8")
+    payload_hash = hashlib.sha256(encoded).hexdigest()
+    idempotency_key = f"aigc_write_record:{context.biz_dt}:{payload_hash}"
+    payload_uri: str | None = None
+    stored_payload: dict[str, Any] | None = payload
+    if len(encoded) > _MAX_INLINE_EFFECT_BYTES:
+        log_dir = Path(get_infra_settings().pipeline_log_dir)
+        if not log_dir.is_absolute():
+            log_dir = _REPO_ROOT / log_dir
+        effect_dir = log_dir / context.run_id / "effects"
+        effect_dir.mkdir(parents=True, exist_ok=True)
+        effect_path = effect_dir / f"{context.step_run_id}-{payload_hash}.json"
+        temporary_path = effect_path.with_suffix(".json.tmp")
+        temporary_path.write_bytes(encoded)
+        temporary_path.replace(effect_path)
+        payload_uri = str(effect_path)
+        stored_payload = {
+            "externalized": True,
+            "payload_bytes": len(encoded),
+            "payload_hash": payload_hash,
+        }
+    with get_session() as session:
+        record = PipelineOutboxRepository(session).record_dry_run(
+            run_id=context.run_id,
+            step_run_id=context.step_run_id,
+            effect_type="aigc_write_record",
+            idempotency_key=idempotency_key,
+            payload_hash=payload_hash,
+            payload=stored_payload,
+            payload_uri=payload_uri,
+        )
+        outbox_id = record.outbox_id
+    return {
+        "success": True,
+        "dry_run_recorded": True,
+        "external_request_made": False,
+        "outbox_id": outbox_id,
+        "payload_hash": payload_hash,
+        "payload_uri": payload_uri,
+        "candidate_count": payload.get("candidate_count", 0),
+        "batch_count": payload.get("batch_count", 0),
+    }
+
+
+STEP_REGISTRY: dict[str, StepHandler] = {
+    "global_tree_sync": _global_tree,
+    "demand_pool_source_sync": _demand_source,
+    "demand_classify": _demand_classify,
+    "demand_belong_rel_sync": _demand_rel,
+    "real_metrics_sync": _real_metrics,
+    "popularity_stats": _popularity,
+    "category_tree_weight": _tree_weight,
+    "source_video_sync": _source_videos,
+    "demand_grade": _grade,
+    "demand_expand": _expand,
+    "video_discovery": _discover,
+    "aigc_write_record": _aigc_write_record,
+}
+
+
+def execute_registered_step(context: StepContext) -> dict[str, Any]:
+    handler = STEP_REGISTRY.get(context.step_key)
+    if handler is None:
+        raise KeyError(f"Unknown pipeline step: {context.step_key}")
+    payload = handler(context)
+    if not isinstance(payload, dict):
+        return {"success": True, "result": payload}
+    return payload

+ 369 - 0
supply_infra/pipeline/run_service.py

@@ -0,0 +1,369 @@
+from __future__ import annotations
+
+import os
+import uuid
+from dataclasses import dataclass
+from datetime import datetime, timezone
+from typing import Any
+
+from sqlalchemy.exc import IntegrityError
+
+from supply_infra.config import InfraSettings, get_infra_settings
+from supply_infra.db.models.pipeline_run import PipelineRun
+from supply_infra.db.models.pipeline_step_run import PipelineStepRun
+from supply_infra.db.repositories.pipeline_lock_repo import PipelineLockRepository
+from supply_infra.db.repositories.pipeline_outbox_repo import PipelineOutboxRepository
+from supply_infra.db.repositories.pipeline_run_repo import PipelineRunRepository
+from supply_infra.db.repositories.pipeline_step_run_repo import PipelineStepRunRepository
+from supply_infra.db.session import get_session
+from supply_infra.pipeline.dag import PIPELINE_STEPS
+from supply_infra.pipeline.dates import (
+    build_date_snapshot,
+    deadline_for_trigger,
+    next_schedule_utc,
+    resolve_biz_dt,
+    utc_now,
+)
+from supply_infra.pipeline.enums import RunStatus, StepStatus
+
+PIPELINE_KEY = "supply_pipeline"
+
+
+@dataclass(frozen=True)
+class RunSubmission:
+    run_id: str
+    created: bool
+    status: str
+    biz_dt: str
+
+    def to_dict(self) -> dict[str, Any]:
+        return {
+            "accepted": True,
+            "run_id": self.run_id,
+            "created": self.created,
+            "status": self.status,
+            "biz_dt": self.biz_dt,
+            "dry_run": True,
+        }
+
+
+def _config_snapshot(settings: InfraSettings) -> dict[str, Any]:
+    return {
+        "scheduler_timezone": settings.scheduler_timezone,
+        "scheduler_cron_hour": settings.scheduler_cron_hour,
+        "scheduler_cron_minute": settings.scheduler_cron_minute,
+        "lease_seconds": settings.pipeline_lease_seconds,
+        "heartbeat_seconds": settings.pipeline_heartbeat_seconds,
+        "worker_processes": settings.pipeline_worker_processes,
+        "max_active_steps": settings.pipeline_max_active_steps,
+        "mysql_connection_budget": settings.mysql_connection_budget,
+        "aigc_dry_run": True,
+        "external_effects_enabled": False,
+    }
+
+
+def _code_version() -> str | None:
+    return os.getenv("BUILD_TIMESTAMP") or os.getenv("GIT_COMMIT")
+
+
+def submit_pipeline_run(
+    *,
+    biz_dt: str | None = None,
+    trigger_type: str = "api",
+    trigger_source: str | None = None,
+    trigger_reason: str | None = None,
+    scheduled_for: datetime | None = None,
+    settings: InfraSettings | None = None,
+) -> RunSubmission:
+    active = settings or get_infra_settings()
+    resolved_biz_dt = resolve_biz_dt(biz_dt, settings=active)
+    dedupe_key = f"{PIPELINE_KEY}:{resolved_biz_dt}:full"
+    run_id = str(uuid.uuid4())
+    date_snapshot = build_date_snapshot(resolved_biz_dt)
+
+    try:
+        with get_session() as session:
+            run_repo = PipelineRunRepository(session)
+            PipelineLockRepository(session).lock_control_plane(now=utc_now())
+            existing = run_repo.get_by_dedupe_key(dedupe_key)
+            if existing is not None:
+                return RunSubmission(
+                    run_id=existing.run_id,
+                    created=False,
+                    status=existing.status,
+                    biz_dt=existing.biz_dt,
+                )
+
+            # All entry points share the same no-overlap rule. Linking each new
+            # run to the latest nonterminal run also creates a safe FIFO chain
+            # when several future batches are submitted in advance.
+            previous = run_repo.get_latest_nonterminal()
+            initial_status = (
+                RunStatus.BLOCKED_PREVIOUS_RUN.value
+                if previous is not None
+                else RunStatus.QUEUED.value
+            )
+            run = run_repo.create(
+                {
+                    "run_id": run_id,
+                    "dedupe_key": dedupe_key,
+                    "pipeline_key": PIPELINE_KEY,
+                    "biz_dt": resolved_biz_dt,
+                    "trigger_type": trigger_type,
+                    "trigger_source": trigger_source or trigger_type,
+                    "triggered_by": None,
+                    "trigger_reason": trigger_reason,
+                    "run_mode": "full",
+                    "dry_run": True,
+                    "status": initial_status,
+                    "scheduled_for": scheduled_for,
+                    "deadline_at": deadline_for_trigger(
+                        trigger_type,
+                        settings=active,
+                    ),
+                    "code_version": _code_version(),
+                    "config_snapshot_json": _config_snapshot(active),
+                    "date_snapshot_json": date_snapshot,
+                    "summary_json": (
+                        {"blocked_by_run_id": previous.run_id}
+                        if previous is not None
+                        else None
+                    ),
+                }
+            )
+            rows: list[dict[str, Any]] = []
+            for index, step in enumerate(PIPELINE_STEPS):
+                if previous is not None:
+                    status = StepStatus.BLOCKED.value
+                else:
+                    status = (
+                        StepStatus.READY.value if index == 0 else StepStatus.PENDING.value
+                    )
+                rows.append(
+                    {
+                        "step_run_id": str(uuid.uuid4()),
+                        "run_id": run.run_id,
+                        "step_key": step.key,
+                        "step_order": step.order,
+                        "attempt": 1,
+                        "status": status,
+                        "critical": step.critical,
+                        "dependency_snapshot_json": list(step.dependencies),
+                        "input_snapshot_json": {},
+                        "timeout_seconds": step.timeout_seconds,
+                        "max_attempts": step.max_attempts,
+                        "retryable": step.retryable,
+                        "error_code": (
+                            "previous_run_active" if previous is not None else None
+                        ),
+                        "error_message": (
+                            f"Blocked by previous run {previous.run_id}"
+                            if previous is not None
+                            else None
+                        ),
+                    }
+                )
+            PipelineStepRunRepository(session).create_steps(rows)
+            return RunSubmission(
+                run_id=run.run_id,
+                created=True,
+                status=run.status,
+                biz_dt=run.biz_dt,
+            )
+    except IntegrityError:
+        with get_session() as session:
+            existing = PipelineRunRepository(session).get_by_dedupe_key(dedupe_key)
+            if existing is None:
+                raise
+            return RunSubmission(
+                run_id=existing.run_id,
+                created=False,
+                status=existing.status,
+                biz_dt=existing.biz_dt,
+            )
+
+
+def get_pipeline_run(run_id: str) -> dict[str, Any] | None:
+    with get_session() as session:
+        run = PipelineRunRepository(session).get(run_id)
+        if run is None:
+            return None
+        steps = PipelineStepRunRepository(session).list_for_run(run_id)
+        outbox = PipelineOutboxRepository(session).list_for_run(run_id)
+        return {
+            **serialize_run(run),
+            "steps": [serialize_step(item) for item in steps],
+            "effects": [
+                {
+                    "outbox_id": item.outbox_id,
+                    "step_run_id": item.step_run_id,
+                    "effect_type": item.effect_type,
+                    "payload_hash": item.payload_hash,
+                    "payload_uri": item.payload_uri,
+                    "status": item.status,
+                    "dry_run": item.dry_run,
+                    "created_at": _iso(item.created_at),
+                }
+                for item in outbox
+            ],
+        }
+
+
+def list_pipeline_runs(
+    *,
+    limit: int = 50,
+    status: str | None = None,
+    biz_dt: str | None = None,
+) -> list[dict[str, Any]]:
+    with get_session() as session:
+        rows = PipelineRunRepository(session).list_recent(
+            limit=min(max(limit, 1), 200),
+            status=status,
+            biz_dt=biz_dt,
+        )
+        return [serialize_run(item) for item in rows]
+
+
+def cancel_pipeline_run(run_id: str) -> bool:
+    now = utc_now()
+    with get_session() as session:
+        run_repo = PipelineRunRepository(session)
+        step_repo = PipelineStepRunRepository(session)
+        run = run_repo.get(run_id, for_update=True)
+        if run is None:
+            return False
+        if run.status in {
+            RunStatus.SUCCEEDED.value,
+            RunStatus.FAILED.value,
+            RunStatus.CANCELLED.value,
+            RunStatus.DEADLINE_EXCEEDED.value,
+        }:
+            return True
+        has_running_step = False
+        for step in step_repo.list_for_run(run_id):
+            if step.status == StepStatus.RUNNING.value:
+                has_running_step = True
+                continue
+            if step.status in {
+                StepStatus.PENDING.value,
+                StepStatus.READY.value,
+                StepStatus.RETRY_WAIT.value,
+                StepStatus.BLOCKED.value,
+            }:
+                step.status = StepStatus.CANCELLED.value
+                step.finished_at = now
+        if has_running_step:
+            run.status = RunStatus.CANCELLING.value
+            run.error_code = "cancellation_requested"
+            run.error_message = "Waiting for the running step to stop"
+            run.heartbeat_at = now
+            run.lease_owner = None
+            run.lease_until = None
+        else:
+            run_repo.finish(run, status=RunStatus.CANCELLED.value, now=now)
+        return True
+
+
+def resume_pipeline_run(run_id: str, *, step_key: str | None = None) -> bool:
+    with get_session() as session:
+        run_repo = PipelineRunRepository(session)
+        step_repo = PipelineStepRunRepository(session)
+        run = run_repo.get(run_id, for_update=True)
+        if run is None:
+            return False
+        if run.status not in {
+            RunStatus.FAILED.value,
+            RunStatus.DEADLINE_EXCEEDED.value,
+        }:
+            return False
+        latest = step_repo.latest_attempts(run_id)
+        failed = next(
+            (
+                item
+                for item in sorted(latest.values(), key=lambda row: row.step_order)
+                if item.status
+                in {
+                    StepStatus.FAILED.value,
+                    StepStatus.TIMED_OUT.value,
+                    StepStatus.BLOCKED.value,
+                }
+            ),
+            None,
+        )
+        if failed is None:
+            return False
+        if step_key is not None and failed.step_key != step_key:
+            return False
+        retry = step_repo.create_retry_attempt(failed, status=StepStatus.READY.value)
+        for item in latest.values():
+            if item.step_order > failed.step_order and item.status == StepStatus.BLOCKED.value:
+                item.status = StepStatus.PENDING.value
+                item.error_code = None
+                item.error_message = None
+        run.status = RunStatus.QUEUED.value
+        run.current_step = retry.step_key
+        if run.deadline_at is not None and run.deadline_at <= utc_now():
+            run.deadline_at = next_schedule_utc()
+        run.finished_at = None
+        run.error_code = None
+        run.error_message = None
+        return True
+
+
+def retry_pipeline_step(run_id: str, step_key: str) -> bool:
+    return resume_pipeline_run(run_id, step_key=step_key)
+
+
+def serialize_run(run: PipelineRun) -> dict[str, Any]:
+    return {
+        "run_id": run.run_id,
+        "pipeline_key": run.pipeline_key,
+        "biz_dt": run.biz_dt,
+        "trigger_type": run.trigger_type,
+        "trigger_source": run.trigger_source,
+        "run_mode": run.run_mode,
+        "dry_run": run.dry_run,
+        "status": run.status,
+        "current_step": run.current_step,
+        "scheduled_for": _iso(run.scheduled_for),
+        "deadline_at": _iso(run.deadline_at),
+        "started_at": _iso(run.started_at),
+        "finished_at": _iso(run.finished_at),
+        "heartbeat_at": _iso(run.heartbeat_at),
+        "summary": run.summary_json,
+        "error_code": run.error_code,
+        "error_message": run.error_message,
+        "created_at": _iso(run.created_at),
+        "updated_at": _iso(run.updated_at),
+    }
+
+
+def serialize_step(step: PipelineStepRun) -> dict[str, Any]:
+    return {
+        "step_run_id": step.step_run_id,
+        "run_id": step.run_id,
+        "step_key": step.step_key,
+        "step_order": step.step_order,
+        "attempt": step.attempt,
+        "status": step.status,
+        "critical": step.critical,
+        "timeout_seconds": step.timeout_seconds,
+        "started_at": _iso(step.started_at),
+        "finished_at": _iso(step.finished_at),
+        "heartbeat_at": _iso(step.heartbeat_at),
+        "result": step.result_summary_json,
+        "error_code": step.error_code,
+        "error_message": step.error_message,
+        "log_uri": step.log_uri,
+    }
+
+
+def _iso(value: datetime | None) -> str | None:
+    if value is None:
+        return None
+    aware = (
+        value.replace(tzinfo=timezone.utc)
+        if value.tzinfo is None
+        else value.astimezone(timezone.utc)
+    )
+    return aware.isoformat().replace("+00:00", "Z")

+ 67 - 0
supply_infra/pipeline/step_cli.py

@@ -0,0 +1,67 @@
+from __future__ import annotations
+
+import argparse
+import json
+import logging
+from pathlib import Path
+from typing import Any
+
+from supply_infra.db.repositories.pipeline_run_repo import PipelineRunRepository
+from supply_infra.db.repositories.pipeline_step_run_repo import PipelineStepRunRepository
+from supply_infra.db.session import dispose_engine, get_session
+from supply_infra.pipeline.contracts import StepContext
+from supply_infra.pipeline.registry import execute_registered_step
+
+logger = logging.getLogger(__name__)
+
+
+def execute_step_run(step_run_id: str) -> dict[str, Any]:
+    with get_session() as session:
+        step = PipelineStepRunRepository(session).get(step_run_id)
+        if step is None:
+            raise ValueError(f"step_run_id not found: {step_run_id}")
+        run = PipelineRunRepository(session).get(step.run_id)
+        if run is None:
+            raise ValueError(f"pipeline run not found: {step.run_id}")
+        context = StepContext(
+            run_id=run.run_id,
+            step_run_id=step.step_run_id,
+            step_key=step.step_key,
+            biz_dt=run.biz_dt,
+            date_snapshot=dict(run.date_snapshot_json or {}),
+            config_snapshot=dict(run.config_snapshot_json or {}),
+            input_snapshot=dict(step.input_snapshot_json or {}),
+        )
+    return execute_registered_step(context)
+
+
+def main() -> None:
+    parser = argparse.ArgumentParser(description="Execute one durable pipeline step")
+    parser.add_argument("step_run_id")
+    parser.add_argument("--result-file", required=True)
+    args = parser.parse_args()
+    logging.basicConfig(
+        level=logging.INFO,
+        format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
+    )
+    result: dict[str, Any]
+    exit_code = 0
+    try:
+        result = execute_step_run(args.step_run_id)
+        if result.get("success") is False:
+            exit_code = 1
+    except Exception as exc:
+        logger.exception("Pipeline step failed: step_run_id=%s", args.step_run_id)
+        result = {"success": False, "error_code": "step_exception", "error": str(exc)}
+        exit_code = 1
+    finally:
+        dispose_engine()
+    Path(args.result_file).write_text(
+        json.dumps(result, ensure_ascii=False, default=str),
+        encoding="utf-8",
+    )
+    raise SystemExit(exit_code)
+
+
+if __name__ == "__main__":
+    main()

+ 151 - 0
supply_infra/pipeline/step_runner.py

@@ -0,0 +1,151 @@
+from __future__ import annotations
+
+import json
+import logging
+import os
+import signal
+import subprocess
+import sys
+from dataclasses import dataclass
+from pathlib import Path
+from typing import Any
+
+from supply_infra.config import InfraSettings, get_infra_settings
+from supply_infra.pipeline.contracts import StepResult
+
+logger = logging.getLogger(__name__)
+_REPO_ROOT = Path(__file__).resolve().parents[2]
+_MAX_RESULT_BYTES = 100_000
+
+
+@dataclass(frozen=True)
+class StepExecutionRequest:
+    run_id: str
+    step_run_id: str
+    step_key: str
+    timeout_seconds: int
+
+
+def _log_directory(settings: InfraSettings, run_id: str) -> Path:
+    base = Path(settings.pipeline_log_dir)
+    if not base.is_absolute():
+        base = _REPO_ROOT / base
+    path = base / run_id
+    path.mkdir(parents=True, exist_ok=True)
+    return path
+
+
+def _read_result(path: Path) -> dict[str, Any]:
+    if not path.is_file():
+        return {"success": False, "error_code": "missing_result", "error": "Result file missing"}
+    raw = path.read_bytes()
+    if len(raw) > _MAX_RESULT_BYTES:
+        return {
+            "success": False,
+            "error_code": "result_too_large",
+            "error": f"Step result exceeds {_MAX_RESULT_BYTES} bytes",
+            "result_bytes": len(raw),
+        }
+    try:
+        payload = json.loads(raw.decode("utf-8"))
+    except (UnicodeDecodeError, json.JSONDecodeError) as exc:
+        return {
+            "success": False,
+            "error_code": "invalid_result_json",
+            "error": str(exc),
+        }
+    if not isinstance(payload, dict):
+        return {
+            "success": False,
+            "error_code": "invalid_result_type",
+            "error": "Step result must be a JSON object",
+        }
+    return payload
+
+
+def run_step_subprocess(
+    request: StepExecutionRequest,
+    *,
+    settings: InfraSettings | None = None,
+) -> StepResult:
+    active = settings or get_infra_settings()
+    directory = _log_directory(active, request.run_id)
+    stem = f"{request.step_key}-attempt-{request.step_run_id}"
+    log_path = directory / f"{stem}.log"
+    result_path = directory / f"{stem}.result.json"
+    result_path.unlink(missing_ok=True)
+    command = [
+        sys.executable,
+        "-m",
+        "supply_infra.pipeline.step_cli",
+        request.step_run_id,
+        "--result-file",
+        str(result_path),
+    ]
+    env = os.environ.copy()
+    env["PROCESS_ROLE"] = "step"
+    logger.info("Starting pipeline step: step_run_id=%s", request.step_run_id)
+
+    timed_out = False
+    exit_code: int | None = None
+    with log_path.open("a", encoding="utf-8") as log_file:
+        try:
+            process = subprocess.Popen(
+                command,
+                cwd=str(_REPO_ROOT),
+                env=env,
+                stdout=log_file,
+                stderr=subprocess.STDOUT,
+                text=True,
+                start_new_session=True,
+            )
+        except Exception as exc:
+            logger.exception("Failed to launch pipeline step: %s", request.step_run_id)
+            return StepResult(
+                success=False,
+                payload={},
+                error_code="step_launch_failed",
+                error_message=str(exc),
+                log_uri=str(log_path),
+            )
+        try:
+            exit_code = process.wait(timeout=request.timeout_seconds)
+        except subprocess.TimeoutExpired:
+            timed_out = True
+            os.killpg(process.pid, signal.SIGTERM)
+            try:
+                exit_code = process.wait(timeout=min(active.pipeline_shutdown_grace_seconds, 30))
+            except subprocess.TimeoutExpired:
+                os.killpg(process.pid, signal.SIGKILL)
+                exit_code = process.wait()
+
+    if timed_out:
+        return StepResult(
+            success=False,
+            payload={},
+            error_code="step_timed_out",
+            error_message=f"Step timed out after {request.timeout_seconds}s",
+            exit_code=exit_code,
+            log_uri=str(log_path),
+            timed_out=True,
+        )
+
+    payload = _read_result(result_path)
+    result_path.unlink(missing_ok=True)
+    success = exit_code == 0 and payload.get("success") is not False
+    return StepResult(
+        success=success,
+        payload=payload,
+        error_code=(
+            None
+            if success
+            else str(payload.get("error_code") or "step_failed")
+        ),
+        error_message=(
+            None
+            if success
+            else str(payload.get("error") or f"Step exited with code {exit_code}")
+        ),
+        exit_code=exit_code,
+        log_uri=str(log_path),
+    )

+ 170 - 0
supply_infra/pipeline/worker.py

@@ -0,0 +1,170 @@
+from __future__ import annotations
+
+import logging
+import os
+import signal
+import socket
+import threading
+import uuid
+from dataclasses import dataclass
+from datetime import timedelta
+
+from supply_infra.config import InfraSettings, get_infra_settings
+from supply_infra.db.repositories.pipeline_run_repo import PipelineRunRepository
+from supply_infra.db.repositories.pipeline_step_run_repo import PipelineStepRunRepository
+from supply_infra.db.session import dispose_engine, get_session
+from supply_infra.pipeline.dates import utc_now
+from supply_infra.pipeline.orchestrator import complete_step
+from supply_infra.pipeline.contracts import StepResult
+from supply_infra.pipeline.step_runner import StepExecutionRequest, run_step_subprocess
+
+logger = logging.getLogger(__name__)
+
+
+@dataclass(frozen=True)
+class ClaimedStep:
+    run_id: str
+    step_run_id: str
+    step_key: str
+    timeout_seconds: int
+
+
+class PipelineWorker:
+    def __init__(
+        self,
+        *,
+        settings: InfraSettings | None = None,
+        worker_id: str | None = None,
+    ) -> None:
+        self.settings = settings or get_infra_settings()
+        self.worker_id = worker_id or (
+            f"{socket.gethostname()}:{os.getpid()}:{uuid.uuid4().hex[:8]}"
+        )
+        self._stop = threading.Event()
+
+    def stop(self, *_args: object) -> None:
+        logger.info("Worker stop requested: worker_id=%s", self.worker_id)
+        self._stop.set()
+
+    def claim(self) -> ClaimedStep | None:
+        now = utc_now()
+        with get_session() as session:
+            step = PipelineStepRunRepository(session).claim_next_ready(
+                owner=self.worker_id,
+                lease_seconds=self.settings.pipeline_lease_seconds,
+                max_active_steps=self.settings.pipeline_max_active_steps,
+                now=now,
+            )
+            if step is None:
+                return None
+            run = PipelineRunRepository(session).get(step.run_id, for_update=True)
+            if run is None:
+                raise ValueError(f"pipeline run not found: {step.run_id}")
+            PipelineRunRepository(session).mark_running(
+                run,
+                step_key=step.step_key,
+                owner=self.worker_id,
+                now=now,
+                lease_until=now + timedelta(seconds=self.settings.pipeline_lease_seconds),
+            )
+            return ClaimedStep(
+                run_id=step.run_id,
+                step_run_id=step.step_run_id,
+                step_key=step.step_key,
+                timeout_seconds=step.timeout_seconds,
+            )
+
+    def _heartbeat_loop(self, claimed: ClaimedStep, stopped: threading.Event) -> None:
+        while not stopped.wait(self.settings.pipeline_heartbeat_seconds):
+            now = utc_now()
+            lease_until = now + timedelta(seconds=self.settings.pipeline_lease_seconds)
+            try:
+                with get_session() as session:
+                    step_ok = PipelineStepRunRepository(session).heartbeat(
+                        claimed.step_run_id,
+                        owner=self.worker_id,
+                        now=now,
+                        lease_until=lease_until,
+                    )
+                    run_ok = PipelineRunRepository(session).heartbeat(
+                        claimed.run_id,
+                        owner=self.worker_id,
+                        now=now,
+                        lease_until=lease_until,
+                    )
+                    if not step_ok or not run_ok:
+                        logger.error(
+                            "Pipeline lease lost: worker=%s step=%s",
+                            self.worker_id,
+                            claimed.step_run_id,
+                        )
+                        stopped.set()
+            except Exception:
+                logger.exception("Pipeline heartbeat failed: step=%s", claimed.step_run_id)
+
+    def run_once(self) -> bool:
+        claimed = self.claim()
+        if claimed is None:
+            return False
+        heartbeat_stop = threading.Event()
+        heartbeat = threading.Thread(
+            target=self._heartbeat_loop,
+            args=(claimed, heartbeat_stop),
+            name=f"pipeline-heartbeat-{claimed.step_run_id[:8]}",
+            daemon=True,
+        )
+        heartbeat.start()
+        try:
+            try:
+                result = run_step_subprocess(
+                    StepExecutionRequest(
+                        run_id=claimed.run_id,
+                        step_run_id=claimed.step_run_id,
+                        step_key=claimed.step_key,
+                        timeout_seconds=claimed.timeout_seconds,
+                    ),
+                    settings=self.settings,
+                )
+            except Exception as exc:
+                logger.exception("Unexpected step runner failure: %s", claimed.step_run_id)
+                result = StepResult(
+                    success=False,
+                    payload={},
+                    error_code="step_runner_exception",
+                    error_message=str(exc),
+                )
+        finally:
+            heartbeat_stop.set()
+            heartbeat.join(timeout=2)
+        try:
+            with get_session() as session:
+                state = complete_step(
+                    session,
+                    step_run_id=claimed.step_run_id,
+                    owner=self.worker_id,
+                    result=result,
+                )
+        except RuntimeError:
+            logger.exception(
+                "Discarding step result after lease loss: step=%s",
+                claimed.step_run_id,
+            )
+            return True
+        logger.info(
+            "Pipeline step completed: step=%s state=%s",
+            claimed.step_run_id,
+            state,
+        )
+        return True
+
+    def run_forever(self) -> None:
+        signal.signal(signal.SIGTERM, self.stop)
+        signal.signal(signal.SIGINT, self.stop)
+        logger.info("Pipeline worker started: worker_id=%s", self.worker_id)
+        try:
+            while not self._stop.is_set():
+                if not self.run_once():
+                    self._stop.wait(self.settings.pipeline_worker_poll_seconds)
+        finally:
+            dispose_engine()
+            logger.info("Pipeline worker stopped: worker_id=%s", self.worker_id)

+ 46 - 0
supply_infra/scheduler/__main__.py

@@ -0,0 +1,46 @@
+from __future__ import annotations
+
+import logging
+import os
+import signal
+import socket
+import threading
+
+from supply_infra.config import get_infra_settings
+from supply_infra.db import dispose_engine
+from supply_infra.pipeline.health import touch_scheduler_heartbeat
+from supply_infra.scheduler.app import start_scheduler, stop_scheduler
+
+
+def main() -> None:
+    logging.basicConfig(
+        level=logging.INFO,
+        format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
+    )
+    stopped = threading.Event()
+
+    def _stop(*_args: object) -> None:
+        stopped.set()
+
+    signal.signal(signal.SIGTERM, _stop)
+    signal.signal(signal.SIGINT, _stop)
+    scheduler = start_scheduler()
+    if scheduler is None:
+        logging.getLogger(__name__).warning("Scheduler process exits because it is disabled")
+        return
+    settings = get_infra_settings()
+    owner = f"{socket.gethostname()}:{os.getpid()}"
+    try:
+        while not stopped.is_set():
+            try:
+                touch_scheduler_heartbeat(owner=owner, settings=settings)
+            except Exception:
+                logging.getLogger(__name__).exception("Scheduler heartbeat failed")
+            stopped.wait(settings.pipeline_heartbeat_seconds)
+    finally:
+        stop_scheduler()
+        dispose_engine()
+
+
+if __name__ == "__main__":
+    main()

+ 17 - 15
supply_infra/scheduler/app.py

@@ -7,13 +7,8 @@ from apscheduler.schedulers.background import BackgroundScheduler
 from apscheduler.triggers.cron import CronTrigger
 from apscheduler.triggers.cron import CronTrigger
 
 
 from supply_infra.config import get_infra_settings
 from supply_infra.config import get_infra_settings
-from supply_infra.scheduler.constants import (
-    PIPELINE_CRON_HOUR,
-    PIPELINE_CRON_MINUTE,
-    SUPPLY_PIPELINE_JOB_ID,
-    SUPPLY_PIPELINE_JOB_NAME,
-)
-from supply_infra.scheduler.jobs.run_supply_pipeline import run_supply_pipeline
+from supply_infra.pipeline.run_service import submit_pipeline_run
+from supply_infra.scheduler.constants import SUPPLY_PIPELINE_JOB_ID, SUPPLY_PIPELINE_JOB_NAME
 
 
 if TYPE_CHECKING:
 if TYPE_CHECKING:
     from apscheduler.schedulers.base import BaseScheduler
     from apscheduler.schedulers.base import BaseScheduler
@@ -23,18 +18,25 @@ logger = logging.getLogger(__name__)
 _scheduler: BackgroundScheduler | None = None
 _scheduler: BackgroundScheduler | None = None
 
 
 
 
+def submit_scheduled_pipeline() -> dict:
+    """Cron callback: create a durable batch and return immediately."""
+    return submit_pipeline_run(
+        trigger_type="cron",
+        trigger_source="scheduler",
+    ).to_dict()
+
+
 def create_scheduler() -> BackgroundScheduler:
 def create_scheduler() -> BackgroundScheduler:
-    """Create and configure the scheduler with the chained supply pipeline job."""
+    """Create a Scheduler that only submits durable pipeline batches."""
     settings = get_infra_settings()
     settings = get_infra_settings()
     tz = settings.scheduler_timezone
     tz = settings.scheduler_timezone
     scheduler = BackgroundScheduler(timezone=tz)
     scheduler = BackgroundScheduler(timezone=tz)
 
 
-    # 每天 14:30(上海时区)串行执行:全局树 → 需求池 → 分级 → 拓展 → find_agent → 发布
     scheduler.add_job(
     scheduler.add_job(
-        run_supply_pipeline,
+        submit_scheduled_pipeline,
         trigger=CronTrigger(
         trigger=CronTrigger(
-            hour=PIPELINE_CRON_HOUR,
-            minute=PIPELINE_CRON_MINUTE,
+            hour=settings.scheduler_cron_hour,
+            minute=settings.scheduler_cron_minute,
             timezone=tz,
             timezone=tz,
         ),
         ),
         id=SUPPLY_PIPELINE_JOB_ID,
         id=SUPPLY_PIPELINE_JOB_ID,
@@ -49,8 +51,8 @@ def create_scheduler() -> BackgroundScheduler:
         "Scheduler configured with %d job(s) | timezone=%s | pipeline_cron=%02d:%02d",
         "Scheduler configured with %d job(s) | timezone=%s | pipeline_cron=%02d:%02d",
         len(scheduler.get_jobs()),
         len(scheduler.get_jobs()),
         tz,
         tz,
-        PIPELINE_CRON_HOUR,
-        PIPELINE_CRON_MINUTE,
+        settings.scheduler_cron_hour,
+        settings.scheduler_cron_minute,
     )
     )
     return scheduler
     return scheduler
 
 
@@ -64,7 +66,7 @@ def _log_next_runs(scheduler: BaseScheduler) -> None:
 
 
 
 
 def start_scheduler() -> BackgroundScheduler | None:
 def start_scheduler() -> BackgroundScheduler | None:
-    """Start the background scheduler (idempotent). Used by API lifespan."""
+    """Start the independent scheduler process (idempotent)."""
     global _scheduler
     global _scheduler
 
 
     settings = get_infra_settings()
     settings = get_infra_settings()

+ 10 - 6
supply_infra/scheduler/jobs/demand_pool/videos.py

@@ -178,23 +178,27 @@ def sync_multi_demand_videos(
     limit: int | None = None,
     limit: int | None = None,
     offset: int = 0,
     offset: int = 0,
     batch_size: int = VIDEO_SYNC_BATCH_SIZE,
     batch_size: int = VIDEO_SYNC_BATCH_SIZE,
+    *,
+    decode_dt: str | None = None,
 ) -> dict[str, Any]:
 ) -> dict[str, Any]:
     """
     """
     增量同步需求池视频与最终选题。
     增量同步需求池视频与最终选题。
 
 
     - 视频来源:multi_demand_pool_di 全表 video_list
     - 视频来源:multi_demand_pool_di 全表 video_list
     - 已有 vid 跳过
     - 已有 vid 跳过
-    - ODPS decode 分区永远用「今天的昨天」
+    - ODPS decode 分区由批次冻结参数传入;兼容调用未传时使用「今天的昨天」
     - 按 batch_size 分批:每批查询后立刻写入
     - 按 batch_size 分批:每批查询后立刻写入
     - limit: 本轮最多处理的 pending 数;None 表示从 offset 起全部
     - limit: 本轮最多处理的 pending 数;None 表示从 offset 起全部
     - offset: pending 列表起始偏移(手动分批用)
     - offset: pending 列表起始偏移(手动分批用)
     """
     """
-    decode_dt = (datetime.now() - timedelta(days=1)).strftime("%Y%m%d")
+    resolved_decode_dt = decode_dt or (
+        datetime.now() - timedelta(days=1)
+    ).strftime("%Y%m%d")
     start = max(0, int(offset))
     start = max(0, int(offset))
     chunk = max(1, int(batch_size))
     chunk = max(1, int(batch_size))
     logger.info(
     logger.info(
         "Starting multi demand video sync: decode_dt=%s limit=%s offset=%d batch_size=%d",
         "Starting multi demand video sync: decode_dt=%s limit=%s offset=%d batch_size=%d",
-        decode_dt,
+        resolved_decode_dt,
         limit,
         limit,
         start,
         start,
         chunk,
         chunk,
@@ -224,7 +228,7 @@ def sync_multi_demand_videos(
     )
     )
 
 
     empty = {
     empty = {
-        "decode_dt": decode_dt,
+        "decode_dt": resolved_decode_dt,
         "pool_video_lists": len(video_lists),
         "pool_video_lists": len(video_lists),
         "unique_vids": len(all_vids),
         "unique_vids": len(all_vids),
         "pending_total": len(pending_all),
         "pending_total": len(pending_all),
@@ -251,7 +255,7 @@ def sync_multi_demand_videos(
     total_missing = 0
     total_missing = 0
 
 
     for idx, batch_vids in enumerate(batches, start=1):
     for idx, batch_vids in enumerate(batches, start=1):
-        stats = _sync_one_batch(odps, decode_dt, batch_vids, idx, len(batches))
+        stats = _sync_one_batch(odps, resolved_decode_dt, batch_vids, idx, len(batches))
         total_odps_rows += stats["odps_rows"]
         total_odps_rows += stats["odps_rows"]
         total_inserted += stats["inserted"]
         total_inserted += stats["inserted"]
         total_skipped += stats["skipped_no_topic"]
         total_skipped += stats["skipped_no_topic"]
@@ -259,7 +263,7 @@ def sync_multi_demand_videos(
 
 
     next_offset = start + len(pending)
     next_offset = start + len(pending)
     result = {
     result = {
-        "decode_dt": decode_dt,
+        "decode_dt": resolved_decode_dt,
         "pool_video_lists": len(video_lists),
         "pool_video_lists": len(video_lists),
         "unique_vids": len(all_vids),
         "unique_vids": len(all_vids),
         "pending_total": len(pending_all),
         "pending_total": len(pending_all),

+ 16 - 9
supply_infra/scheduler/jobs/publish_videos_from_discovery.py

@@ -15,7 +15,6 @@ from supply_infra.aigc.plan_map import (
     list_unique_plan_pairs,
     list_unique_plan_pairs,
 )
 )
 from supply_infra.config import get_infra_settings
 from supply_infra.config import get_infra_settings
-from supply_infra.db.models.video_discovery import VideoDiscoveryCandidate
 from supply_infra.db.repositories.demand_grade_repo import DemandGradeRepository
 from supply_infra.db.repositories.demand_grade_repo import DemandGradeRepository
 from supply_infra.db.repositories.video_discovery_repo import VideoDiscoveryRepository
 from supply_infra.db.repositories.video_discovery_repo import VideoDiscoveryRepository
 from supply_infra.db.session import get_session
 from supply_infra.db.session import get_session
@@ -102,6 +101,12 @@ def publish_videos_from_discovery(
 
 
     仅处理 decision_bucket 为 primary / backup 且 aweme_id 非空的候选,不区分品类。
     仅处理 decision_bucket 为 primary / backup 且 aweme_id 非空的候选,不区分品类。
     """
     """
+    settings = get_infra_settings()
+    effective_dry_run = (
+        dry_run
+        or settings.aigc_dry_run
+        or not settings.pipeline_external_effects_enabled
+    )
     resolved_biz_dt = _resolve_biz_dt(biz_dt)
     resolved_biz_dt = _resolve_biz_dt(biz_dt)
     plan_pairs = list_unique_plan_pairs()
     plan_pairs = list_unique_plan_pairs()
     if not plan_pairs:
     if not plan_pairs:
@@ -127,10 +132,11 @@ def publish_videos_from_discovery(
             "biz_dt": resolved_biz_dt,
             "biz_dt": resolved_biz_dt,
             "run_id": run_id,
             "run_id": run_id,
             "plan_count": len(plan_pairs),
             "plan_count": len(plan_pairs),
-        "candidate_count": 0,
-        "decision_buckets": list(_PUBLISHABLE_BUCKETS),
-        "batches": [],
-    }
+            "candidate_count": 0,
+            "decision_buckets": list(_PUBLISHABLE_BUCKETS),
+            "dry_run": effective_dry_run,
+            "batches": [],
+        }
 
 
     grouped = _group_candidates_by_plan(candidates, plan_pairs)
     grouped = _group_candidates_by_plan(candidates, plan_pairs)
     distribution_preview = assignment_summary(
     distribution_preview = assignment_summary(
@@ -138,8 +144,8 @@ def publish_videos_from_discovery(
         [bucket for _, bucket in grouped],
         [bucket for _, bucket in grouped],
     )
     )
 
 
-    client = AigcClient(dry_run=dry_run)
-    timezone = ZoneInfo(get_infra_settings().scheduler_timezone)
+    client = AigcClient(dry_run=effective_dry_run)
+    timezone = ZoneInfo(settings.scheduler_timezone)
     timestamp = datetime.now(timezone).strftime("%Y%m%d%H%M%S")
     timestamp = datetime.now(timezone).strftime("%Y%m%d%H%M%S")
     batch_results: list[PublishBatchResult] = []
     batch_results: list[PublishBatchResult] = []
 
 
@@ -162,7 +168,7 @@ def publish_videos_from_discovery(
                 publish_plan_id=plan_pair.publish_plan_id,
                 publish_plan_id=plan_pair.publish_plan_id,
                 aweme_ids=aweme_batch,
                 aweme_ids=aweme_batch,
                 candidate_ids=id_batch,
                 candidate_ids=id_batch,
-                dry_run=dry_run,
+                dry_run=effective_dry_run,
             )
             )
 
 
             if not create_result.get("success"):
             if not create_result.get("success"):
@@ -184,7 +190,7 @@ def publish_videos_from_discovery(
             if not batch.bind_success:
             if not batch.bind_success:
                 batch.bind_error = str(bind_result.get("error") or "绑定生成计划失败")
                 batch.bind_error = str(bind_result.get("error") or "绑定生成计划失败")
 
 
-            if not dry_run and crawler_plan_id and batch.bind_success:
+            if not effective_dry_run and crawler_plan_id and batch.bind_success:
                 with get_session() as session:
                 with get_session() as session:
                     updated = VideoDiscoveryRepository(session).mark_candidates_aigc_plans(
                     updated = VideoDiscoveryRepository(session).mark_candidates_aigc_plans(
                         id_batch,
                         id_batch,
@@ -214,6 +220,7 @@ def publish_videos_from_discovery(
         "distribution": distribution_preview,
         "distribution": distribution_preview,
         "batch_count": len(batch_results),
         "batch_count": len(batch_results),
         "failed_batch_count": len(failed_batches),
         "failed_batch_count": len(failed_batches),
+        "dry_run": effective_dry_run,
         "batches": [item.to_dict() for item in batch_results],
         "batches": [item.to_dict() for item in batch_results],
     }
     }
 
 

+ 55 - 206
supply_infra/scheduler/jobs/run_supply_pipeline.py

@@ -1,223 +1,72 @@
-"""
-串联执行的供给数据流水线(可重复执行、幂等):
-
-1. ODPS → MySQL 全局树同步(T-1 分区)
-2. ODPS → MySQL 策略需求池同步(当天 biz_dt)
-3. 需求池分级评估(同上 biz_dt)
-4. S/A 需求视频点位拓展判断
-5. find_agent 视频发现(top 200 需求,2 线程并行)
-6. AIGC 发布(find_agent 完成后发布全部符合条件的视频)
-
-各子步骤以独立 CLI 子进程执行(``python -m supply_infra.scheduler.jobs.*``);
-内部已做去重(INSERT IGNORE、diff 同步、跳过已分级词等)。
-本文件额外用进程内锁防止同一轮次并发重入,并隔离各步骤异常:前一步失败时记录
-error 后继续后续步骤,最终返回失败结果而不向 APScheduler 抛异常。
-"""
+"""Compatibility entry point for the durable supply pipeline."""
 from __future__ import annotations
 from __future__ import annotations
 
 
-import logging
-import sys
-import threading
-from datetime import datetime, timedelta
+import argparse
+import time
 from typing import Any
 from typing import Any
-from zoneinfo import ZoneInfo
 
 
-from supply_infra.config import get_infra_settings
+from supply_infra.pipeline.run_service import get_pipeline_run, submit_pipeline_run
 from supply_infra.scheduler.cli_result import run_cli
 from supply_infra.scheduler.cli_result import run_cli
-from supply_infra.scheduler.constants import (
-    PIPELINE_FIND_AGENT_TOP_DEMANDS,
-    PIPELINE_FIND_AGENT_WORKERS,
-    SUPPLY_PIPELINE_JOB_ID,
-    SUPPLY_PIPELINE_JOB_NAME,
-)
-from supply_infra.scheduler.job_execution import JobExecutionRecorder, record_skipped
-from supply_infra.scheduler.step_runner import run_step_module
-
-logger = logging.getLogger(__name__)
-
-_pipeline_lock = threading.Lock()
-
-
-def _resolve_dates(biz_dt: str | None) -> tuple[str, str]:
-    """返回 (biz_dt, global_tree_partition_date),global_tree 使用 biz_dt 前一日。"""
-    if biz_dt:
-        resolved_biz_dt = biz_dt
-    else:
-        timezone = ZoneInfo(get_infra_settings().scheduler_timezone)
-        resolved_biz_dt = datetime.now(timezone).strftime("%Y%m%d")
-    tree_partition = (
-        datetime.strptime(resolved_biz_dt, "%Y%m%d") - timedelta(days=1)
-    ).strftime("%Y%m%d")
-    return resolved_biz_dt, tree_partition
-
 
 
-def _preflight_failure_result(biz_dt: str | None, exc: Exception) -> dict[str, Any]:
-    """日期/配置解析失败时也记录结果并正常返回,避免调度线程出现未捕获异常。"""
-    started_at = datetime.now()
-    recorder = JobExecutionRecorder(
-        job_name=SUPPLY_PIPELINE_JOB_NAME,
-        job_id=SUPPLY_PIPELINE_JOB_ID,
-        biz_dt=str(biz_dt) if biz_dt is not None else None,
-    )
-    recorder.record_started()
-    result: dict[str, Any] = {
-        "run_id": recorder.run_id,
-        "biz_dt": str(biz_dt) if biz_dt is not None else None,
-        "success": False,
-        "error": str(exc),
-        "started_at": started_at.isoformat(),
-    }
-    finished_at = datetime.now()
-    result["finished_at"] = finished_at.isoformat()
-    result["duration_seconds"] = round((finished_at - started_at).total_seconds(), 2)
-    recorder.record_finished(success=False, result=result, error_message=str(exc))
-    return result
-
-
-def run_supply_pipeline(biz_dt: str | None = None) -> dict[str, Any]:
+_TERMINAL = {
+    "succeeded",
+    "failed",
+    "cancelled",
+    "deadline_exceeded",
+}
+
+
+def run_supply_pipeline(
+    biz_dt: str | None = None,
+    *,
+    wait: bool = False,
+    wait_timeout_seconds: int = 86400,
+    poll_seconds: float = 2.0,
+) -> dict[str, Any]:
     """
     """
-    按顺序以子进程执行:全局树同步 → 需求池同步 → 需求分级 → 视频点位拓展
-    → find_agent 找片 → AIGC 发布。
-
-    Args:
-        biz_dt: 业务日 YYYYMMDD;省略则取当天。
+    Submit a durable batch.
 
 
-    Returns:
-        各步骤统计;若上一轮仍在执行则返回 skipped。
+    This function no longer executes business steps directly. ``wait=True`` is
+    retained for diagnostics and waits on MySQL state rather than an in-memory
+    thread.
     """
     """
-    try:
-        resolved_biz_dt, tree_partition = _resolve_dates(biz_dt)
-    except Exception as exc:
-        logger.exception("Supply pipeline preflight failed: biz_dt=%s", biz_dt)
-        return _preflight_failure_result(biz_dt, exc)
-
-    if not _pipeline_lock.acquire(blocking=False):
-        logger.warning("Supply pipeline already running, skip this round")
-        record_skipped(
-            job_name=SUPPLY_PIPELINE_JOB_NAME,
-            job_id=SUPPLY_PIPELINE_JOB_ID,
-            biz_dt=resolved_biz_dt,
-            reason="already_running",
-        )
-        return {
-            "skipped": True,
-            "reason": "already_running",
-            "run_at": datetime.now().isoformat(),
-        }
-
-    started_at = datetime.now()
-    recorder = JobExecutionRecorder(
-        job_name=SUPPLY_PIPELINE_JOB_NAME,
-        job_id=SUPPLY_PIPELINE_JOB_ID,
-        biz_dt=resolved_biz_dt,
-    )
-    recorder.record_started()
-
-    logger.info(
-        "Supply pipeline start: biz_dt=%s global_tree_partition=%s run_id=%s",
-        resolved_biz_dt,
-        tree_partition,
-        recorder.run_id,
+    submission = submit_pipeline_run(
+        biz_dt=biz_dt,
+        trigger_type="cli",
+        trigger_source="legacy_run_supply_pipeline",
     )
     )
-
-    result: dict[str, Any] = {
-        "run_id": recorder.run_id,
-        "biz_dt": resolved_biz_dt,
-        "global_tree_partition": tree_partition,
-        "started_at": started_at.isoformat(),
-    }
-    errors: list[str] = []
-    step_status: dict[str, dict[str, Any]] = {}
-    success = False
-
-    try:
-        steps: list[tuple[str, str, list[str]]] = [
-            (
-                "global_tree",
-                "supply_infra.scheduler.jobs.sync_global_tree_odps_to_mysql",
-                [tree_partition],
-            ),
-            (
-                "demand_pool",
-                "supply_infra.scheduler.jobs.demand_pool",
-                [resolved_biz_dt],
-            ),
-            (
-                "grade",
-                "supply_infra.scheduler.jobs.grade_demand_pool",
-                [resolved_biz_dt],
-            ),
-            (
-                "expand_video_points",
-                "supply_infra.scheduler.jobs.expand_demand_from_video_points",
-                [resolved_biz_dt, "5"],
-            ),
-            (
-                "discover_videos",
-                "supply_infra.scheduler.jobs.discover_videos_from_demands",
-                [
-                    resolved_biz_dt,
-                    str(PIPELINE_FIND_AGENT_WORKERS),
-                    "--top-limit",
-                    str(PIPELINE_FIND_AGENT_TOP_DEMANDS),
-                ],
-            ),
-            (
-                "publish_videos",
-                "supply_infra.scheduler.jobs.publish_videos_from_discovery",
-                [resolved_biz_dt],
-            ),
-        ]
-        for step_name, module, module_args in steps:
-            payload, step_success, step_error = run_step_module(
-                module,
-                module_args,
-            )
-            result[step_name] = payload
-            step_status[step_name] = {
-                "success": step_success,
-                "error": step_error,
+    accepted = submission.to_dict()
+    if not wait:
+        return accepted
+
+    deadline = time.monotonic() + wait_timeout_seconds
+    while time.monotonic() < deadline:
+        payload = get_pipeline_run(submission.run_id)
+        if payload is None:
+            return {
+                **accepted,
+                "success": False,
+                "error": "Persistent run disappeared",
             }
             }
-            if step_error:
-                errors.append(f"{step_name}: {step_error}")
-
-        success = all(item["success"] for item in step_status.values())
-        result["success"] = success
-    except Exception as exc:
-        # 保护流水线编排本身;正常子步骤异常应已由 run_step_module 消化。
-        result["success"] = False
-        errors.append(f"pipeline: {exc}")
-        logger.exception(
-            "Supply pipeline orchestration failed but will not escape scheduler: "
-            "biz_dt=%s global_tree_partition=%s",
-            resolved_biz_dt,
-            tree_partition,
-        )
-    finally:
-        finished_at = datetime.now()
-        result["steps"] = step_status
-        if errors:
-            result["errors"] = errors
-        result["finished_at"] = finished_at.isoformat()
-        result["duration_seconds"] = round((finished_at - started_at).total_seconds(), 2)
-        try:
-            recorder.record_finished(
-                success=success,
-                result=result,
-                error_message=" | ".join(errors) if errors else None,
-            )
-        except Exception:
-            # recorder 当前已自行兜底;这里防止未来实现变化造成锁无法释放。
-            logger.exception("Unexpected failure while recording supply pipeline finish")
-        finally:
-            _pipeline_lock.release()
-        logger.info("Supply pipeline finished: %s", result)
-
-    return result
+        if payload["status"] in _TERMINAL:
+            return {
+                **payload,
+                "success": payload["status"] == "succeeded",
+            }
+        time.sleep(max(0.1, poll_seconds))
+    return {
+        **accepted,
+        "success": False,
+        "error": f"Timed out waiting for run after {wait_timeout_seconds}s",
+    }
 
 
 
 
 if __name__ == "__main__":
 if __name__ == "__main__":
+    parser = argparse.ArgumentParser(description="Submit the durable supply pipeline")
+    parser.add_argument("biz_dt", nargs="?")
+    parser.add_argument("--wait", action="store_true")
+    args = parser.parse_args()
     run_cli(
     run_cli(
-        lambda: run_supply_pipeline(sys.argv[1] if len(sys.argv) > 1 else None),
+        lambda: run_supply_pipeline(args.biz_dt, wait=args.wait),
         label="run_supply_pipeline",
         label="run_supply_pipeline",
     )
     )

+ 22 - 273
supply_infra/scheduler/manual_jobs.py

@@ -1,25 +1,11 @@
-"""手动触发定时任务:任务注册表、后台执行与运行状态查询。"""
+"""Deprecated compatibility facade for durable pipeline submissions."""
 from __future__ import annotations
 from __future__ import annotations
 
 
-import logging
-import threading
-import uuid
-from collections.abc import Callable
 from dataclasses import dataclass
 from dataclasses import dataclass
-from datetime import datetime, timedelta
 from typing import Any
 from typing import Any
-from zoneinfo import ZoneInfo
 
 
-from supply_infra.config import get_infra_settings
-from supply_infra.scheduler.constants import (
-    SUPPLY_PIPELINE_JOB_ID,
-    SUPPLY_PIPELINE_JOB_NAME,
-)
-
-logger = logging.getLogger(__name__)
-
-_RUNS: dict[str, dict[str, Any]] = {}
-_RUNS_LOCK = threading.Lock()
+from supply_infra.pipeline.run_service import get_pipeline_run, submit_pipeline_run
+from supply_infra.scheduler.constants import SUPPLY_PIPELINE_JOB_ID, SUPPLY_PIPELINE_JOB_NAME
 
 
 
 
 @dataclass(frozen=True)
 @dataclass(frozen=True)
@@ -27,161 +13,29 @@ class ManualJobSpec:
     job_id: str
     job_id: str
     name: str
     name: str
     description: str
     description: str
-    runner: Callable[..., dict[str, Any]]
-    accepts_biz_dt: bool = False
+    accepts_biz_dt: bool = True
     accepts_partition_date: bool = False
     accepts_partition_date: bool = False
 
 
 
 
-def _resolve_biz_dt(biz_dt: str | None) -> str:
-    if biz_dt:
-        return biz_dt
-    timezone = ZoneInfo(get_infra_settings().scheduler_timezone)
-    return datetime.now(timezone).strftime("%Y%m%d")
-
-
-def _resolve_tree_partition(biz_dt: str | None, partition_date: str | None) -> str:
-    if partition_date:
-        return partition_date
-    resolved_biz_dt = _resolve_biz_dt(biz_dt)
-    return (
-        datetime.strptime(resolved_biz_dt, "%Y%m%d") - timedelta(days=1)
-    ).strftime("%Y%m%d")
-
-
-def _run_supply_pipeline_job(
-    *,
-    biz_dt: str | None,
-    partition_date: str | None,
-) -> dict[str, Any]:
-    from supply_infra.scheduler.jobs.run_supply_pipeline import run_supply_pipeline
-
-    del partition_date
-    return run_supply_pipeline(biz_dt=biz_dt)
-
-
-def _run_global_tree_job(
-    *,
-    biz_dt: str | None,
-    partition_date: str | None,
-) -> dict[str, Any]:
-    from supply_infra.scheduler.jobs.sync_global_tree_odps_to_mysql import (
-        sync_global_tree_odps_to_mysql,
-    )
-
-    return sync_global_tree_odps_to_mysql(
-        partition_date=_resolve_tree_partition(biz_dt, partition_date),
-    )
-
-
-def _run_demand_pool_job(
-    *,
-    biz_dt: str | None,
-    partition_date: str | None,
-) -> dict[str, Any]:
-    from supply_infra.scheduler.jobs.demand_pool import (
-        sync_multi_demand_pool_odps_to_mysql,
-    )
-
-    return sync_multi_demand_pool_odps_to_mysql(
-        partition_date=partition_date or _resolve_biz_dt(biz_dt),
-    )
-
-
-def _run_biz_dt_job(
-    import_path: str,
-    fn_name: str,
-    *,
-    biz_dt: str | None,
-    partition_date: str | None,
-) -> dict[str, Any]:
-    del partition_date
-    module = __import__(import_path, fromlist=[fn_name])
-    fn = getattr(module, fn_name)
-    return fn(biz_dt)
-
-
-MANUAL_JOBS: dict[str, ManualJobSpec] = {
+MANUAL_JOBS = {
     SUPPLY_PIPELINE_JOB_ID: ManualJobSpec(
     SUPPLY_PIPELINE_JOB_ID: ManualJobSpec(
         job_id=SUPPLY_PIPELINE_JOB_ID,
         job_id=SUPPLY_PIPELINE_JOB_ID,
         name=SUPPLY_PIPELINE_JOB_NAME,
         name=SUPPLY_PIPELINE_JOB_NAME,
-        description="串行执行:全局树 → 需求池 → 分级 → 拓展 → find_agent → AIGC 发布",
-        runner=_run_supply_pipeline_job,
-        accepts_biz_dt=True,
-    ),
-    "sync_global_tree_odps_to_mysql": ManualJobSpec(
-        job_id="sync_global_tree_odps_to_mysql",
-        name="全局树 ODPS 同步",
-        description="从 ODPS 同步 global_tree_category / global_tree_element 到 MySQL",
-        runner=_run_global_tree_job,
-        accepts_biz_dt=True,
-        accepts_partition_date=True,
-    ),
-    "sync_multi_demand_pool_odps_to_mysql": ManualJobSpec(
-        job_id="sync_multi_demand_pool_odps_to_mysql",
-        name="策略需求池 ODPS 同步",
-        description="同步 multi_demand_pool_di,并执行归属分类、热度统计、树权重与视频同步",
-        runner=_run_demand_pool_job,
-        accepts_biz_dt=True,
-        accepts_partition_date=True,
-    ),
-    "grade_demand_pool": ManualJobSpec(
-        job_id="grade_demand_pool",
-        name="需求池分级",
-        description="对指定业务日的需求词执行分级评估",
-        runner=lambda **kwargs: _run_biz_dt_job(
-            "supply_infra.scheduler.jobs.grade_demand_pool",
-            "grade_demand_pool",
-            **kwargs,
-        ),
-        accepts_biz_dt=True,
-    ),
-    "expand_demand_from_video_points": ManualJobSpec(
-        job_id="expand_demand_from_video_points",
-        name="视频点位拓展",
-        description="对 S/A 需求执行视频点位拓展判断",
-        runner=lambda **kwargs: _run_biz_dt_job(
-            "supply_infra.scheduler.jobs.expand_demand_from_video_points",
-            "expand_demand_from_video_points",
-            **kwargs,
-        ),
-        accepts_biz_dt=True,
-    ),
-    "discover_videos_from_demands": ManualJobSpec(
-        job_id="discover_videos_from_demands",
-        name="find_agent 视频发现",
-        description="对 S/A 需求拓展点位调用 find_agent 找片",
-        runner=lambda **kwargs: _run_biz_dt_job(
-            "supply_infra.scheduler.jobs.discover_videos_from_demands",
-            "discover_videos_from_demands",
-            **kwargs,
-        ),
-        accepts_biz_dt=True,
-    ),
-    "publish_videos_from_discovery": ManualJobSpec(
-        job_id="publish_videos_from_discovery",
-        name="AIGC 视频发布",
-        description="将 find_agent 发现结果发布到 AIGC 平台",
-        runner=lambda **kwargs: _run_biz_dt_job(
-            "supply_infra.scheduler.jobs.publish_videos_from_discovery",
-            "publish_videos_from_discovery",
-            **kwargs,
-        ),
-        accepts_biz_dt=True,
-    ),
+        description="提交严格门禁的持久化供给流水线",
+    )
 }
 }
 
 
 
 
 def list_manual_jobs() -> list[dict[str, Any]]:
 def list_manual_jobs() -> list[dict[str, Any]]:
-    """返回可手动触发的任务列表。"""
     return [
     return [
         {
         {
-            "id": spec.job_id,
-            "name": spec.name,
-            "description": spec.description,
-            "accepts_biz_dt": spec.accepts_biz_dt,
-            "accepts_partition_date": spec.accepts_partition_date,
+            "id": item.job_id,
+            "name": item.name,
+            "description": item.description,
+            "accepts_biz_dt": item.accepts_biz_dt,
+            "accepts_partition_date": item.accepts_partition_date,
         }
         }
-        for spec in MANUAL_JOBS.values()
+        for item in MANUAL_JOBS.values()
     ]
     ]
 
 
 
 
@@ -190,77 +44,7 @@ def get_manual_job(job_id: str) -> ManualJobSpec | None:
 
 
 
 
 def get_manual_run(run_id: str) -> dict[str, Any] | None:
 def get_manual_run(run_id: str) -> dict[str, Any] | None:
-    with _RUNS_LOCK:
-        payload = _RUNS.get(run_id)
-        return dict(payload) if payload else None
-
-
-def _set_run_state(run_id: str, payload: dict[str, Any]) -> None:
-    with _RUNS_LOCK:
-        _RUNS[run_id] = payload
-
-
-def _execute_job(
-    run_id: str,
-    spec: ManualJobSpec,
-    *,
-    biz_dt: str | None,
-    partition_date: str | None,
-) -> None:
-    started_at = datetime.now().isoformat()
-    _set_run_state(
-        run_id,
-        {
-            "run_id": run_id,
-            "job_id": spec.job_id,
-            "job_name": spec.name,
-            "status": "running",
-            "biz_dt": biz_dt,
-            "partition_date": partition_date,
-            "started_at": started_at,
-        },
-    )
-    try:
-        result = spec.runner(biz_dt=biz_dt, partition_date=partition_date)
-        success = not (isinstance(result, dict) and result.get("success") is False)
-        finished_at = datetime.now().isoformat()
-        _set_run_state(
-            run_id,
-            {
-                "run_id": run_id,
-                "job_id": spec.job_id,
-                "job_name": spec.name,
-                "status": "finished" if success else "failed",
-                "biz_dt": biz_dt,
-                "partition_date": partition_date,
-                "started_at": started_at,
-                "finished_at": finished_at,
-                "result": result,
-            },
-        )
-        logger.info(
-            "Manual job finished: job_id=%s run_id=%s status=%s",
-            spec.job_id,
-            run_id,
-            "finished" if success else "failed",
-        )
-    except Exception as exc:
-        finished_at = datetime.now().isoformat()
-        logger.exception("Manual job failed: job_id=%s run_id=%s", spec.job_id, run_id)
-        _set_run_state(
-            run_id,
-            {
-                "run_id": run_id,
-                "job_id": spec.job_id,
-                "job_name": spec.name,
-                "status": "failed",
-                "biz_dt": biz_dt,
-                "partition_date": partition_date,
-                "started_at": started_at,
-                "finished_at": finished_at,
-                "error": str(exc),
-            },
-        )
+    return get_pipeline_run(run_id)
 
 
 
 
 def trigger_manual_job(
 def trigger_manual_job(
@@ -270,48 +54,13 @@ def trigger_manual_job(
     partition_date: str | None = None,
     partition_date: str | None = None,
     wait: bool = False,
     wait: bool = False,
 ) -> dict[str, Any]:
 ) -> dict[str, Any]:
-    """
-    手动触发任务。
-
-    wait=False 时在后台线程执行并立即返回 run_id;
-    wait=True 时同步执行并返回完整结果。
-    """
-    spec = get_manual_job(job_id)
-    if spec is None:
+    del partition_date
+    if job_id != SUPPLY_PIPELINE_JOB_ID:
         raise KeyError(job_id)
         raise KeyError(job_id)
-
-    if spec.accepts_biz_dt and biz_dt is None:
-        biz_dt = _resolve_biz_dt(None)
-    if spec.job_id == "sync_global_tree_odps_to_mysql" and partition_date is None:
-        partition_date = _resolve_tree_partition(biz_dt, None)
-    if spec.job_id == "sync_multi_demand_pool_odps_to_mysql" and partition_date is None:
-        partition_date = biz_dt or _resolve_biz_dt(None)
-
-    run_id = str(uuid.uuid4())
     if wait:
     if wait:
-        _execute_job(run_id, spec, biz_dt=biz_dt, partition_date=partition_date)
-        payload = get_manual_run(run_id)
-        assert payload is not None
-        return payload
-
-    thread = threading.Thread(
-        target=_execute_job,
-        kwargs={
-            "run_id": run_id,
-            "spec": spec,
-            "biz_dt": biz_dt,
-            "partition_date": partition_date,
-        },
-        name=f"manual-job-{job_id}-{run_id[:8]}",
-        daemon=True,
-    )
-    thread.start()
-    return {
-        "accepted": True,
-        "run_id": run_id,
-        "job_id": spec.job_id,
-        "job_name": spec.name,
-        "status": "running",
-        "biz_dt": biz_dt,
-        "partition_date": partition_date,
-    }
+        raise ValueError("wait=true is disabled; poll the persistent run_id")
+    return submit_pipeline_run(
+        biz_dt=biz_dt,
+        trigger_type="api",
+        trigger_source="legacy_manual_job",
+    ).to_dict()

+ 6 - 120
supply_infra/scheduler/step_runner.py

@@ -1,122 +1,8 @@
-"""Run pipeline step modules as isolated subprocesses via ``python -m``."""
-from __future__ import annotations
+"""Deprecated import compatibility for the durable pipeline step runner."""
 
 
-import json
-import logging
-import subprocess
-import sys
-from pathlib import Path
-from typing import Any
+from supply_infra.pipeline.step_runner import (
+    StepExecutionRequest,
+    run_step_subprocess,
+)
 
 
-logger = logging.getLogger(__name__)
-
-_REPO_ROOT = Path(__file__).resolve().parents[2]
-
-
-def run_step_module(
-    module: str,
-    args: list[str] | None = None,
-    *,
-    timeout: float | None = None,
-) -> tuple[Any, bool, str | None]:
-    """
-    Execute ``python -m <module>`` with the current interpreter.
-
-    Returns:
-        (payload, success, error_message)
-    """
-    cmd = [sys.executable, "-m", module, *(args or [])]
-    logger.info("Pipeline step subprocess start: cmd=%s", cmd)
-    try:
-        completed = subprocess.run(
-            cmd,
-            cwd=str(_REPO_ROOT),
-            capture_output=True,
-            text=True,
-            timeout=timeout,
-            check=False,
-        )
-    except subprocess.TimeoutExpired as exc:
-        error = f"step timed out after {timeout}s: {module}"
-        logger.exception(error)
-        stderr_tail = (exc.stderr or "")[-2000:] if isinstance(exc.stderr, str) else ""
-        return (
-            {
-                "success": False,
-                "error": error,
-                "stderr": stderr_tail,
-            },
-            False,
-            error,
-        )
-    except Exception as exc:
-        error = f"failed to launch step module {module}: {exc}"
-        logger.exception(error)
-        return {"success": False, "error": error}, False, error
-
-    stdout = (completed.stdout or "").strip()
-    stderr = (completed.stderr or "").strip()
-    if stderr:
-        logger.info(
-            "Pipeline step stderr: module=%s exit=%s stderr_tail=%s",
-            module,
-            completed.returncode,
-            stderr[-2000:],
-        )
-
-    payload: Any
-    parse_error: str | None = None
-    if not stdout:
-        payload = {
-            "success": False,
-            "error": "empty stdout from step module",
-            "exit_code": completed.returncode,
-            "stderr": stderr[-2000:] if stderr else None,
-        }
-        parse_error = "empty stdout from step module"
-    else:
-        # Prefer the last non-empty line in case logging leaked to stdout.
-        last_line = stdout.splitlines()[-1]
-        try:
-            payload = json.loads(last_line)
-        except json.JSONDecodeError:
-            try:
-                payload = json.loads(stdout)
-            except json.JSONDecodeError as exc:
-                parse_error = f"invalid JSON from step module: {exc}"
-                payload = {
-                    "success": False,
-                    "error": parse_error,
-                    "exit_code": completed.returncode,
-                    "stdout_tail": stdout[-2000:],
-                    "stderr": stderr[-2000:] if stderr else None,
-                }
-
-    success = completed.returncode == 0
-    if isinstance(payload, dict) and payload.get("success") is False:
-        success = False
-
-    error: str | None = None
-    if not success:
-        if isinstance(payload, dict):
-            error = str(
-                payload.get("error")
-                or parse_error
-                or f"{module} failed with exit_code={completed.returncode}"
-            )
-        else:
-            error = parse_error or f"{module} failed with exit_code={completed.returncode}"
-        logger.error(
-            "Pipeline step failed: module=%s exit=%s error=%s",
-            module,
-            completed.returncode,
-            error,
-        )
-    else:
-        logger.info(
-            "Pipeline step ok: module=%s exit=%s",
-            module,
-            completed.returncode,
-        )
-
-    return payload, success, error
+__all__ = ["StepExecutionRequest", "run_step_subprocess"]

+ 0 - 0
tests/__init__.py


+ 1 - 0
tests/supply_infra/__init__.py

@@ -0,0 +1 @@
+"""supply_infra tests."""

+ 1 - 0
tests/supply_infra/pipeline/__init__.py

@@ -0,0 +1 @@
+"""Pipeline control-plane tests."""

+ 34 - 0
tests/supply_infra/pipeline/test_aigc_safety.py

@@ -0,0 +1,34 @@
+from __future__ import annotations
+
+from supply_infra.aigc.client import AigcClient
+from supply_infra.config import InfraSettings
+
+
+def test_aigc_client_forces_dry_run_when_external_effects_are_disabled(
+    monkeypatch,
+) -> None:
+    called = False
+
+    def _unexpected_post(*_args, **_kwargs):
+        nonlocal called
+        called = True
+        raise AssertionError("network must not be called")
+
+    monkeypatch.setattr("supply_infra.aigc.client.requests.post", _unexpected_post)
+    client = AigcClient(token="secret", dry_run=False)
+    result = client.create_video_crawler_plan(["123"])
+    assert result["dry_run"] is True
+    assert called is False
+
+
+def test_global_aigc_dry_run_cannot_be_disabled_by_caller(monkeypatch) -> None:
+    settings = InfraSettings(
+        PIPELINE_EXTERNAL_EFFECTS_ENABLED=True,
+        AIGC_DRY_RUN=True,
+    )
+    monkeypatch.setattr(
+        "supply_infra.aigc.client.get_infra_settings",
+        lambda: settings,
+    )
+    client = AigcClient(token="secret", dry_run=False)
+    assert client.dry_run is True

+ 52 - 0
tests/supply_infra/pipeline/test_config_budget.py

@@ -0,0 +1,52 @@
+from __future__ import annotations
+
+import pytest
+from pydantic import ValidationError
+
+from supply_infra.config import InfraSettings
+
+
+def _settings(**overrides: object) -> InfraSettings:
+    values: dict[str, object] = {
+        "PROCESS_ROLE": "api",
+        "MYSQL_CONNECTION_BUDGET": 40,
+        "MYSQL_POOL_SIZE_API": 6,
+        "MYSQL_POOL_SIZE_CONTROL": 1,
+        "MYSQL_OPERATIONAL_RESERVE": 4,
+        "MYSQL_MAX_OVERFLOW": 0,
+        "PIPELINE_WORKER_PROCESSES": 4,
+        "PIPELINE_MAX_ACTIVE_STEPS": 4,
+        "AIGC_DRY_RUN": True,
+        "PIPELINE_EXTERNAL_EFFECTS_ENABLED": False,
+    }
+    values.update(overrides)
+    return InfraSettings(_env_file=None, **values)
+
+
+def test_standard_connection_profile_uses_twenty_of_forty() -> None:
+    settings = _settings()
+    assert settings.pipeline_required_connections == 20
+    assert settings.mysql_connection_budget == 40
+
+
+def test_high_concurrency_profile_stays_within_fifty() -> None:
+    settings = _settings(
+        MYSQL_CONNECTION_BUDGET=50,
+        PIPELINE_WORKER_PROCESSES=20,
+        PIPELINE_MAX_ACTIVE_STEPS=10,
+    )
+    assert settings.pipeline_required_connections == 42
+
+
+def test_connection_budget_rejects_unbounded_twenty_by_twenty() -> None:
+    with pytest.raises(ValidationError, match="connection budget exceeded"):
+        _settings(
+            MYSQL_CONNECTION_BUDGET=50,
+            PIPELINE_WORKER_PROCESSES=20,
+            PIPELINE_MAX_ACTIVE_STEPS=20,
+        )
+
+
+def test_pool_overflow_must_remain_zero() -> None:
+    with pytest.raises(ValidationError, match="MYSQL_MAX_OVERFLOW"):
+        _settings(MYSQL_MAX_OVERFLOW=1)

+ 37 - 0
tests/supply_infra/pipeline/test_dag_and_gates.py

@@ -0,0 +1,37 @@
+from __future__ import annotations
+
+from supply_infra.pipeline.dag import PIPELINE_STEPS
+from supply_infra.pipeline.gates import evaluate_step_gate
+
+
+def test_pipeline_has_twelve_strictly_ordered_steps() -> None:
+    assert len(PIPELINE_STEPS) == 12
+    assert PIPELINE_STEPS[0].key == "global_tree_sync"
+    assert PIPELINE_STEPS[-1].key == "aigc_write_record"
+    for previous, current in zip(PIPELINE_STEPS, PIPELINE_STEPS[1:]):
+        assert current.dependencies == (previous.key,)
+        assert current.critical is True
+
+
+def test_aigc_gate_requires_durable_dry_run_record() -> None:
+    failed = evaluate_step_gate("aigc_write_record", {"success": True})
+    assert failed.passed is False
+    passed = evaluate_step_gate(
+        "aigc_write_record",
+        {
+            "success": True,
+            "dry_run_recorded": True,
+            "payload_hash": "abc",
+            "external_request_made": False,
+        },
+    )
+    assert passed.passed is True
+
+
+def test_explicit_step_failure_fails_closed() -> None:
+    decision = evaluate_step_gate(
+        "global_tree_sync",
+        {"success": False, "error": "source missing"},
+    )
+    assert decision.passed is False
+    assert decision.error_code == "step_reported_failure"

+ 74 - 0
tests/supply_infra/pipeline/test_dates.py

@@ -0,0 +1,74 @@
+from __future__ import annotations
+
+from datetime import datetime
+from zoneinfo import ZoneInfo
+
+from supply_infra.config import InfraSettings
+from supply_infra.pipeline.dates import (
+    build_date_snapshot,
+    deadline_for_trigger,
+    next_daily_deadline_utc,
+    next_schedule_utc,
+    resolve_biz_dt,
+)
+
+
+def _settings() -> InfraSettings:
+    return InfraSettings(
+        _env_file=None,
+        SCHEDULER_TIMEZONE="Asia/Shanghai",
+        SCHEDULER_CRON_HOUR=15,
+        SCHEDULER_CRON_MINUTE=0,
+        AIGC_DRY_RUN=True,
+    )
+
+
+def test_date_snapshot_is_frozen_from_biz_dt() -> None:
+    assert build_date_snapshot("20260727") == {
+        "biz_dt": "20260727",
+        "global_tree_partition": "20260726",
+        "demand_pool_partition": "20260727",
+        "video_decode_partition": "20260726",
+    }
+
+
+def test_resolve_biz_dt_uses_scheduler_timezone() -> None:
+    now = datetime(2026, 7, 27, 1, 2, tzinfo=ZoneInfo("Asia/Shanghai"))
+    assert resolve_biz_dt(None, settings=_settings(), now=now) == "20260727"
+
+
+def test_deadline_is_next_scheduler_start() -> None:
+    now = datetime(2026, 7, 27, 15, 1, tzinfo=ZoneInfo("Asia/Shanghai"))
+    deadline = next_schedule_utc(settings=_settings(), now=now)
+    assert deadline.isoformat() == "2026-07-28T07:00:00"
+
+
+def test_scheduled_daily_deadline_is_always_next_local_day() -> None:
+    before_cron = datetime(2026, 7, 27, 14, 0, tzinfo=ZoneInfo("Asia/Shanghai"))
+    after_cron = datetime(2026, 7, 27, 16, 0, tzinfo=ZoneInfo("Asia/Shanghai"))
+
+    assert next_daily_deadline_utc(
+        settings=_settings(),
+        now=before_cron,
+    ).isoformat() == "2026-07-28T07:00:00"
+    assert next_daily_deadline_utc(
+        settings=_settings(),
+        now=after_cron,
+    ).isoformat() == "2026-07-28T07:00:00"
+
+
+def test_only_scheduled_triggers_receive_a_deadline() -> None:
+    now = datetime(2026, 7, 27, 15, 0, tzinfo=ZoneInfo("Asia/Shanghai"))
+
+    assert deadline_for_trigger(
+        "cron",
+        settings=_settings(),
+        now=now,
+    ).isoformat() == "2026-07-28T07:00:00"
+    assert deadline_for_trigger(
+        "reconcile",
+        settings=_settings(),
+        now=now,
+    ).isoformat() == "2026-07-28T07:00:00"
+    assert deadline_for_trigger("api", settings=_settings(), now=now) is None
+    assert deadline_for_trigger("cli", settings=_settings(), now=now) is None

+ 97 - 0
tests/supply_infra/pipeline/test_health.py

@@ -0,0 +1,97 @@
+from __future__ import annotations
+
+from datetime import timedelta
+
+from supply_infra.config import InfraSettings
+from supply_infra.pipeline.dates import utc_now
+from supply_infra.pipeline.health import run_alert_level
+
+
+def _settings() -> InfraSettings:
+    return InfraSettings(
+        _env_file=None,
+        AIGC_DRY_RUN=True,
+        PIPELINE_EXTERNAL_EFFECTS_ENABLED=False,
+    )
+
+
+def test_alert_level_escalates_against_next_schedule_deadline() -> None:
+    settings = _settings()
+    now = utc_now()
+
+    assert (
+        run_alert_level(
+            status="running",
+            started_at=now - timedelta(hours=13),
+            created_at=now - timedelta(hours=13),
+            deadline_at=now + timedelta(hours=5),
+            now=now,
+            settings=settings,
+        )
+        == "warning"
+    )
+    assert (
+        run_alert_level(
+            status="running",
+            started_at=now - timedelta(hours=1),
+            created_at=now - timedelta(hours=1),
+            deadline_at=now + timedelta(minutes=90),
+            now=now,
+            settings=settings,
+        )
+        == "critical"
+    )
+    assert (
+        run_alert_level(
+            status="blocked_previous_run",
+            started_at=None,
+            created_at=now - timedelta(hours=1),
+            deadline_at=now + timedelta(minutes=20),
+            now=now,
+            settings=settings,
+        )
+        == "final_warning"
+    )
+
+
+def test_terminal_run_has_no_duration_alert() -> None:
+    now = utc_now()
+    assert (
+        run_alert_level(
+            status="failed",
+            started_at=now - timedelta(days=2),
+            created_at=now - timedelta(days=2),
+            deadline_at=now - timedelta(days=1),
+            now=now,
+            settings=_settings(),
+        )
+        is None
+    )
+
+
+def test_manual_run_without_deadline_only_uses_duration_warning() -> None:
+    now = utc_now()
+    settings = _settings()
+
+    assert (
+        run_alert_level(
+            status="running",
+            started_at=now - timedelta(hours=1),
+            created_at=now - timedelta(hours=1),
+            deadline_at=None,
+            now=now,
+            settings=settings,
+        )
+        is None
+    )
+    assert (
+        run_alert_level(
+            status="running",
+            started_at=now - timedelta(hours=13),
+            created_at=now - timedelta(hours=13),
+            deadline_at=None,
+            now=now,
+            settings=settings,
+        )
+        == "warning"
+    )

+ 151 - 0
tests/supply_infra/pipeline/test_orchestrator.py

@@ -0,0 +1,151 @@
+from __future__ import annotations
+
+from datetime import datetime, timedelta, timezone
+
+from sqlalchemy import create_engine
+from sqlalchemy.orm import Session
+
+from supply_infra.db.models.pipeline_run import PipelineRun
+from supply_infra.db.models.pipeline_step_run import PipelineStepRun
+from supply_infra.pipeline.contracts import StepResult
+from supply_infra.pipeline.orchestrator import complete_step
+
+
+def _session() -> Session:
+    engine = create_engine("sqlite+pysqlite:///:memory:")
+    PipelineRun.__table__.create(engine)
+    PipelineStepRun.__table__.create(engine)
+    return Session(engine, expire_on_commit=False)
+
+
+def _seed(session: Session) -> tuple[PipelineRun, PipelineStepRun, PipelineStepRun]:
+    run = PipelineRun(
+        run_id="run-1",
+        dedupe_key="supply_pipeline:20260727:full",
+        pipeline_key="supply_pipeline",
+        biz_dt="20260727",
+        trigger_type="test",
+        run_mode="full",
+        dry_run=True,
+        status="running",
+        deadline_at=datetime.now(timezone.utc).replace(tzinfo=None) + timedelta(days=1),
+        config_snapshot_json={},
+        date_snapshot_json={},
+        lease_owner="worker-1",
+    )
+    first = PipelineStepRun(
+        step_run_id="step-1",
+        run_id=run.run_id,
+        step_key="global_tree_sync",
+        step_order=1,
+        attempt=1,
+        status="running",
+        critical=True,
+        dependency_snapshot_json=[],
+        input_snapshot_json={},
+        timeout_seconds=10,
+        max_attempts=1,
+        retryable=False,
+        lease_owner="worker-1",
+    )
+    second = PipelineStepRun(
+        step_run_id="step-2",
+        run_id=run.run_id,
+        step_key="demand_pool_source_sync",
+        step_order=2,
+        attempt=1,
+        status="pending",
+        critical=True,
+        dependency_snapshot_json=["global_tree_sync"],
+        input_snapshot_json={},
+        timeout_seconds=10,
+        max_attempts=1,
+        retryable=False,
+    )
+    session.add_all([run, first, second])
+    session.flush()
+    return run, first, second
+
+
+def test_failure_blocks_all_downstream_steps() -> None:
+    with _session() as session:
+        run, first, second = _seed(session)
+        state = complete_step(
+            session,
+            step_run_id=first.step_run_id,
+            owner="worker-1",
+            result=StepResult(
+                success=False,
+                payload={"success": False},
+                error_code="test_failure",
+                error_message="boom",
+                exit_code=1,
+            ),
+        )
+        assert state == "failed"
+        assert run.status == "failed"
+        assert first.status == "failed"
+        assert second.status == "blocked"
+
+
+def test_success_only_releases_immediate_next_step() -> None:
+    with _session() as session:
+        run, first, second = _seed(session)
+        state = complete_step(
+            session,
+            step_run_id=first.step_run_id,
+            owner="worker-1",
+            result=StepResult(
+                success=True,
+                payload={"success": True},
+                exit_code=0,
+            ),
+        )
+        assert state == "queued"
+        assert run.status == "queued"
+        assert first.status == "succeeded"
+        assert second.status == "ready"
+
+
+def test_completion_cannot_revive_a_cancelled_run() -> None:
+    with _session() as session:
+        run, first, second = _seed(session)
+        run.status = "cancelling"
+
+        state = complete_step(
+            session,
+            step_run_id=first.step_run_id,
+            owner="worker-1",
+            result=StepResult(
+                success=True,
+                payload={"success": True},
+                exit_code=0,
+            ),
+        )
+
+        assert state == "cancelled"
+        assert run.status == "cancelled"
+        assert first.status == "cancelled"
+        assert second.status == "pending"
+
+
+def test_completion_cannot_revive_a_deadline_exceeded_run() -> None:
+    with _session() as session:
+        run, first, second = _seed(session)
+        run.status = "deadline_exceeded"
+
+        state = complete_step(
+            session,
+            step_run_id=first.step_run_id,
+            owner="worker-1",
+            result=StepResult(
+                success=True,
+                payload={"success": True},
+                exit_code=0,
+            ),
+        )
+
+        assert state == "deadline_exceeded"
+        assert run.status == "deadline_exceeded"
+        assert first.status == "timed_out"
+        assert second.status == "pending"

+ 30 - 0
tests/supply_infra/pipeline/test_run_serialization.py

@@ -0,0 +1,30 @@
+from __future__ import annotations
+
+from datetime import datetime
+
+from supply_infra.db.models.pipeline_run import PipelineRun
+from supply_infra.pipeline.run_service import serialize_run
+
+
+def test_manual_run_serializes_null_deadline_and_utc_timestamps() -> None:
+    run = PipelineRun(
+        run_id="manual-run",
+        dedupe_key="supply_pipeline:20260727:full",
+        pipeline_key="supply_pipeline",
+        biz_dt="20260727",
+        trigger_type="api",
+        run_mode="full",
+        dry_run=True,
+        status="queued",
+        deadline_at=None,
+        config_snapshot_json={},
+        date_snapshot_json={},
+        created_at=datetime(2026, 7, 27, 6, 30),
+        updated_at=datetime(2026, 7, 27, 6, 31),
+    )
+
+    payload = serialize_run(run)
+
+    assert payload["deadline_at"] is None
+    assert payload["created_at"] == "2026-07-27T06:30:00Z"
+    assert payload["updated_at"] == "2026-07-27T06:31:00Z"

+ 129 - 0
tests/supply_infra/pipeline/test_step_claim.py

@@ -0,0 +1,129 @@
+from __future__ import annotations
+
+from datetime import timedelta
+
+from sqlalchemy import create_engine
+from sqlalchemy.orm import Session
+
+from supply_infra.db.models.pipeline_lock import PipelineLock
+from supply_infra.db.models.pipeline_run import PipelineRun
+from supply_infra.db.models.pipeline_step_run import PipelineStepRun
+from supply_infra.db.repositories.pipeline_step_run_repo import (
+    PipelineStepRunRepository,
+)
+from supply_infra.pipeline.dates import utc_now
+
+
+def _session() -> Session:
+    engine = create_engine("sqlite+pysqlite:///:memory:")
+    PipelineRun.__table__.create(engine)
+    PipelineStepRun.__table__.create(engine)
+    PipelineLock.__table__.create(engine)
+    return Session(engine, expire_on_commit=False)
+
+
+def _run(run_id: str) -> PipelineRun:
+    now = utc_now()
+    return PipelineRun(
+        run_id=run_id,
+        dedupe_key=f"supply_pipeline:20260727:{run_id}",
+        pipeline_key="supply_pipeline",
+        biz_dt="20260727",
+        trigger_type="test",
+        run_mode="full",
+        dry_run=True,
+        status="queued",
+        deadline_at=now + timedelta(days=1),
+        config_snapshot_json={},
+        date_snapshot_json={},
+    )
+
+
+def _step(
+    *,
+    step_run_id: str,
+    run_id: str,
+    status: str,
+    order: int,
+) -> PipelineStepRun:
+    return PipelineStepRun(
+        step_run_id=step_run_id,
+        run_id=run_id,
+        step_key=f"step-{step_run_id}",
+        step_order=order,
+        attempt=1,
+        status=status,
+        critical=True,
+        dependency_snapshot_json=[],
+        input_snapshot_json={},
+        timeout_seconds=10,
+        max_attempts=1,
+        retryable=False,
+    )
+
+
+def test_claim_respects_global_active_step_cap() -> None:
+    with _session() as session:
+        session.add_all(
+            [
+                _run("run-1"),
+                _run("run-2"),
+                _step(
+                    step_run_id="running",
+                    run_id="run-1",
+                    status="running",
+                    order=1,
+                ),
+                _step(
+                    step_run_id="ready",
+                    run_id="run-2",
+                    status="ready",
+                    order=1,
+                ),
+            ]
+        )
+        session.flush()
+
+        claimed = PipelineStepRunRepository(session).claim_next_ready(
+            owner="worker-2",
+            lease_seconds=120,
+            max_active_steps=1,
+            now=utc_now(),
+        )
+
+        assert claimed is None
+        ready = session.get(PipelineStepRun, "ready")
+        assert ready is not None
+        assert ready.status == "ready"
+
+
+def test_claim_does_not_overlap_a_different_pipeline_run() -> None:
+    with _session() as session:
+        session.add_all(
+            [
+                _run("run-1"),
+                _run("run-2"),
+                _step(
+                    step_run_id="running",
+                    run_id="run-1",
+                    status="running",
+                    order=1,
+                ),
+                _step(
+                    step_run_id="ready",
+                    run_id="run-2",
+                    status="ready",
+                    order=1,
+                ),
+            ]
+        )
+        session.flush()
+
+        claimed = PipelineStepRunRepository(session).claim_next_ready(
+            owner="worker-2",
+            lease_seconds=120,
+            max_active_steps=2,
+            now=utc_now(),
+        )
+
+        assert claimed is None

+ 1 - 0
tests/supply_infra/scheduler/__init__.py

@@ -0,0 +1 @@
+"""scheduler tests."""

+ 48 - 0
tests/supply_infra/scheduler/test_grade_demand_pool.py

@@ -0,0 +1,48 @@
+"""Current demand-grade scheduler wrapper tests."""
+from __future__ import annotations
+
+from unittest.mock import patch
+
+from supply_infra.scheduler.jobs.grade_demand_pool import (
+    execute_plan_tasks_until_complete,
+    grade_demand_pool,
+)
+
+
+@patch("supply_infra.scheduler.jobs.grade_demand_pool._execute_plan_tasks")
+def test_plan_execution_stops_when_no_groups_are_claimable(mock_execute) -> None:
+    snapshot = {
+        "execution_complete": True,
+        "assigned_category_ids": [],
+        "planned_groups": 0,
+        "group_status": {},
+    }
+    mock_execute.return_value = {
+        "attempted_groups": 0,
+        "groups_run": 0,
+        "workers": 0,
+        "final_snapshot": snapshot,
+        "execution_complete": True,
+    }
+
+    result = execute_plan_tasks_until_complete(
+        "20260721",
+        workers=5,
+        max_demands_per_batch=20,
+    )
+
+    assert result["rounds"] == 1
+    assert result["execution_complete"] is True
+    mock_execute.assert_called_once()
+
+
+@patch("supply_infra.scheduler.jobs.grade_demand_pool._grade_demand_pool_impl")
+@patch("supply_infra.scheduler.jobs.grade_demand_pool._resolve_biz_dt", return_value="20260721")
+def test_grade_job_never_raises_to_pipeline(_mock_resolve, mock_impl) -> None:
+    mock_impl.side_effect = RuntimeError("boom")
+
+    result = grade_demand_pool("20260721")
+
+    assert result["success"] is False
+    assert result["biz_dt"] == "20260721"
+    assert result["error"] == "boom"

+ 57 - 0
tests/supply_infra/scheduler/test_run_supply_pipeline.py

@@ -0,0 +1,57 @@
+"""Compatibility wrapper tests for the durable pipeline."""
+from __future__ import annotations
+
+from unittest.mock import patch
+
+from supply_infra.pipeline.run_service import RunSubmission
+from supply_infra.scheduler.jobs.run_supply_pipeline import run_supply_pipeline
+
+
+@patch("supply_infra.scheduler.jobs.run_supply_pipeline.submit_pipeline_run")
+def test_pipeline_wrapper_only_submits_durable_run(mock_submit) -> None:
+    mock_submit.return_value = RunSubmission(
+        run_id="run-1",
+        created=True,
+        status="queued",
+        biz_dt="20260721",
+    )
+
+    result = run_supply_pipeline("20260721")
+
+    assert result["run_id"] == "run-1"
+    assert result["status"] == "queued"
+    assert result["dry_run"] is True
+    mock_submit.assert_called_once()
+
+
+@patch("supply_infra.scheduler.jobs.run_supply_pipeline.get_pipeline_run")
+@patch("supply_infra.scheduler.jobs.run_supply_pipeline.submit_pipeline_run")
+def test_wait_reads_terminal_state_from_mysql(mock_submit, mock_get) -> None:
+    mock_submit.return_value = RunSubmission(
+        run_id="run-2",
+        created=True,
+        status="queued",
+        biz_dt="20260721",
+    )
+    mock_get.return_value = {
+        "run_id": "run-2",
+        "status": "failed",
+        "error_message": "strict gate failed",
+    }
+
+    result = run_supply_pipeline("20260721", wait=True, poll_seconds=0.01)
+
+    assert result["success"] is False
+    assert result["status"] == "failed"
+
+
+@patch("supply_infra.scheduler.jobs.run_supply_pipeline.submit_pipeline_run")
+def test_submit_validation_error_is_not_hidden(mock_submit) -> None:
+    mock_submit.side_effect = ValueError("Invalid biz_dt")
+
+    try:
+        run_supply_pipeline("invalid-date")
+    except ValueError as exc:
+        assert "Invalid biz_dt" in str(exc)
+    else:
+        raise AssertionError("validation error must propagate to the CLI/API boundary")

+ 88 - 0
tests/supply_infra/scheduler/test_sync_multi_demand_pool.py

@@ -0,0 +1,88 @@
+"""需求池同步内部子阶段的异常隔离测试。"""
+from __future__ import annotations
+
+from unittest.mock import MagicMock, patch
+
+from supply_infra.scheduler.jobs.demand_pool.sync import (
+    _classify_words,
+    sync_multi_demand_pool_odps_to_mysql,
+)
+
+
+@patch(
+    "supply_infra.scheduler.jobs.demand_pool.sync.sync_multi_demand_videos"
+)
+@patch(
+    "supply_infra.scheduler.jobs.demand_pool.sync.compute_category_tree_weight"
+)
+@patch(
+    "supply_infra.scheduler.jobs.demand_pool.sync.compute_popularity_stats"
+)
+@patch(
+    "supply_infra.scheduler.jobs.demand_pool.sync.enrich_real_rov_vov_7d"
+)
+@patch(
+    "supply_infra.scheduler.jobs.demand_pool.sync.sync_demand_belong_pool_rel"
+)
+@patch("supply_infra.scheduler.jobs.demand_pool.sync._classify_words")
+@patch("supply_infra.scheduler.jobs.demand_pool.sync._sync_pool_rows")
+def test_substep_failure_does_not_skip_later_substeps(
+    mock_source_sync,
+    mock_classify,
+    mock_rel,
+    mock_real_metrics,
+    mock_popularity,
+    mock_tree_weight,
+    mock_videos,
+) -> None:
+    mock_source_sync.return_value = {"inserted": 10}
+    mock_classify.side_effect = RuntimeError("agent unavailable")
+    mock_rel.return_value = {"inserted": 2}
+    mock_real_metrics.return_value = {"updated_rows": 3}
+    mock_popularity.return_value = {"upserted": 4}
+    mock_tree_weight.return_value = {"upserted": 5}
+    mock_videos.return_value = {"inserted": 6}
+
+    result = sync_multi_demand_pool_odps_to_mysql("20260721")
+
+    assert result["success"] is False
+    assert result["classify"] == {"success": False, "error": "agent unavailable"}
+    assert result["belong_pool_rel"] == {"inserted": 2}
+    assert result["real_metrics"] == {"updated_rows": 3}
+    assert result["popularity"] == {"upserted": 4}
+    assert result["tree_weight"] == {"upserted": 5}
+    assert result["videos"] == {"inserted": 6}
+    assert [item["stage"] for item in result["errors"]] == ["classify"]
+
+
+@patch(
+    "supply_infra.scheduler.jobs.demand_pool.sync.classify_demand_words"
+)
+@patch(
+    "supply_infra.scheduler.jobs.demand_pool.sync.DemandBelongCategoryRepository"
+)
+@patch(
+    "supply_infra.scheduler.jobs.demand_pool.sync.MultiDemandPoolDiRepository"
+)
+@patch("supply_infra.scheduler.jobs.demand_pool.sync.get_session")
+def test_classification_batch_failure_continues_remaining_batches(
+    mock_get_session,
+    mock_pool_repo_cls,
+    mock_belong_repo_cls,
+    mock_classify,
+) -> None:
+    mock_get_session.return_value.__enter__.return_value = MagicMock()
+    mock_pool_repo_cls.return_value.list_demand_names_by_biz_dt.return_value = [
+        f"需求{i:03d}" for i in range(120)
+    ]
+    mock_belong_repo_cls.return_value.get_existing_names.return_value = set()
+    mock_classify.side_effect = [RuntimeError("first batch failed"), None, None]
+
+    result = _classify_words("20260721")
+
+    assert mock_classify.call_count == 3
+    assert result["success"] is False
+    assert result["batches"] == 3
+    assert result["failed_batches"] == [
+        {"batch": 1, "size": 50, "error": "first batch failed"}
+    ]

+ 1 - 0
web/src/App.vue

@@ -12,6 +12,7 @@ const navItems = [
   { to: '/', label: '供给总览', icon: '◫' },
   { to: '/', label: '供给总览', icon: '◫' },
   { to: '/demand-map', label: '全局需求地图', icon: '⌁' },
   { to: '/demand-map', label: '全局需求地图', icon: '⌁' },
   { to: '/video-discovery', label: '需求汇总', icon: '▷' },
   { to: '/video-discovery', label: '需求汇总', icon: '▷' },
+  { to: '/pipeline-runs', label: '定时任务', icon: '◷' },
   { to: '/demand-process', label: 'Agent 审计', icon: '◎' },
   { to: '/demand-process', label: 'Agent 审计', icon: '◎' },
 ]
 ]
 
 

+ 7 - 6
web/src/api/dashboard.ts

@@ -6,7 +6,7 @@ export interface SchedulerJobStatus {
 
 
 export interface SchedulerStatus {
 export interface SchedulerStatus {
   enabled: boolean
   enabled: boolean
-  running: boolean
+  running: boolean | null
   timezone: string
   timezone: string
   jobs: SchedulerJobStatus[]
   jobs: SchedulerJobStatus[]
 }
 }
@@ -22,14 +22,15 @@ export interface ManualJob {
 export interface ManualRun {
 export interface ManualRun {
   accepted?: boolean
   accepted?: boolean
   run_id: string
   run_id: string
-  job_id: string
-  job_name: string
-  status: 'running' | 'finished' | 'failed' | string
-  biz_dt: string | null
-  partition_date: string | null
+  job_id?: string
+  job_name?: string
+  status: 'queued' | 'running' | 'succeeded' | 'failed' | 'blocked_previous_run' | string
+  biz_dt: string
+  partition_date?: string | null
   started_at?: string
   started_at?: string
   finished_at?: string
   finished_at?: string
   error?: string
   error?: string
+  error_message?: string
 }
 }
 
 
 async function readJson<T>(response: Response, message: string): Promise<T> {
 async function readJson<T>(response: Response, message: string): Promise<T> {

+ 53 - 0
web/src/api/pipeline.ts

@@ -0,0 +1,53 @@
+import type { PipelineRun } from '../types/pipeline'
+
+async function readJson<T>(response: Response, message: string): Promise<T> {
+  if (!response.ok) {
+    const detail = await response.text()
+    throw new Error(`${message}: ${response.status} ${detail || response.statusText}`)
+  }
+  return response.json() as Promise<T>
+}
+
+export async function fetchPipelineRuns(): Promise<{ items: PipelineRun[] }> {
+  return readJson(await fetch('/api/pipeline/runs'), '加载流水线批次失败')
+}
+
+export async function fetchPipelineRun(runId: string): Promise<PipelineRun> {
+  return readJson(
+    await fetch(`/api/pipeline/runs/${encodeURIComponent(runId)}`),
+    '加载流水线详情失败',
+  )
+}
+
+export async function createPipelineRun(bizDt: string): Promise<{
+  accepted: boolean
+  run_id: string
+  status: string
+}> {
+  return readJson(
+    await fetch('/api/pipeline/runs', {
+      method: 'POST',
+      headers: { 'Content-Type': 'application/json' },
+      body: JSON.stringify({ biz_dt: bizDt || null }),
+    }),
+    '创建流水线批次失败',
+  )
+}
+
+export async function resumePipelineRun(runId: string): Promise<void> {
+  await readJson(
+    await fetch(`/api/pipeline/runs/${encodeURIComponent(runId)}/resume`, {
+      method: 'POST',
+    }),
+    '恢复流水线失败',
+  )
+}
+
+export async function cancelPipelineRun(runId: string): Promise<void> {
+  await readJson(
+    await fetch(`/api/pipeline/runs/${encodeURIComponent(runId)}/cancel`, {
+      method: 'POST',
+    }),
+    '取消流水线失败',
+  )
+}

+ 29 - 0
web/src/components/pipeline/PipelineRunActions.vue

@@ -0,0 +1,29 @@
+<script setup lang="ts">
+import type { PipelineRun } from '../../types/pipeline'
+
+defineProps<{ run: PipelineRun; busy?: boolean }>()
+defineEmits<{ resume: []; cancel: [] }>()
+</script>
+
+<template>
+  <div class="pipeline-actions">
+    <button
+      :disabled="busy || !['failed', 'deadline_exceeded'].includes(run.status)"
+      @click="$emit('resume')"
+    >
+      从失败点恢复
+    </button>
+    <button
+      :disabled="busy || ['succeeded', 'failed', 'deadline_exceeded', 'cancelling', 'cancelled'].includes(run.status)"
+      @click="$emit('cancel')"
+    >
+      取消批次
+    </button>
+  </div>
+</template>
+
+<style scoped>
+.pipeline-actions { display: flex; gap: 8px; }
+button { border: 1px solid #cbd2df; border-radius: 8px; background: white; padding: 8px 12px; cursor: pointer; }
+button:disabled { opacity: .45; cursor: default; }
+</style>

+ 29 - 0
web/src/components/pipeline/PipelineStatusCard.vue

@@ -0,0 +1,29 @@
+<script setup lang="ts">
+import type { PipelineRun } from '../../types/pipeline'
+
+defineProps<{ run: PipelineRun }>()
+</script>
+
+<template>
+  <article class="pipeline-status-card">
+    <div>
+      <strong>{{ run.biz_dt }}</strong>
+      <span :class="['pipeline-status', `is-${run.status}`]">{{ run.status }}</span>
+    </div>
+    <p>{{ run.current_step || '无执行中步骤' }}</p>
+    <small v-if="run.deadline_at">
+      截止 {{ new Date(run.deadline_at).toLocaleString('zh-CN') }}
+    </small>
+    <small v-else>手工任务无截止时间</small>
+    <b v-if="run.dry_run">AIGC DRY-RUN</b>
+  </article>
+</template>
+
+<style scoped>
+.pipeline-status-card { border: 1px solid #d9dee8; border-radius: 12px; padding: 14px; }
+.pipeline-status-card > div { display: flex; justify-content: space-between; gap: 12px; }
+.pipeline-status { font-size: 12px; text-transform: uppercase; }
+.is-succeeded { color: #16835d; }
+.is-failed, .is-deadline_exceeded { color: #c23d4b; }
+b { display: inline-block; margin-top: 8px; color: #8a5b00; font-size: 11px; }
+</style>

+ 26 - 0
web/src/components/pipeline/PipelineStepTimeline.vue

@@ -0,0 +1,26 @@
+<script setup lang="ts">
+import type { PipelineStepRun } from '../../types/pipeline'
+
+defineProps<{ steps: PipelineStepRun[] }>()
+</script>
+
+<template>
+  <ol class="step-timeline">
+    <li v-for="step in steps" :key="step.step_run_id">
+      <span>{{ String(step.step_order).padStart(2, '0') }}</span>
+      <div>
+        <strong>{{ step.step_key }}</strong>
+        <small>attempt {{ step.attempt }} · {{ step.status }}</small>
+        <p v-if="step.error_message">{{ step.error_message }}</p>
+      </div>
+    </li>
+  </ol>
+</template>
+
+<style scoped>
+.step-timeline { list-style: none; padding: 0; display: grid; gap: 8px; }
+li { display: grid; grid-template-columns: 34px 1fr; gap: 10px; padding: 10px; border: 1px solid #e4e7ee; border-radius: 10px; }
+li > span { color: #6b7280; font-variant-numeric: tabular-nums; }
+small { display: block; color: #6b7280; }
+p { margin: 6px 0 0; color: #b42335; }
+</style>

+ 7 - 0
web/src/router.ts

@@ -3,6 +3,7 @@ import CategoryTreeView from './views/CategoryTreeView.vue'
 import DemandProcessView from './views/DemandProcessView.vue'
 import DemandProcessView from './views/DemandProcessView.vue'
 import GlobalDemandMapView from './views/GlobalDemandMapView.vue'
 import GlobalDemandMapView from './views/GlobalDemandMapView.vue'
 import OverviewView from './views/OverviewView.vue'
 import OverviewView from './views/OverviewView.vue'
+import PipelineRunsView from './views/PipelineRunsView.vue'
 import VideoDiscoveryView from './views/VideoDiscoveryView.vue'
 import VideoDiscoveryView from './views/VideoDiscoveryView.vue'
 
 
 export const router = createRouter({
 export const router = createRouter({
@@ -38,5 +39,11 @@ export const router = createRouter({
       component: VideoDiscoveryView,
       component: VideoDiscoveryView,
       meta: { title: '需求汇总' },
       meta: { title: '需求汇总' },
     },
     },
+    {
+      path: '/pipeline-runs',
+      name: 'pipeline-runs',
+      component: PipelineRunsView,
+      meta: { title: '定时任务运行中心' },
+    },
   ],
   ],
 })
 })

+ 74 - 0
web/src/types/pipeline.ts

@@ -0,0 +1,74 @@
+export type PipelineRunStatus =
+  | 'queued'
+  | 'running'
+  | 'succeeded'
+  | 'failed'
+  | 'blocked'
+  | 'cancelling'
+  | 'cancelled'
+  | 'deadline_exceeded'
+  | 'blocked_previous_run'
+
+export type PipelineStepStatus =
+  | 'pending'
+  | 'ready'
+  | 'running'
+  | 'retry_wait'
+  | 'succeeded'
+  | 'failed'
+  | 'blocked'
+  | 'timed_out'
+  | 'cancelled'
+
+export interface PipelineStepRun {
+  step_run_id: string
+  run_id: string
+  step_key: string
+  step_order: number
+  attempt: number
+  status: PipelineStepStatus
+  critical: boolean
+  timeout_seconds: number
+  started_at: string | null
+  finished_at: string | null
+  heartbeat_at: string | null
+  result: Record<string, unknown> | null
+  error_code: string | null
+  error_message: string | null
+  log_uri: string | null
+}
+
+export interface PipelineEffect {
+  outbox_id: string
+  step_run_id: string
+  effect_type: string
+  payload_hash: string
+  payload_uri: string | null
+  status: 'dry_run_recorded' | 'record_failed'
+  dry_run: boolean
+  created_at: string | null
+}
+
+export interface PipelineRun {
+  run_id: string
+  pipeline_key: string
+  biz_dt: string
+  trigger_type: string
+  trigger_source: string | null
+  run_mode: string
+  dry_run: boolean
+  status: PipelineRunStatus
+  current_step: string | null
+  scheduled_for: string | null
+  deadline_at: string | null
+  started_at: string | null
+  finished_at: string | null
+  heartbeat_at: string | null
+  summary: Record<string, unknown> | null
+  error_code: string | null
+  error_message: string | null
+  created_at: string
+  updated_at: string
+  steps?: PipelineStepRun[]
+  effects?: PipelineEffect[]
+}

+ 10 - 9
web/src/views/OverviewView.vue

@@ -72,8 +72,8 @@ const pipeline = [
   },
   },
   {
   {
     index: '06',
     index: '06',
-    title: 'AIGC 分发',
-    description: '把合格候选幂等交付给视频爬取与生产计划。',
+    title: 'AIGC 写入记录',
+    description: '以 dry-run 记录准备写入的数据与哈希,不调用真实下游。',
     color: 'green',
     color: 'green',
   },
   },
 ]
 ]
@@ -429,16 +429,17 @@ function pollRun(runId: string) {
     try {
     try {
       const run = await fetchManualRun(runId)
       const run = await fetchManualRun(runId)
       activeRun.value = run
       activeRun.value = run
-      if (run.status === 'running') {
+      if (['queued', 'running', 'retry_wait', 'cancelling', 'blocked_previous_run'].includes(run.status)) {
         pollRun(runId)
         pollRun(runId)
         return
         return
       }
       }
       runningJobId.value = null
       runningJobId.value = null
-      noticeType.value = run.status === 'finished' ? 'success' : 'error'
+      noticeType.value = run.status === 'succeeded' ? 'success' : 'error'
+      const runName = run.job_name || '供给流水线'
       notice.value =
       notice.value =
-        run.status === 'finished'
-          ? `${run.job_name}执行完成`
-          : `${run.job_name}执行失败:${run.error || '请查看运行日志'}`
+        run.status === 'succeeded'
+          ? `${runName}执行完成`
+          : `${runName}执行失败:${run.error_message || run.error || '请查看运行中心'}`
       await loadDashboard()
       await loadDashboard()
     } catch {
     } catch {
       runningJobId.value = null
       runningJobId.value = null
@@ -458,7 +459,7 @@ function pollRun(runId: string) {
         </div>
         </div>
         <h1>从需求信号到<br /><em>可执行内容供给</em></h1>
         <h1>从需求信号到<br /><em>可执行内容供给</em></h1>
         <p>
         <p>
-          SupplyAgent 把分类、热度、分级、视频拓展、内容发现与 AIGC 分发连接成一条
+          SupplyAgent 把分类、热度、分级、视频拓展、内容发现与 AIGC dry-run 记录连接成一条
           可追溯的智能流水线。
           可追溯的智能流水线。
         </p>
         </p>
         <div class="hero-actions">
         <div class="hero-actions">
@@ -668,7 +669,7 @@ function pollRun(runId: string) {
           <h2>每日自动供给流水线</h2>
           <h2>每日自动供给流水线</h2>
         </div>
         </div>
         <div class="pipeline-meta">
         <div class="pipeline-meta">
-          <span class="live-pill"><i /> 每日 14:30 自动执行</span>
+          <span class="live-pill"><i /> 每日 15:00 自动执行</span>
           <span>6 个阶段串行推进</span>
           <span>6 个阶段串行推进</span>
         </div>
         </div>
       </div>
       </div>

+ 130 - 0
web/src/views/PipelineRunsView.vue

@@ -0,0 +1,130 @@
+<script setup lang="ts">
+import { onMounted, ref } from 'vue'
+import {
+  cancelPipelineRun,
+  createPipelineRun,
+  fetchPipelineRun,
+  fetchPipelineRuns,
+  resumePipelineRun,
+} from '../api/pipeline'
+import PipelineRunActions from '../components/pipeline/PipelineRunActions.vue'
+import PipelineStatusCard from '../components/pipeline/PipelineStatusCard.vue'
+import PipelineStepTimeline from '../components/pipeline/PipelineStepTimeline.vue'
+import type { PipelineRun } from '../types/pipeline'
+
+const runs = ref<PipelineRun[]>([])
+const selected = ref<PipelineRun | null>(null)
+const loading = ref(false)
+const busy = ref(false)
+const error = ref('')
+const bizDt = ref(new Intl.DateTimeFormat('sv-SE', {
+  timeZone: 'Asia/Shanghai',
+  year: 'numeric',
+  month: '2-digit',
+  day: '2-digit',
+}).format(new Date()).replaceAll('-', ''))
+
+async function loadRuns() {
+  loading.value = true
+  error.value = ''
+  try {
+    runs.value = (await fetchPipelineRuns()).items
+    if (!selected.value && runs.value[0]) await selectRun(runs.value[0].run_id)
+  } catch (cause) {
+    error.value = cause instanceof Error ? cause.message : String(cause)
+  } finally {
+    loading.value = false
+  }
+}
+
+async function selectRun(runId: string) {
+  selected.value = await fetchPipelineRun(runId)
+}
+
+async function submit() {
+  busy.value = true
+  try {
+    const result = await createPipelineRun(bizDt.value)
+    await loadRuns()
+    await selectRun(result.run_id)
+  } finally {
+    busy.value = false
+  }
+}
+
+async function resume() {
+  if (!selected.value) return
+  busy.value = true
+  try {
+    await resumePipelineRun(selected.value.run_id)
+    await selectRun(selected.value.run_id)
+  } finally {
+    busy.value = false
+  }
+}
+
+async function cancel() {
+  if (!selected.value) return
+  busy.value = true
+  try {
+    await cancelPipelineRun(selected.value.run_id)
+    await selectRun(selected.value.run_id)
+  } finally {
+    busy.value = false
+  }
+}
+
+onMounted(loadRuns)
+</script>
+
+<template>
+  <main class="pipeline-runs">
+    <header>
+      <div>
+        <span>PIPELINE CONTROL CENTER</span>
+        <h1>定时任务运行中心</h1>
+        <p>MySQL 持久化状态 · 严格门禁 · AIGC dry-run</p>
+      </div>
+      <form @submit.prevent="submit">
+        <input v-model="bizDt" pattern="\d{8}" aria-label="业务日" />
+        <button :disabled="busy">创建批次</button>
+      </form>
+    </header>
+
+    <p v-if="error" class="error">{{ error }}</p>
+    <div class="layout">
+      <aside>
+        <p v-if="loading">加载中…</p>
+        <button v-for="run in runs" :key="run.run_id" class="run-item" @click="selectRun(run.run_id)">
+          <PipelineStatusCard :run="run" />
+        </button>
+      </aside>
+      <section v-if="selected" class="detail">
+        <div class="detail-head">
+          <div>
+            <h2>{{ selected.biz_dt }} · {{ selected.status }}</h2>
+            <code>{{ selected.run_id }}</code>
+          </div>
+          <PipelineRunActions :run="selected" :busy="busy" @resume="resume" @cancel="cancel" />
+        </div>
+        <PipelineStepTimeline :steps="selected.steps || []" />
+      </section>
+    </div>
+  </main>
+</template>
+
+<style scoped>
+.pipeline-runs { max-width: 1240px; margin: 0 auto; padding: 32px 24px 72px; }
+header, .detail-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 24px; }
+header span { font-size: 12px; letter-spacing: .12em; color: #667085; }
+h1 { margin: 6px 0; }
+form { display: flex; gap: 8px; }
+input, form button { border: 1px solid #cbd2df; border-radius: 8px; padding: 9px 12px; }
+.layout { display: grid; grid-template-columns: 320px 1fr; gap: 24px; margin-top: 28px; }
+aside { display: grid; gap: 10px; align-content: start; }
+.run-item { text-align: left; border: 0; background: transparent; padding: 0; cursor: pointer; }
+.detail { border: 1px solid #d9dee8; border-radius: 14px; padding: 20px; }
+.detail-head { margin-bottom: 20px; }
+.error { color: #b42335; }
+@media (max-width: 800px) { .layout { grid-template-columns: 1fr; } header { flex-direction: column; } }
+</style>

+ 1 - 1
zhangbo.md

@@ -503,7 +503,7 @@ multi_demand_pool_di.id
 
 
 `supply_infra/scheduler/app.py` 当前只注册一条任务:
 `supply_infra/scheduler/app.py` 当前只注册一条任务:
 
 
-1. 每天 `14:30`(`SCHEDULER_TIMEZONE`):串行执行全链路  
+1. 每天 `15:00`(`SCHEDULER_TIMEZONE`):串行执行全链路
    `run_supply_pipeline`(全局树 → 需求池 → 分级 → 拓展 → 找片 → AIGC 发布)。
    `run_supply_pipeline`(全局树 → 需求池 → 分级 → 拓展 → 找片 → AIGC 发布)。
 
 
 ### 9.2 全局树同步
 ### 9.2 全局树同步