Переглянути джерело

feat(s8): execute watch rules by runtime schedule

S8-SCHED-EXEC-1:DB 驱动调度执行层。

S8WatchSchedulerService:
- 新增 ResetExpiredLeasesAsync / PickReadyRulesAsync / RunSingleRuleAsync /
  OnRuleCompletedAsync / RunDispatchTickAsync 五件套
- 抽出 ProcessSingleRuleAsync(与 legacy ProcessRulesByTypeAsync 共享语义)
- 建单前抗抖:UpsertDetectionStateOnHitAsync 累加 detection_state.consecutive_hit_count,
  未达 trigger_count_required 不建单、不写 CREATED
- 已建异常刷新:consecutive_hit_count += 1、consecutive_miss_count = 0、
  recovered_at = NULL(复发清空)
- 恢复抗抖:每次 miss 异常 consecutive_miss_count += 1 与 detection_state 同步累加;
  未达 recover_count_required 不写 recovered_at、不写 RECOVERED
- 失败治理:consecutive_failure_count >= 3 → paused_until = NOW + 1h、
  pause_reason = AUTO_PAUSED_AFTER_3_FAILURES + 错误摘要
- Lease 乐观抢锁:候选 SELECT → 逐行 UPDATE WHERE id=? AND lock_until<=NOW;
  affectedRows=1 才进 lease 列表;OnRuleCompletedAsync WHERE id=? AND lock_token=?

S8WatchSchedulerJob:
- IntervalMs 300000 → 60000(5min → 1min)
- 替换为 RunDispatchTickAsync 主流程;删除进程内 _consecutiveFailureCount + TryAutoPause
- BuildLockedBy = MachineName-Pid 作为 lease owner
- 留痕字段扩展:tickId/runId/picked/created/refreshed/pending/perRuleFailed/leaseReleased

验证:B/C/D/F/G/H/失败治理 全部端到端通过;baseline=3、demo 规则守恒;
exception 70 保留作演示样本,TEMP_SCHED_BAD_FAILURE fixture 已清理。

debug run-once 端点保持向后兼容(CreateExceptionsAsync → ProcessRulesByTypeAsync,
trigger=1/recover=1 等价旧版行为)。
YY968XX 3 місяців тому
батько
коміт
446aad9b67

+ 184 - 0
lwb/journals/2026-04-28-S8-sched-exec.md

@@ -0,0 +1,184 @@
+# S8-SCHED-EXEC-1 执行记录
+
+> 日期:2026-04-28
+> 范围:S8 自动监控调度执行层(DB 驱动派发 + lease 抢占 + 抗抖触发/恢复 + 失败治理)
+
+## 任务状态
+
+- 任务名:S8-SCHED-EXEC-1
+- 开始:2026-04-28 12:08
+- 结束:2026-04-28 12:50
+- 耗时:约 42 分钟
+
+## 修改文件
+
+- `server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/S8WatchSchedulerService.cs`
+  - 新增依赖 `_detectionStateRep`
+  - 新增 lease/dispatch 公共方法:`ResetExpiredLeasesAsync` / `PickReadyRulesAsync` / `RunSingleRuleAsync` / `OnRuleCompletedAsync` / `RunDispatchTickAsync`
+  - 抽出 `ProcessSingleRuleAsync` 单规则处理体(与 legacy `ProcessRulesByTypeAsync` 共享 evaluator → reconcile → hit 循环语义)
+  - `RefreshDetectionAsync` 增加 `consecutive_hit_count += 1` / `consecutive_miss_count = 0` / `recovered_at = null`(复发清空)
+  - `ProcessRulesByTypeAsync` 创建分支注入 `UpsertDetectionStateOnHitAsync` 抗抖累计(trigger_count_required 兜底 [1,10])
+  - `ReconcileRecoveriesForRuleAsync` 增加 miss 累计 + recover_count_required 抗抖(仅累计未达阈值时不写 recovered_at / 不写 RECOVERED)
+  - 新增 DTO:`S8RuleLease` / `S8RuleRunResult` / `S8RuleRunStats` / `S8DispatchTickResult`
+- `server/Plugins/Admin.NET.Plugin.AiDOP/Job/S8WatchSchedulerJob.cs`
+  - `IntervalMs`:300000 → 60000(5min → 1min)
+  - 替换为 `RunDispatchTickAsync` 主流程(删除进程内 `_consecutiveFailureCount` / `TryAutoPause`)
+  - `BuildLockedBy()` = "{MachineName}-{ProcessId}" 作为 lease owner
+  - 留痕字段扩展:tickId / runId / picked / created / refreshed / pending / perRuleFailed / leaseReleased
+
+## 核心变更点
+
+### Lease 模型
+- 乐观 UPDATE 抢锁:候选 SELECT → 逐行 `WHERE id=? AND enabled AND (paused_until<=NOW) AND (next_run_at<=NOW) AND (lock_until<=NOW)` → affectedRows=1 才进 lease 列表
+- LeaseDuration = 5 min
+- `lock_token` GUID,`locked_by = $"{MachineName}-{Pid}"`,`running_started_at = NOW`,`last_run_id = runId`
+- OnRuleCompletedAsync 必须 `WHERE id=? AND lock_token=?`,丢锁记 Warning 不覆盖状态
+
+### 抗抖
+- 建单前:`UpsertDetectionStateOnHitAsync` 命中累加 `consecutive_hit_count`,未达 `trigger_count_required` 写 `antiflap_pending_hit` skip 结果(不建单、不写 CREATED)
+- 已建异常刷新:RefreshDetectionAsync 自增 `consecutive_hit_count`,清 miss & recovered_at
+- 恢复抗抖:每次 miss 异常 `consecutive_miss_count += 1` & 异常 `consecutive_hit_count = 0`;同步 detection_state;未达 `recover_count_required` 写 `antiflap_pending_recovery` 不写 recovered_at / 不写 RECOVERED
+- 兜底:trigger / recover 计数 null / <1 / >10 一律按 1
+
+### 失败治理
+- evaluator 抛 `S8RuleEvaluatorException` → `Result.Success=false` → `consecutive_failure_count += 1`
+- 达到 3 次 → `paused_until = NOW + 1h` / `pause_reason = "AUTO_PAUSED_AFTER_3_FAILURES: {error 摘要}"`(截断 64 字符)
+- 暂停期 PickReadyRulesAsync 自然过滤
+
+### Job 节拍
+- 5min → 1min;单规则节奏由 `poll_interval_seconds` + `next_run_at` 决定,未到期规则在 PickReady 阶段过滤
+
+### 调度入口
+- Job:`RunDispatchTickAsync(tenantId=1, factoryId=1, batchSize=32, lockedBy)`
+- Debug `run-once`:保留旧 `CreateExceptionsAsync`,路径仍走 legacy `ProcessRulesByTypeAsync`(已注入抗抖;trigger=1/recover=1 时行为等价旧版)
+
+## 测试命令与结果
+
+```
+# 编译
+dotnet build server/Plugins/Admin.NET.Plugin.AiDOP/Admin.NET.Plugin.AiDOP.csproj -f net10.0
+  → 0 Error, 64 Warning(pre-existing XML doc)
+
+# 重启
+bash restart_aidop.sh
+  → 后端 5005 + 前端 8888 启动成功;首次激活日志:
+    "S8WatchSchedulerJob 首次激活:IntervalMs=60000 BatchSize=32 ..."
+```
+
+### A. 编译与启动
+- ✅ 0 Error
+- ✅ 后端启动无 ERROR
+
+### B. PickReady + lease(rule 10/11/12,nudge next_run_at 至过去)
+| ruleId | last_run_at | next_run_at | last_status | last_run_id | lock_token | duration_ms |
+|---|---|---|---|---|---|---|
+| 10 | 12:22:40 | 12:23:40 | SUCCESS | 6e329636829e4f2f | NULL | 656 |
+| 11 | 12:22:41 | 12:27:41 | SUCCESS | 6e329636829e4f2f | NULL | 665 |
+| 12 | 12:22:41 | 12:27:41 | SUCCESS | 6e329636829e4f2f | NULL | 675 |
+- ✅ lease 三件套全部 NULL
+- ✅ last_duration_ms 有值
+- ✅ last_run_id 有值
+
+### C. poll_interval_seconds 差异化
+- rule 10 (poll=60):next - last = 60s ✅
+- rule 11 (poll=300):next - last = 300s ✅
+- rule 12 (poll=300):next - last = 300s ✅
+
+### D. lease 防重复
+1. `UPDATE WHERE id=10 SET lock_token='TEST_LOCK_BLOCK', lock_until=NOW+5min` → 等 1 tick
+   - ✅ rule 10 last_run_at 不变(未被拾取)
+2. 改 `lock_until = NOW - 1min` → 等 1 tick
+   - ✅ ResetExpiredLeasesAsync 释放(log: `lease_reset releasedCount=1`)
+   - ✅ 下个 tick 重新拾取 → SUCCESS
+
+### F. 建单前抗抖(trigger_count_required=3)
+准备:`UPDATE exception 64 SET status='CLOSED'`,`DELETE detection_state`,`SET trigger_count_required=3 ON rule 10`
+
+| tick | hit_count | active_exception_id | created |
+|---|---|---|---|
+| 1 | 1 | NULL | ❌ pending |
+| 2 | 2 | NULL | ❌ pending |
+| 3 | 3 | 70 | ✅ CREATED 写 detection_log |
+
+新建异常 70:dedup_key 与原一致;`consecutive_hit_count=3`、`consecutive_miss_count=0`
+
+### G. 恢复抗抖(recover_count_required=3)
+准备:`UPDATE demo_test_order id=1 SET status='COMPLETED'`(rule 不再命中)
+
+| tick | exception 70 miss_count | recovered_at | RECOVERED log |
+|---|---|---|---|
+| 1 | 1 | NULL | 否 |
+| 2 | 2 | NULL | 否 |
+| 3 | 3 | 12:42:39 | ✅ |
+
+### H. 复发清空 recovered_at
+准备:`UPDATE exception 70 SET recovered_at=NOW`,恢复 demo_test_order 让 rule 重新命中
+
+| 项 | 结果 |
+|---|---|
+| recovered_at | NOW → NULL ✅ |
+| last_detected_at | 12:38:39 ✅ |
+| consecutive_hit_count | 3 → 4 ✅ |
+| status | NEW(未自动改)✅ |
+| detection_log | REFRESHED ✅ |
+
+### 失败治理(TEMP_SCHED_BAD_FAILURE,params_json='{}')
+| tick | last_status | consecutive_failure_count | paused_until | pause_reason |
+|---|---|---|---|---|
+| 1 | FAILED | 1 | NULL | NULL |
+| 2 | FAILED | 2 | NULL | NULL |
+| 3 | FAILED | 3 | 13:46:39 (NOW+1h) | AUTO_PAUSED_AFTER_3_FAILURES: TIMEOUT 规则 TEMP_SCHED_BAD_FAILURE |
+| 4 | (未拾取)| 3 | 13:46:39 | 不变 |
+
+其它规则 (10/11/12) 同期 last_run_at 持续推进 → 单条规则失败不影响其他规则。
+
+### I. baseline & demo 守恒
+- baseline = `SELECT COUNT(*) FROM ado_s8_exception_type WHERE tenant_id=0 AND factory_id=0 AND enabled=1` = 3 ✅
+- rule 10/11/12 enabled=1,paused_until=NULL,trigger_count_required=1,recover_count_required=1 ✅
+
+## SQL 写入摘要(仅限 dev/aidopdev)
+
+```
+-- F 准备
+UPDATE ado_s8_exception SET status='CLOSED' WHERE id=64;
+DELETE FROM ado_s8_rule_detection_state WHERE rule_code='DEMO_ORDER_DELIVERY_TIMEOUT';
+UPDATE ado_s8_watch_rule SET trigger_count_required=3, recover_count_required=3 WHERE id=10;
+
+-- 失败治理 fixture
+INSERT INTO ado_s8_watch_rule (...) VALUES (..., 'TEMP_SCHED_BAD_FAILURE', ..., '{}', ...);
+
+-- H 准备
+UPDATE ado_s8_exception SET recovered_at=NOW() WHERE id=70;
+
+-- G 准备
+UPDATE demo_test_order SET status='COMPLETED' WHERE id=1;
+
+-- 清理
+DELETE FROM ado_s8_watch_rule WHERE rule_code='TEMP_SCHED_BAD_FAILURE';
+UPDATE demo_test_order SET status='PENDING' WHERE id=1;
+UPDATE ado_s8_watch_rule SET trigger_count_required=1, recover_count_required=1 WHERE id=10;
+DELETE FROM ado_s8_rule_detection_state WHERE rule_code='DEMO_ORDER_DELIVERY_TIMEOUT';
+```
+
+注:exception 64 仍 CLOSED;exception 70 保留作为演示数据(复发链路样本)。
+
+## 未解决风险
+
+1. **新调度路径不再扫描"未分类"旧 AlertRule 规则**:legacy `LoadExecutionRulesAsync` 走 `RuleType IS NULL OR ''` 分支,仅在 debug `CreateExceptionsAsync` 调用链可达。Job 现在只走 dispatch tick + RuleType 分派。如生产/预发环境仍存在未分类规则,需要补 RuleType 标记,否则 Job 不再拾取它们。当前 dev 三条 demo 全部 RuleType 已分类,无影响。
+2. **调度 batchSize 硬编码 32**:dev 仅 3 条 demo,规模上来后需要下放到 appsettings 或 watch_rule 配置;本轮按"先可用"处理。
+3. **trigger / recover ≥ 10 的极端值被当作 1**:`NormalizeAntiflapCount` 兜底策略偏保守,如未来需要"≥ 10 次抗抖"必须先调整边界。
+4. **Job 进程多实例**:lease 模型已支持,但本轮仅单实例验证;多实例并发抢锁需要在测试环境复制后再做压测验证。
+5. **detection_state 不软删**:state 行只追加/累加,没有清理机制。如 dedup_key 增量上去后体积不可控;下一轮立 retention 任务(按 last_seen_at 过期)。
+6. **legacy `ProcessRulesByTypeAsync` 与新 `ProcessSingleRuleAsync` 有 ~80% 代码重复**:本轮按"双写不删旧"处理保留 debug run-once 的语义安全。下一轮 cleanup 立 `S8-SCHED-CLEANUP-LEGACY-PATH-1` 把 legacy 路径改成 wrapper。
+7. **回归脚本仍脱节**:rule-evaluator-regression / r6-detection-log-edge-regression baseline=13 + G01_TEST_* 硬编码,不可跑;保持 `S8-REGRESSION-FIXTURE-1` 待办。
+
+## CTO 判断
+
+**通过**。lease + dispatch + 抗抖触发 + 抗抖恢复 + 复发 + 失败治理 + 节拍 1min 全部端到端验证通过;baseline / demo 规则守恒;其它规则不受单条失败影响。
+
+## 下一步建议
+
+- **S8-SCHED-FRONTEND-1**:watch_rule 配置页暴露 poll_interval_seconds / trigger_count_required / recover_count_required;列表加 last_run_at / next_run_at / last_status;run-now / pause / resume controller 端点
+- **S8-SCHED-CLEANUP-LEGACY-PATH-1**:把 `ProcessRulesByTypeAsync` 改为 `ProcessSingleRuleAsync` 的轻 wrapper
+- **S8-DETECTION-STATE-RETENTION-1**:state 表 retention 任务(按 last_seen_at 过期)
+- **S8-REGRESSION-FIXTURE-1**:注入 G01_TEST_* fixture seed;driver baseline 参数化

+ 33 - 71
server/Plugins/Admin.NET.Plugin.AiDOP/Job/S8WatchSchedulerJob.cs

@@ -15,42 +15,31 @@ namespace Admin.NET.Plugin.AiDOP.Job;
 /// </summary>
 [JobDetail("job_s8_watch_scheduler", Description = "S8 自动监控主链定时调度",
     GroupName = "default", Concurrent = false)]
-[Period(S8WatchSchedulerJob.IntervalMs, TriggerId = "trigger_s8_watch_scheduler", Description = "演示节奏:5 分钟一次")]
+[Period(S8WatchSchedulerJob.IntervalMs, TriggerId = "trigger_s8_watch_scheduler", Description = "S8-SCHED-EXEC-1:DB 驱动派发,1 分钟节拍")]
 public class S8WatchSchedulerJob : IJob
 {
     private readonly IServiceScopeFactory _scopeFactory;
-    private readonly ISchedulerFactory _schedulerFactory;
     private readonly ILogger _logger;
 
-    // G-08 调度周期(毫秒)。属性固化锚点,见 G08-02 配置分类表第 2 项。
-    // 既供 [Period] 使用,也供首次激活日志打印,避免两处数值漂移。
-    // 2026-04-28:演示阶段调整为 5 分钟,便于现场看到自动监控效果;R&D 接通"单规则差异化调度"后再回归。
-    public const int IntervalMs = 300000;
+    // S8-SCHED-EXEC-1:节拍 5min → 1min;单规则节奏由 watch_rule.poll_interval_seconds + next_run_at 控制,
+    // Job 仅做 tick;未到期规则在 PickReadyRulesAsync 阶段自然过滤。
+    public const int IntervalMs = 60000;
 
-    // G-08 固定租户 / 工厂上下文。与 G-01 验收口径一致。
+    // 默认租户 / 工厂上下文。与 G-01 / S8-SCHED-SCHEMA-1 验收口径一致。
     private const long DefaultTenantId = 1;
     private const long DefaultFactoryId = 1;
 
-    // G-08 作业与触发器 ID。用于自动停调度时精确定位本作业
-    private const string JobId = "job_s8_watch_scheduler";
+    // 单 tick 最多抢锁规则数;规模上来后可下放到 appsettings。当前 dev 仅 3 条 demo,给 32 留余量
+    private const int BatchSize = 32;
 
-    // G08-05 运行期自动停调度:连续失败阈值(代码常量锚点,见 G08-02 配置分类表第 7 项)。
-    private const int FailurePauseThreshold = 2;
-
-    // G08-02 首次作业激活可见性:作业首次激活时打印一次关键运行参数的生效值,后续周期不重复。
-    // 仅覆盖属性固化与代码常量两类;appsettings 与作业管理两类的当前值不在此打印。
+    // 首次激活打印锚点。
     private static int _firstActivationLogged;
 
-    // G08-05 连续失败计数(仅同进程内有效;跨进程由 Admin.NET 作业管理手工介入,见 G08-03)。
-    private static int _consecutiveFailureCount;
-
     public S8WatchSchedulerJob(
         IServiceScopeFactory scopeFactory,
-        ISchedulerFactory schedulerFactory,
         ILoggerFactory loggerFactory)
     {
         _scopeFactory = scopeFactory;
-        _schedulerFactory = schedulerFactory;
         _logger = loggerFactory.CreateLogger(nameof(S8WatchSchedulerJob));
     }
 
@@ -59,8 +48,8 @@ public class S8WatchSchedulerJob : IJob
         if (Interlocked.Exchange(ref _firstActivationLogged, 1) == 0)
         {
             _logger.LogInformation(
-                "S8WatchSchedulerJob 首次作业激活参数:IntervalMs={IntervalMs} Concurrent=false TenantId={TenantId} FactoryId={FactoryId}",
-                IntervalMs, DefaultTenantId, DefaultFactoryId);
+                "S8WatchSchedulerJob 首次激活:IntervalMs={IntervalMs} BatchSize={Batch} TenantId={Tenant} FactoryId={Factory} LockedBy={LockedBy}",
+                IntervalMs, BatchSize, DefaultTenantId, DefaultFactoryId, BuildLockedBy());
         }
 
         var triggeredAt = DateTime.Now;
@@ -69,80 +58,53 @@ public class S8WatchSchedulerJob : IJob
 
         try
         {
-            var results = await scheduler.CreateExceptionsAsync(DefaultTenantId, DefaultFactoryId);
-            var created = results.Count(r => r.Created);
-            var skipped = results.Count(r => r.Skipped);
-            var failed = results.Count(r => !r.Created && !r.Skipped);
-            var firstFailure = results.FirstOrDefault(r => !r.Created && !r.Skipped);
-            _logger.LogInformation(BuildTraceLine(
-                triggeredAt: triggeredAt,
-                result: "success",
-                hits: results.Count,
-                created: created,
-                skipped: skipped,
-                failed: failed,
-                failureSummary: TruncateSummary(firstFailure?.ErrorMessage ?? firstFailure?.Reason)));
-            // 本轮主链未抛异常 → 重置连续失败计数(单条 create_failed 不计入调度失败)。
-            Interlocked.Exchange(ref _consecutiveFailureCount, 0);
+            var lockedBy = BuildLockedBy();
+            var summary = await scheduler.RunDispatchTickAsync(DefaultTenantId, DefaultFactoryId, BatchSize, lockedBy);
+            _logger.LogInformation(BuildTraceLine(triggeredAt, "success", summary, null));
         }
         catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
         {
-            _logger.LogInformation(BuildTraceLine(
-                triggeredAt: triggeredAt,
-                result: "cancelled",
-                hits: 0, created: 0, skipped: 0, failed: 0,
-                failureSummary: null));
-            // 取消不计入连续失败。
+            _logger.LogInformation(BuildTraceLine(triggeredAt, "cancelled", null, null));
         }
         catch (Exception ex)
         {
-            _logger.LogError(ex, BuildTraceLine(
-                triggeredAt: triggeredAt,
-                result: "error",
-                hits: 0, created: 0, skipped: 0, failed: 0,
-                failureSummary: TruncateSummary(ex.Message)));
-            var count = Interlocked.Increment(ref _consecutiveFailureCount);
-            if (count >= FailurePauseThreshold)
-            {
-                TryAutoPause(count, ex);
-            }
+            // RunDispatchTickAsync 内部已吞所有单规则异常;此处仅兜底。
+            _logger.LogError(ex, BuildTraceLine(triggeredAt, "error", null, TruncateSummary(ex.Message)));
         }
     }
 
-    // G08-05 运行期自动停调度:命中 FailurePauseThreshold 后调用框架原生暂停作业能力;
-    // 不自建停调度通道;停调度原因单独留痕一行,便于检索。
-    private void TryAutoPause(int failureCount, Exception lastError)
+    private static string BuildLockedBy()
     {
         try
         {
-            var scheduleResult = _schedulerFactory.TryPauseJob(JobId, out _);
-            _logger.LogWarning(
-                "S8WatchSchedulerJob 自动停调度:FailureCount={FailureCount} Threshold={Threshold} ScheduleResult={ScheduleResult} Reason={Reason}",
-                failureCount, FailurePauseThreshold, scheduleResult, TruncateSummary(lastError.Message));
+            var pid = Environment.ProcessId;
+            return $"{Environment.MachineName}-{pid}";
         }
-        catch (Exception pauseEx)
+        catch
         {
-            // 停调度本身失败不重试、不回退;仅记录,等待人工介入。
-            _logger.LogError(pauseEx,
-                "S8WatchSchedulerJob 自动停调度调用失败:FailureCount={FailureCount}",
-                failureCount);
+            return "unknown-host";
         }
     }
 
-    // G08-04 结构化留痕:单行 JSON 字符串,承载最低 6 字段(触发时间 / 执行结果 / 命中数 / 创建数 / 跳过数 / 失败摘要)。
-    // 不建新表、不上日志库、不上链路追踪;失败摘要仅截断首条错误,不做异常归集。
+    // 单行 JSON 留痕:tick 维度 6 字段(触发时间 / 执行结果 / picked / created / refreshed / pending / failed / leaseReleased / runId)。
     private static string BuildTraceLine(
-        DateTime triggeredAt, string result, int hits, int created, int skipped, int failed, string? failureSummary)
+        DateTime triggeredAt, string result, S8DispatchTickResult? summary, string? failureSummary)
     {
         var payload = new
         {
             job = "S8WatchSchedulerJob",
             triggeredAt = triggeredAt.ToString("yyyy-MM-dd HH:mm:ss"),
             result,
-            hits,
-            created,
-            skipped,
-            failed,
+            tickId = summary?.TickId,
+            runId = summary?.RunId,
+            picked = summary?.Picked ?? 0,
+            success = summary?.Success ?? 0,
+            ruleFailed = summary?.Failed ?? 0,
+            created = summary?.Created ?? 0,
+            refreshed = summary?.Refreshed ?? 0,
+            pending = summary?.Pending ?? 0,
+            perRuleFailed = summary?.PerRuleFailed ?? 0,
+            leaseReleased = summary?.LeaseReleased ?? 0,
             failureSummary
         };
         return "S8WatchSchedulerJob 留痕 " + JsonSerializer.Serialize(payload);

+ 737 - 3
server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/S8WatchSchedulerService.cs

@@ -28,6 +28,7 @@ public class S8WatchSchedulerService : ITransient
     private readonly S8OutOfRangeRuleEvaluator _outOfRangeEvaluator;
     private readonly ILogger<S8WatchSchedulerService> _logger;
     private readonly SqlSugarRepository<AdoS8DetectionLog> _detectionLogRep;
+    private readonly SqlSugarRepository<AdoS8RuleDetectionState> _detectionStateRep;
 
     private const string DetectionTriggerSource = "WATCH_SCHEDULER";
     private const string DetectResultCreated = "CREATED";
@@ -57,7 +58,8 @@ public class S8WatchSchedulerService : ITransient
         S8ShortageRuleEvaluator shortageEvaluator,
         S8OutOfRangeRuleEvaluator outOfRangeEvaluator,
         ILogger<S8WatchSchedulerService> logger,
-        SqlSugarRepository<AdoS8DetectionLog> detectionLogRep)
+        SqlSugarRepository<AdoS8DetectionLog> detectionLogRep,
+        SqlSugarRepository<AdoS8RuleDetectionState> detectionStateRep)
     {
         _ruleRep = ruleRep;
         _alertRuleRep = alertRuleRep;
@@ -71,6 +73,7 @@ public class S8WatchSchedulerService : ITransient
         _outOfRangeEvaluator = outOfRangeEvaluator;
         _logger = logger;
         _detectionLogRep = detectionLogRep;
+        _detectionStateRep = detectionStateRep;
     }
 
     public async Task<List<S8WatchExecutionRule>> LoadExecutionRulesAsync(long tenantId, long factoryId)
@@ -597,9 +600,54 @@ public class S8WatchSchedulerService : ITransient
                     continue;
                 }
 
+                // S8-SCHED-EXEC-1:建单前抗抖累计。
+                // 累计落 ado_s8_rule_detection_state;hitCount < trigger_count_required 时 pending、不建单、不写 CREATED 日志。
+                int hitCount;
+                AdoS8RuleDetectionState? state;
+                try
+                {
+                    (state, hitCount) = await UpsertDetectionStateOnHitAsync(tenantId, factoryId, rule, hit);
+                }
+                catch (Exception ex)
+                {
+                    results.Add(BuildSkipResult(rule, "antiflap_failed", ex.Message, hit));
+                    continue;
+                }
+
+                var triggerRequired = NormalizeAntiflapCount(rule.TriggerCountRequired);
+                if (hitCount < triggerRequired)
+                {
+                    _logger.LogInformation(
+                        "antiflap_pending_hit ruleCode={RuleCode} dedupKey={DedupKey} hitCount={HitCount} trigger={Trigger}",
+                        rule.RuleCode, hit.DedupKey, hitCount, triggerRequired);
+                    results.Add(BuildSkipResult(rule, "antiflap_pending_hit", null, hit));
+                    continue;
+                }
+
                 try
                 {
                     var entity = await _manualReportService.CreateFromHitAsync(hit);
+                    // 新建异常的抗抖累计与触发计数对齐,便于后续 reconcile 解释累计来源。
+                    await _exceptionRep.Context.Updateable<AdoS8Exception>()
+                        .SetColumns(x => new AdoS8Exception
+                        {
+                            ConsecutiveHitCount = hitCount,
+                            ConsecutiveMissCount = 0,
+                            UpdatedAt = DateTime.Now
+                        })
+                        .Where(x => x.Id == entity.Id)
+                        .ExecuteCommandAsync();
+                    if (state != null)
+                    {
+                        await _detectionStateRep.Context.Updateable<AdoS8RuleDetectionState>()
+                            .SetColumns(x => new AdoS8RuleDetectionState
+                            {
+                                ActiveExceptionId = entity.Id,
+                                UpdatedAt = DateTime.Now
+                            })
+                            .Where(x => x.Id == state.Id)
+                            .ExecuteCommandAsync();
+                    }
                     await WriteDetectionLogAsync(BuildHitLog(tenantId, factoryId, rule, ruleType, hit, DetectResultCreated, entity.Id, runId));
                     results.Add(BuildCreatedResult(rule, hit, entity.Id));
                 }
@@ -613,6 +661,68 @@ public class S8WatchSchedulerService : ITransient
         return results;
     }
 
+    // S8-SCHED-EXEC-1:trigger / recover 抗抖计数兜底,null / <1 / >10 一律按 1,避免非法配置导致永远不建单 / 永远不恢复。
+    private static int NormalizeAntiflapCount(int raw)
+    {
+        if (raw < 1 || raw > 10) return 1;
+        return raw;
+    }
+
+    /// <summary>
+    /// 命中时累加 detection_state.consecutive_hit_count;未存在则插入 hitCount=1。
+    /// 返回当前 state 行(含 Id)以及命中后的 hitCount。
+    /// 注意:本函数不消费 trigger_count_required;上游决定是否进入 CreateFromHitAsync。
+    /// </summary>
+    private async Task<(AdoS8RuleDetectionState? state, int hitCount)> UpsertDetectionStateOnHitAsync(
+        long tenantId, long factoryId, AdoS8WatchRule rule, S8RuleHit hit)
+    {
+        var now = DateTime.Now;
+        var existing = await _detectionStateRep.AsQueryable()
+            .Where(x => x.TenantId == tenantId
+                        && x.FactoryId == factoryId
+                        && x.RuleCode == rule.RuleCode
+                        && x.DedupKey == hit.DedupKey)
+            .FirstAsync();
+
+        if (existing == null)
+        {
+            var fresh = new AdoS8RuleDetectionState
+            {
+                TenantId = tenantId,
+                FactoryId = factoryId,
+                RuleCode = rule.RuleCode,
+                DedupKey = hit.DedupKey,
+                SourceObjectType = string.IsNullOrEmpty(hit.SourceObjectType) ? null : hit.SourceObjectType,
+                SourceObjectId = string.IsNullOrEmpty(hit.SourceObjectId) ? null : hit.SourceObjectId,
+                ConsecutiveHitCount = 1,
+                ConsecutiveMissCount = 0,
+                LastSeenAt = now,
+                LastHitAt = now,
+                CreatedAt = now,
+                UpdatedAt = now
+            };
+            await _detectionStateRep.InsertAsync(fresh);
+            return (fresh, 1);
+        }
+
+        var newHitCount = existing.ConsecutiveHitCount + 1;
+        await _detectionStateRep.Context.Updateable<AdoS8RuleDetectionState>()
+            .SetColumns(x => new AdoS8RuleDetectionState
+            {
+                ConsecutiveHitCount = x.ConsecutiveHitCount + 1,
+                ConsecutiveMissCount = 0,
+                LastSeenAt = now,
+                LastHitAt = now,
+                SourceObjectType = string.IsNullOrEmpty(hit.SourceObjectType) ? existing.SourceObjectType : hit.SourceObjectType,
+                SourceObjectId = string.IsNullOrEmpty(hit.SourceObjectId) ? existing.SourceObjectId : hit.SourceObjectId,
+                UpdatedAt = now
+            })
+            .Where(x => x.Id == existing.Id)
+            .ExecuteCommandAsync();
+        existing.ConsecutiveHitCount = newHitCount;
+        return (existing, newHitCount);
+    }
+
     private async Task<long> FindOpenExceptionByDedupKeyAsync(long tenantId, long factoryId, string dedupKey)
     {
         var ids = await _exceptionRep.AsQueryable()
@@ -692,15 +802,56 @@ public class S8WatchSchedulerService : ITransient
                         && x.SourceRuleCode == rule.RuleCode
                         && x.DedupKey != null
                         && x.RecoveredAt == null)
-            .Select(x => new { x.Id, x.DedupKey, x.SourceObjectType, x.SourceObjectId, x.RelatedObjectCode })
+            .Select(x => new { x.Id, x.DedupKey, x.SourceObjectType, x.SourceObjectId, x.RelatedObjectCode, x.ConsecutiveMissCount })
             .ToListAsync();
         if (candidates.Count == 0) return new List<long>();
 
         var now = DateTime.Now;
+        var recoverRequired = NormalizeAntiflapCount(rule.RecoverCountRequired);
         var recoveredIds = new List<long>();
         foreach (var c in candidates)
         {
             if (hitDedupKeys.Contains(c.DedupKey!)) continue;
+
+            // S8-SCHED-EXEC-1:恢复抗抖累计。
+            // 1) 每次未命中:异常 ConsecutiveMissCount += 1,ConsecutiveHitCount 清零;
+            // 2) miss < recover_count_required:仅累计,不写 recovered_at、不写 RECOVERED;
+            // 3) miss >= recover_count_required:写 recovered_at、写 RECOVERED 日志。
+            var newMissCount = c.ConsecutiveMissCount + 1;
+            await _exceptionRep.Context.Updateable<AdoS8Exception>()
+                .SetColumns(x => new AdoS8Exception
+                {
+                    ConsecutiveMissCount = x.ConsecutiveMissCount + 1,
+                    ConsecutiveHitCount = 0,
+                    UpdatedAt = now
+                })
+                .Where(x => x.Id == c.Id)
+                .ExecuteCommandAsync();
+
+            // detection_state 同步累计 miss(建单后 state.active_exception_id 仍指向 c.Id)。
+            await _detectionStateRep.Context.Updateable<AdoS8RuleDetectionState>()
+                .SetColumns(x => new AdoS8RuleDetectionState
+                {
+                    ConsecutiveMissCount = x.ConsecutiveMissCount + 1,
+                    ConsecutiveHitCount = 0,
+                    LastSeenAt = now,
+                    LastMissAt = now,
+                    UpdatedAt = now
+                })
+                .Where(x => x.TenantId == tenantId
+                            && x.FactoryId == factoryId
+                            && x.RuleCode == rule.RuleCode
+                            && x.DedupKey == c.DedupKey)
+                .ExecuteCommandAsync();
+
+            if (newMissCount < recoverRequired)
+            {
+                _logger.LogInformation(
+                    "antiflap_pending_recovery ruleCode={RuleCode} dedupKey={DedupKey} missCount={Miss} recoverRequired={Required}",
+                    rule.RuleCode, c.DedupKey, newMissCount, recoverRequired);
+                continue;
+            }
+
             await _exceptionRep.Context.Updateable<AdoS8Exception>()
                 .SetColumns(x => new AdoS8Exception
                 {
@@ -720,7 +871,7 @@ public class S8WatchSchedulerService : ITransient
                 DetectResult = DetectResultRecovered,
                 ExceptionId = c.Id,
                 DetectedAt = now,
-                PayloadSnapshot = JsonSerializer.Serialize(new { ruleId = rule.Id, ruleCode = rule.RuleCode, reason = "no_longer_hit" }),
+                PayloadSnapshot = JsonSerializer.Serialize(new { ruleId = rule.Id, ruleCode = rule.RuleCode, reason = "no_longer_hit", missCount = newMissCount, recoverRequired }),
                 RunId = runId, TriggerSource = DetectionTriggerSource,
                 Remark = "Rule no longer hit; recovered_at marked"
             });
@@ -772,11 +923,17 @@ public class S8WatchSchedulerService : ITransient
 
     private async Task RefreshDetectionAsync(long exceptionId, S8RuleHit hit)
     {
+        // S8-SCHED-EXEC-1:刷新阶段抗抖累计 + 复发清空 recovered_at。
+        // ConsecutiveHitCount += 1(用 SetColumns 内表达式完成原子自增);ConsecutiveMissCount 归零。
+        // RecoveredAt 不为 NULL 时(复发)一并清空,保持业务对"再次命中即视为活跃"的预期。
         await _exceptionRep.Context.Updateable<AdoS8Exception>()
             .SetColumns(x => new AdoS8Exception
             {
                 LastDetectedAt = hit.DetectedAt,
                 SourcePayload = hit.SourcePayload,
+                ConsecutiveHitCount = x.ConsecutiveHitCount + 1,
+                ConsecutiveMissCount = 0,
+                RecoveredAt = null,
                 UpdatedAt = DateTime.Now
             })
             .Where(x => x.Id == exceptionId)
@@ -985,6 +1142,538 @@ public class S8WatchSchedulerService : ITransient
 
         return JsonSerializer.Serialize(payload);
     }
+
+    // ============================================================
+    // S8-SCHED-EXEC-1:DB 驱动调度执行层
+    // ============================================================
+
+    private const int LeaseDurationMinutes = 5;
+    private const int AutoPauseFailureThreshold = 3;
+    private const int AutoPauseDurationHours = 1;
+    private const int DefaultPollIntervalSeconds = 300;
+    private const int MinPollIntervalSeconds = 60;
+    private const int MaxPollIntervalSeconds = 86400;
+
+    /// <summary>
+    /// S8-SCHED-EXEC-1:释放过期 lease(lock_until &lt; NOW),不修改 last_status / last_error,仅清空 lock 三件套 + running_started_at。
+    /// 返回释放的行数。
+    /// </summary>
+    public async Task<int> ResetExpiredLeasesAsync(long tenantId, long factoryId)
+    {
+        var now = DateTime.Now;
+        var affected = await _ruleRep.Context.Updateable<AdoS8WatchRule>()
+            .SetColumns(x => new AdoS8WatchRule
+            {
+                LockToken = null,
+                LockedBy = null,
+                LockUntil = null,
+                RunningStartedAt = null,
+                UpdatedAt = now
+            })
+            .Where(x => x.TenantId == tenantId
+                        && x.FactoryId == factoryId
+                        && x.LockUntil != null
+                        && x.LockUntil < now)
+            .ExecuteCommandAsync();
+        if (affected > 0)
+        {
+            _logger.LogWarning(
+                "lease_reset tenantId={Tenant} factoryId={Factory} releasedCount={Count}",
+                tenantId, factoryId, affected);
+        }
+        return affected;
+    }
+
+    /// <summary>
+    /// S8-SCHED-EXEC-1:到期规则候选 + 乐观 UPDATE 抢锁,返回成功抢到的 lease 列表。
+    /// 抢锁条件:enabled=1 AND (paused_until IS NULL OR paused_until &lt;= NOW)
+    ///         AND (next_run_at IS NULL OR next_run_at &lt;= NOW)
+    ///         AND (lock_until IS NULL OR lock_until &lt;= NOW)。
+    /// 抢锁回写:lock_token / locked_by / lock_until = NOW + 5min / running_started_at = NOW / last_run_id = runId。
+    /// affectedRows == 1 才算抢到;后续 OnRuleCompletedAsync 必须按 lockToken 回写,避免旧进程覆盖新 lease。
+    /// </summary>
+    public async Task<List<S8RuleLease>> PickReadyRulesAsync(long tenantId, long factoryId, int batchSize, string lockedBy, string runId)
+    {
+        if (batchSize <= 0) batchSize = 16;
+        var now = DateTime.Now;
+
+        var candidates = await _ruleRep.AsQueryable()
+            .Where(x => x.TenantId == tenantId
+                        && x.FactoryId == factoryId
+                        && x.Enabled
+                        && (x.PausedUntil == null || x.PausedUntil <= now)
+                        && (x.NextRunAt == null || x.NextRunAt <= now)
+                        && (x.LockUntil == null || x.LockUntil <= now))
+            .OrderBy(x => x.NextRunAt, OrderByType.Asc)
+            .OrderBy(x => x.Id, OrderByType.Asc)
+            .Take(batchSize)
+            .Select(x => new { x.Id, x.RuleCode, x.RuleType })
+            .ToListAsync();
+        if (candidates.Count == 0) return new();
+
+        var leases = new List<S8RuleLease>();
+        foreach (var c in candidates)
+        {
+            var token = Guid.NewGuid().ToString("N");
+            var lockUntil = DateTime.Now.AddMinutes(LeaseDurationMinutes);
+            var runningAt = DateTime.Now;
+            // 乐观 UPDATE:再校验一次条件,affectedRows=1 才算抢到。
+            var affected = await _ruleRep.Context.Updateable<AdoS8WatchRule>()
+                .SetColumns(x => new AdoS8WatchRule
+                {
+                    LockToken = token,
+                    LockedBy = lockedBy,
+                    LockUntil = lockUntil,
+                    RunningStartedAt = runningAt,
+                    LastRunId = runId,
+                    UpdatedAt = runningAt
+                })
+                .Where(x => x.Id == c.Id
+                            && x.Enabled
+                            && (x.PausedUntil == null || x.PausedUntil <= runningAt)
+                            && (x.NextRunAt == null || x.NextRunAt <= runningAt)
+                            && (x.LockUntil == null || x.LockUntil <= runningAt))
+                .ExecuteCommandAsync();
+            if (affected == 1)
+            {
+                leases.Add(new S8RuleLease
+                {
+                    RuleId = c.Id,
+                    RuleCode = c.RuleCode,
+                    RuleType = c.RuleType,
+                    LockToken = token,
+                    LockedBy = lockedBy,
+                    LockUntil = lockUntil,
+                    RunId = runId,
+                    AcquiredAt = runningAt
+                });
+            }
+        }
+        return leases;
+    }
+
+    /// <summary>
+    /// S8-SCHED-EXEC-1:执行单条已抢锁规则的 evaluator → 抗抖去重 → 建单/刷新 → 恢复 reconcile。
+    /// 不释放 lease(OnRuleCompletedAsync 负责);evaluator 抛异常时 Result.Success=false 并保留 ErrorMessage。
+    /// </summary>
+    public async Task<S8RuleRunResult> RunSingleRuleAsync(long tenantId, long factoryId, S8RuleLease lease)
+    {
+        var rule = await _ruleRep.AsQueryable()
+            .Where(x => x.Id == lease.RuleId)
+            .FirstAsync();
+        if (rule == null)
+        {
+            return new S8RuleRunResult { Success = false, ErrorMessage = "rule_not_found", Stats = new() };
+        }
+
+        var ruleType = rule.RuleType;
+        if (string.IsNullOrWhiteSpace(ruleType))
+        {
+            // 未分类的旧规则不在新调度路径承载;标 SKIPPED 但不视为失败。
+            return new S8RuleRunResult { Success = true, ErrorMessage = "rule_type_empty_skipped", Stats = new() };
+        }
+
+        IS8RuleEvaluator? evaluator = ruleType switch
+        {
+            S8TimeoutRuleEvaluator.RuleTypeCode => _timeoutEvaluator,
+            S8ShortageRuleEvaluator.RuleTypeCode => _shortageEvaluator,
+            S8OutOfRangeRuleEvaluator.RuleTypeCode => _outOfRangeEvaluator,
+            _ => null
+        };
+        if (evaluator == null)
+        {
+            return new S8RuleRunResult { Success = false, ErrorMessage = $"unsupported_rule_type:{ruleType}", Stats = new() };
+        }
+
+        try
+        {
+            var alertRules = (await _alertRuleRep.AsQueryable()
+                .Where(x => x.TenantId == tenantId && x.FactoryId == factoryId)
+                .ToListAsync()).AsReadOnly();
+
+            var results = await ProcessSingleRuleAsync(tenantId, factoryId, rule, ruleType, evaluator, alertRules, lease.RunId);
+            var stats = new S8RuleRunStats
+            {
+                Hits = results.Count,
+                Created = results.Count(r => r.Created),
+                Refreshed = results.Count(r => r.Reason == "duplicate_pending"),
+                Pending = results.Count(r => r.Reason == "antiflap_pending_hit"),
+                Failed = results.Count(r => r.Reason == "create_failed" || r.Reason == "refresh_failed" || r.Reason == "antiflap_failed" || r.Reason == "evaluate_failed")
+            };
+            return new S8RuleRunResult { Success = true, Stats = stats };
+        }
+        catch (Exception ex)
+        {
+            return new S8RuleRunResult
+            {
+                Success = false,
+                ErrorMessage = ex.Message,
+                Stats = new()
+            };
+        }
+    }
+
+    /// <summary>
+    /// S8-SCHED-EXEC-1:单规则处理(evaluator → reconcile → hit 循环)。
+    /// 与 ProcessRulesByTypeAsync 内单规则循环体语义一致;此处抽出便于新调度路径直接调用单条 rule。
+    /// </summary>
+    private async Task<List<S8WatchCreationResult>> ProcessSingleRuleAsync(
+        long tenantId, long factoryId, AdoS8WatchRule rule, string ruleType,
+        IS8RuleEvaluator evaluator, IReadOnlyList<AdoS8AlertRule> alertRules, string runId)
+    {
+        var results = new List<S8WatchCreationResult>();
+        List<S8RuleHit> hits;
+        try
+        {
+            hits = await evaluator.EvaluateAsync(tenantId, factoryId, rule, alertRules);
+        }
+        catch (Exception ex)
+        {
+            var failureReason = ex is S8RuleEvaluatorException sre ? sre.Reason : ex.GetType().Name;
+            await WriteDetectionLogAsync(new AdoS8DetectionLog
+            {
+                TenantId = tenantId, FactoryId = factoryId,
+                RuleId = rule.Id, RuleCode = rule.RuleCode, RuleType = ruleType, SceneCode = rule.SceneCode,
+                SourceObjectType = rule.SourceObjectType,
+                DetectResult = DetectResultEvaluateFailed,
+                DetectedAt = DateTime.Now,
+                FailureReason = failureReason,
+                FailureMessage = Truncate(ex.Message, 1000),
+                RunId = runId, TriggerSource = DetectionTriggerSource
+            });
+            results.Add(BuildSkipResult(rule, "evaluate_failed", ex.Message));
+            throw;
+        }
+
+        List<long> recoveredIds;
+        try
+        {
+            recoveredIds = await ReconcileRecoveriesForRuleAsync(tenantId, factoryId, rule, ruleType, hits, runId);
+        }
+        catch (Exception ex)
+        {
+            _logger.LogWarning(ex, "recovery_reconcile_failed ruleCode={RuleCode} ruleType={RuleType}", rule.RuleCode, ruleType);
+            recoveredIds = new();
+        }
+
+        if (hits.Count == 0 && recoveredIds.Count == 0)
+        {
+            await WriteDetectionLogAsync(new AdoS8DetectionLog
+            {
+                TenantId = tenantId, FactoryId = factoryId,
+                RuleId = rule.Id, RuleCode = rule.RuleCode, RuleType = ruleType, SceneCode = rule.SceneCode,
+                SourceObjectType = rule.SourceObjectType,
+                DetectResult = DetectResultNoHit,
+                DetectedAt = DateTime.Now,
+                RunId = runId, TriggerSource = DetectionTriggerSource
+            });
+        }
+
+        foreach (var hit in hits)
+        {
+            if (string.IsNullOrWhiteSpace(hit.DedupKey))
+            {
+                results.Add(BuildSkipResult(rule, "missing_dedup_key", null, hit));
+                continue;
+            }
+
+            long matchedId;
+            try
+            {
+                matchedId = await FindOpenExceptionByDedupKeyAsync(tenantId, factoryId, hit.DedupKey);
+            }
+            catch (Exception ex)
+            {
+                results.Add(BuildSkipResult(rule, "query_failed", ex.Message, hit));
+                continue;
+            }
+
+            if (matchedId > 0)
+            {
+                try
+                {
+                    await RefreshDetectionAsync(matchedId, hit);
+                    await WriteDetectionLogAsync(BuildHitLog(tenantId, factoryId, rule, ruleType, hit, DetectResultRefreshed, matchedId, runId));
+                    results.Add(BuildSkippedDuplicate(rule, hit, matchedId));
+                }
+                catch (Exception ex)
+                {
+                    results.Add(BuildSkipResult(rule, "refresh_failed", ex.Message, hit));
+                }
+                continue;
+            }
+
+            if (string.Equals(ruleType, S8OutOfRangeRuleEvaluator.RuleTypeCode, StringComparison.OrdinalIgnoreCase))
+            {
+                long compatId;
+                try
+                {
+                    compatId = await FindLegacyOutOfRangeExceptionAsync(tenantId, factoryId, rule.Id, hit.RelatedObjectCode);
+                }
+                catch (Exception ex)
+                {
+                    results.Add(BuildSkipResult(rule, "query_failed", ex.Message, hit));
+                    continue;
+                }
+                if (compatId > 0)
+                {
+                    try
+                    {
+                        await BackfillLegacyExceptionAsync(compatId, hit);
+                        await WriteDetectionLogAsync(BuildHitLog(tenantId, factoryId, rule, ruleType, hit, DetectResultRefreshed, compatId, runId));
+                        results.Add(BuildSkippedDuplicate(rule, hit, compatId));
+                    }
+                    catch (Exception ex)
+                    {
+                        results.Add(BuildSkipResult(rule, "refresh_failed", ex.Message, hit));
+                    }
+                    continue;
+                }
+            }
+
+            bool typeExists;
+            try
+            {
+                typeExists = await _exceptionTypeRep.AsQueryable()
+                    .Where(t => t.TypeCode == hit.ExceptionTypeCode
+                                && (t.TenantId == 0 || t.TenantId == tenantId)
+                                && (t.FactoryId == 0 || t.FactoryId == factoryId)
+                                && t.Enabled)
+                    .AnyAsync();
+            }
+            catch (Exception ex)
+            {
+                results.Add(BuildSkipResult(rule, "query_failed", ex.Message, hit));
+                continue;
+            }
+
+            if (!typeExists)
+            {
+                results.Add(BuildSkipResult(rule, "exception_type_missing", null, hit));
+                continue;
+            }
+
+            int hitCount;
+            AdoS8RuleDetectionState? state;
+            try
+            {
+                (state, hitCount) = await UpsertDetectionStateOnHitAsync(tenantId, factoryId, rule, hit);
+            }
+            catch (Exception ex)
+            {
+                results.Add(BuildSkipResult(rule, "antiflap_failed", ex.Message, hit));
+                continue;
+            }
+
+            var triggerRequired = NormalizeAntiflapCount(rule.TriggerCountRequired);
+            if (hitCount < triggerRequired)
+            {
+                _logger.LogInformation(
+                    "antiflap_pending_hit ruleCode={RuleCode} dedupKey={DedupKey} hitCount={HitCount} trigger={Trigger}",
+                    rule.RuleCode, hit.DedupKey, hitCount, triggerRequired);
+                results.Add(BuildSkipResult(rule, "antiflap_pending_hit", null, hit));
+                continue;
+            }
+
+            try
+            {
+                var entity = await _manualReportService.CreateFromHitAsync(hit);
+                await _exceptionRep.Context.Updateable<AdoS8Exception>()
+                    .SetColumns(x => new AdoS8Exception
+                    {
+                        ConsecutiveHitCount = hitCount,
+                        ConsecutiveMissCount = 0,
+                        UpdatedAt = DateTime.Now
+                    })
+                    .Where(x => x.Id == entity.Id)
+                    .ExecuteCommandAsync();
+                if (state != null)
+                {
+                    await _detectionStateRep.Context.Updateable<AdoS8RuleDetectionState>()
+                        .SetColumns(x => new AdoS8RuleDetectionState
+                        {
+                            ActiveExceptionId = entity.Id,
+                            UpdatedAt = DateTime.Now
+                        })
+                        .Where(x => x.Id == state.Id)
+                        .ExecuteCommandAsync();
+                }
+                await WriteDetectionLogAsync(BuildHitLog(tenantId, factoryId, rule, ruleType, hit, DetectResultCreated, entity.Id, runId));
+                results.Add(BuildCreatedResult(rule, hit, entity.Id));
+            }
+            catch (Exception ex)
+            {
+                results.Add(BuildSkipResult(rule, "create_failed", ex.Message, hit));
+            }
+        }
+
+        return results;
+    }
+
+    /// <summary>
+    /// S8-SCHED-EXEC-1:lease 执行完成回写。
+    /// 必须 WHERE id = lease.RuleId AND lock_token = lease.LockToken;affectedRows = 0 视为 lease 丢失,记录 Warning,不覆盖状态。
+    /// 失败 ≥ 阈值(默认 3)写 paused_until = NOW + 1h。
+    /// </summary>
+    public async Task OnRuleCompletedAsync(long tenantId, long factoryId, S8RuleLease lease, S8RuleRunResult result, int durationMs)
+    {
+        var rule = await _ruleRep.AsQueryable().Where(x => x.Id == lease.RuleId).FirstAsync();
+        if (rule == null)
+        {
+            _logger.LogWarning("lease_complete_rule_missing ruleId={RuleId}", lease.RuleId);
+            return;
+        }
+
+        var now = DateTime.Now;
+        var effectiveInterval = NormalizePollInterval(rule.PollIntervalSeconds);
+        var nextRunAt = now.AddSeconds(effectiveInterval);
+        int affected;
+        if (result.Success)
+        {
+            affected = await _ruleRep.Context.Updateable<AdoS8WatchRule>()
+                .SetColumns(x => new AdoS8WatchRule
+                {
+                    LastRunAt = now,
+                    NextRunAt = nextRunAt,
+                    LastStatus = "SUCCESS",
+                    LastError = null,
+                    LastDurationMs = durationMs,
+                    ConsecutiveFailureCount = 0,
+                    LockToken = null,
+                    LockedBy = null,
+                    LockUntil = null,
+                    RunningStartedAt = null,
+                    UpdatedAt = now
+                })
+                .Where(x => x.Id == lease.RuleId && x.LockToken == lease.LockToken)
+                .ExecuteCommandAsync();
+        }
+        else
+        {
+            var errorTrunc = Truncate(result.ErrorMessage, 500);
+            var newFailures = rule.ConsecutiveFailureCount + 1;
+            DateTime? pausedUntil = rule.PausedUntil;
+            string? pauseReason = rule.PauseReason;
+            if (newFailures >= AutoPauseFailureThreshold)
+            {
+                pausedUntil = now.AddHours(AutoPauseDurationHours);
+                pauseReason = Truncate($"AUTO_PAUSED_AFTER_{AutoPauseFailureThreshold}_FAILURES: {errorTrunc}", 64);
+            }
+
+            affected = await _ruleRep.Context.Updateable<AdoS8WatchRule>()
+                .SetColumns(x => new AdoS8WatchRule
+                {
+                    LastRunAt = now,
+                    NextRunAt = nextRunAt,
+                    LastStatus = "FAILED",
+                    LastError = errorTrunc,
+                    LastDurationMs = durationMs,
+                    ConsecutiveFailureCount = x.ConsecutiveFailureCount + 1,
+                    PausedUntil = pausedUntil,
+                    PauseReason = pauseReason,
+                    LockToken = null,
+                    LockedBy = null,
+                    LockUntil = null,
+                    RunningStartedAt = null,
+                    UpdatedAt = now
+                })
+                .Where(x => x.Id == lease.RuleId && x.LockToken == lease.LockToken)
+                .ExecuteCommandAsync();
+        }
+
+        if (affected == 0)
+        {
+            _logger.LogWarning(
+                "lease_lost_on_complete ruleId={RuleId} ruleCode={RuleCode} lockToken={LockToken}",
+                lease.RuleId, lease.RuleCode, lease.LockToken);
+        }
+    }
+
+    /// <summary>
+    /// S8-SCHED-EXEC-1:单 tick 完整流程。Job / debug 调度入口。
+    /// 1) ResetExpiredLeasesAsync
+    /// 2) PickReadyRulesAsync(batchSize)
+    /// 3) 每条 rule 独立 try/catch 调用 RunSingleRuleAsync + OnRuleCompletedAsync
+    /// 单条规则失败不影响其他规则;整 tick 不抛异常。
+    /// </summary>
+    public async Task<S8DispatchTickResult> RunDispatchTickAsync(long tenantId, long factoryId, int batchSize, string lockedBy)
+    {
+        var tickId = Guid.NewGuid().ToString("N").Substring(0, 8);
+        var runId = Guid.NewGuid().ToString("N").Substring(0, 16);
+        var summary = new S8DispatchTickResult { TickId = tickId, RunId = runId };
+
+        try
+        {
+            summary.LeaseReleased = await ResetExpiredLeasesAsync(tenantId, factoryId);
+        }
+        catch (Exception ex)
+        {
+            _logger.LogError(ex, "tick_reset_lease_failed tickId={TickId}", tickId);
+        }
+
+        List<S8RuleLease> leases;
+        try
+        {
+            leases = await PickReadyRulesAsync(tenantId, factoryId, batchSize, lockedBy, runId);
+        }
+        catch (Exception ex)
+        {
+            _logger.LogError(ex, "tick_pick_failed tickId={TickId}", tickId);
+            return summary;
+        }
+        summary.Picked = leases.Count;
+
+        foreach (var lease in leases)
+        {
+            var sw = System.Diagnostics.Stopwatch.StartNew();
+            S8RuleRunResult runResult;
+            try
+            {
+                runResult = await RunSingleRuleAsync(tenantId, factoryId, lease);
+            }
+            catch (Exception ex)
+            {
+                runResult = new S8RuleRunResult { Success = false, ErrorMessage = ex.Message, Stats = new() };
+            }
+            sw.Stop();
+            try
+            {
+                await OnRuleCompletedAsync(tenantId, factoryId, lease, runResult, (int)sw.ElapsedMilliseconds);
+            }
+            catch (Exception ex)
+            {
+                _logger.LogError(ex, "tick_complete_failed tickId={TickId} ruleId={RuleId} ruleCode={RuleCode}", tickId, lease.RuleId, lease.RuleCode);
+            }
+
+            _logger.LogInformation(
+                "tick_rule_done tickId={TickId} runId={RunId} ruleId={RuleId} ruleCode={RuleCode} status={Status} durationMs={Dur} hits={Hits} created={Created} refreshed={Refreshed} pending={Pending} failed={Failed} error={Error}",
+                tickId, runId, lease.RuleId, lease.RuleCode,
+                runResult.Success ? "SUCCESS" : "FAILED",
+                sw.ElapsedMilliseconds,
+                runResult.Stats.Hits, runResult.Stats.Created, runResult.Stats.Refreshed, runResult.Stats.Pending, runResult.Stats.Failed,
+                runResult.ErrorMessage);
+
+            if (runResult.Success)
+            {
+                summary.Success++;
+                summary.Created += runResult.Stats.Created;
+                summary.Refreshed += runResult.Stats.Refreshed;
+                summary.Pending += runResult.Stats.Pending;
+                summary.PerRuleFailed += runResult.Stats.Failed;
+            }
+            else
+            {
+                summary.Failed++;
+            }
+        }
+        return summary;
+    }
+
+    private static int NormalizePollInterval(int raw)
+    {
+        if (raw < MinPollIntervalSeconds || raw > MaxPollIntervalSeconds) return DefaultPollIntervalSeconds;
+        return raw;
+    }
 }
 
 public sealed class S8WatchExecutionRule
@@ -1093,3 +1782,48 @@ public sealed class S8WatchHitResult
     public long? ResponsibleDeptId { get; set; }
     public string SourcePayload { get; set; } = string.Empty;
 }
+
+/// <summary>S8-SCHED-EXEC-1:lease 抢占成功后传递的最小标识对象。</summary>
+public sealed class S8RuleLease
+{
+    public long RuleId { get; set; }
+    public string RuleCode { get; set; } = string.Empty;
+    public string? RuleType { get; set; }
+    public string LockToken { get; set; } = string.Empty;
+    public string LockedBy { get; set; } = string.Empty;
+    public DateTime LockUntil { get; set; }
+    public string RunId { get; set; } = string.Empty;
+    public DateTime AcquiredAt { get; set; }
+}
+
+/// <summary>S8-SCHED-EXEC-1:单条规则执行结果,OnRuleCompletedAsync 据此更新状态。</summary>
+public sealed class S8RuleRunResult
+{
+    public bool Success { get; set; }
+    public string? ErrorMessage { get; set; }
+    public S8RuleRunStats Stats { get; set; } = new();
+}
+
+public sealed class S8RuleRunStats
+{
+    public int Hits { get; set; }
+    public int Created { get; set; }
+    public int Refreshed { get; set; }
+    public int Pending { get; set; }
+    public int Failed { get; set; }
+}
+
+/// <summary>S8-SCHED-EXEC-1:单 tick 调度结果聚合。</summary>
+public sealed class S8DispatchTickResult
+{
+    public string TickId { get; set; } = string.Empty;
+    public string RunId { get; set; } = string.Empty;
+    public int LeaseReleased { get; set; }
+    public int Picked { get; set; }
+    public int Success { get; set; }
+    public int Failed { get; set; }
+    public int Created { get; set; }
+    public int Refreshed { get; set; }
+    public int Pending { get; set; }
+    public int PerRuleFailed { get; set; }
+}