系列第16篇 | 从15层任务ID嵌套到最大重试3次,中间踩了哪些坑

引言

凌晨00:48,我收到一条飞书通知:

❌ 复核不通过: TEST-DEV-IOT-1.1-DATABASE
🔄 已重派: RETEST-TEST-DEV-IOT-1.1-DATABASE

30秒后又来一条:

❌ 复核不通过: RETEST-TEST-DEV-IOT-1.1-DATABASE
🔄 已重派: RETEST-RETEST-TEST-DEV-IOT-1.1-DATABASE

再30秒:

❌ 复核不通过: RETEST-RETEST-TEST-DEV-IOT-1.1-DATABASE
🔄 已重派: RETEST-RETEST-RETEST-TEST-DEV-IOT-1.1-DATABASE

我盯着屏幕,看着任务ID像俄罗斯套娃一样一层层叠加。15分钟后,统筹报告变成了这样:

| 9  | RETEST-RETEST-RETEST-TEST-DEV-IOT-1.1-DATABASE |
| 10 | RETEST-RETEST-TEST-DEV-IOT-1.1-DATABASE        |
| 11 | RETEST-TEST-DEV-IOT-1.1-DATABASE               |
| 12 | TEST-DEV-IOT-1.1-DATABASE                      |

4个任务,其实是同一个任务的4个"分身"。而且还在继续生…

问题1:RETEST无限嵌套

现象

ws_server.log可以清晰看到嵌套过程:

00:48:50 [WS] 复核失败,review.py已自动重派RETEST: TEST-DEV-IOT-1.1-DATABASE
00:49:38 [WS] 复核失败,review.py已自动重派RETEST: RETEST-TEST-DEV-IOT-1.1-DATABASE
00:50:21 [WS] 复核失败,review.py已自动重派RETEST: RETEST-RETEST-TEST-DEV-IOT-1.1-DATABASE
00:51:04 [WS] 复核失败,review.py已自动重派RETEST: RETEST-RETEST-RETEST-TEST-DEV-IOT-1.1-DATABASE

每44秒一轮,任务ID不断变长。最夸张的时候,一个任务的ID变成了:

RETEST-RETEST-RETEST-RETEST-RETEST-RETEST-RETEST-RETEST-RETEST-RETEST-RETEST-RETEST-RETEST-RETEST-TEST-DEV-IOT-1.1-DATABASE

15层嵌套,52个字符的前缀。而且这个"怪物"还在继续生长。

原因

问题出在review.pydispatch_retest()函数:

# 原始代码(无限制)
def dispatch_retest(task_id, reason):
    retest_id = f"RETEST-{task_id}"  # 每次都加前缀
    # ... 派发任务

这个函数的逻辑是:

  1. 复核失败
  2. 生成新任务ID:RETEST- + 原ID
  3. 派发新任务

看起来没问题?但问题是——没有上限

TEST-DEV-IOT-1.1-DATABASE复核失败,变成RETEST-TEST-DEV-IOT-1.1-DATABASE
RETEST-TEST-DEV-IOT-1.1-DATABASE又失败,变成RETEST-RETEST-TEST-DEV-IOT-1.1-DATABASE
RETEST-RETEST-TEST-DEV-IOT-1.1-DATABASE还失败…

无限循环。

修复

def dispatch_retest(task_id, reason):
    """自动重派测试任务(最多重试3次,防止无限循环)"""
    # 检查重试次数:计算task_id中有多少层RETEST前缀
    retry_count = task_id.count('RETEST-') - (1 if task_id.startswith('RETEST-') else 0)
    if task_id.startswith('RETEST-'):
        # RETEST-RETEST-xxx 的retry_count = 前缀中RETEST的个数
        parts = task_id.split('-TEST-')
        if len(parts) > 1:
            retry_count = parts[0].count('RETEST')
    
    if retry_count >= 3:
        return f"⚠️ 已达最大重试次数(3次),跳过重派: {task_id}"
    
    retest_id = f"RETEST-{task_id}"
    # ... 继续派发

关键改动:

  1. 添加重试计数:从task_id中解析已重试次数
  2. 设置上限:最多3次
  3. 超限返回警告:不再继续派发

问题2:计数逻辑的坑

第一次尝试:简单count

retry_count = task_id.count('RETEST-')

看起来对?但有个边界情况:

  • RETEST-TEST-DEV-xxx → count = 1 ✓
  • RETEST-RETEST-TEST-DEV-xxx → count = 2 ✓
  • TEST-DEV-xxx → count = 0 ✓

RETEST-DEV-xxx呢?count = 1,但其实应该也是1次重试。好像没问题?

等等,再想想。如果原始任务ID本身就包含RETEST呢?比如FIX-RETEST-0714-xxx。这时count会误判。

第二次尝试:拆分解析

parts = task_id.split('-TEST-')
if len(parts) > 1:
    retry_count = parts[0].count('RETEST')

这个逻辑更精确:

  • -TEST-为分隔符拆分
  • 第一部分是前缀(如RETEST-RETEST
  • 统计前缀中RETEST的个数

但还有个问题:如果原始任务ID是TEST-DEV-xxx,拆分后parts = ['TEST', 'DEV-xxx']parts[0] = 'TEST',count(‘RETEST’) = 0。正确。

如果是RETEST-TEST-DEV-xxx,拆分后parts = ['RETEST', 'DEV-xxx']parts[0] = 'RETEST',count = 1。正确。

如果是RETEST-RETEST-TEST-DEV-xxx,拆分后parts = ['RETEST-RETEST', 'DEV-xxx']parts[0] = 'RETEST-RETEST',count = 2。正确。

最终方案

retry_count = task_id.count('RETEST-') - (1 if task_id.startswith('RETEST-') else 0)
if task_id.startswith('RETEST-'):
    parts = task_id.split('-TEST-')
    if len(parts) > 1:
        retry_count = parts[0].count('RETEST')

两套逻辑双重验证,确保计数准确。

问题3:dev_id提取的连锁反应

RETEST前缀叠加不只影响计数,还影响了review.py中提取原始任务ID的逻辑。

原来的问题

review.py需要从task_id中提取dev_id来匹配MD文件:

# 从RETEST-TEST-DEV-IOT-1.1-DATABASE提取DEV-IOT-1.1-DATABASE
dev_id = task_id.replace('RETEST-', '')

当只有1层前缀时,这行代码能工作。但当有3层前缀时:

task_id = "RETEST-RETEST-RETEST-TEST-DEV-IOT-1.1-DATABASE"
dev_id = task_id.replace('RETEST-', '')  # → "TEST-DEV-IOT-1.1-DATABASE"

还是不对,因为还有TEST-前缀。而且如果原始任务就是TEST-DEV-xxx,去掉RETEST-后还剩TEST-DEV-xxx,需要再去掉TEST-

修复方案

def extract_dev_id(task_id):
    """从嵌套的RETEST前缀中提取原始dev_id"""
    # 去掉所有RETEST-前缀
    dev_id = task_id
    while dev_id.startswith('RETEST-'):
        dev_id = dev_id[8:]  # len('RETEST-') = 8
    
    # 如果还有TEST-前缀,也去掉
    if dev_id.startswith('TEST-'):
        dev_id = dev_id[5:]  # len('TEST-') = 5
    
    return dev_id

用循环确保无论嵌套多少层都能正确提取。

问题4:DB查询的模糊匹配

任务ID变形后,数据库查询也出了问题。

原来的问题

SELECT * FROM tasks WHERE task_id = 'TEST-DEV-IOT-1.1-DATABASE'

当任务被重派为RETEST-TEST-DEV-IOT-1.1-DATABASE后,这条SQL查不到原任务了。

修复方案

SELECT * FROM tasks WHERE task_id LIKE '%DEV-IOT-1.1-DATABASE'

LIKE模糊匹配,只要ID包含原始任务名就能查到。

但这也带来新问题:如果同时存在DEV-IOT-1.1-DATABASEDEV-IOT-1.1-DATABASE-MOBILE,模糊匹配会返回多条记录。

最终方案

SELECT * FROM tasks 
WHERE task_id LIKE '%DEV-IOT-1.1-DATABASE' 
  AND task_id NOT LIKE '%-%-DEV-IOT-1.1-DATABASE'
ORDER BY created_at DESC LIMIT 1

排除带前缀的记录,只取最新的。

问题5:统筹报告的混乱

嵌套的任务ID让统筹报告变得不可读:

| 9  | RETEST-RETEST-RETEST-TEST-DEV-IOT-1.1-DATABASE | RETESTRETESTRETESTTESTDE |
| 10 | RETEST-RETEST-TEST-DEV-IOT-1.1-DATABASE        | RETESTRETESTTESTDEVIOT1. |
| 11 | RETEST-TEST-DEV-IOT-1.1-DATABASE               | RETESTTESTDEVIOT1.1DATAB |
| 12 | TEST-DEV-IOT-1.1-DATABASE                      | IOT1.1DATABASE            |

第2列的"完善内容"被截断到10个字符,根本看不出是什么任务。

修复方案

在生成报告时,提取原始任务名:

def shorten_task_id(task_id):
    """提取可读的任务名"""
    # 去掉所有RETEST-前缀
    name = task_id
    while name.startswith('RETEST-'):
        name = name[8:]
    # 去掉TEST-/FIX-前缀
    for prefix in ['TEST-', 'FIX-']:
        if name.startswith(prefix):
            name = name[len(prefix):]
    return name[:15]  # 截断到15字符

经验总结

1. 自动化机制必须有上限

任何"自动重试"、"自动重派"的逻辑,都必须有最大次数限制。否则一个失败的任务会无限繁殖,最终拖垮整个系统。

2. 任务ID设计要考虑可追溯性

RETEST前缀叠加的设计虽然能保留历史,但会让ID变得不可读。更好的方案是在DB中单独记录retry_count字段,而不是修改任务ID本身。

3. 字符串操作要防御性编程

从task_id中提取信息时,不能假设ID的格式是固定的。要用循环、正则等稳健的方式处理各种边界情况。

4. 日志是最好的调试工具

这次问题能快速定位,全靠ws_server.log中清晰的记录。如果日志不详细,光看统筹报告的混乱输出,很难快速找到根因。


系列第16篇 | 2026-07-15
基于真实事件还原,所有代码和日志均来自实际系统

Logo

中国智能体开发者社区,聚焦智能体与大模型开发,提供前沿资讯、实用工具链、开源项目及行业案例。通过技术沙龙、开发者大赛等活动,促进经验交流与协作,助力开发者快速构建创新智能应用。

更多推荐