Browse Source

fix(startup): 撤回启动期火忘来源登记,消除与审批流建表的连接竞态 | server 1.0.571

AiDOP.Configure 里用 Task.Run 火忘跑来源登记,与后执行的 ApprovalFlow 插件同步 InitTables 共享单例 SqlSugarScope 的同一个连接,撞上即未处理异常、进程退出。该火忘是未提交回归,HEAD 上本是同步调用,现撤回并补守卫测试拦住再犯。JobSchedule:Enabled 开关保留并恢复为 true。

Co-authored-by: Cursor <cursoragent@cursor.com>
YY968XX 2 days ago
parent
commit
5d6e4c66a5

+ 318 - 0
doc/plan/天缘对接/20260926启动期连接竞态根治执行任务书.md

@@ -0,0 +1,318 @@
+# 启动期数据库连接竞态根治执行任务书
+
+> **文档用途**:给**其他大模型 / 编码代理**照着执行。根因已于 2026-09-26 复现并三层取证完毕,**禁止再把它当成「MySQL 权限不足」或「SqlSugar 连接泄漏」去排查**。
+> **状态**:**全部完成(2026-09-26 23:49)**。火忘 `Task.Run` 已撤回为同步调用;5 次冷启动全部成功且竞态报错归零;`JobSchedule:Enabled` 已恢复 `true` 并复验一次仍干净(246.3s,竞态 0,未处理异常 0);守卫测试已补且验证过能拦住回归。后端版本 `1.0.581`。端口开放延迟实测约 160~200 秒(不含编译),超过建议阈值,是否改走方案 A1 仍待负责人定,但不阻塞本次收口。
+> **问题背景与完整证据**:[`20260926问题描述.md`](20260926问题描述.md),尤其第七节「复核结论与证据」。
+> **创建日期**:2026-09-26
+> **适用范围**:本机 `dotnet run` 连远程库 `123.60.180.165:3306/aidopdev`。本问题**只存在于当前工作区**,HEAD 主线不受影响(见 §1.2)。
+> **前置规则**:`best-solution-default.mdc`、`capability-claims-evidence.mdc`、`impact-backtrace.mdc`、`collaboration-scope.mdc`、`version-bump-on-commit.mdc`、`approval-flow-integration.mdc`。
+
+## 进度表
+
+| 包 | 状态 | 说明 |
+|---|---|---|
+| R 主修:撤回火忘任务 | **已完成(2026-09-26 22:24)** | `AiDOP/Startup.cs` 的火忘 `Task.Run` 已换回同步调用;三处必须保留的未提交改动(`AidopOutboundOptions`、两个 `AddHostedService`)未动 |
+| V 验证:连起 5 次 + 竞态归零 | **已完成(2026-09-26 22:46)** | 5/5 `listen=True`,`race=0`,`fatal=0`。耗时 232.4 / 242.2 / 206.1 / 204.6 / 201.0 秒。对齐结果与修复前一致:`mdp_source_table_registry` 53 行;`mdp_std_inv_trans` DB_SYNC 594230;`mdp_std_inventory` DB_SYNC 103600 / SEED 5;`mdp_std_item` DB_SYNC 49803 / PLATFORM_FORM 22091 |
+| M 实测:端口开放延迟 | **已完成,但口径要读批注** | 上面 5 个数字**含编译**(脚本按任务书原文每次都带编译,且重定向把输出缓冲到进程退出,无法从日志里拆出编译段)。编译本身约 40 秒(宿主进程在 `dotnet run` 之后约 40 秒才出现),故纯启动延迟约 **160~200 秒**,**超过 §4 建议的 60 秒阈值**。是否改走方案 A1 由负责人定 |
+| S 守卫:补回归测试 | **已完成** | `Infrastructure/StartupConcurrencyGuardTests.cs` 通过;临时贴回 `Task.Run` 探针后该测试确实变红,探针已撤回 |
+| X 收口:App.json 提交风险 | **已完成(2026-09-26 23:49)** | `JobSchedule:Enabled` 恢复为 `true`。同文件的 `AiDOP:Jobs:Enabled=false`、`Outbound:Enabled=false`、`MdpRebuild` 各项属 165 治理既定决策,未动。恢复后复验一次:listen=True,246.3s,竞态 0,未处理异常 0 |
+| P 可选:ApprovalFlow 容错 | **不需要** | 包 V 已全绿且竞态归零,按 §7 第 1 条本包不做 |
+
+---
+
+## 0. 新会话复制提示词
+
+把下面整段贴进新对话,不要改口径。
+
+```
+请按 doc/plan/天缘对接/20260926启动期连接竞态根治执行任务书.md 执行。
+
+已核实、禁止重查或改写的结论:
+1. 崩溃点是 Admin.NET.Plugin.ApprovalFlow/Startup.cs:26 的 CodeFirst.InitTables 抛未处理异常,
+   进程退出,5005 从未监听。不是 MySQL 权限问题,不是连接泄漏,不是网络问题。
+   服务端已排除:threads_connected 13/500,无挂起 DDL,无 MDL 锁等待。
+2. 机理:ISqlSugarClient 注册为单例 SqlSugarScope(SqlSugarSetup.cs:74),其 AsyncLocal 上下文
+   在启动期被 Configure 管道与火忘 Task.Run 共享,两个执行流拿到同一个 MySqlConnection。
+3. 主凶是 AiDOP/Startup.cs:152 的 `_ = Task.Run(...)`(来源登记 MdpSchemaAligner)。
+   这一处是**未提交的工作区回归**,HEAD 上是同步调用。不要去改 1.0.565 的提交。
+4. 崩溃**非必现**(本机实测 2/4)。禁止用「这次起来了」判定修复成功,验收按 §3 的 5 次口径。
+5. 禁止用 git checkout HEAD -- 整个 AiDOP/Startup.cs:该文件还有别的必须保留的未提交改动。
+6. 禁止修改 Admin.NET.Plugin.ApprovalFlow 插件代码,除非先按 §7 登记 P4-XX 并得到确认。
+7. 禁止把 App.json 的 JobSchedule:Enabled=false 提交进去。
+8. 动手前先按 §2 列出本轮文件清单,等我确认后再改代码。
+```
+
+---
+
+## 1. 根因(一句话版 + 不要重查的取证)
+
+### 1.1 致死链条
+
+`AiDOP(900).Configure` 一进来就用 `_ = Task.Run(...)` 火忘启动来源登记(扫全部 `mdp_stg_*` 贴源表,远程库上拉开数秒量级的窗口);Furion 按 `Order` 降序执行插件 `Configure`,轮到 `ApprovalFlow(100)` 同步 `InitTables` 时,火忘任务还在跑,两者共用同一个底层连接 → `ApprovalFlow` 没有任何 try-catch → 未处理异常 → 进程死亡。
+
+`AiDOP.Configure` 自己的同步对账块也在同时被撞,但它每块都有 try-catch,只记日志,所以看不见。
+
+### 1.2 为什么主线没事(关键,决定修法)
+
+| 断言 | 取证命令 | 结果 |
+|---|---|---|
+| 火忘 `Task.Run` 在 HEAD 不存在 | `git show HEAD:server/Plugins/Admin.NET.Plugin.AiDOP/Startup.cs` | HEAD 为 `.GetAwaiter().GetResult()`,try-catch 包着 |
+| `EtlInstanceRegistrar` / `MdpSourceConnectionImportService` 的 `AddHostedService` 在 HEAD 不存在 | 同上文件内检索 | 零匹配 |
+| 1.0.565 引入的是同步调用 | `git log -S"EnsureSourceIdentityAsync(sourceDb)" --oneline -- server/Plugins/Admin.NET.Plugin.AiDOP/Startup.cs` | 仅 `c6a6d96db` |
+| `EnsureSourceIdentityAsync` 全仓唯一调用点 | 全仓检索 | `AiDOP/Startup.cs:158` |
+
+**结论**:这是一处只存在于工作区的回归。所以最优修法是**撤回它**,不是在它之上再加设计。
+
+---
+
+## 2. 包 R:主修 —— 把火忘任务撤回为同步调用
+
+### 2.1 改动清单(先列给负责人确认)
+
+| 文件 | 改动 | 行数级别 |
+|---|---|---|
+| `server/Plugins/Admin.NET.Plugin.AiDOP/Startup.cs` | 把 `Configure` 里的 `_ = Task.Run(...)` 块换回 HEAD 的同步版本 | 一个 hunk,约 −16/+10 |
+
+本包**不动**:`ApprovalFlow` 插件、`SqlSugarSetup`、`Web.Core/Startup.cs`、任何配置文件、任何数据库。
+
+### 2.2 ⚠ 禁止的做法
+
+**不要** `git checkout HEAD -- server/Plugins/Admin.NET.Plugin.AiDOP/Startup.cs`。
+
+该文件还有三处必须保留的未提交改动,整文件回退会把它们一起抹掉:
+
+```csharp
+services.AddConfigurableOptions<AidopOutboundOptions>();
+services.AddHostedService<Admin.NET.Plugin.AiDOP.Infrastructure.EtlInstanceRegistrar>();
+services.AddHostedService<Admin.NET.Plugin.AiDOP.DataPlatform.MdpSourceConnectionImportService>();
+```
+
+只能手工替换下面这一个 hunk。
+
+### 2.3 把这一段(当前工作区,约 `Startup.cs:149-164`)
+
+```csharp
+        // 来源登记会扫全部贴源表。放在 Configure 里同步执行时,端口要等它结束才开。
+        // 失败只记日志,不阻断启动(与本方法里其它对账相同)。
+        var sourceServices = app.ApplicationServices;
+        _ = Task.Run(async () =>
+        {
+            try
+            {
+                using var sourceScope = sourceServices.CreateScope();
+                var sourceDb = sourceScope.ServiceProvider.GetRequiredService<ISqlSugarClient>();
+                await Admin.NET.Plugin.AiDOP.DataPlatform.Schema.MdpSchemaAligner.EnsureSourceIdentityAsync(sourceDb);
+            }
+            catch (Exception ex)
+            {
+                Trace.TraceError("Ai-DOP 来源登记与 written_by 对齐失败: " + ex);
+            }
+        });
+```
+
+### 2.4 换成这一段(HEAD 原样 + 说明为什么不能改回火忘)
+
+```csharp
+        // 来源登记扫全部贴源表,同步执行会让端口晚开数秒。**不要改成 Task.Run 火忘**:
+        // ISqlSugarClient 是单例 SqlSugarScope,其 AsyncLocal 上下文在启动期会被 Configure 管道
+        // 与火忘任务共享,两个执行流拿到同一个 MySqlConnection;后执行的 ApprovalFlow(100) 插件
+        // 同步 InitTables 时没有任何容错,撞上即未处理异常、进程退出。
+        // 2026-09-26 的启动崩溃就是这么来的,见 doc/plan/天缘对接/20260926问题描述.md。
+        try
+        {
+            using var sourceScope = app.ApplicationServices.CreateScope();
+            var sourceDb = sourceScope.ServiceProvider.GetRequiredService<ISqlSugarClient>();
+            Admin.NET.Plugin.AiDOP.DataPlatform.Schema.MdpSchemaAligner.EnsureSourceIdentityAsync(sourceDb)
+                .GetAwaiter().GetResult();
+        }
+        catch (Exception ex)
+        {
+            Trace.TraceError("Ai-DOP 来源登记与 written_by 对齐失败: " + ex);
+        }
+```
+
+改完确认 `git diff` 里该文件只剩 `AddConfigurableOptions` / 两个 `AddHostedService` 三处新增,来源登记那段回到与 HEAD 一致。
+
+---
+
+## 3. 包 V:验证(崩溃非必现,这是唯一有效验收)
+
+### 3.1 为什么不能只起一次
+
+当日 4 次启动里 2 次成功,**且两次成功的日志中同样有 3~5 条相同的连接竞态报错**,只是恰好落在带 try-catch 的一方被吞掉。「起来了」完全可能是运气。
+
+### 3.2 验收标准(三条全中才算通过)
+
+1. **连续冷启动 5 次**(每次都带编译,模拟最坏时序),5 次全部成功监听 5005;
+2. 5 次日志里 `already in use` 与 `Cannot Open when State is Connecting` **合计归零**。被吞掉的竞态仍是缺陷,不算通过;
+3. 对齐结果不变:`mdp_source_table_registry` 行数与 `mdp_std_*` 的 `written_by` 分布与修复前一致(该逻辑幂等,不应有差异)。
+
+### 3.3 可照抄的验证脚本
+
+> ⚠ 脚本里有两处**不能省**的细节,省了会得到假绿:
+> 1. `dotnet run` 会把真正的服务进程作为**子进程**拉起。只 `Stop-Process` 掉 `dotnet run` 本体,服务仍在跑并占着 5005,下一轮会瞬间判定 `listen=True`——五轮全绿其实一轮都没测。必须**按进程树杀**(`taskkill /T /F`)。
+> 2. 每轮开始前必须确认 5005 **已经空出来**,否则同样是上一轮的残留在冒充本轮。
+
+```powershell
+$proj = "D:\Projects\Ai-DOP\SourceCode\ZZYDOP\server\Admin.NET.Web.Entry\Admin.NET.Web.Entry.csproj"
+$log  = "$env:TEMP\startup_trials"
+New-Item -ItemType Directory -Force -Path $log | Out-Null
+
+function Wait-PortFree([int]$port, [int]$sec = 60) {
+    $sw = [Diagnostics.Stopwatch]::StartNew()
+    while ($sw.Elapsed.TotalSeconds -lt $sec) {
+        if (-not (Get-NetTCPConnection -LocalPort $port -State Listen -ErrorAction SilentlyContinue)) { return $true }
+        Start-Sleep -Milliseconds 500
+    }
+    return $false
+}
+
+for ($i = 1; $i -le 5; $i++) {
+    if (-not (Wait-PortFree 5005)) { "trial $i: 跳过——5005 仍被占用,先清理残留进程"; break }
+
+    $out = "$log\trial$i.txt"; $err = "$log\trial$i.err.txt"
+    $p = Start-Process "C:\Program Files\dotnet\dotnet.exe" `
+        -ArgumentList "run --project `"$proj`" --framework net10.0 --no-launch-profile" `
+        -RedirectStandardOutput $out -RedirectStandardError $err -PassThru -NoNewWindow
+
+    $sw = [Diagnostics.Stopwatch]::StartNew(); $ok = $false
+    while ($sw.Elapsed.TotalSeconds -lt 600 -and -not $p.HasExited) {
+        if (Get-NetTCPConnection -LocalPort 5005 -State Listen -ErrorAction SilentlyContinue) { $ok = $true; break }
+        Start-Sleep -Milliseconds 500
+    }
+
+    $race = @(Select-String -Path $out, $err -Pattern 'already in use|State is Connecting' -ErrorAction SilentlyContinue).Count
+    $fatal = @(Select-String -Path $out, $err -Pattern 'Unhandled exception' -ErrorAction SilentlyContinue).Count
+    "trial {0}: listen={1} 用时={2,6:N1}s 竞态={3} 未处理异常={4}" -f $i, $ok, $sw.Elapsed.TotalSeconds, $race, $fatal
+
+    # 按进程树杀,否则 dotnet run 的子进程会继续占着 5005
+    taskkill /T /F /PID $p.Id 2>&1 | Out-Null
+    Start-Sleep -Seconds 3
+}
+```
+
+期望输出:5 行全部 `listen=True`、`竞态=0`、`未处理异常=0`。三者缺一不算通过。
+
+**核对第 3 条(对齐结果未变)**,修复前后各跑一次,结果必须一致:
+
+```sql
+SELECT (SELECT COUNT(*) FROM mdp_source_table_registry) AS registry_rows;
+SELECT TABLE_NAME, COLUMN_NAME FROM information_schema.COLUMNS
+ WHERE TABLE_SCHEMA = DATABASE() AND COLUMN_NAME = 'written_by' AND TABLE_NAME LIKE 'mdp_std%'
+ ORDER BY TABLE_NAME;
+-- 再挑 2~3 张行数较大的 mdp_std_* 表逐张比对 written_by 分布:
+-- SELECT written_by, COUNT(*) FROM `<表名>` GROUP BY written_by;
+```
+
+---
+
+## 4. 包 M:实测端口开放延迟(决定 R 是否定稿)
+
+原作者把它改成火忘,注释写的理由是「放在 Configure 里同步执行时,端口要等它结束才开」。这个代价必须量化后再定稿。
+
+- 数据来源:直接用 §3.3 脚本输出的「用时」列(首次带编译,后 4 次可加 `--no-build` 去掉编译噪声)。
+- 参考基准:2026-09-26 03:06 那次成功启动里,可观测到的对齐活动集中在 03:06:39,宿主 03:06:46 进入稳态轮询,相差 7 秒。**但这是稀疏日志的推断,不是实测总耗时**,必须以本包实测为准。
+- 判定:
+  - 延迟可接受(由负责人定阈值,建议 ≤ 60s)→ **包 R 定稿**,任务书收口;
+  - 延迟不可接受 → 退回问题描述文档的**方案 A1**:把这段改为订阅 `IHostApplicationLifetime.ApplicationStarted` 后再执行。注意 A1 仍需过一遍包 V 的 5 次验证,因为它只是把窗口挪开,没有消除共享上下文。
+
+---
+
+## 5. 包 S:补守卫测试(当前零防护)
+
+这类回归能悄悄进工作区,就是因为没有任何测试能拦住它。
+
+- 文件:`server/Plugins/Admin.NET.Plugin.AiDOP.Tests/Infrastructure/StartupConcurrencyGuardTests.cs`(新建)
+- **放 `Infrastructure/` 而不是新建 `Startup/`**:该测试项目的命名空间跟随目录名,`...Tests.Startup` 这一段会和插件里的 `Startup` 类型产生解析歧义,没必要惹。
+- 测试项目 `<ImplicitUsings>enable</ImplicitUsings>`,`System` / `System.IO` 已隐式引入,下面只需显式 `using Xunit;`。
+- 手法沿用仓库既有的源文本扫描测试(参考 `DataPlatform/MdpMonitorAdminGuardTests.cs` 的路径解析写法)。
+
+```csharp
+using Xunit;
+
+namespace Admin.NET.Plugin.AiDOP.Tests.Infrastructure;
+
+/// <summary>
+/// 启动期并发守卫。ISqlSugarClient 是单例 SqlSugarScope,其 AsyncLocal 上下文在 Host 启动阶段
+/// 会被 Configure 管道与任何火忘任务共享,共享即同一个 MySqlConnection;后执行的 ApprovalFlow
+/// 插件同步 InitTables 没有容错,撞上就是进程退出。
+/// 2026-09-26 事故复盘:doc/plan/天缘对接/20260926问题描述.md
+/// </summary>
+public class StartupConcurrencyGuardTests
+{
+    private static string PluginStartupSource()
+    {
+        var dir = new DirectoryInfo(AppContext.BaseDirectory);
+        while (dir != null && !File.Exists(Path.Combine(dir.FullName, "AGENTS.md")))
+            dir = dir.Parent;
+        Assert.NotNull(dir);
+        return File.ReadAllText(Path.Combine(
+            dir!.FullName, "server", "Plugins", "Admin.NET.Plugin.AiDOP", "Startup.cs"));
+    }
+
+    [Fact]
+    public void AiDopStartup_DoesNotFireAndForgetDatabaseWork()
+    {
+        var source = PluginStartupSource();
+        Assert.DoesNotContain("Task.Run", source);
+        Assert.DoesNotContain("Task.Factory.StartNew", source);
+    }
+}
+```
+
+验收:该测试通过;把火忘代码临时贴回去,该测试必须失败(**验证守卫真的能拦住**,不要只看它绿)。
+
+---
+
+## 6. 包 X:收口 App.json 的提交风险
+
+`server/Admin.NET.Application/Configuration/App.json` 里 `JobSchedule:Enabled` 当前是 `false`,注释写着「本地UAT测试临时关闭,测完恢复 true」。它在 git 跟踪范围内。
+
+风险:一旦被 `git add .` 带进提交,**所有部署环境的定时任务会被整体关闭**;且缺省为 `true`、改前行为一致,代码评审几乎看不出来。
+
+处置(二选一,须明确执行其中一条):
+
+- **恢复为 `true`**,本机改用环境变量或 `dotnet run` 的 `--JobSchedule:Enabled=false` 临时覆盖;
+- 或在本任务书收口时确认该文件**不纳入任何提交**,并在提交前用 `git diff --cached -- server/Admin.NET.Application/Configuration/App.json` 复查为空。
+
+> 注:同文件里 `AiDOP:Jobs:Enabled=false`、`AiDOP:Outbound:Enabled=false`、`MdpRebuild` 的几项,是 165 连接耗尽治理的既有决策(见 [`165-aidopdev连接耗尽根治执行任务书.md`](../165-aidopdev连接耗尽根治执行任务书.md)),**不在本包处置范围**,不要顺手改回去。
+
+---
+
+## 7. 包 P(可选,且必须先登记):给 ApprovalFlow 加容错
+
+想法本身合理:给 `ApprovalFlow/Startup.cs` 的 8 个 `InitTables` + 2 个模板种子包有限重试(如 5 次 × 3s),把启动期偶发竞态从「进程崩溃」降级为「稍后成功」,兜底防并发方④⑤。
+
+**但不能直接动手。** `.cursor/rules/approval-flow-integration.mdc` 明令:
+
+> ❌ 不得在未登记为后期待办的前提下修改 `Admin.NET.Plugin.ApprovalFlow` 插件自身代码;若业务强需求要动插件,先在 `doc/plan/审批流-综合优化方案.md` 登记新编号(P4-XX),再按「动手前须列清单、等确认」规则操作。
+
+执行顺序:
+
+1. 先做包 R + V。**如果 5 次验证全绿且竞态归零,本包不需要做** —— 日志里被实证互撞的只有①↔②,④⑤ 目前只是推断,没有证据。
+2. 只有在包 R 之后仍能观测到竞态报错,才在 `doc/plan/审批流-综合优化方案.md` 登记 P4-XX,说明触发场景与证据,等确认后再改。
+
+---
+
+## 8. 反向影响推演(针对包 R)
+
+| 面 | 核查方式 | 结论 |
+|---|---|---|
+| 代码调用面 | 全仓检索 `EnsureSourceIdentityAsync`,唯一调用点 `AiDOP/Startup.cs:158` | **无影响** |
+| 数据契约面 | 只改调用时机,不改 `mdp_source_table_registry` / `written_by` 写入逻辑,且该逻辑幂等 | **无影响** |
+| 多数据源面 | 对齐按表名规则登记,认不出的只记日志不猜测,来源缺失行为不变 | **无影响** |
+| 多租户 / Domain 面 | 该段不涉及 `tenant_id` / `domain` 过滤 | **无影响** |
+| 运行面 | 唯一可见变化是端口开放时间后移;`AutoVersionUpdate` 在此之前已完成,不受影响 | **需实测(包 M)** |
+| 验证面 | 目前无任何测试能拦住「Configure 里起火忘 DB 任务」 | **需补(包 S)** |
+
+---
+
+## 9. 版本号与提交
+
+按 `version-bump-on-commit.mdc`:
+
+- 包 R / S 改的是 `server/` 下的运行代码与测试 → 同一提交内把 `server/Admin.NET.Web.Entry/Admin.NET.Web.Entry.csproj` 的 `<Version>` / `<AssemblyVersion>` / `<FileVersion>` **三处同步 patch +1**(当前 `1.0.580` → `1.0.581`)。
+- 本次不动前端 → **不要**递增 `Web/package.json`。
+- 本任务书与问题描述文档属纯文档 → 单独提交时不递增任何版本号。
+- 提交前按包 X 复查 `App.json` 未被误纳入。
+- 本仓库「提交」= commit + push(`git-submit-intent.mdc`),收尾须汇报远端可达的 commit 哈希。

+ 220 - 0
doc/plan/天缘对接/20260926问题描述.md

@@ -0,0 +1,220 @@
+# 20260926 问题描述:后端本地启动高概率崩溃(启动期数据库连接并发竞态)
+
+> 原标题为「必崩」,2026-09-26 复核证伪(实测 2/4),已改。理由见第一节的复核更正与第七节。
+
+> 记录日期:2026-09-26
+> 环境:本机 `dotnet run`(net10.0)连远程库 `123.60.180.165:3306/aidopdev`
+> 状态:**已修复并验证**(2026-09-26 22:46)。火忘 `Task.Run` 已撤回为同步调用,5 次冷启动全部成功、竞态报错归零,后端版本 `1.0.581`。端口开放延迟实测约 160~200 秒(不含编译),超过任务书建议阈值,是否改走方案 A1 待负责人定。详见 [`20260926启动期连接竞态根治执行任务书.md`](20260926启动期连接竞态根治执行任务书.md) 进度表)
+
+## 一、现象
+
+后端连续 **5 次启动全部在启动阶段崩溃退出**,端口 5005 从未起来。崩溃点始终是同一处:
+
+> **2026-09-26 复核更正:「必崩」不成立。**
+> 同一份工作区、同一条命令,当日另有 2 次启动**成功**(其中一次连跑 1 小时 59 分并完成全部超管接口验收),本机实测崩溃率 2/4。
+> 更关键的是:**那两次成功的启动日志里同样出现 3~5 条一模一样的连接竞态报错**,只是恰好全部落在带 try-catch 的一方,ApprovalFlow 侥幸躲过。
+> 结论应表述为「高命中率竞态(本机 2/4,合并本文档 5/5 约 7/9),崩不崩取决于谁输掉这次竞争」,不是确定性崩溃。
+> **直接后果:修复后不能靠「这次起来了」判定修好**,验证口径见第七节与任务书。
+
+```
+Admin.NET.Plugin.ApprovalFlow  Startup.cs:26
+  db.CodeFirst.InitTables<ApprovalFlowCommentTemplate>()
+  → SqlSugarException
+  → 未处理异常 → Host 终止
+```
+
+5 次错误形态略有不同,但全是「同一个 MySqlConnection 对象被两个执行流同时使用」的典型症状:
+
+| 次数 | 错误消息 |
+|------|----------|
+| 1 | `Packet received out-of-order. Expected 1; got 2` |
+| 2/3 | `Cannot Open when State is Connecting` |
+| 4 | `Dbfirst and Codefirst requires system table permissions`(SqlSugar 对底层异常的包装) |
+| 5 | `Cannot Open when State is Connecting` + 并发方 SQL 报 `This MySqlConnection is already in use` |
+
+已排除服务端因素:165 MySQL `threads_connected` 13/500 健康、无挂起 DDL、无 MDL 锁等待;网络毫秒级可达。
+
+## 二、根因(三层取证)
+
+### 2.1 底层机制(为什么会撞)
+
+Admin.NET 把 `ISqlSugarClient` 注册为**单例 `SqlSugarScope`**,其内部用 `AsyncLocal` 决定"当前上下文使用哪个底层连接"。
+
+在 **Host 启动阶段**,`BackgroundService.ExecuteAsync`、Configure 管道、`Task.Run` 火忘任务继承的是**同一个 ExecutionContext** → SqlSugarScope 认为它们在"同一上下文" → **全部拿到同一个 MySqlConnection 对象** → 两个执行流同时使用一条物理连接 → 上述四种错误。
+
+正常运行期每个 HTTP 请求有独立 ExecutionContext,不会撞;**只有启动期会**。
+
+### 2.2 执行顺序(谁跟谁撞)
+
+Furion 按 `[AppStartup(Order)]` **降序**执行插件 `Configure`:
+
+```
+AiDOP(900) 先执行  →  ApprovalFlow(100) 后执行
+```
+
+### 2.3 启动期并发方清单(按威胁排序)
+
+| # | 并发方 | 位置 | 行为 | 第 5 次日志证据 |
+|---|--------|------|------|----------------|
+| ① | **来源登记火忘任务(主凶)** | `Plugins/Admin.NET.Plugin.AiDOP/Startup.cs:152` `_ = Task.Run(...)` | `MdpSchemaAligner.EnsureSourceIdentityAsync`:扫全部 `mdp_stg_*` 贴源表的 information_schema + 逐表 UPDATE,远程库上**持续跑很久** | ✅ 崩溃时正在跑("中台结构对齐 来源表未登记: aidop_action_run_log"),其自身 SQL 也在报 `already in use` |
+| ② | AiDOP 自己的同步对账块 | `AiDOP/Startup.cs:168-322` | S8 规则供给、动作授权供给、`S8ApprovalFlowRepair`(查 `ApprovalFlow` 表)、S6/S7 快照迁移、S4 种子,全是 `GetAwaiter().GetResult()` 同步阻塞 | ✅ 崩溃瞬间其 `SELECT ... FROM ApprovalFlow WHERE BizType IN ('EXCEPTION_CLOSURE','EXCEPTION_ESCALATION')` 也在报 `Cannot Open when State is Connecting` |
+| ③ | ~~Furion Schedule Preload~~ | `Admin.NET.Core/Service/Job/DbJobPersistence.cs:112` | 启动即同步一批作业定义写库 | 已通过关闭 `AddSchedule` 消除(第 5 次不再出现) |
+| ④ | `EtlInstanceRegistrar` | AiDOP 注册的 BackgroundService | 常开心跳、周期写库注册 ETL 实例 | 日志未见报错,属潜在并发方 |
+| ⑤ | `MdpSourceConnectionImportService` | BackgroundService | 启动一次性导入源连接配置 | 同上 |
+
+### 2.4 致死链条
+
+1. `AiDOP(900).Configure` 一进来就 `_ = Task.Run(...)` 火忘启动 ①(来源登记,扫全贴源表,跑很久);
+2. 同一 Configure 里的 ② 同步块与 ① **自撞**——但 ② 每块都有 try-catch(失败只记 Trace,不阻断启动),撞了被吞掉;
+3. 轮到 `ApprovalFlow(100).Configure` 同步建 8 张表 + 2 个模板种子时,① 还在后台扫表,而 ApprovalFlow **没有任何容错** → 未处理异常 → 进程死亡。
+
+一句话:**AiDOP(900) 启动了一个自己都没法等的长任务,先把自己撞了一遍(被 try-catch 掩盖),再把没穿护甲的 ApprovalFlow(100) 撞死了。**
+
+### 2.5 为什么"以前能起来"(~~推测,未完全确证~~ → 2026-09-26 已确证,原推测有误)
+
+> **原文(有误,保留以便对照)**:① 这个来源登记火忘任务是近期治理批次(`mdp_source_table_registry`、`written_by` 回填,代码注释标 1.0.565)才加的……窗口被拉到几秒~几十秒,**必撞**。
+
+**更正**:不需要推测,答案是「①②④⑤ 全都还没进主线」。
+
+1.0.565(提交 `c6a6d96db`)引入的是**同步调用**,不是火忘:
+
+```
+git show HEAD:server/Plugins/Admin.NET.Plugin.AiDOP/Startup.cs
+  → MdpSchemaAligner.EnsureSourceIdentityAsync(sourceDb).GetAwaiter().GetResult();   // try-catch 包着
+```
+
+同步调用跑在 `AiDOP.Configure` 自己的调用栈里,**不可能**与后执行的 `ApprovalFlow.Configure` 并发。改成 `_ = Task.Run(...)` 是**工作区里一处尚未提交的改动**(`git diff -- server/Plugins/Admin.NET.Plugin.AiDOP/Startup.cs` 可见)。
+
+同理,2.3 表中列为并发方的 ④ `EtlInstanceRegistrar`、⑤ `MdpSourceConnectionImportService`,它们的 `services.AddHostedService<...>()` 注册行在 HEAD 上同样**不存在**,也是未提交改动。
+
+所以「以前能起来」的原因是:**主线上压根没有这个竞态**。它整体是一处只存在于工作区的回归。
+
+**这一条直接改变最优修法**,见第四节方案 A 的复核批注。
+
+## 三、已实施的处置(保留)
+
+`server/Admin.NET.Web.Core/Startup.cs`:给 `services.AddSchedule(...)` 包了一层开关
+
+```csharp
+if (App.GetConfig<bool?>("JobSchedule:Enabled", true) ?? true)
+{
+    services.AddSchedule(options => { ... });
+}
+```
+
+- 背景:App.json 里早有 `JobSchedule:Enabled` 配置项,但**全代码库从未有读取点**(摆设)。本次改动让配置项真正生效,缺省/为 true 时行为与改动前完全一致,部署环境零影响。
+- 本机 `App.json` 已置 `false`(注释标明:本地 UAT 临时关闭,测完恢复 true),消除了并发方 ③。
+- 第 5 次启动证明:仅消除 ③ 不够,① 仍是主凶。
+
+## 四、可选修复方案(待决策)
+
+> **2026-09-26 复核批注(先读这段再选方案)**
+>
+> - **A1 应替换为「revert」**:既然火忘 `Task.Run` 是未提交回归(见 2.5 更正),把它改成「等 `ApplicationStarted`」等于在一处回归上再叠一层设计。更小且更稳的做法是 `git checkout HEAD --` 那一个 hunk,直接回到已被验证过的同步版本。代价只有原作者注释里那句「端口要等它结束才开」,**须实测该延迟**后再定是否接受。
+> - **A2 撞了仓库硬规则**:`.cursor/rules/approval-flow-integration.mdc` 明令「不得在未登记为后期待办的前提下修改 `Admin.NET.Plugin.ApprovalFlow` 插件自身代码」,要动须先在 `doc/plan/审批流-综合优化方案.md` 登记 P4-XX。A2 本身合理(把崩溃降级为稍后成功),但**不能直接落地**。
+> - **④⑤ 只是推断,没有实证**:日志里被证实互撞的只有 ①↔②(逐条 SQL 见第七节)。消掉 ① 之后是否还撞,须按第七节口径连起多次验证,不要预先假定还需要 A2。
+>
+> 综合建议:**先只做 revert,再验证,按结果决定要不要走 A2 并登记 P4-XX**。详见任务书。
+
+### 方案 A(推荐,2 处改动)
+
+1. `Plugins/Admin.NET.Plugin.AiDOP/Startup.cs:149-164`:① 的火忘 `Task.Run` 改为**等待 `IHostApplicationLifetime.ApplicationStarted` 后再执行**——Configure 阶段不再有它;端口开放仍不等它(保持原作者"不堵端口"的意图)。
+2. `Plugins/Admin.NET.Plugin.ApprovalFlow/Startup.cs:24-39`:8 个 `InitTables` + 2 个模板种子包**有限重试**(5 次 × 3s),把启动期偶发竞态从"进程崩溃"降级为"稍后成功";全部失败仍抛出保持可见。兜底防 ④⑤ 等小型并发方。
+
+影响评估:两处均为启动健壮性修复,正常路径行为不变;来源登记是幂等对账、本来就无人等待,延迟无感;部署环境连远程库同样受益;无 API/行为变化。
+
+### 方案 B(只改 1 处)
+
+只做①的延迟,ApprovalFlow 不加重试。④⑤ 若恰好撞上仍可能偶发崩溃(概率小很多,重启一两次能过)。
+
+### 方案 C(不改代码,操作规避)
+
+每次启动前人工确保无并发——**实际做不到**:①是进程内火忘任务,无法从外部错峰。
+
+### 方案 D(彻底但大改,不建议现在做)
+
+给启动期所有 DB 初始化加全局串行锁 / 改 SqlSugar 注册方式。涉及面大,不符合"小问题最小改动"约定。
+
+## 五、待决策点
+
+1. 是否认同根因判断(①火忘任务为主凶、ApprovalFlow 无容错为死因)?
+2. 选 A / B / C / D 或其他处理方式?
+3. 无论选哪个,第三节的 `JobSchedule:Enabled` 开关修正建议保留(本身修正了"配置项从未生效"的缺陷)。
+
+## 六、复现与取证命令
+
+```powershell
+# 启动(高概率崩溃,本机实测 2/4,合并约 7/9;**非必现**)
+dotnet run --project server/Admin.NET.Web.Entry/Admin.NET.Web.Entry.csproj --framework net10.0 --no-launch-profile
+
+# 服务端健康排除(pymysql,脚本 _tmp_check_mysql_procs.py)
+#   SHOW FULL PROCESSLIST → 13/500,无长事务/DDL
+#   performance_schema.metadata_locks → 无 PENDING
+```
+
+## 七、2026-09-26 复核结论与证据
+
+复核方式:重跑第六节的复现命令 + 读取当日全部 4 次启动的终端日志 + `git show HEAD` / `git diff` 三层取证。
+
+### 7.1 复现结果(成立)
+
+崩溃原样复现,栈与错误形态与本文档第一节一致(属其中的第 4 种形态):
+
+```
+Unhandled exception. System.Reflection.TargetInvocationException
+ ---> SqlSugar.SqlSugarException: Dbfirst and  Codefirst requires system table permissions
+   at SqlSugar.CodeFirstProvider.InitTables(Type entityType)
+   at Admin.NET.Plugin.ApprovalFlow.Startup.Configure(...) ApprovalFlow\Startup.cs:line 26
+   at Furion.StartupFilter.UseStartups(IEnumerable`1 startups, IApplicationBuilder app)
+```
+
+崩溃时刻 `MdpSchemaAligner` 正在运行,周围 6 条 `This MySqlConnection is already in use` / `Cannot Open when State is Connecting`。
+
+### 7.2 ①↔② 互撞的逐条 SQL 证据
+
+取自 2026-09-26 03:06:39 那次**成功**启动的日志(同一秒内,火忘任务①与 `AiDOP.Configure` 的同步对账块②同时报错):
+
+| 报错 SQL | 归属 | 有无 try-catch |
+|---|---|---|
+| `SELECT Id FROM SysMenu WHERE Id IN (...)` | `AidopMenuLinkSync.EnsureLinked` | 有 → 被吞 |
+| `SELECT Id FROM SysTenant WHERE Status=1`(2 次) | S8 规则供给 / 动作授权供给 | 有 → 被吞 |
+| `SELECT ... FROM ApprovalFlow WHERE BizType IN ('EXCEPTION_CLOSURE','EXCEPTION_ESCALATION')` | `S8ApprovalFlowRepairService` | 有 → 被吞 |
+| `SELECT DISTINCT source_table FROM mdp_stg_emp_duty` | 火忘任务①本体 | 有 → 被吞 |
+| `select 1 from Information_schema.columns ...` | SqlSugar CodeFirst 探测 | 视调用方 |
+| `InitTables<...>` | **ApprovalFlow 插件** | **无 → 进程死** |
+
+本文档 2.3、2.4 的链条据此成立。
+
+### 7.3 当日 4 次启动对照(「必崩」证伪依据)
+
+| 时间 | 命令 | 结果 | 日志内竞态报错 |
+|---|---|---|---|
+| 02:51 | `dotnet run`(带编译) | 崩(ApprovalFlow InitTables) | 有 |
+| 02:55 | `dotnet run --no-build` | **成功**,稳态轮询 6.7 分钟后人为停止 | 有(4 条,全被吞) |
+| 03:03 | `dotnet run`(带编译) | **成功**,连跑 1h59m,完成超管接口验收 | 有(5 条,全被吞) |
+| 22:03 | `dotnet run`(带编译) | 崩(本次复核复现) | 有(6 条) |
+
+### 7.4 代码层取证(2.5 更正依据)
+
+| 断言 | 取证 | 结论 |
+|---|---|---|
+| 火忘 `Task.Run` 在 HEAD 上不存在 | `git show HEAD:.../AiDOP/Startup.cs` 为 `.GetAwaiter().GetResult()` | **未提交回归** |
+| ④⑤ 的 `AddHostedService` 在 HEAD 上不存在 | 同上文件内零匹配 | **未提交回归** |
+| 1.0.565 引入的是同步调用 | `git log -S"EnsureSourceIdentityAsync(sourceDb)"` → 仅 `c6a6d96db` | 成立 |
+| `EnsureSourceIdentityAsync` 全仓唯一调用点 | `AiDOP/Startup.cs:158` | 成立(revert 无旁路影响) |
+| 单例 `SqlSugarScope` | `SqlSugarSetup.cs:74` `services.AddSingleton<ISqlSugarClient>(sqlSugar)` | 成立 |
+| `JobSchedule:Enabled` 开关已生效 | `Web.Core/Startup.cs:82` | 成立(第三节处置有效) |
+
+### 7.5 本文档未提、但会出事的风险
+
+`server/Admin.NET.Application/Configuration/App.json` 的 `JobSchedule:Enabled` 已被改为 `false`,且**在 git 跟踪范围内**(`git diff` 可见)。一旦被 `git add .` 带进提交,所有部署环境的定时任务会被整体关闭;又因缺省为 `true`、改前行为一致,代码评审几乎看不出来。
+
+**提交前必须恢复为 `true`,或确保该文件不纳入本次提交。**
+
+### 7.6 修复后的验证口径(因 7.3 而必须)
+
+由于崩溃非必现,**不得以「这次起来了」判定修复成功**。最低验收标准:
+
+1. 连续冷启动 **5 次**(每次都带编译,模拟最坏时序),全部成功监听 5005;
+2. 5 次日志中 `already in use` + `Cannot Open when State is Connecting` 合计 **归零**(不是「没崩就行」——被吞掉的竞态仍是缺陷);
+3. `mdp_source_table_registry` 与 `written_by` 的对齐结果与修复前一致(幂等性未被破坏)。

+ 20 - 1
server/Admin.NET.Application/Configuration/App.json

@@ -43,7 +43,7 @@
   },
   // 定时任务/作业调度
   "JobSchedule": {
-    "Enabled": true // 是否开启
+    "Enabled": true // 是否开启。2026-09-26 曾临时置 false 规避启动期连接竞态,根因(火忘 Task.Run)已撤回,恢复 true。本机如需关闭调度,用启动参数覆盖,不要再改这里。
   },
   "S8ActiveFlowWatch": {
     "Enabled": true, // 是否启用卡死扫描
@@ -88,8 +88,27 @@
     "S1Rebuild": {
       "MaxParallelTenants": 2
     },
+    "Outbound": {
+      "Enabled": false // 开发库总开关:false=只入队不写 165。正式环境删除本段或置 true(缺省即为 true)
+    },
+    "Jobs": {
+      // ETL 执行机开关:false=本实例不跑任何 AiDOP 定时作业与轮询后台(页面手工重算不受影响)。
+      // 开发库这里必须是 false —— 每台开发机启动后端都会变成一台完整的 ETL 引擎,
+      // 多实例共用 aidopdev 时同一套作业被重复执行 N 份,2026-09-24 就是这样把
+      // MySQL 连接耗尽的(1040 Too many connections)。
+      // 真正的执行机用环境变量 AIDOP_JOBS_ENABLED=true 显式打开,不要改这里;
+      // env 优先于本文件(Furion 的 JSON 装配在 env provider 之后,故代码直读 env)。
+      // 缺省(整段删除)即为 true,与修复前行为一致。
+      "Enabled": false
+    },
     "MdpRebuild": {
       "MaxParallelScopes": 1,
+      // 全库并发上限:所有连同一个库的实例加起来最多同时跑几个 scope。
+      // MaxParallelScopes 只约束单进程,多实例共库会成倍放大。缺省回落到 MaxParallelScopes。
+      // 2026-09-26 包 C:开发库改为 1,夜间全量兜底靠它串行消费。
+      "GlobalMaxParallelScopes": 1,
+      // AUTO / BOOTSTRAP 入队的最小间隔小时数。0 表示关闭冷却。
+      "AutoMinIntervalHours": 6,
       "Modules": {
         "S1": { "Enabled": true },
         "S2": { "Enabled": true },

+ 10 - 5
server/Admin.NET.Web.Core/Startup.cs

@@ -76,12 +76,17 @@ public class Startup : AppStartup
         services.AddHttpRemote();
         // 任务队列
         services.AddTaskQueue();
-        // 任务调度
-        services.AddSchedule(options =>
+        // 任务调度(JobSchedule:Enabled 可整体关闭,缺省为开启;启动期 Schedule Preload 与
+        // 插件 Configure 阶段同步建表共享 SqlSugarScope 的 AsyncLocal 连接存在并发竞态,
+        // 本地连远程库调试时可临时置 false 规避)
+        if (App.GetConfig<bool?>("JobSchedule:Enabled", true) ?? true)
         {
-            options.AddPersistence<DbJobPersistence>(); // 添加作业持久化器
-            options.AddMonitor<JobMonitor>(); // 添加作业执行监视器
-        });
+            services.AddSchedule(options =>
+            {
+                options.AddPersistence<DbJobPersistence>(); // 添加作业持久化器
+                options.AddMonitor<JobMonitor>(); // 添加作业执行监视器
+            });
+        }
         // 脱敏检测
         services.AddSensitiveDetection();
 

+ 3 - 3
server/Admin.NET.Web.Entry/Admin.NET.Web.Entry.csproj

@@ -11,9 +11,9 @@
     <GenerateSatelliteAssembliesForCore>true</GenerateSatelliteAssembliesForCore>
     <Copyright>Admin.NET</Copyright>
     <Description>Admin.NET 通用权限开发平台</Description>
-    <AssemblyVersion>1.0.570</AssemblyVersion>
-    <FileVersion>1.0.570</FileVersion>
-    <Version>1.0.570</Version>
+    <AssemblyVersion>1.0.571</AssemblyVersion>
+    <FileVersion>1.0.571</FileVersion>
+    <Version>1.0.571</Version>
   </PropertyGroup>
 
   <ItemGroup>

+ 39 - 0
server/Plugins/Admin.NET.Plugin.AiDOP.Tests/Infrastructure/StartupConcurrencyGuardTests.cs

@@ -0,0 +1,39 @@
+using Xunit;
+
+namespace Admin.NET.Plugin.AiDOP.Tests.Infrastructure;
+
+/// <summary>
+/// 启动期并发守卫。ISqlSugarClient 是单例 SqlSugarScope,其 AsyncLocal 上下文在 Host 启动阶段
+/// 会被 Configure 管道与任何火忘任务共享,共享即同一个 MySqlConnection;后执行的 ApprovalFlow
+/// 插件同步 InitTables 没有容错,撞上就是进程退出。
+/// 2026-09-26 事故复盘:doc/plan/天缘对接/20260926问题描述.md
+/// </summary>
+public class StartupConcurrencyGuardTests
+{
+    private static string PluginStartupSource()
+    {
+        var dir = new DirectoryInfo(AppContext.BaseDirectory);
+        while (dir != null && !File.Exists(Path.Combine(dir.FullName, "AGENTS.md")))
+            dir = dir.Parent;
+        Assert.NotNull(dir);
+        return File.ReadAllText(Path.Combine(
+            dir!.FullName, "server", "Plugins", "Admin.NET.Plugin.AiDOP", "Startup.cs"));
+    }
+
+    /// <summary>去掉注释,否则「不要改成 Task.Run」这类说明文字会被误判为违规调用。</summary>
+    private static string StripComments(string source)
+    {
+        var noBlock = System.Text.RegularExpressions.Regex.Replace(source, @"/\*.*?\*/", "",
+            System.Text.RegularExpressions.RegexOptions.Singleline);
+        return System.Text.RegularExpressions.Regex.Replace(noBlock, @"//.*?$", "",
+            System.Text.RegularExpressions.RegexOptions.Multiline);
+    }
+
+    [Fact]
+    public void AiDopStartup_DoesNotFireAndForgetDatabaseWork()
+    {
+        var code = StripComments(PluginStartupSource());
+        Assert.DoesNotContain("Task.Run", code);
+        Assert.DoesNotContain("Task.Factory.StartNew", code);
+    }
+}

+ 10 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Startup.cs

@@ -43,6 +43,7 @@ public class Startup : AppStartup
         services.AddConfigurableOptions<AidopStockOptions>();
         services.AddConfigurableOptions<AidopInventoryOptions>();
         services.AddConfigurableOptions<AidopPoCompletionOptions>();
+        services.AddConfigurableOptions<AidopOutboundOptions>();
         services.AddTransient<IInventoryBalanceReader, InventoryBalanceReader>();
         services.AddTransient<IInventoryTransactionReader, StdInventoryTransactionReader>();
         // WP9:IPickBillCreator 唯一实现为 Facade(避免 Local/Mes 多实现竞注)
@@ -55,8 +56,12 @@ public class Startup : AppStartup
             options.Filters.AddService<AdoS0ExceptionFilter>();
             options.Filters.AddService<AdoS0ResultFilter>();
         });
+        // ETL 执行机注册与心跳:AidopJobGate 的数据来源,必须常开且不受闸门管辖
+        // (被闸门关掉会死锁:没人心跳 → 没人发布指派 → 永远不是执行机)
+        services.AddHostedService<Admin.NET.Plugin.AiDOP.Infrastructure.EtlInstanceRegistrar>();
         // MDP 出站事件驱动:入队 Pulse → 立即推送;Period Job 仅兜底
         services.AddHostedService<MdpOutboxPushWorker>();
+        services.AddHostedService<Admin.NET.Plugin.AiDOP.DataPlatform.MdpSourceConnectionImportService>();
         services.AddHostedService<MdpHotWatchWorker>();
         services.AddHostedService<Admin.NET.Plugin.AiDOP.DataPlatform.S1Refresh.S1DashboardRebuildWorker>();
         services.AddHostedService<Admin.NET.Plugin.AiDOP.DataPlatform.MdpRebuild.ModuleRebuildWorker>();
@@ -141,6 +146,11 @@ public class Startup : AppStartup
             Trace.TraceInformation($"Ai-DOP S8RuleCatalog: {ruleCatalog.Definitions.Count} rule definition(s) loaded");
         }
 
+        // 来源登记扫全部贴源表,同步执行会让端口晚开数秒。**不要改成 Task.Run 火忘**:
+        // ISqlSugarClient 是单例 SqlSugarScope,其 AsyncLocal 上下文在启动期会被 Configure 管道
+        // 与火忘任务共享,两个执行流拿到同一个 MySqlConnection;后执行的 ApprovalFlow(100) 插件
+        // 同步 InitTables 时没有任何容错,撞上即未处理异常、进程退出。
+        // 2026-09-26 的启动崩溃就是这么来的,见 doc/plan/天缘对接/20260926问题描述.md。
         try
         {
             using var sourceScope = app.ApplicationServices.CreateScope();