Explorar o código

fix(etl): 执行机指派改按部署槽位,使指派能跨进程重启存活

根因是范畴错误:指派存在 ado_etl_instance.is_runner,而 instance_id 是
「机器名:进程ID:启动时间」(进程级唯一是刻意的),于是角色被绑死在一次
进程化身上——执行机每重启一次必然丢指派、全库零台存活执行机、定时 ETL
静默停摆。2026-09-28 实测:03:40 扇出 28 条 AUTO_NIGHTLY 的实例 04:10 退出
(33 条成功全部落在 03:40:14-04:10:03 那 30 分钟内),剩下 28 条此后无人可领。

P0 指派落到跨重启稳定的槽位:
- 新建 ado_etl_runner_designation(单行,主键固定为 1)——「最多一台执行机」
  由结构保证,不再依赖写入侧事务先清后置
- AidopInstanceIdentity 增 SlotCode(AIDOP_ETL_SLOT / AiDOP:Jobs:Slot,惰性求值)
- 判定 = 本进程槽位 == 被指派槽位;未声明槽位者永不是执行机(比旧模型更严)
- 同槽位多个存活实例则两边都不跑(失败关闭),避免无仲裁者时双跑全量
- is_runner 降级为遗留列,运行期不再读写;CleanupDeadAsync 改按槽位保留
- AidopJobGate.Evaluate 形状不变,24 个作业/Worker 调用点零改动

P2 队列与可观测性收口:
- 夜间作业扇出前自清上一轮未开跑的 AUTO_NIGHTLY(只碰 QUEUED 且无 requested_by)
- 新增 QUEUED 陈旧回收,阈值 24h(队尾合法等待可达 7h,短了会误杀)
- MDP 运行监控新增 designatedSlot / liveRunnerCount / 队列积压,
  并报出「零台存活执行机」「槽位撞车」「队列积压」三个全库告警
- 前端指派改按槽位操作,新增槽位表与告警条

迁移 1.0.589:建表、加列加索引、按机器名回填槽位、把现有指派带过来
(保持升级前后 ETL 连续)、收口本次事故遗留的 28 条孤立队列。

守卫测试:
- 重写 AssignRunner 唯一性守卫(结构保证取代事务保证),新增 5 条
  (按槽位判定/无槽位永不执行/撞车失败关闭/批量收口不误伤手工/阈值下限)
- 新增 MigrationScriptRegistrationTests:迁移脚本必须在 csproj 登记
  CopyToOutputDirectory,否则「在仓库里但永远不执行」。本守卫首次运行即
  查出 11 个既有未登记脚本,已记入显式基线待逐个处置

零回归:3213 个测试,7 个失败 = 既有基线原样。

chore: bump version Web 2.4.412 / server 1.0.589
Co-authored-by: Cursor <cursoragent@cursor.com>
YY968XX hai 3 semanas
pai
achega
5e21330d04
Modificáronse 21 ficheiros con 1074 adicións e 126 borrados
  1. 1 1
      Web/package.json
  2. 40 2
      Web/src/views/aidop/data-platform/api/mdpMonitor.ts
  3. 97 10
      Web/src/views/aidop/data-platform/mdpMonitor.vue
  4. 9 3
      server/Admin.NET.Web.Entry/Admin.NET.Web.Entry.csproj
  5. 87 0
      server/Admin.NET.Web.Entry/UpdateScripts/1.0.589.sql
  6. 25 0
      server/Admin.NET.Web.Entry/UpdateScripts/1.0.589.verify.sql
  7. 11 1
      server/Plugins/Admin.NET.Plugin.AiDOP.Tests/DataPlatform/MdpMonitorAdminGuardTests.cs
  8. 149 0
      server/Plugins/Admin.NET.Plugin.AiDOP.Tests/DataPlatform/MigrationScriptRegistrationTests.cs
  9. 39 0
      server/Plugins/Admin.NET.Plugin.AiDOP.Tests/DataPlatform/ModuleRebuildServiceTests.cs
  10. 109 11
      server/Plugins/Admin.NET.Plugin.AiDOP.Tests/Infrastructure/AidopJobGateTests.cs
  11. 10 0
      server/Plugins/Admin.NET.Plugin.AiDOP/DataPlatform/MdpRebuild/ModuleRebuildLock.cs
  12. 21 1
      server/Plugins/Admin.NET.Plugin.AiDOP/DataPlatform/MdpRebuild/ModuleRebuildService.cs
  13. 70 0
      server/Plugins/Admin.NET.Plugin.AiDOP/DataPlatform/MdpRebuild/ModuleRebuildStore.cs
  14. 19 6
      server/Plugins/Admin.NET.Plugin.AiDOP/Entity/SmartOps/AdoEtlInstance.cs
  15. 51 0
      server/Plugins/Admin.NET.Plugin.AiDOP/Entity/SmartOps/AdoEtlRunnerDesignation.cs
  16. 45 0
      server/Plugins/Admin.NET.Plugin.AiDOP/Infrastructure/AidopInstanceIdentity.cs
  17. 18 4
      server/Plugins/Admin.NET.Plugin.AiDOP/Infrastructure/AidopJobGate.cs
  18. 80 21
      server/Plugins/Admin.NET.Plugin.AiDOP/Infrastructure/EtlInstanceRegistrar.cs
  19. 83 43
      server/Plugins/Admin.NET.Plugin.AiDOP/Infrastructure/EtlInstanceStore.cs
  20. 17 1
      server/Plugins/Admin.NET.Plugin.AiDOP/Job/MdpNightlyFullRebuildJob.cs
  21. 93 22
      server/Plugins/Admin.NET.Plugin.AiDOP/Order/MdpMonitorService.cs

+ 1 - 1
Web/package.json

@@ -1,7 +1,7 @@
 {
 	"name": "admin.net",
 	"type": "module",
-	"version": "2.4.411",
+	"version": "2.4.412",
 	"packageManager": "pnpm@10.32.1",
 	"lastBuildTime": "2026.03.15",
 	"description": "Admin.NET 站在巨人肩膀上的 .NET 通用权限开发框架",

+ 40 - 2
Web/src/views/aidop/data-platform/api/mdpMonitor.ts

@@ -163,6 +163,9 @@ export interface EtlInstanceRow {
 	machineName: string;
 	processId: number;
 	appVersion?: string | null;
+	/** 该进程声明所属的部署槽位;null = 未声明,永不是执行机 */
+	slotCode?: string | null;
+	/** 派生值:槽位 == 被指派槽位。后端已不再读 ado_etl_instance.is_runner */
 	isRunner: boolean;
 	startedAt: string;
 	lastHeartbeatAt: string;
@@ -170,12 +173,43 @@ export interface EtlInstanceRow {
 	self: boolean;
 }
 
+export interface EtlSlotRow {
+	slotCode: string;
+	designated: boolean;
+	liveCount: number;
+	totalCount: number;
+}
+
+export interface EtlRunnerAlerts {
+	/** 已指派,但该槽位没有存活实例 —— 定时 ETL 此刻完全停摆 */
+	noLiveRunner: boolean;
+	/** 无任何指派。设计上允许,但定时 ETL 不会运行 */
+	notDesignated: boolean;
+	/** 同一槽位多个存活实例,双方都会拒跑以免双跑全量 */
+	slotConflict: boolean;
+	queueStuck: boolean;
+}
+
 export interface EtlRunnerStatus {
 	instanceId: string;
+	slotCode?: string | null;
+	slotEnvName: string;
+	slotConfigKey: string;
+	/** 被指派的槽位;null = 无指派 */
+	designatedSlot?: string | null;
+	designatedBy?: string | null;
+	designatedAt?: string | null;
 	isRunner: boolean;
 	claimAuto: boolean;
 	assignmentKnown: boolean;
 	runningScopes: number;
+	queueDepth: number;
+	oldestQueuedAt?: string | null;
+	oldestQueuedMinutes?: number | null;
+	queuedStaleAfterHours: number;
+	liveRunnerCount: number;
+	alerts: EtlRunnerAlerts;
+	slots: EtlSlotRow[];
 	instances: EtlInstanceRow[];
 }
 
@@ -211,8 +245,12 @@ export function fetchAdminRunLogList(params: Record<string, unknown>) {
 	return service.get<AdminRunLogPage>('/api/DataPlatform/mdp-monitor/admin/list', { params }).then((r) => r.data);
 }
 
-export function assignEtlRunner(instanceId: string) {
-	return service.post('/api/DataPlatform/mdp-monitor/admin/assign-runner', { instanceId }).then((r) => r.data);
+/**
+ * 指派的对象是**部署槽位**而不是实例:实例标识含进程ID与启动时间,
+ * 指派挂上去每次重启都会丢(2026-09-28 的队列孤立事故即由此而来)。
+ */
+export function assignEtlRunner(slotCode: string) {
+	return service.post('/api/DataPlatform/mdp-monitor/admin/assign-runner', { slotCode }).then((r) => r.data);
 }
 
 export function clearEtlRunner() {

+ 97 - 10
Web/src/views/aidop/data-platform/mdpMonitor.vue

@@ -40,16 +40,66 @@
 					<el-button size="small" :loading="adminLoading" @click="loadAdmin">刷新</el-button>
 				</div>
 			</template>
+			<!-- 全库级告警。「未指派」是设计允许的状态,「已指派但没有存活实例」才是故障;
+			     后者过去完全不可观测,2026-09-28 的 28 条队列孤立就是这么积压出来的。 -->
+			<el-alert v-if="runner.alerts?.noLiveRunner" type="error" :closable="false" show-icon class="mb12">
+				已指派槽位 <b>{{ runner.designatedSlot }}</b> 上没有存活实例 —— 定时 ETL 此刻完全停摆。
+				请启动该槽位的进程,或把指派改到一台在线槽位。
+			</el-alert>
+			<el-alert v-else-if="runner.alerts?.notDesignated" type="warning" :closable="false" show-icon class="mb12">
+				当前无执行机指派,定时 ETL 不会运行(这是设计行为,不是故障)。请在下方槽位列表中指派一个。
+			</el-alert>
+			<el-alert v-if="runner.alerts?.slotConflict" type="warning" :closable="false" show-icon class="mb12">
+				槽位 <b>{{ runner.designatedSlot }}</b> 上有 {{ runner.liveRunnerCount }} 个存活实例。
+				为避免双跑全量,它们全部拒跑定时作业;滚动重启期间短暂出现属正常,持续出现说明有两个进程配了同一槽位。
+			</el-alert>
+			<el-alert v-if="runner.alerts?.queueStuck" type="warning" :closable="false" show-icon class="mb12">
+				最老的排队任务已等待 {{ fmtQueuedAge(runner.oldestQueuedMinutes) }}(队列共 {{ runner.queueDepth }} 条),
+				超过 {{ runner.queuedStaleAfterHours }} 小时会被自动收口。通常是上面的执行机问题导致的。
+			</el-alert>
+
 			<div class="admin-runner">
 				<span>当前实例:{{ runner.instanceId || '--' }}</span>
 				<el-tag :type="runner.isRunner ? 'success' : 'info'" size="small">{{ runner.isRunner ? '执行机' : '非执行机' }}</el-tag>
-				<span>正在跑 {{ runner.runningScopes ?? 0 }} 个 scope</span>
-				<el-button size="small" type="warning" plain @click="onClearRunner">取消全部指派</el-button>
+				<span>本机槽位:{{ runner.slotCode || '未声明' }}</span>
+				<span>指派槽位:{{ runner.designatedSlot || '无' }}</span>
+				<span>正在跑 {{ runner.runningScopes ?? 0 }} 个 scope,排队 {{ runner.queueDepth ?? 0 }} 条</span>
+				<el-button size="small" type="warning" plain @click="onClearRunner">取消指派</el-button>
 			</div>
+
+			<!-- 指派按槽位操作,故单列一张槽位表;下面的实例表只做观测 -->
+			<el-table :data="runner.slots" border size="small" class="mb12">
+				<el-table-column prop="slotCode" label="部署槽位" min-width="160" />
+				<el-table-column label="指派" width="90" align="center">
+					<template #default="{ row }">
+						<el-tag v-if="row.designated" type="success" size="small">执行机</el-tag>
+						<span v-else>--</span>
+					</template>
+				</el-table-column>
+				<el-table-column label="存活实例" width="110" align="center">
+					<template #default="{ row }">
+						<el-tag :type="row.liveCount > 1 ? 'warning' : row.liveCount === 1 ? 'success' : 'info'" size="small">
+							{{ row.liveCount }} / {{ row.totalCount }}
+						</el-tag>
+					</template>
+				</el-table-column>
+				<el-table-column label="操作" width="150" align="center">
+					<template #default="{ row }">
+						<el-button v-if="!row.designated" link type="primary" @click="onAssignRunner(row.slotCode)">指派为执行机</el-button>
+					</template>
+				</el-table-column>
+			</el-table>
+
 			<el-table :data="runner.instances" border size="small" class="mb12">
 				<el-table-column prop="machineName" label="机器" min-width="140" />
 				<el-table-column prop="processId" label="进程" width="90" />
 				<el-table-column prop="appVersion" label="版本" width="110" />
+				<el-table-column label="槽位" min-width="130">
+					<template #default="{ row }">
+						<span v-if="row.slotCode">{{ row.slotCode }}</span>
+						<el-tag v-else type="info" size="small">未声明</el-tag>
+					</template>
+				</el-table-column>
 				<el-table-column label="执行机" width="90" align="center">
 					<template #default="{ row }">
 						<el-tag v-if="row.isRunner" type="success" size="small">是</el-tag>
@@ -64,13 +114,16 @@
 				<el-table-column label="心跳" width="170">
 					<template #default="{ row }">{{ fmtDateTime(row.lastHeartbeatAt) }}</template>
 				</el-table-column>
-				<el-table-column label="操作" width="140" align="center">
+				<el-table-column label="本机" width="80" align="center">
 					<template #default="{ row }">
-						<el-button v-if="!row.isRunner" link type="primary" @click="onAssignRunner(row.instanceId)">设为执行机</el-button>
-						<el-tag v-else-if="row.self" size="small">本机</el-tag>
+						<el-tag v-if="row.self" size="small">本机</el-tag>
 					</template>
 				</el-table-column>
 			</el-table>
+			<div class="runner-hint">
+				槽位来自各进程的 <code>{{ runner.slotEnvName }}</code> 环境变量或 <code>{{ runner.slotConfigKey }}</code> 配置。
+				指派的对象是槽位而不是实例,因此进程重启、版本升级都不会丢指派;显示「未声明」的进程无法被指派。
+			</div>
 			<el-form inline>
 				<el-form-item label="租户">
 					<el-select v-model="adminTenantId" clearable placeholder="全部租户" style="width: 220px" @change="loadAdminList">
@@ -410,7 +463,30 @@ const adminTenantId = ref<number | undefined>(undefined);
 const adminModule = ref('S1');
 const adminRows = ref<Record<string, unknown>[]>([]);
 const adminJobs = ref<AdminActiveJob[]>([]);
-const runner = ref<EtlRunnerStatus>({ instanceId: '', isRunner: false, claimAuto: false, assignmentKnown: false, runningScopes: 0, instances: [] });
+const runner = ref<EtlRunnerStatus>({
+	instanceId: '',
+	slotCode: null,
+	slotEnvName: 'AIDOP_ETL_SLOT',
+	slotConfigKey: 'AiDOP:Jobs:Slot',
+	designatedSlot: null,
+	isRunner: false,
+	claimAuto: false,
+	assignmentKnown: false,
+	runningScopes: 0,
+	queueDepth: 0,
+	oldestQueuedMinutes: null,
+	queuedStaleAfterHours: 24,
+	liveRunnerCount: 0,
+	alerts: { noLiveRunner: false, notDesignated: false, slotConflict: false, queueStuck: false },
+	slots: [],
+	instances: [],
+});
+
+function fmtQueuedAge(minutes?: number | null) {
+	if (minutes == null) return '--';
+	if (minutes < 90) return `${Math.round(minutes)} 分钟`;
+	return `${(minutes / 60).toFixed(1)} 小时`;
+}
 
 async function loadAdmin() {
 	adminLoading.value = true;
@@ -429,15 +505,19 @@ async function loadAdminList() {
 	adminJobs.value = data.activeJobs || [];
 }
 
-async function onAssignRunner(instanceId: string) {
-	await ElMessageBox.confirm(`把 ${instanceId} 指派为唯一执行机?其余实例会立即失去执行机身份。`, '指派执行机');
-	await assignEtlRunner(instanceId);
+async function onAssignRunner(slotCode: string) {
+	await ElMessageBox.confirm(
+		`把槽位 ${slotCode} 指派为唯一执行机?原槽位会立即失去执行机身份。` +
+			'指派跟随槽位而不是进程,该槽位上的进程重启后仍然是执行机。',
+		'指派执行机'
+	);
+	await assignEtlRunner(slotCode);
 	ElMessage.success('已指派,最迟 60 秒生效');
 	await loadAdmin();
 }
 
 async function onClearRunner() {
-	await ElMessageBox.confirm('取消后没有任何实例跑定时 ETL,手工重算不受影响。', '取消全部指派');
+	await ElMessageBox.confirm('取消后没有任何实例跑定时 ETL,手工重算不受影响。', '取消执行机指派');
 	await clearEtlRunner();
 	ElMessage.success('已取消指派');
 	await loadAdmin();
@@ -746,6 +826,13 @@ onMounted(async () => {
 	gap: 12px;
 	align-items: center;
 	margin-bottom: 12px;
+	flex-wrap: wrap;
+}
+.runner-hint {
+	font-size: 12px;
+	color: var(--el-text-color-secondary);
+	line-height: 1.7;
+	margin-bottom: 12px;
 }
 .pager {
 	display: flex;

+ 9 - 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.588</AssemblyVersion>
-    <FileVersion>1.0.588</FileVersion>
-    <Version>1.0.588</Version>
+    <AssemblyVersion>1.0.589</AssemblyVersion>
+    <FileVersion>1.0.589</FileVersion>
+    <Version>1.0.589</Version>
   </PropertyGroup>
 
   <ItemGroup>
@@ -1006,6 +1006,12 @@
     <None Update="UpdateScripts\1.0.582.verify.sql">
       <CopyToOutputDirectory>Always</CopyToOutputDirectory>
     </None>
+    <None Update="UpdateScripts\1.0.589.sql">
+      <CopyToOutputDirectory>Always</CopyToOutputDirectory>
+    </None>
+    <None Update="UpdateScripts\1.0.589.verify.sql">
+      <CopyToOutputDirectory>Always</CopyToOutputDirectory>
+    </None>
     <None Update="UpdateScripts\UAT-PLACEHOLDER-MENU-HIDE.ops.sql">
       <CopyToOutputDirectory>Always</CopyToOutputDirectory>
     </None>

+ 87 - 0
server/Admin.NET.Web.Entry/UpdateScripts/1.0.589.sql

@@ -0,0 +1,87 @@
+-- 1.0.589 —— ETL 执行机指派改按「部署槽位」,使指派能跨进程重启存活。
+--
+-- 背景(根因):指派原来存在 ado_etl_instance.is_runner,而 instance_id 是
+--   「机器名:进程ID:启动时间」(AidopInstanceIdentity,进程级唯一是刻意的),
+--   于是「角色」被绑死在一次进程化身上——执行机每重启一次,指派必然留在一行死记录上,
+--   全库变成零台存活执行机,定时 ETL 静默停摆。
+--   2026-09-28 实测:03:40 扇出 28 条 AUTO_NIGHTLY 的实例(ERICGUO:69756,1.0.583)
+--   04:10 退出,此后没有任何实例能领这批任务(ClaimNextQueuedAsync 对非执行机
+--   只放行 MANUAL),S5/S6/S7 的队列被永久孤立。
+--
+-- 修法:角色是部署槽位的属性,不是进程化身的属性。
+--   ① 新建 ado_etl_runner_designation —— 指派独立成表,主键固定为 1,
+--      「最多一台执行机」由结构保证,不再靠写入侧事务先清后置。
+--   ② ado_etl_instance 加 slot_code —— 进程声明自己属于哪个槽位(AIDOP_ETL_SLOT)。
+--      执行机判定 = 本进程槽位 == 被指派槽位;未声明槽位者永不是执行机(比旧模型更严)。
+--   ③ is_runner 保留为遗留列,运行期代码不再读写;待全部实例升级后单独发版删除。
+--
+-- 注意这不是退回 2026-09-25 之前的配置模型:配置只声明**身份**(我是哪个槽位),
+--   唯一**权威**仍是本表那条指派记录,页面仍可热切换、无需重启。
+--
+-- 幂等:建表用 IF NOT EXISTS,加列前查 information_schema,回填带 NOT EXISTS 守卫。
+
+-- ① 指派表
+CREATE TABLE IF NOT EXISTS ado_etl_runner_designation (
+  id BIGINT NOT NULL PRIMARY KEY COMMENT '固定为 1:全表只有一行,指派唯一性由主键保证',
+  slot_code VARCHAR(128) NULL COMMENT '被指派的部署槽位;NULL=无指派(此时无人跑定时 ETL,是允许的状态)',
+  assigned_by VARCHAR(64) NULL COMMENT '操作人账号,仅供审计',
+  assigned_at DATETIME NULL,
+  remark VARCHAR(512) NULL,
+  create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+  update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
+) COMMENT 'ETL执行机指派(单行)';
+
+-- ② 实例表加槽位列
+SET @sql := IF((SELECT COUNT(*) FROM information_schema.COLUMNS
+  WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='ado_etl_instance' AND COLUMN_NAME='slot_code')=0,
+  'ALTER TABLE ado_etl_instance ADD COLUMN slot_code VARCHAR(128) NULL COMMENT ''本进程声明所属的部署槽位;NULL=未声明,永不是执行机'' AFTER app_version',
+  'DO 0');
+PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
+
+SET @sql := IF((SELECT COUNT(*) FROM information_schema.STATISTICS
+  WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='ado_etl_instance' AND INDEX_NAME='idx_etl_instance_slot')=0,
+  'ALTER TABLE ado_etl_instance ADD INDEX idx_etl_instance_slot (slot_code, last_heartbeat_at)',
+  'DO 0');
+PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
+
+-- ③ 现有实例行按机器名补槽位。
+--    机器名是该行天然稳定的部分,用它当默认槽位码,升级后同机重启即可继承指派。
+UPDATE ado_etl_instance
+   SET slot_code = LOWER(machine_name)
+ WHERE slot_code IS NULL
+   AND IFNULL(machine_name,'') <> '';
+
+-- ④ 把现有指派带过来,使升级前后 ETL 连续(决策 D1)。
+--    取 is_runner=1 那行的机器名作为初始槽位。
+--    当前库里这一行是开发机 ERICGUO —— 与升级前的实际状态一致,不是新增风险;
+--    上线正式执行机后请在 MDP 运行监控页改指派到对应槽位。
+--    NOT EXISTS 守卫保证重跑不会覆盖运维之后手工改过的指派。
+INSERT INTO ado_etl_runner_designation (id, slot_code, assigned_by, assigned_at, remark, create_time, update_time)
+SELECT 1,
+       LOWER(i.machine_name),
+       'migration-1.0.589',
+       NOW(),
+       CONCAT('由 1.0.589 从 ado_etl_instance.is_runner=1 迁移(原 instance_id=', i.instance_id, ')'),
+       NOW(),
+       NOW()
+  FROM ado_etl_instance i
+ WHERE i.is_runner = 1
+   AND IFNULL(i.machine_name,'') <> ''
+   AND NOT EXISTS (SELECT 1 FROM ado_etl_runner_designation d WHERE d.id = 1)
+ ORDER BY i.last_heartbeat_at DESC
+ LIMIT 1;
+
+-- ⑤ 收口本次事故遗留的孤立队列。
+--    这批 AUTO_NIGHTLY 已无消费者且一直挡着同 scope 的后续入队;
+--    夜间作业从本版起会在扇出前自清,故这里只需处理历史存量。
+--    只碰 QUEUED 且无 requested_by 的行:RUNNING 与页面手工任务一律不动。
+UPDATE ado_module_dashboard_rebuild_job
+   SET status = 'CANCELLED',
+       current_stage = 'CANCELLED',
+       finished_at = NOW(),
+       error_message = '执行机指派随进程重启失效导致长期无人领取,由 1.0.589 统一收口',
+       update_time = NOW()
+ WHERE status = 'QUEUED'
+   AND trigger_type = 'AUTO_NIGHTLY'
+   AND requested_by IS NULL
+   AND submitted_at < DATE_SUB(NOW(), INTERVAL 2 HOUR);

+ 25 - 0
server/Admin.NET.Web.Entry/UpdateScripts/1.0.589.verify.sql

@@ -0,0 +1,25 @@
+-- 1.0.589 部署后断言。表尚未建成时放行(首次建库场景)。
+-- 要求同时成立:
+--   ① 指派表存在且最多一行 —— 「最多一台执行机」这条不变式的结构保证没有被破坏。
+--   ② ado_etl_instance 有 slot_code 列 —— 槽位判定链不会因缺列而静默退化为「谁都不是执行机」。
+--   ③ 若已有指派,则不存在「指派了一个没有任何实例行声明过的槽位」这种拼错情况
+--      (只查是否有实例行用过该槽位,不要求此刻存活 —— 指派先于部署是合法用法)。
+SELECT
+  (SELECT COUNT(*) FROM information_schema.TABLES
+    WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='ado_etl_instance') = 0
+  OR (
+    (SELECT COUNT(*) FROM information_schema.TABLES
+      WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='ado_etl_runner_designation') = 1
+    AND (SELECT COUNT(*) FROM ado_etl_runner_designation) <= 1
+    AND (SELECT COUNT(*) FROM information_schema.COLUMNS
+          WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='ado_etl_instance'
+            AND COLUMN_NAME='slot_code') = 1
+    AND (
+      (SELECT COUNT(*) FROM ado_etl_runner_designation
+        WHERE IFNULL(slot_code,'') <> '') = 0
+      OR EXISTS (
+        SELECT 1 FROM ado_etl_instance i
+         WHERE i.slot_code = (SELECT slot_code FROM ado_etl_runner_designation WHERE id = 1)
+      )
+    )
+  );

+ 11 - 1
server/Plugins/Admin.NET.Plugin.AiDOP.Tests/DataPlatform/MdpMonitorAdminGuardTests.cs

@@ -118,13 +118,23 @@ public class MdpMonitorAdminGuardTests
         Assert.True(generic > specific, "取消异常的 catch 必须早于通用 catch,否则会被吞成 FAILED");
     }
 
+    /// <summary>
+    /// 指派必须委托给 <c>IEtlInstanceStore</c>,控制器不得自己写指派。
+    ///
+    /// <para>2026-09-28:指派对象由实例改为部署槽位(<c>AssignRunnerSlotAsync</c>),
+    /// 因为 <c>instance_id</c> 含进程ID与启动时间,指派挂上去每次重启都会丢。
+    /// 本守卫的意图不变——唯一性与事务边界归 store,控制器只转发;
+    /// 另外 <c>is_runner</c> 已降级为遗留列,控制器连读都不该有。</para>
+    /// </summary>
     [Fact]
     public void AssignRunner_ReusesPackageBStore()
     {
         var source = MonitorSource();
-        Assert.Contains("_etlInstances.AssignRunnerAsync", source);
+        Assert.Contains("_etlInstances.AssignRunnerSlotAsync", source);
         Assert.DoesNotContain("SET is_runner", source);
         Assert.DoesNotContain("IsRunner = true", source);
+        // 退回按实例指派就是把根因放回去
+        Assert.DoesNotContain("AssignRunnerAsync(input.InstanceId", source);
     }
 
     [Fact]

+ 149 - 0
server/Plugins/Admin.NET.Plugin.AiDOP.Tests/DataPlatform/MigrationScriptRegistrationTests.cs

@@ -0,0 +1,149 @@
+using System.Text.RegularExpressions;
+using Xunit;
+
+namespace Admin.NET.Plugin.AiDOP.Tests.DataPlatform;
+
+/// <summary>
+/// 迁移脚本的「登记」守卫:仓库里每一个版本号命名的 <c>UpdateScripts/*.sql</c>
+/// 都必须在 <c>Admin.NET.Web.Entry.csproj</c> 里有 <c>CopyToOutputDirectory</c> 条目。
+///
+/// <para><b>为什么需要</b>:<c>AutoVersionUpdate.LoadMigrationScripts</c> 扫的是
+/// <c>AppContext.BaseDirectory/UpdateScripts</c>,也就是**输出目录**。
+/// 漏登记的脚本不会被复制过去,于是它「在仓库里、却永远不会执行」——
+/// 没有报错、没有日志、部署看起来一切正常,直到有人发现表结构或数据没变。
+/// 2026-09-28 的 1.0.589 就这么静默失效过一次:表和列是 SqlSugar CodeFirst 顺手建的,
+/// 脚本里的数据迁移部分一行都没跑。</para>
+///
+/// <para>已有 <c>S8LegacyNotifyDataRetirementTests.Csproj_RegistersScriptsAndVersionIsAtLeastScriptVersion</c>
+/// 守着单个版本;本组把它推广到全部脚本,新增脚本无需再各写一条。</para>
+/// </summary>
+public class MigrationScriptRegistrationTests
+{
+    /// <summary>
+    /// 2026-09-28 本组守卫首次引入时**已经存在**的未登记脚本,即从未被
+    /// <c>AutoVersionUpdate</c> 执行过的脚本。列在这里只是为了让守卫能卡住**新增**遗漏,
+    /// <b>不代表它们没问题</b>。
+    ///
+    /// <para>为什么不顺手补登记:补上之后,这 11 个从未跑过的迁移会在下一次启动时
+    /// 对现网库依次执行(含 <c>1.0.463</c> 的 <c>BEFORE UPDATE</c> 触发器、
+    /// <c>1.0.257</c> 的 29 张 <c>qms_*</c> 加列)。其中至少 <c>1.0.257</c> 自己注明
+    /// 「Applied on aidopdev 2026-07-23」——是走配套 Python 手工执行的,
+    /// 也就是说这些脚本各自的「该不该自动跑」需要逐个判断,不是一次批量登记能解决的。
+    /// 故此处只做登记留痕,处置另开任务。</para>
+    ///
+    /// <para><b>名单只允许变短,不允许变长</b>:逐个核实并处置后从本列表删除;
+    /// 若有人往里加新项,说明又漏登记了一个,应该去补 csproj 而不是加白名单。</para>
+    /// </summary>
+    private static readonly string[] KnownUnregisteredAsOf20260928 =
+    [
+        "1.0.208.sql", "1.0.212.sql",
+        "1.0.257.sql", "1.0.258.sql", "1.0.259.sql", "1.0.260.sql",
+        "1.0.261.sql", "1.0.262.sql", "1.0.263.sql", "1.0.264.sql",
+        "1.0.463.sql",
+    ];
+
+    [Fact]
+    public void EveryVersionedScript_IsRegisteredInCsproj()
+    {
+        var csproj = File.ReadAllText(CsprojPath());
+        var missing = new List<string>();
+
+        foreach (var file in VersionedScripts())
+        {
+            var name = Path.GetFileName(file);
+            if (csproj.Contains($@"<None Update=""UpdateScripts\{name}"">", StringComparison.Ordinal))
+                continue;
+            if (KnownUnregisteredAsOf20260928.Contains(name, StringComparer.OrdinalIgnoreCase))
+                continue;
+            missing.Add(name);
+        }
+
+        Assert.True(missing.Count == 0,
+            "以下迁移脚本未在 csproj 登记 CopyToOutputDirectory,不会被复制到输出目录,"
+            + "AutoVersionUpdate 永远扫不到它们(静默失效):" + string.Join(", ", missing));
+    }
+
+    /// <summary>
+    /// 已知名单里的项必须真的还没登记。补登记后应从名单里删掉,
+    /// 否则名单会腐烂成一份没人敢动的陈旧白名单。
+    /// </summary>
+    [Fact]
+    public void KnownUnregisteredList_HasNoStaleEntries()
+    {
+        var csproj = File.ReadAllText(CsprojPath());
+        var stale = KnownUnregisteredAsOf20260928
+            .Where(n => csproj.Contains($@"<None Update=""UpdateScripts\{n}"">", StringComparison.Ordinal))
+            .ToList();
+
+        Assert.True(stale.Count == 0,
+            "以下脚本已补登记,请从 KnownUnregisteredAsOf20260928 中删除:" + string.Join(", ", stale));
+    }
+
+    /// <summary>
+    /// 版本号必须不低于最大脚本编号,否则 <c>AutoVersionUpdate</c> 不会应用尾部那几个脚本。
+    /// </summary>
+    [Fact]
+    public void AssemblyVersion_IsAtLeastMaxScriptVersion()
+    {
+        var csproj = File.ReadAllText(CsprojPath());
+        var m = Regex.Match(csproj, @"<Version>([\d.]+)</Version>");
+        Assert.True(m.Success, "csproj 未找到 <Version>");
+
+        var appVersion = Version.Parse(m.Groups[1].Value);
+        var maxScript = VersionedScripts()
+            .Select(f => Version.Parse(Path.GetFileNameWithoutExtension(f)))
+            .DefaultIfEmpty(new Version(0, 0, 0))
+            .Max();
+
+        Assert.True(appVersion >= maxScript,
+            $"csproj 版本 {appVersion} 低于最大脚本编号 {maxScript},AutoVersionUpdate 不会应用它");
+    }
+
+    /// <summary>
+    /// 每个 <c>*.verify.sql</c> 都必须有同号的主脚本,并且同样要登记。
+    /// 只登记主脚本、漏掉 verify,会让部署后断言静默跳过。
+    /// </summary>
+    [Fact]
+    public void EveryVerifyScript_HasSiblingAndIsRegistered()
+    {
+        var csproj = File.ReadAllText(CsprojPath());
+        var problems = new List<string>();
+
+        foreach (var file in Directory.GetFiles(ScriptsDir(), "*.verify.sql"))
+        {
+            var name = Path.GetFileName(file);
+            var version = name[..^".verify.sql".Length];
+
+            if (!File.Exists(Path.Combine(ScriptsDir(), $"{version}.sql")))
+                problems.Add($"{name}(缺同号主脚本)");
+            if (!csproj.Contains($@"<None Update=""UpdateScripts\{name}"">", StringComparison.Ordinal))
+                problems.Add($"{name}(未登记)");
+        }
+
+        Assert.True(problems.Count == 0, "verify 脚本问题:" + string.Join(", ", problems));
+    }
+
+    /// <summary>
+    /// 与 <c>AutoVersionUpdate.LoadMigrationScripts</c> 的筛选口径保持一致:
+    /// 排除 <c>*.verify.sql</c>,只取文件名能解析成版本号的(WIP 占位名不参与)。
+    /// </summary>
+    private static IEnumerable<string> VersionedScripts() =>
+        Directory.GetFiles(ScriptsDir(), "*.sql", SearchOption.TopDirectoryOnly)
+            .Where(f => !Path.GetFileName(f).EndsWith(".verify.sql", StringComparison.OrdinalIgnoreCase))
+            .Where(f => Version.TryParse(Path.GetFileNameWithoutExtension(f), out _));
+
+    private static string ScriptsDir() =>
+        Path.Combine(EntryDir(), "UpdateScripts");
+
+    private static string CsprojPath() =>
+        Path.Combine(EntryDir(), "Admin.NET.Web.Entry.csproj");
+
+    private static string EntryDir()
+    {
+        var dir = new DirectoryInfo(AppContext.BaseDirectory);
+        while (dir != null && !File.Exists(Path.Combine(dir.FullName, "AGENTS.md")))
+            dir = dir.Parent;
+        Assert.NotNull(dir);
+        return Path.Combine(dir!.FullName, "server", "Admin.NET.Web.Entry");
+    }
+}

+ 39 - 0
server/Plugins/Admin.NET.Plugin.AiDOP.Tests/DataPlatform/ModuleRebuildServiceTests.cs

@@ -684,6 +684,45 @@ public class ModuleRebuildServiceTests
             }
         }
 
+        public Task<int> CancelUnstartedByTriggerAsync(string triggerType, TimeSpan olderThan, string reason, CancellationToken ct = default)
+        {
+            lock (_gate)
+            {
+                var trigger = triggerType?.Trim().ToUpperInvariant();
+                var cutoff = DateTime.Now - olderThan;
+                var hits = Rows.Where(x => x.Status == ModuleRebuildStatus.Queued
+                        && string.Equals(x.TriggerType, trigger, StringComparison.Ordinal)
+                        && x.RequestedBy == null
+                        && (olderThan <= TimeSpan.Zero || x.SubmittedAt < cutoff))
+                    .ToList();
+                foreach (var row in hits)
+                {
+                    row.Status = ModuleRebuildStatus.Cancelled;
+                    row.CurrentStage = ModuleRebuildStages.Cancelled;
+                    row.FinishedAt = DateTime.Now;
+                    row.ErrorMessage = reason;
+                }
+                return Task.FromResult(hits.Count);
+            }
+        }
+
+        public Task<int> FailStaleQueuedAsync(TimeSpan staleAfter, string reason, CancellationToken ct = default)
+        {
+            lock (_gate)
+            {
+                var cutoff = DateTime.Now - staleAfter;
+                var hits = Rows.Where(x => x.Status == ModuleRebuildStatus.Queued && x.SubmittedAt < cutoff).ToList();
+                foreach (var row in hits)
+                {
+                    row.Status = ModuleRebuildStatus.Failed;
+                    row.CurrentStage = ModuleRebuildStages.Failed;
+                    row.FinishedAt = DateTime.Now;
+                    row.ErrorMessage = reason;
+                }
+                return Task.FromResult(hits.Count);
+            }
+        }
+
         public Task<AdoModuleDashboardRebuildJob> GetByIdAsync(string moduleCode, long id, long tenantId, long factoryId, CancellationToken ct = default)
         {
             lock (_gate)

+ 109 - 11
server/Plugins/Admin.NET.Plugin.AiDOP.Tests/Infrastructure/AidopJobGateTests.cs

@@ -1,5 +1,6 @@
 using System.Reflection;
 using Admin.NET.Plugin.AiDOP.DataPlatform.MdpRebuild;
+using Admin.NET.Plugin.AiDOP.Entity.SmartOps;
 using Admin.NET.Plugin.AiDOP.Infrastructure;
 using Furion.Schedule;
 using Xunit;
@@ -119,24 +120,121 @@ public class AidopJobGateTests : IDisposable
     // ───────────────────── 3. 指派唯一性由写入侧保证 ─────────────────────
 
     /// <summary>
-    /// 「最多一台执行机」靠 <c>AssignRunnerAsync</c> 在同一事务内先清后置。
-    /// MySQL 无部分索引,数据库层面表达不了这条约束;拆成两条自动提交语句,
-    /// 中间那一瞬会出现零台或(并发时)两台执行机。
+    /// 「最多一台执行机」现在由 <c>ado_etl_runner_designation</c> 的**结构**保证:
+    /// 主键固定为 <see cref="AdoEtlRunnerDesignation.SingletonId"/>,表里放不下第二行。
+    ///
+    /// <para>这条不变式没有被放弃,而是被更强的机制取代了。旧实现靠
+    /// <c>AssignRunnerAsync</c> 在同一事务内先清后置——那依赖调用方守规矩,
+    /// 且指派寄生在实例行上,进程重启就会丢(2026-09-28 的队列孤立事故)。
+    /// 现在并发指派会被主键与条件更新挡住。**不要改成按 instanceId 指派**。</para>
     /// </summary>
     [Fact]
-    public void AssignRunner_ClearsOthersInSameTransaction()
+    public void RunnerDesignation_IsStructurallyUnique()
     {
-        Assert.NotNull(typeof(IEtlInstanceStore).GetMethod(nameof(IEtlInstanceStore.AssignRunnerAsync)));
+        Assert.NotNull(typeof(IEtlInstanceStore).GetMethod(nameof(IEtlInstanceStore.AssignRunnerSlotAsync)));
+        Assert.NotNull(typeof(IEtlInstanceStore).GetMethod(nameof(IEtlInstanceStore.GetDesignatedSlotAsync)));
+
+        // 指派必须接槽位,不得退回按实例指派
+        var assign = typeof(IEtlInstanceStore).GetMethod(nameof(IEtlInstanceStore.AssignRunnerSlotAsync))!;
+        Assert.Contains(assign.GetParameters(), p => p.Name == "slotCode");
+        Assert.DoesNotContain(assign.GetParameters(), p => p.Name == "instanceId");
 
         var src = ReadPluginSource("Infrastructure/EtlInstanceStore.cs");
-        Assert.Contains("UseTranAsync", src, StringComparison.Ordinal);
+        Assert.Contains("AdoEtlRunnerDesignation.SingletonId", src, StringComparison.Ordinal);
+
+        // 每一处读写都必须钉在那唯一一行上;漏掉 Where 会写出/读到第二行
+        var singletonRefs = src.Split("AdoEtlRunnerDesignation.SingletonId").Length - 1;
+        Assert.True(singletonRefs >= 3, $"引用单行主键的位置只有 {singletonRefs} 处,读写路径可能有遗漏");
+    }
+
+    /// <summary>
+    /// 指派必须落在跨重启稳定的槽位上,不得再挂回进程级标识。
+    ///
+    /// <para><b>根因守卫</b>:<c>instance_id</c> 是「机器名:进程ID:启动时间」,
+    /// 把角色存在它上面意味着执行机每重启一次就必然丢指派、全库零台存活执行机、
+    /// 定时 ETL 静默停摆。2026-09-28 实测 28 条 AUTO_NIGHTLY 无人可领即由此而来。
+    /// 若有人把判定改回读 <c>ado_etl_instance.is_runner</c>,这条测试必须变红。</para>
+    /// </summary>
+    [Fact]
+    public void Registrar_ResolvesRunnerBySlot_NotByInstanceId()
+    {
+        var src = ReadPluginSource("Infrastructure/EtlInstanceRegistrar.cs");
+
+        Assert.Contains("GetDesignatedSlotAsync", src, StringComparison.Ordinal);
+        Assert.Contains("AidopInstanceIdentity.SlotCode", src, StringComparison.Ordinal);
+
+        // 回读 is_runner 就是旧模型,必须彻底断开
+        Assert.DoesNotContain("x.IsRunner)", src, StringComparison.Ordinal);
+        Assert.DoesNotContain("Select(x => x.IsRunner", src, StringComparison.Ordinal);
+    }
+
+    /// <summary>
+    /// 未声明槽位的进程永不是执行机。
+    ///
+    /// <para>这比旧模型更严:旧模型下任何注册上来的进程都能被指派,
+    /// 开发机被指派正是 2026-09-24 连接耗尽事故的成因之一。
+    /// 判定里那条「槽位为空直接返回 false」的短路不得删除。</para>
+    /// </summary>
+    [Fact]
+    public void Registrar_SlotlessInstance_CanNeverBeRunner()
+    {
+        var src = ReadPluginSource("Infrastructure/EtlInstanceRegistrar.cs");
+        Assert.Contains("string.IsNullOrEmpty(slot)", src, StringComparison.Ordinal);
+
+        // 槽位比对必须是精确相等,不能退化成「包含」或大小写不敏感的模糊匹配
+        Assert.Contains("string.Equals(slot, designated, StringComparison.Ordinal)", src, StringComparison.Ordinal);
+    }
+
+    /// <summary>
+    /// 同槽位多个存活实例时失败关闭(两边都不跑),而不是抢着跑。
+    ///
+    /// <para>没有租约就没有仲裁者,此时抢着跑等于双跑全量——那正是 2026-09-24
+    /// 连接耗尽的成因。代价只是滚动重启重叠的几十秒空窗。
+    /// 若将来要改成自动接管,必须先引入租约,不能简单地把这个判断删掉。</para>
+    /// </summary>
+    [Fact]
+    public void Registrar_SameSlotConflict_FailsClosed()
+    {
+        var src = ReadPluginSource("Infrastructure/EtlInstanceRegistrar.cs");
+        Assert.Contains("others == 0", src, StringComparison.Ordinal);
+        Assert.Contains("x.InstanceId != instanceId", src, StringComparison.Ordinal);
+    }
+
+    /// <summary>
+    /// 夜间自清与 QUEUED 回收都不得误伤页面手工任务。
+    ///
+    /// <para><c>ModuleRebuildTriggerType.IsManualTrigger</c> 把带 <c>requested_by</c>
+    /// 的行也算人工触发,所以批量收口的 WHERE 必须同时带 <c>RequestedBy == null</c>;
+    /// 少了这一支,超管在监控页发起的重算会被夜间作业清掉。</para>
+    /// </summary>
+    [Fact]
+    public void BulkCancel_NeverTouchesManualOrRunning()
+    {
+        var store = typeof(IModuleRebuildJobStore);
+        Assert.NotNull(store.GetMethod("CancelUnstartedByTriggerAsync"));
+        Assert.NotNull(store.GetMethod("FailStaleQueuedAsync"));
 
-        var tranIdx = src.IndexOf("UseTranAsync", StringComparison.Ordinal);
-        var clearIdx = src.IndexOf("x.IsRunner && x.InstanceId != id", StringComparison.Ordinal);
-        var setIdx = src.IndexOf("IsRunner = true", StringComparison.Ordinal);
+        var src = ReadPluginSource("DataPlatform/MdpRebuild/ModuleRebuildStore.cs");
+        // 锚在实现上,不能只匹配方法名:接口声明与实现签名同文,先命中声明会读到空的方法体
+        var idx = src.IndexOf("public async Task<int> CancelUnstartedByTriggerAsync", StringComparison.Ordinal);
+        Assert.True(idx > 0, "找不到批量收口的实现,测试的定位串需同步更新");
 
-        Assert.True(clearIdx > tranIdx, "清空其余指派必须发生在事务内");
-        Assert.True(setIdx > clearIdx, "必须先清后置,否则会短暂出现两台执行机");
+        var body = src[idx..Math.Min(src.Length, idx + 1600)];
+        Assert.Contains("x.Status == ModuleRebuildStatus.Queued", body, StringComparison.Ordinal);
+        Assert.Contains("x.RequestedBy == null", body, StringComparison.Ordinal);
+    }
+
+    /// <summary>
+    /// QUEUED 收口阈值必须给足。<c>GlobalMaxParallelScopes = 1</c> 且单 scope 跑 10~20 分钟,
+    /// 夜间扇出 28 个 scope 时队尾**合法**等待可达 7 小时,阈值定短了会误杀正常排队。
+    /// </summary>
+    [Fact]
+    public void QueuedStaleThreshold_LeavesRoomForLegitimateWaiting()
+    {
+        Assert.True(ModuleRebuildLock.QueuedStaleAfter >= TimeSpan.FromHours(12),
+            "QUEUED 收口阈值不足 12 小时,夜间全量队尾会被误杀;改小前请先核算 scope 数 × 单次耗时");
+        Assert.True(ModuleRebuildLock.QueuedStaleAfter > ModuleRebuildLock.StaleAfter,
+            "QUEUED 阈值必须明显宽于 RUNNING 无心跳阈值:前者是排队等待,后者是进程已死");
     }
 
     /// <summary>注册器只报到、不自荐。自动选举已在 B-D1 明确否决。</summary>

+ 10 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/DataPlatform/MdpRebuild/ModuleRebuildLock.cs

@@ -13,6 +13,16 @@ public interface IModuleRebuildLock
 public sealed class ModuleRebuildLock : IModuleRebuildLock, ITransient
 {
     public static readonly TimeSpan StaleAfter = TimeSpan.FromMinutes(30);
+
+    /// <summary>
+    /// QUEUED 滞留多久后判定为「已无消费可能」并收口为 FAILED。
+    ///
+    /// <para><b>为什么是 24 小时而不是更短</b>:<c>GlobalMaxParallelScopes = 1</c> 且单 scope
+    /// 跑 10~20 分钟,夜间全量一次扇出 28 个 scope 时,队尾那个**合法**等待可达 7 小时左右。
+    /// 阈值取到日级才能确保「超时」只捕获真正无人消费的行(指派丢失、Worker 全停),
+    /// 不会误杀正常排队。改小前请先核算当前 scope 数 × 单次耗时。</para>
+    /// </summary>
+    public static readonly TimeSpan QueuedStaleAfter = TimeSpan.FromHours(24);
     private readonly ISqlSugarClient _db;
 
     public ModuleRebuildLock(ISqlSugarClient db) => _db = db;

+ 21 - 1
server/Plugins/Admin.NET.Plugin.AiDOP/DataPlatform/MdpRebuild/ModuleRebuildService.cs

@@ -166,9 +166,29 @@ public sealed class ModuleRebuildService : ITransient
         return row == null ? null : ToDto(row);
     }
 
-    public async Task FailStaleAsync(CancellationToken ct = default) =>
+    /// <summary>
+    /// 陈旧任务收口:RUNNING 无心跳、以及 QUEUED 长期无人消费,两类都要捞。
+    ///
+    /// <para>QUEUED 那一支是 2026-09-28 补的:非执行机只领手工任务,所以执行机指派一丢,
+    /// 自动任务在队列里没有任何消费者,却会一直挡着同 scope 的后续入队。
+    /// 只扫 RUNNING 的旧实现看不见这种滞留。</para>
+    /// </summary>
+    public async Task FailStaleAsync(CancellationToken ct = default)
+    {
         await _jobs.FailStaleRunningAsync(ModuleRebuildLock.StaleAfter, ct);
 
+        var reaped = await _jobs.FailStaleQueuedAsync(
+            ModuleRebuildLock.QueuedStaleAfter,
+            $"排队超过 {ModuleRebuildLock.QueuedStaleAfter.TotalHours:0} 小时仍无人领取,已收口;"
+            + "常见成因是 ETL 执行机未指派或指派已失效",
+            ct);
+        if (reaped > 0)
+            _logger.LogWarning(
+                "[ModuleRebuild] 收口长期滞留的 QUEUED 任务 {Count} 条(阈值 {Hours} 小时)。"
+                + "请检查 MDP 运行监控页是否存在「零台存活执行机」",
+                reaped, ModuleRebuildLock.QueuedStaleAfter.TotalHours);
+    }
+
     public async Task TouchHeartbeatAsync(long jobId, CancellationToken ct = default)
     {
         try

+ 70 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/DataPlatform/MdpRebuild/ModuleRebuildStore.cs

@@ -68,6 +68,33 @@ public interface IModuleRebuildJobStore
     /// 调用方必须据此退回 409,不得把正在跑的任务掀掉。</para>
     /// </summary>
     Task<bool> TrySupersedeQueuedAsync(long jobId, string reason, CancellationToken ct = default);
+    /// <summary>
+    /// 批量把某个自动触发类型下**仍未开跑**的任务收口为 CANCELLED,返回收口行数。
+    ///
+    /// <para><b>只碰 <c>QUEUED</c> 且 <c>requested_by IS NULL</c> 的行</b>:
+    /// 条件里带 <c>trigger_type</c> 是为了让调用方只清自己那一类,
+    /// 带 <c>requested_by IS NULL</c> 是为了绝不误伤页面手工任务
+    /// (<c>ModuleRebuildTriggerType.IsManualTrigger</c> 把带 <c>requested_by</c> 的行也算人工)。
+    /// RUNNING 一律不碰。</para>
+    ///
+    /// <para><b>刻意跨租户</b>:调用方是夜间全量作业,它本来就一次性扇出全部启用 scope,
+    /// 清的也正是自己上一轮那一批。不要给这里加 tenant 过滤,否则只能清掉一个租户。</para>
+    /// </summary>
+    /// <param name="triggerType">要清理的触发类型,如 <c>AUTO_NIGHTLY</c>。</param>
+    /// <param name="olderThan">仅清理入队时间早于 now - olderThan 的行;<c>TimeSpan.Zero</c> 表示不限。</param>
+    Task<int> CancelUnstartedByTriggerAsync(string triggerType, TimeSpan olderThan, string reason, CancellationToken ct = default);
+    /// <summary>
+    /// 把长期滞留在 QUEUED、已无消费可能的任务收口为 FAILED,返回收口行数。
+    ///
+    /// <para><b>为什么 <see cref="FailStaleRunningAsync"/> 不够</b>:它只管 RUNNING。
+    /// QUEUED 的滞留有独立成因——非执行机只领手工任务,所以指派一丢,
+    /// 自动任务在队列里没有任何消费者,却会一直挡着同 scope 的后续入队。</para>
+    ///
+    /// <para><b>阈值必须给足</b>:<c>GlobalMaxParallelScopes = 1</c> 且单 scope 跑 10~20 分钟,
+    /// 夜间一次扇出 28 个 scope 时,最后一个**合法**等待可达 7 小时左右。
+    /// 阈值定短了会把正常排队的任务误杀,见 <c>ModuleRebuildLock.QueuedStaleAfter</c>。</para>
+    /// </summary>
+    Task<int> FailStaleQueuedAsync(TimeSpan staleAfter, string reason, CancellationToken ct = default);
     /// <summary>该 scope 最近一条 SUCCESS,用于 AUTO 冷却判定。没有则返回 null。</summary>
     Task<AdoModuleDashboardRebuildJob> FindLastSuccessAsync(string moduleCode, long tenantId, long factoryId, CancellationToken ct = default);
     Task<AdoModuleDashboardRebuildJob> GetByIdAsync(string moduleCode, long id, long tenantId, long factoryId, CancellationToken ct = default);
@@ -143,6 +170,49 @@ public sealed class ModuleRebuildJobStore : IModuleRebuildJobStore, ITransient
         return n > 0;
     }
 
+    public async Task<int> CancelUnstartedByTriggerAsync(string triggerType, TimeSpan olderThan, string reason, CancellationToken ct = default)
+    {
+        if (string.IsNullOrWhiteSpace(triggerType)) return 0;
+        var trigger = triggerType.Trim().ToUpperInvariant();
+        var now = DateTime.Now;
+        var cutoff = now - olderThan;
+
+        return await _db.Updateable<AdoModuleDashboardRebuildJob>()
+            .SetColumns(x => new AdoModuleDashboardRebuildJob
+            {
+                Status = ModuleRebuildStatus.Cancelled,
+                CurrentStage = ModuleRebuildStages.Cancelled,
+                FinishedAt = now,
+                ErrorMessage = reason,
+                UpdateTime = now
+            })
+            // requested_by IS NULL 这一支不可省:带 requested_by 的行按
+            // ModuleRebuildTriggerType.IsManualTrigger 算人工触发,清了就是误伤页面按钮。
+            .Where(x => x.Status == ModuleRebuildStatus.Queued
+                && x.TriggerType == trigger
+                && x.RequestedBy == null)
+            .WhereIF(olderThan > TimeSpan.Zero, x => x.SubmittedAt < cutoff)
+            .ExecuteCommandAsync(ct);
+    }
+
+    public async Task<int> FailStaleQueuedAsync(TimeSpan staleAfter, string reason, CancellationToken ct = default)
+    {
+        var now = DateTime.Now;
+        var cutoff = now - staleAfter;
+
+        return await _db.Updateable<AdoModuleDashboardRebuildJob>()
+            .SetColumns(x => new AdoModuleDashboardRebuildJob
+            {
+                Status = ModuleRebuildStatus.Failed,
+                CurrentStage = ModuleRebuildStages.Failed,
+                FinishedAt = now,
+                ErrorMessage = reason,
+                UpdateTime = now
+            })
+            .Where(x => x.Status == ModuleRebuildStatus.Queued && x.SubmittedAt < cutoff)
+            .ExecuteCommandAsync(ct);
+    }
+
     public Task<AdoModuleDashboardRebuildJob> FindLastSuccessAsync(string moduleCode, long tenantId, long factoryId, CancellationToken ct = default) =>
         _db.Queryable<AdoModuleDashboardRebuildJob>()
             .Where(x => x.ModuleCode == moduleCode && x.TenantId == tenantId && x.FactoryId == factoryId

+ 19 - 6
server/Plugins/Admin.NET.Plugin.AiDOP/Entity/SmartOps/AdoEtlInstance.cs

@@ -2,14 +2,14 @@ namespace Admin.NET.Plugin.AiDOP.Entity.SmartOps;
 
 /// <summary>
 /// ETL 执行机实例注册表。每个连本库的后端进程注册一行并定期心跳,
-/// <c>is_runner=1</c> 的那一行是当前被指派跑定时 ETL 的实例。
+/// 用于运维在页面上看清「有哪些进程连着这个库、各自属于哪个槽位、还活着没」。
+///
+/// <para><b>本表不再持有执行机指派</b>:指派在 <c>ado_etl_runner_designation</c>,
+/// 指向的是 <see cref="SlotCode"/> 而不是某一行实例。原因见该表注释——
+/// <c>instance_id</c> 是进程级标识,把角色挂上去会让每次重启都丢指派。</para>
 ///
 /// <para><b>无租户列是刻意的</b>:实例是部署事实,不属于任何租户。
 /// 本类不实现租户过滤接口,SqlSugar 不会给它加租户条件。</para>
-///
-/// <para><b>指派唯一性由写入侧保证</b>:置某行 <c>is_runner=1</c> 时必须在同一事务内
-/// 把其余行置 0。不要依赖前端或唯一索引——「最多一行为 1」在 MySQL 里没有
-/// 直接可用的约束表达(部分索引不支持)。</para>
 /// </summary>
 [SugarTable("ado_etl_instance", "ETL执行机实例注册")]
 public class AdoEtlInstance
@@ -30,7 +30,20 @@ public class AdoEtlInstance
     [SugarColumn(ColumnName = "app_version", Length = 64, IsNullable = true)]
     public string AppVersion { get; set; }
 
-    /// <summary>是否被指派为执行机。**只由人工指派写入**,注册器不会自行置 1。</summary>
+    /// <summary>
+    /// 本进程声明所属的部署槽位(来自 <c>AIDOP_ETL_SLOT</c> / <c>AiDOP:Jobs:Slot</c>),
+    /// 未声明为 null。执行机判定就是拿它和指派表里的槽位比对。
+    /// </summary>
+    [SugarColumn(ColumnName = "slot_code", Length = 128, IsNullable = true)]
+    public string SlotCode { get; set; }
+
+    /// <summary>
+    /// 遗留列,**不要再读写**。指派已迁到 <c>ado_etl_runner_designation.slot_code</c>,
+    /// 执行机身份请用 <c>AidopJobGate.IsRunner</c> 或页面返回的派生值。
+    ///
+    /// <para>保留一个版本而不立即删列:升级窗口内可能仍有旧二进制在跑,
+    /// 它们会继续读这一列;待全部实例升到槽位模型后再单独发版删除。</para>
+    /// </summary>
     [SugarColumn(ColumnName = "is_runner")]
     public bool IsRunner { get; set; }
 

+ 51 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Entity/SmartOps/AdoEtlRunnerDesignation.cs

@@ -0,0 +1,51 @@
+namespace Admin.NET.Plugin.AiDOP.Entity.SmartOps;
+
+/// <summary>
+/// ETL 执行机指派。全表**只有一行**,记录当前被指派跑定时 ETL 的**槽位**。
+///
+/// <para><b>为什么指派不能存在 <c>ado_etl_instance</c> 上</b>:实例标识是
+/// <c>机器名:进程ID:启动时间</c>(见 <c>AidopInstanceIdentity</c>,进程级唯一是刻意的),
+/// 于是「指派」一旦挂在实例行上,就绑死在一次进程化身上——执行机每重启一次,
+/// 指派必然留在一行死记录上,全库变成零台存活执行机,定时 ETL 静默停摆。
+/// 2026-09-28 实测即是此情形:03:40 扇出 28 条 AUTO_NIGHTLY 的实例 04:10 退出,
+/// 队列此后无人可领。角色是部署槽位的属性,不是进程化身的属性,故独立成表。</para>
+///
+/// <para><b>「最多一台执行机」由结构保证</b>:主键固定为 <see cref="SingletonId"/>,
+/// 表里放不下第二行,无需再靠事务先清后置。这比原来的写入侧约束更强——
+/// 并发指派会被主键挡住,而不是依赖调用方守规矩。</para>
+///
+/// <para><b>无租户列是刻意的</b>:指派是部署事实,不属于任何租户。
+/// 本类不实现租户过滤接口,SqlSugar 不会给它加租户条件。</para>
+/// </summary>
+[SugarTable("ado_etl_runner_designation", "ETL执行机指派")]
+public class AdoEtlRunnerDesignation
+{
+    /// <summary>全表唯一一行的主键值。</summary>
+    public const long SingletonId = 1;
+
+    [SugarColumn(ColumnName = "id", IsPrimaryKey = true, IsIdentity = false)]
+    public long Id { get; set; } = SingletonId;
+
+    /// <summary>
+    /// 被指派的槽位码,与进程侧 <c>AidopInstanceIdentity.SlotCode</c> 比对。
+    /// 为 null / 空表示「当前无指派」——此后无人跑定时 ETL,这是允许的状态。
+    /// </summary>
+    [SugarColumn(ColumnName = "slot_code", Length = 128, IsNullable = true)]
+    public string SlotCode { get; set; }
+
+    /// <summary>操作人账号,仅供审计与页面展示。</summary>
+    [SugarColumn(ColumnName = "assigned_by", Length = 64, IsNullable = true)]
+    public string AssignedBy { get; set; }
+
+    [SugarColumn(ColumnName = "assigned_at", IsNullable = true)]
+    public DateTime? AssignedAt { get; set; }
+
+    [SugarColumn(ColumnName = "remark", Length = 512, IsNullable = true)]
+    public string Remark { get; set; }
+
+    [SugarColumn(ColumnName = "create_time")]
+    public DateTime CreateTime { get; set; }
+
+    [SugarColumn(ColumnName = "update_time")]
+    public DateTime UpdateTime { get; set; }
+}

+ 45 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Infrastructure/AidopInstanceIdentity.cs

@@ -14,6 +14,10 @@ namespace Admin.NET.Plugin.AiDOP.Infrastructure;
 /// <para><b>为什么用启动时间而不是随机 GUID</b>:运维要在页面上认出「这是哪台、什么时候起的」。
 /// GUID 每次重启都变且不可读,排障时无法和服务器上的进程对上。</para>
 ///
+/// <para><b>身份与角色是两件事</b>:<see cref="InstanceId"/> 只回答「我是哪次进程」,
+/// 执行机角色则指派给 <see cref="SlotCode"/> 这个跨重启稳定的部署槽位
+/// (见 <c>AdoEtlRunnerDesignation</c>)。把角色挂在进程级标识上会让每次重启都丢指派。</para>
+///
 /// <para>静态只读,进程生命周期内不变,可在任意线程反复读取。</para>
 /// </summary>
 public static class AidopInstanceIdentity
@@ -46,6 +50,47 @@ public static class AidopInstanceIdentity
     /// <summary>入口程序集版本,仅供页面展示与排障;取不到为 null。</summary>
     public static string AppVersion { get; }
 
+    /// <summary>配置键:本进程所属的 ETL 执行机槽位。</summary>
+    public const string SlotConfigKey = "AiDOP:Jobs:Slot";
+
+    /// <summary>环境变量名:优先级高于配置,便于容器与服务单元各自声明。</summary>
+    public const string SlotEnvName = "AIDOP_ETL_SLOT";
+
+    /// <summary>
+    /// 本进程所属的执行机槽位;未声明则为 null,**此时本进程永远不可能成为执行机**。
+    ///
+    /// <para>这比旧模型更严:旧模型下任何一台注册上来的进程都能被指派,
+    /// 开发机被指派正是 2026-09-24 连接耗尽事故的成因之一。未声明槽位者连候选都不是。</para>
+    ///
+    /// <para><b>槽位不同于「配置里写自己是不是执行机」</b>:配置只声明身份(我是哪个槽位),
+    /// 唯一权威仍在库里那条指派记录。这保留了 2026-09-25 治理的核心——
+    /// 单一开关、页面可见、热切换无需重启——只是让指派能跨重启存活。</para>
+    ///
+    /// <para><b>为什么惰性求值</b>:静态构造可能早于宿主配置就绪,
+    /// 那时读 <c>App.GetConfig</c> 会抛,若在构造里一次性定下来就会永久退化成 null。
+    /// 首次访问来自心跳(启动后 5 秒),此时配置一定已就绪。</para>
+    /// </summary>
+    public static string SlotCode => _slotCode.Value;
+
+    private static readonly Lazy<string> _slotCode = new(ResolveSlotCode, LazyThreadSafetyMode.ExecutionAndPublication);
+
+    private static string ResolveSlotCode()
+    {
+        var fromEnv = SafeGet(() => Environment.GetEnvironmentVariable(SlotEnvName), null);
+        if (!string.IsNullOrWhiteSpace(fromEnv))
+            return Normalize(fromEnv);
+
+        var fromConfig = SafeGet(() => Furion.App.GetConfig<string>(SlotConfigKey, true), null);
+        return string.IsNullOrWhiteSpace(fromConfig) ? null : Normalize(fromConfig);
+    }
+
+    /// <summary>槽位码要能被人工比对,故统一去空白并折成小写,长度受 128 字符列约束。</summary>
+    private static string Normalize(string raw)
+    {
+        var v = raw.Trim().ToLowerInvariant();
+        return v.Length > 128 ? v[..128] : v;
+    }
+
     /// <summary>
     /// 身份计算不得成为启动故障点:容器内取进程启动时间、受限环境取机器名都可能抛异常,
     /// 一旦抛出会让整个插件装配失败,而这里拿到的只是展示用信息。

+ 18 - 4
server/Plugins/Admin.NET.Plugin.AiDOP/Infrastructure/AidopJobGate.cs

@@ -24,10 +24,21 @@ namespace Admin.NET.Plugin.AiDOP.Infrastructure;
 ///
 /// <para><b>执行机身份来自数据库,不再来自配置</b>(2026-09-25 起):
 /// 每个进程由 <see cref="EtlInstanceRegistrar"/> 注册进 <c>ado_etl_instance</c> 并定期心跳,
-/// 其中 <c>is_runner=1</c> 的那一行是当前执行机。指派是**人工动作**
+/// 心跳时把「本进程是否为执行机」读回并发布到 <see cref="AidopRunnerState"/>。指派是**人工动作**
 /// (超管在 MDP 运行监控页操作,或直接改库),切换**不需要重启进程**,
 /// 最迟在 <see cref="AidopRunnerState.FreshnessWindow"/> 内生效。</para>
 ///
+/// <para><b>指派的对象是槽位,不是实例</b>(2026-09-28 起):指派存在
+/// <c>ado_etl_runner_designation.slot_code</c>,与进程侧 <c>AidopInstanceIdentity.SlotCode</c>
+/// (来自 <c>AIDOP_ETL_SLOT</c> / <c>AiDOP:Jobs:Slot</c>)比对。
+/// 原来指派存在 <c>ado_etl_instance.is_runner</c> 上,而 <c>instance_id</c> 是
+/// 「机器名:进程ID:启动时间」,于是**执行机每次重启都必然丢指派**、全库变成零台存活执行机,
+/// 定时 ETL 静默停摆——2026-09-28 实测 28 条 <c>AUTO_NIGHTLY</c> 无人可领即由此而来。
+/// 角色是部署槽位的属性,不是进程化身的属性。
+/// <b>未声明槽位的进程永不是执行机</b>,这比旧模型更严:旧模型下任何注册上来的进程都能被指派。
+/// 另需注意这不是退回 9-25 之前的配置模型——配置只声明**身份**(我是哪个槽位),
+/// 唯一**权威**仍是库里那条指派记录。</para>
+///
 /// <para><b>热路径不查库</b>:本类仍然是纯内存计算。指派位由注册器在心跳时
 /// 读回并发布到 <see cref="AidopRunnerState"/>,这里只读那个投影。
 /// 这一点是刻意的——<see cref="ShouldRun"/> 的调用面是 21 个 <c>IJob</c> 加 2 个 Worker,
@@ -113,9 +124,12 @@ public static class AidopJobGate
         if (!enabled && Interlocked.Exchange(ref _firstDisabledLogged, 1) == 0)
             logger?.LogInformation(
                 "本实例不是 ETL 执行机,AiDOP 定时作业与轮询后台全部跳过(首个:{Job})。"
-                + "页面上的手工重算不受影响。指派方式:在 MDP 运行监控页把 ado_etl_instance 中"
-                + " instanceId={InstanceId} 的 is_runner 置 1,无需重启进程",
-                jobName, AidopInstanceIdentity.InstanceId);
+                + "页面上的手工重算不受影响。本实例 instanceId={InstanceId} slot={Slot}。"
+                + "指派方式:在 MDP 运行监控页把该槽位指派为执行机,无需重启进程;"
+                + "若 slot 为「未声明」,须先给该进程配 {SlotEnv} 或 {SlotKey}",
+                jobName, AidopInstanceIdentity.InstanceId,
+                AidopInstanceIdentity.SlotCode ?? "(未声明)",
+                AidopInstanceIdentity.SlotEnvName, AidopInstanceIdentity.SlotConfigKey);
 
         return enabled;
     }

+ 80 - 21
server/Plugins/Admin.NET.Plugin.AiDOP/Infrastructure/EtlInstanceRegistrar.cs

@@ -7,13 +7,24 @@ namespace Admin.NET.Plugin.AiDOP.Infrastructure;
 
 /// <summary>
 /// ETL 执行机实例注册器:把本进程登记进 <c>ado_etl_instance</c> 并定期心跳,
-/// 同时把本实例的指派位发布到 <see cref="AidopRunnerState"/> 供 <see cref="AidopJobGate"/> 读取。
+/// 同时把本实例是否为执行机发布到 <see cref="AidopRunnerState"/> 供 <see cref="AidopJobGate"/> 读取。
 ///
-/// <para><b>只读指派、从不自行指派</b>:本注册器插入新行时 <c>is_runner</c> 恒为 0。
-/// 指派是人工动作(超管在 MDP 运行监控页操作,或直接改库)。
+/// <para><b>判定方式:槽位比对</b>。本进程声明自己属于哪个部署槽位
+/// (<c>AidopInstanceIdentity.SlotCode</c>),指派记在 <c>ado_etl_runner_designation</c>;
+/// 两者相等即为执行机。<b>未声明槽位者永不是执行机</b>。
+/// 之所以不拿 <c>instance_id</c> 比对,是因为它含进程ID与启动时间,
+/// 指派挂上去每次重启都会丢——2026-09-28 的 28 条积压即由此而来。</para>
+///
+/// <para><b>只读指派、从不自行指派</b>:本注册器插入新行时 <c>is_runner</c> 恒为 0,
+/// 也绝不写指派表。指派是人工动作(超管在 MDP 运行监控页操作,或直接改库)。
 /// 禁止在这里实现「抢锁自荐」或「心跳超时自动接管」——
 /// 那会让开发机在某次重启后意外变成执行机,正是本次治理要消除的情形。</para>
 ///
+/// <para><b>同槽位撞车则两边都不跑</b>:若同一槽位另有存活实例(滚动重启的重叠窗口,
+/// 或有人把两个进程配成同一槽位),本进程发布 false 而不是照常跑。
+/// 没有租约就没有仲裁者,此时抢着跑等于双跑全量,那正是 2026-09-24 连接耗尽的成因;
+/// 代价只是重启重叠的几十秒空窗,定时作业不受影响。</para>
+///
 /// <para><b>本服务不受 <see cref="AidopJobGate"/> 管辖</b>,必须常开。
 /// 它是闸门的数据来源,被闸门关掉就成了死锁:没人心跳 → 没人发布指派 → 永远不是执行机。</para>
 /// </summary>
@@ -25,9 +36,12 @@ public sealed class EtlInstanceRegistrar : BackgroundService
     /// <summary>启动延时:等宿主配置与建表脚本就绪,避免首拍必然失败刷一条 warn。</summary>
     private static readonly TimeSpan StartupDelay = TimeSpan.FromSeconds(5);
 
-    /// <summary>下线实例行的保留期。超期且未被指派的行会被清理,防止表随重启无限增长。</summary>
+    /// <summary>下线实例行的保留期。超期且不在被指派槽位上的行会被清理,防止表随重启无限增长。</summary>
     private static readonly TimeSpan DeadInstanceRetention = TimeSpan.FromDays(7);
 
+    /// <summary>存活判据:连续三拍没心跳即视为下线,与 <see cref="AidopRunnerState.FreshnessWindow"/> 同宽。</summary>
+    private static readonly TimeSpan LivenessWindow = TimeSpan.FromSeconds(60);
+
     private readonly IServiceScopeFactory _scopeFactory;
     private readonly ILogger _logger;
     private int _consecutiveFailures;
@@ -45,26 +59,44 @@ public sealed class EtlInstanceRegistrar : BackgroundService
         catch (OperationCanceledException) { return; }
 
         _logger.LogInformation(
-            "[EtlInstanceRegistrar] 本实例 instanceId={InstanceId} machine={Machine} pid={Pid} version={Version}",
+            "[EtlInstanceRegistrar] 本实例 instanceId={InstanceId} machine={Machine} pid={Pid} version={Version} slot={Slot}",
             AidopInstanceIdentity.InstanceId, AidopInstanceIdentity.MachineName,
-            AidopInstanceIdentity.ProcessId, AidopInstanceIdentity.AppVersion);
+            AidopInstanceIdentity.ProcessId, AidopInstanceIdentity.AppVersion,
+            AidopInstanceIdentity.SlotCode ?? "(未声明)");
 
         var nextCleanupAt = DateTimeOffset.UtcNow;
+        var lastConflictLogged = false;
         while (!stoppingToken.IsCancellationRequested)
         {
             try
             {
-                var assigned = await HeartbeatAsync(stoppingToken);
-                AidopRunnerState.Publish(assigned);
+                var beat = await HeartbeatAsync(stoppingToken);
+                AidopRunnerState.Publish(beat.IsRunner);
+
+                // 撞车是配置事故或滚动重启重叠,两者都必须让人看见;
+                // 但每 20 秒一条会刷屏,故只在状态翻转时打。
+                if (beat.SlotConflict != lastConflictLogged)
+                {
+                    if (beat.SlotConflict)
+                        _logger.LogWarning(
+                            "[EtlInstanceRegistrar] 槽位撞车:slot={Slot} 另有 {Count} 个存活实例,"
+                            + "本实例暂不作为执行机(避免双跑全量)",
+                            AidopInstanceIdentity.SlotCode, beat.SameSlotLiveOthers);
+                    else
+                        _logger.LogInformation(
+                            "[EtlInstanceRegistrar] 槽位撞车已解除 slot={Slot} isRunner={IsRunner}",
+                            AidopInstanceIdentity.SlotCode, beat.IsRunner);
+                    lastConflictLogged = beat.SlotConflict;
+                }
 
                 if (Interlocked.Exchange(ref _guardrailsLogged, 1) == 0)
-                    await LogMySqlGuardrailsAsync(assigned, stoppingToken);
+                    await LogMySqlGuardrailsAsync(beat.IsRunner, stoppingToken);
 
                 if (_consecutiveFailures > 0)
                 {
                     _logger.LogInformation(
                         "[EtlInstanceRegistrar] 心跳恢复,连续失败 {Count} 次后已重新发布指派 isRunner={IsRunner}",
-                        _consecutiveFailures, assigned);
+                        _consecutiveFailures, beat.IsRunner);
                     _consecutiveFailures = 0;
                 }
 
@@ -97,23 +129,31 @@ public sealed class EtlInstanceRegistrar : BackgroundService
         }
     }
 
+    /// <summary>一拍心跳的结果。</summary>
+    private readonly record struct HeartbeatResult(bool IsRunner, string DesignatedSlot, int SameSlotLiveOthers)
+    {
+        public bool SlotConflict => SameSlotLiveOthers > 0;
+    }
+
     /// <summary>
-    /// 心跳一拍:更新本实例行(不存在则插入),回读指派位。
-    /// 返回本实例是否被指派为执行机。
+    /// 心跳一拍:更新本实例行(不存在则插入),读取指派槽位并与本进程槽位比对。
     /// </summary>
-    private async Task<bool> HeartbeatAsync(CancellationToken ct)
+    private async Task<HeartbeatResult> HeartbeatAsync(CancellationToken ct)
     {
         using var scope = _scopeFactory.CreateScope();
         var db = scope.ServiceProvider.GetRequiredService<ISqlSugarClient>();
+        var instances = scope.ServiceProvider.GetRequiredService<IEtlInstanceStore>();
         var instanceId = AidopInstanceIdentity.InstanceId;
+        var slot = AidopInstanceIdentity.SlotCode;
         var now = DateTime.Now;
 
-        // 刻意不更新 is_runner:指派归人工,注册器只报到。
+        // 刻意不更新 is_runner:那已是遗留列,指派在 ado_etl_runner_designation。
         var affected = await db.Updateable<AdoEtlInstance>()
             .SetColumns(x => new AdoEtlInstance
             {
                 LastHeartbeatAt = now,
                 AppVersion = AidopInstanceIdentity.AppVersion,
+                SlotCode = slot,
                 UpdateTime = now
             })
             .Where(x => x.InstanceId == instanceId)
@@ -127,6 +167,7 @@ public sealed class EtlInstanceRegistrar : BackgroundService
                 MachineName = AidopInstanceIdentity.MachineName,
                 ProcessId = AidopInstanceIdentity.ProcessId,
                 AppVersion = AidopInstanceIdentity.AppVersion,
+                SlotCode = slot,
                 IsRunner = false,
                 StartedAt = AidopInstanceIdentity.StartedAt,
                 LastHeartbeatAt = now,
@@ -135,10 +176,19 @@ public sealed class EtlInstanceRegistrar : BackgroundService
             }).ExecuteCommandAsync(ct);
         }
 
-        return await db.Queryable<AdoEtlInstance>()
-            .Where(x => x.InstanceId == instanceId)
-            .Select(x => x.IsRunner)
-            .FirstAsync(ct);
+        var designated = await instances.GetDesignatedSlotAsync(ct);
+
+        // 没声明槽位就不是候选,连撞车都不必查——省一次查询,也省得把「未声明」
+        // 误报成撞车(多台未配置的开发机 slot 全是 NULL,NULL 之间不构成同槽位)。
+        if (string.IsNullOrEmpty(slot) || !string.Equals(slot, designated, StringComparison.Ordinal))
+            return new HeartbeatResult(false, designated, 0);
+
+        var liveCutoff = now - LivenessWindow;
+        var others = await db.Queryable<AdoEtlInstance>()
+            .Where(x => x.SlotCode == slot && x.InstanceId != instanceId && x.LastHeartbeatAt >= liveCutoff)
+            .CountAsync(ct);
+
+        return new HeartbeatResult(others == 0, designated, others);
     }
 
     /// <summary>
@@ -182,8 +232,12 @@ public sealed class EtlInstanceRegistrar : BackgroundService
     }
 
     /// <summary>
-    /// 清理早已下线的实例行。被指派的那行即使心跳超期也保留——
-    /// 运维需要看到「指派的机器挂了」,而不是记录凭空消失。
+    /// 清理早已下线的实例行。**被指派槽位上的行即使心跳超期也保留**——
+    /// 运维需要看到「指派槽位上那台机器挂了」,而不是记录凭空消失。
+    ///
+    /// <para>注意这里保留的只是**观测线索**:指派本身存在
+    /// <c>ado_etl_runner_designation</c>,即使这些行被清掉也不会丢指派。
+    /// 旧模型正相反——指派就寄生在实例行上,所以那时必须靠这个过滤条件兜着。</para>
     /// </summary>
     private async Task CleanupDeadAsync(CancellationToken ct)
     {
@@ -191,9 +245,14 @@ public sealed class EtlInstanceRegistrar : BackgroundService
         {
             using var scope = _scopeFactory.CreateScope();
             var db = scope.ServiceProvider.GetRequiredService<ISqlSugarClient>();
+            var instances = scope.ServiceProvider.GetRequiredService<IEtlInstanceStore>();
+            var designated = await instances.GetDesignatedSlotAsync(ct);
             var cutoff = DateTime.Now - DeadInstanceRetention;
             var removed = await db.Deleteable<AdoEtlInstance>()
-                .Where(x => !x.IsRunner && x.LastHeartbeatAt < cutoff)
+                .Where(x => x.LastHeartbeatAt < cutoff)
+                // 必须显式放行 slot_code IS NULL:SQL 里 NULL <> 'x' 求值为 NULL 而非真,
+                // 少了这一支,未声明槽位的开发机死行会永远清不掉。
+                .WhereIF(designated != null, x => x.SlotCode == null || x.SlotCode != designated)
                 .ExecuteCommandAsync(ct);
             if (removed > 0)
                 _logger.LogInformation("[EtlInstanceRegistrar] 已清理下线实例 {Count} 行", removed);

+ 83 - 43
server/Plugins/Admin.NET.Plugin.AiDOP/Infrastructure/EtlInstanceStore.cs

@@ -3,21 +3,32 @@ using Admin.NET.Plugin.AiDOP.Entity.SmartOps;
 namespace Admin.NET.Plugin.AiDOP.Infrastructure;
 
 /// <summary>
-/// ETL 执行机实例注册表的写入侧。
+/// ETL 执行机指派与实例注册表的写入侧。
 ///
-/// <para><b>「最多一台执行机」这条不变式由本类保证</b>,不要在控制器、前端或数据库约束里另做一份:
-/// MySQL 没有部分索引,无法用唯一索引表达「最多一行 <c>is_runner=1</c>」;
-/// 而前端保证等于没有保证(两个超管同时点两台就会破)。
-/// 故 <see cref="AssignRunnerAsync"/> 在**同一事务内**先清后置。</para>
+/// <para><b>「最多一台执行机」由 <c>ado_etl_runner_designation</c> 的结构保证</b>:
+/// 该表主键固定为 <see cref="AdoEtlRunnerDesignation.SingletonId"/>,放不下第二行,
+/// 因此不再需要旧模型里「同一事务内先清后置」那套写入侧约束。
+/// 并发指派会被主键与条件更新挡住,而不是依赖调用方守规矩。</para>
+///
+/// <para><b>指派的对象是槽位,不是实例</b>:实例标识含进程ID与启动时间,
+/// 指派挂上去每次重启都会丢(见 <c>AdoEtlRunnerDesignation</c> 注释)。</para>
 /// </summary>
 public interface IEtlInstanceStore
 {
-    /// <summary>把指定实例指派为唯一执行机。同一事务内先把其余行置 0。</summary>
-    /// <returns>false 表示该 instanceId 不存在(进程可能已退出并被清理)。</returns>
-    Task<bool> AssignRunnerAsync(string instanceId, CancellationToken ct = default);
+    /// <summary>
+    /// 把某个部署槽位指派为唯一执行机。槽位无需当前有存活实例——
+    /// 指派可以先于部署,也可以在目标机器重启期间保持有效,这正是本模型的要点。
+    /// </summary>
+    Task AssignRunnerSlotAsync(string slotCode, string assignedBy, CancellationToken ct = default);
+
+    /// <summary>取消指派。此后无人跑定时 ETL——这是允许的状态。</summary>
+    Task ClearRunnerAsync(string clearedBy = null, CancellationToken ct = default);
 
-    /// <summary>取消所有指派。此后无人跑定时 ETL——这是允许的状态。</summary>
-    Task ClearRunnerAsync(CancellationToken ct = default);
+    /// <summary>当前被指派的槽位;无指派返回 null。</summary>
+    Task<string> GetDesignatedSlotAsync(CancellationToken ct = default);
+
+    /// <summary>指派记录全文,供页面展示「谁在什么时候指派的」。无指派返回 null。</summary>
+    Task<AdoEtlRunnerDesignation> GetDesignationAsync(CancellationToken ct = default);
 
     Task<List<AdoEtlInstance>> ListAsync(CancellationToken ct = default);
 }
@@ -28,48 +39,77 @@ public sealed class EtlInstanceStore : IEtlInstanceStore, ITransient
 
     public EtlInstanceStore(ISqlSugarClient db) => _db = db;
 
-    public async Task<bool> AssignRunnerAsync(string instanceId, CancellationToken ct = default)
+    public async Task AssignRunnerSlotAsync(string slotCode, string assignedBy, CancellationToken ct = default)
     {
-        if (string.IsNullOrWhiteSpace(instanceId))
-            return false;
-
-        var id = instanceId.Trim();
-        var assigned = false;
-
-        // 事务边界不可省:先清后置若被拆成两个自动提交语句,
-        // 中间那一瞬会出现「零台执行机」或(并发指派时)「两台执行机」。
-        await _db.Ado.UseTranAsync(async () =>
-        {
-            var now = DateTime.Now;
-
-            await _db.Updateable<AdoEtlInstance>()
-                .SetColumns(x => new AdoEtlInstance { IsRunner = false, UpdateTime = now })
-                .Where(x => x.IsRunner && x.InstanceId != id)
-                .ExecuteCommandAsync(ct);
-
-            var n = await _db.Updateable<AdoEtlInstance>()
-                .SetColumns(x => new AdoEtlInstance { IsRunner = true, UpdateTime = now })
-                .Where(x => x.InstanceId == id)
-                .ExecuteCommandAsync(ct);
-
-            assigned = n > 0;
-        });
-
-        return assigned;
+        var slot = string.IsNullOrWhiteSpace(slotCode) ? null : slotCode.Trim().ToLowerInvariant();
+        await UpsertDesignationAsync(slot, assignedBy, ct);
     }
 
-    public async Task ClearRunnerAsync(CancellationToken ct = default)
+    public async Task ClearRunnerAsync(string clearedBy = null, CancellationToken ct = default) =>
+        await UpsertDesignationAsync(null, clearedBy, ct);
+
+    public async Task<string> GetDesignatedSlotAsync(CancellationToken ct = default)
     {
-        var now = DateTime.Now;
-        await _db.Updateable<AdoEtlInstance>()
-            .SetColumns(x => new AdoEtlInstance { IsRunner = false, UpdateTime = now })
-            .Where(x => x.IsRunner)
-            .ExecuteCommandAsync(ct);
+        var row = await GetDesignationAsync(ct);
+        return string.IsNullOrWhiteSpace(row?.SlotCode) ? null : row.SlotCode;
     }
 
+    public Task<AdoEtlRunnerDesignation> GetDesignationAsync(CancellationToken ct = default) =>
+        _db.Queryable<AdoEtlRunnerDesignation>()
+            .Where(x => x.Id == AdoEtlRunnerDesignation.SingletonId)
+            .FirstAsync(ct);
+
     public Task<List<AdoEtlInstance>> ListAsync(CancellationToken ct = default) =>
         _db.Queryable<AdoEtlInstance>()
             .OrderBy(x => x.MachineName)
             .OrderBy(x => x.ProcessId)
             .ToListAsync(ct);
+
+    /// <summary>
+    /// 写唯一那一行。先 UPDATE 再按影响行数决定是否 INSERT:
+    /// 两个超管同时指派时,后到的 INSERT 会撞主键而不是插出第二行,
+    /// 撞了就退回 UPDATE 重写一次,最终仍是单行单值。
+    /// </summary>
+    private async Task UpsertDesignationAsync(string slot, string operatorName, CancellationToken ct)
+    {
+        var now = DateTime.Now;
+        var by = string.IsNullOrWhiteSpace(operatorName) ? null : operatorName.Trim();
+
+        // 三元运算符不放进表达式树:SqlSugar 要把 SetColumns 的初始化器翻成 SQL,
+        // 取消指派时 assigned_at 应回到 NULL,这个判断在 C# 侧先算完更稳。
+        DateTime? assignedAt = slot == null ? null : now;
+
+        var updated = await UpdateDesignationAsync(slot, by, assignedAt, now, ct);
+        if (updated > 0) return;
+
+        try
+        {
+            await _db.Insertable(new AdoEtlRunnerDesignation
+            {
+                Id = AdoEtlRunnerDesignation.SingletonId,
+                SlotCode = slot,
+                AssignedBy = by,
+                AssignedAt = assignedAt,
+                CreateTime = now,
+                UpdateTime = now
+            }).ExecuteCommandAsync(ct);
+        }
+        catch
+        {
+            // 并发下另一方已插入,改写即可;再失败就让异常上抛给控制器。
+            await UpdateDesignationAsync(slot, by, assignedAt, now, ct);
+        }
+    }
+
+    private Task<int> UpdateDesignationAsync(string slot, string by, DateTime? assignedAt, DateTime now, CancellationToken ct) =>
+        _db.Updateable<AdoEtlRunnerDesignation>()
+            .SetColumns(x => new AdoEtlRunnerDesignation
+            {
+                SlotCode = slot,
+                AssignedBy = by,
+                AssignedAt = assignedAt,
+                UpdateTime = now
+            })
+            .Where(x => x.Id == AdoEtlRunnerDesignation.SingletonId)
+            .ExecuteCommandAsync(ct);
 }

+ 17 - 1
server/Plugins/Admin.NET.Plugin.AiDOP/Job/MdpNightlyFullRebuildJob.cs

@@ -9,7 +9,11 @@ namespace Admin.NET.Plugin.AiDOP.Job;
 /// <summary>
 /// S1–S7 夜间串行全量兜底。triggerType 用 <see cref="ModuleRebuildTriggerType.AutoNightly"/>,
 /// 穿透 AUTO 冷却窗口;串行靠 <c>GlobalMaxParallelScopes = 1</c>,本 Job 一次性入队全部 scope。
-/// </summary>
+///
+/// <para><b>扇出前先清自己上一轮未开跑的入队</b>:日度全量重算的排队记录过了当天就没有价值,
+/// 今天这一轮天然取代昨天那一轮。不清的后果实测过——2026-09-28 有 28 条 03:40 入队的
+/// <c>AUTO_NIGHTLY</c> 因执行机指派丢失而无人可领,一直挡着同 scope 的后续入队。
+/// 只清 <c>QUEUED</c> 且无 <c>requested_by</c> 的行,RUNNING 与页面手工任务一律不碰。</para>
 [JobDetail("job_mdp_nightly_full_rebuild",
     Description = "S1–S7 夜间串行全量兜底(穿透 AUTO 冷却窗口)",
     GroupName = "default",
@@ -37,6 +41,18 @@ public class MdpNightlyFullRebuildJob : IJob
         using var scope = _scopeFactory.CreateScope();
         try
         {
+            var stale = await scope.ServiceProvider.GetRequiredService<IModuleRebuildJobStore>()
+                .CancelUnstartedByTriggerAsync(
+                    ModuleRebuildTriggerType.AutoNightly,
+                    TimeSpan.Zero,
+                    "已被今日夜间全量取代(上一轮未开跑)",
+                    stoppingToken);
+            if (stale > 0)
+                _logger.LogWarning(
+                    "MdpNightlyFullRebuildJob 清理上一轮未开跑的 AUTO_NIGHTLY {Count} 条。"
+                    + "条数偏高说明队列此前长期无人消费,请查 MDP 运行监控页的执行机指派",
+                    stale);
+
             foreach (var module in Modules)
             {
                 stoppingToken.ThrowIfCancellationRequested();

+ 93 - 22
server/Plugins/Admin.NET.Plugin.AiDOP/Order/MdpMonitorService.cs

@@ -1,6 +1,7 @@
 using Admin.NET.Core.Service;
 using Admin.NET.Plugin.AiDOP.DataPlatform;
 using Admin.NET.Plugin.AiDOP.DataPlatform.Executors;
+using Admin.NET.Plugin.AiDOP.DataPlatform.MdpRebuild;
 using Admin.NET.Plugin.AiDOP.Entity.DataPlatform;
 using Admin.NET.Plugin.AiDOP.Entity.SmartOps;
 
@@ -144,19 +145,29 @@ public class MdpMonitorService : IDynamicApiController, ITransient
 
     public sealed class AssignRunnerInput
     {
-        public string InstanceId { get; set; } = string.Empty;
+        /// <summary>要指派的部署槽位码(<c>ado_etl_instance.slot_code</c>)。</summary>
+        public string SlotCode { get; set; } = string.Empty;
     }
 
+    /// <summary>
+    /// 把某个部署槽位指派为唯一执行机。
+    ///
+    /// <para><b>指派对象是槽位而不是实例</b>:实例标识含进程ID与启动时间,
+    /// 指派挂上去每次重启都会丢(详见 <c>AdoEtlRunnerDesignation</c>)。
+    /// 槽位可以先于部署存在,也能在目标机器重启期间保持有效。</para>
+    ///
+    /// <para>不校验该槽位当前是否有存活实例:指派先于部署是合法用法,
+    /// 页面只需把「已指派但该槽位无存活实例」显示成告警。</para>
+    /// </summary>
     [DisplayName("超管:指派执行机")]
     [HttpPost("mdp-monitor/admin/assign-runner")]
     public async Task<object> AssignRunnerAsync([FromBody] AssignRunnerInput input)
     {
         RequireSuperAdmin();
-        if (string.IsNullOrWhiteSpace(input?.InstanceId))
-            throw Oops.Oh("必须指定实例");
-        var ok = await _etlInstances.AssignRunnerAsync(input.InstanceId.Trim());
-        if (!ok) throw Oops.Oh("该实例不存在或已退出");
-        return new { ok = true };
+        if (string.IsNullOrWhiteSpace(input?.SlotCode))
+            throw Oops.Oh("必须指定槽位;若目标进程显示「未声明」,请先为其配置 AIDOP_ETL_SLOT");
+        await _etlInstances.AssignRunnerSlotAsync(input.SlotCode, _userManager.Account);
+        return new { ok = true, slotCode = input.SlotCode.Trim().ToLowerInvariant() };
     }
 
     [DisplayName("超管:取消执行机指派")]
@@ -164,7 +175,7 @@ public class MdpMonitorService : IDynamicApiController, ITransient
     public async Task<object> ClearRunnerAsync()
     {
         RequireSuperAdmin();
-        await _etlInstances.ClearRunnerAsync();
+        await _etlInstances.ClearRunnerAsync(_userManager.Account);
         return new { ok = true };
     }
 
@@ -259,12 +270,20 @@ public class MdpMonitorService : IDynamicApiController, ITransient
     /// 页面上只能看到「最近执行」时间不再前进,看不出原因是本实例没被指派、还是作业跑挂了。
     /// 没有这个接口,未指派就是一个不可观测的状态。</para>
     ///
-    /// <para><b>指派模型</b>:执行机由 <c>ado_etl_instance.is_runner</c> 人工指派,
-    /// 不再由配置决定;<c>assignmentKnown=false</c> 表示注册器尚未心跳成功或已过期,
+    /// <para><b>指派模型</b>:执行机由 <c>ado_etl_runner_designation.slot_code</c> 人工指派,
+    /// 与各进程声明的 <c>slot_code</c> 比对得出身份,不再由配置决定;
+    /// <c>assignmentKnown=false</c> 表示注册器尚未心跳成功或已过期,
     /// 此时 <c>isRunner</c> 取自配置回退,页面必须把这种情况与「确实未被指派」区分开,
     /// 否则运维会误以为刚点的指派已经生效。</para>
     ///
-    /// <para>只读进程内状态、实例注册表与一条 COUNT。实例是部署事实不属于任何租户,故不解析租户。</para>
+    /// <para><b>三个全局告警位</b>(<c>alerts</c>):
+    /// <c>noLiveRunner</c> 指派槽位上没有存活实例——定时 ETL 此刻完全停摆,
+    /// 这是 2026-09-28 那次积压唯一该报而没报出来的信号;
+    /// <c>slotConflict</c> 同一槽位多个存活实例,双方都会拒跑以免双跑全量;
+    /// <c>queueStuck</c> 最老 QUEUED 已超过收口阈值的一半,通常就是前两者的下游表现。
+    /// 这三项是全库事实,与「本实例是不是执行机」不同,后者只回答当前这台。</para>
+    ///
+    /// <para>只读进程内状态、实例注册表与几条聚合。实例是部署事实不属于任何租户,故不解析租户。</para>
     /// </summary>
     [DisplayName("ETL执行机状态")]
     [HttpGet("mdp-monitor/etl-runner")]
@@ -273,6 +292,15 @@ public class MdpMonitorService : IDynamicApiController, ITransient
         var runningScopes = await _db.Ado.GetIntAsync(
             "SELECT COUNT(*) FROM ado_module_dashboard_rebuild_job WHERE status='RUNNING'");
 
+        var queueDepth = await _db.Ado.GetIntAsync(
+            "SELECT COUNT(*) FROM ado_module_dashboard_rebuild_job WHERE status='QUEUED'");
+        var oldestQueuedAt = await _db.Queryable<AdoModuleDashboardRebuildJob>()
+            .Where(x => x.Status == ModuleRebuildStatus.Queued)
+            .MinAsync(x => (DateTime?)x.SubmittedAt);
+
+        var designation = await _etlInstances.GetDesignationAsync();
+        var designatedSlot = string.IsNullOrWhiteSpace(designation?.SlotCode) ? null : designation.SlotCode;
+
         var heartbeatCutoff = DateTime.Now - AidopRunnerState.FreshnessWindow;
         var instances = await _db.Queryable<AdoEtlInstance>()
             .OrderBy(x => x.MachineName)
@@ -283,15 +311,43 @@ public class MdpMonitorService : IDynamicApiController, ITransient
                 x.MachineName,
                 x.ProcessId,
                 x.AppVersion,
-                x.IsRunner,
+                x.SlotCode,
                 x.StartedAt,
                 x.LastHeartbeatAt
             })
             .ToListAsync();
 
+        var rows = instances.Select(x => new
+        {
+            x.InstanceId,
+            x.MachineName,
+            x.ProcessId,
+            x.AppVersion,
+            x.SlotCode,
+            x.StartedAt,
+            x.LastHeartbeatAt,
+            alive = x.LastHeartbeatAt >= heartbeatCutoff,
+            self = x.InstanceId == AidopInstanceIdentity.InstanceId,
+            // 派生值,不再读 ado_etl_instance.is_runner(已降级为遗留列)
+            isRunner = designatedSlot != null && x.SlotCode == designatedSlot
+        }).ToList();
+
+        var liveOnSlot = designatedSlot == null
+            ? 0
+            : rows.Count(x => x.alive && x.SlotCode == designatedSlot);
+        var oldestQueuedMinutes = oldestQueuedAt == null
+            ? (double?)null
+            : Math.Round((DateTime.Now - oldestQueuedAt.Value).TotalMinutes, 1);
+
         return new
         {
             instanceId = AidopInstanceIdentity.InstanceId,
+            slotCode = AidopInstanceIdentity.SlotCode,
+            slotEnvName = AidopInstanceIdentity.SlotEnvName,
+            slotConfigKey = AidopInstanceIdentity.SlotConfigKey,
+            designatedSlot,
+            designatedBy = designation?.AssignedBy,
+            designatedAt = designation?.AssignedAt,
             isRunner = AidopJobGate.IsRunner,
             // 领取 AUTO / BOOTSTRAP 的资格与执行机身份同源,页面上单列一项避免误读为两回事
             claimAuto = AidopJobGate.IsRunner,
@@ -301,18 +357,33 @@ public class MdpMonitorService : IDynamicApiController, ITransient
             maxParallelScopes = _rebuildCapability.MaxParallelScopes,
             globalMaxParallelScopes = _rebuildCapability.GlobalMaxParallelScopes,
             runningScopes,
-            instances = instances.Select(x => new
+            queueDepth,
+            oldestQueuedAt,
+            oldestQueuedMinutes,
+            queuedStaleAfterHours = ModuleRebuildLock.QueuedStaleAfter.TotalHours,
+            liveRunnerCount = liveOnSlot,
+            alerts = new
             {
-                x.InstanceId,
-                x.MachineName,
-                x.ProcessId,
-                x.AppVersion,
-                x.IsRunner,
-                x.StartedAt,
-                x.LastHeartbeatAt,
-                alive = x.LastHeartbeatAt >= heartbeatCutoff,
-                self = x.InstanceId == AidopInstanceIdentity.InstanceId
-            })
+                // 未指派是允许的状态(设计如此),已指派却无活实例才是故障
+                noLiveRunner = designatedSlot != null && liveOnSlot == 0,
+                notDesignated = designatedSlot == null,
+                slotConflict = liveOnSlot > 1,
+                queueStuck = oldestQueuedMinutes != null
+                    && oldestQueuedMinutes > ModuleRebuildLock.QueuedStaleAfter.TotalMinutes / 2
+            },
+            slots = rows
+                .Where(x => x.SlotCode != null)
+                .GroupBy(x => x.SlotCode)
+                .Select(g => new
+                {
+                    slotCode = g.Key,
+                    designated = g.Key == designatedSlot,
+                    liveCount = g.Count(x => x.alive),
+                    totalCount = g.Count()
+                })
+                .OrderBy(x => x.slotCode)
+                .ToList(),
+            instances = rows
         };
     }