系列第17篇 | 一个bug不可怕,三个同时来才致命

凌晨4点的飞书

2026年7月17日,凌晨4点。

我睡不着,打开飞书看了一眼小密(我的AI统筹Agent)的消息。然后我愣住了:

【小密统筹报告 07-17 04:04】
【小密统筹报告 07-17 04:34】
【小密统筹报告 07-17 05:04】
【小密统筹报告 07-17 05:34】
【小密统筹报告 07-17 06:04】
【小密统筹报告 07-17 06:34】
【小密统筹报告 07-17 07:04】

7条消息,每条都只有一个标题,没有任何内容。没有任务表格,没有进度,没有状态。

就像一个员工每半小时给你发一封邮件,标题写"工作汇报",打开里面是空白的。

他没在偷懒——他一直在发邮件。但他也没在工作——邮件里什么都没有。

我发了条消息:“有什么问题,一直在空转。”

然后我开始排查,发现不是一个bug,是三个。它们来自三个完全不同的系统层,互相不知道对方的存在,但效果叠加在一起——整个心跳系统"假装工作"了整整5个小时。

第一个炸弹:空报告

现象:心跳每30分钟发一条报告到飞书,从凌晨2点开始正常,4点开始变成只有标题的空消息。

排查过程

我打开format_report.py,看到了这个函数:

def has_content(notes, timeout_tasks):
    if notes:        # ← 问题在这里
        return True
    if timeout_tasks:
        return True
    return False

看起来没毛病?但问题在于notes里装了什么。

当所有任务都处理完了,脚本会往notes里写一句"无待复核任务"。bool("无待复核任务")返回True,所以has_content说"有内容!",继续生成报告。

但后面有个过滤器:

filtered_notes = [n for n in notes if not n.startswith("无")]

"无待复核任务"以"无"开头,被过滤掉了。filtered_notes变成空列表。

has_content看的是过滤前的notes,不是过滤后的filtered_notes

就像你在厨房检查食材有没有过期,但没检查做出来的菜有没有变质。

最终效果:脚本说"有内容"→生成报告→内容被过滤→只剩标题→发送空报告。

修复

def has_content(notes, timeout_tasks):
    # 不再检查原始notes,检查最终输出
    lines = format_lines(notes, timeout_tasks)
    content_lines = [l for l in lines if l.strip() 
                     and not l.startswith('【') 
                     and not l.startswith('|')]
    return len(content_lines) > 0

教训:判断"有没有内容",要在出口看输出,不要在入口看输入。

第二个炸弹:手机白屏

现象:手机浏览器一片白,控制台没报错,Vue完全没渲染。

排查过程

第一层:资源路径混乱。

mobile/index.html引用了/assets/xxx.js,但文件实际部署在/mobile/assets/下。浏览器请求404,JS不加载,Vue不启动。

但这个修好后,还有第二层——更隐蔽的。

localStorage跨设备污染

我之前在PC端访问系统,点了侧边栏折叠按钮。JS把状态存进localStorage

localStorage.setItem('sidebarCollapsed', 'true')

localStorage按域名存储的,跟设备无关。同一个域名(nasioe.cc.cd),PC端和手机端共享同一份localStorage

我用手机打开同一个域名,浏览器读到sidebarCollapsed = 'true',JS把侧边栏宽度设成60px。手机屏幕375px宽,60px侧边栏+主内容区,布局全乱。

你以为localStorage是"本地"的,其实它是"本域名"的。

就像你在家里的冰箱贴了一张"今晚吃火锅"的便条,结果你出差住酒店,酒店冰箱里也有一张同样的便条——因为你们用的是同一个美团账号。

修复

// 只在PC端恢复折叠状态
if (window.innerWidth > 768) {
    const saved = localStorage.getItem('sidebarCollapsed');
    if (saved === 'true') {
        sidebar.classList.add('collapsed');
    }
}

第三个炸弹:CSS幽灵

现象:PC端侧边栏折叠按钮被遮挡,折叠后菜单文字没隐藏。

排查过程

我grep了CSS,找到了这段:

.sidebar.collapsed span:not(.menu-icon) {
    display: none !important;
}

选择器匹配的是span:not(.menu-icon)——也就是隐藏非图标的span

但实际HTML是这样的:

<a href="/dashboard.html" class="menu-item">
    <span class="menu-icon">📊</span>
    数据看板    <!-- ← 文字直接在<a>里,不在<span>里 -->
</a>

文字不在<span>里,直接写在<a>标签里。CSS选择器根本匹配不到它。

CSS不会报错。它只是静静地失效,看着你的页面一团糟。

就像你给门配了一把钥匙,但门锁已经换了。钥匙插进去,转不动,但门看起来还是那扇门。

修复

.sidebar.collapsed a {
    font-size: 0;      /* 隐藏<a>内的文字 */
}
.sidebar.collapsed .menu-icon {
    font-size: 18px;   /* 强制显示图标 */
}

三个bug的"完美风暴"

Bug 影响 持续时间
空报告 飞书消息全是空白标题 5小时
手机白屏 手机端完全无法访问 直到修复
CSS幽灵 PC端侧边栏折叠失效 直到修复

三个bug单独看都不致命。但叠加在一起:

  1. 空报告→用户看不到系统在干什么(“一直在空转”)
  2. 手机白屏→移动端完全不可用
  3. CSS幽灵→PC端UI也有问题

从用户视角看:飞书里一串空消息,手机打不开,电脑侧边栏也是坏的。整个系统似乎瘫痪了。

但实际上,核心逻辑(任务派发、Agent调度、心跳监控)一直在正常运行。出问题的全是展示层

一个"内部正常但外部表现异常"的系统,在用户眼里跟"完全坏掉"没有区别。

三个教训

1. 判断"有没有内容"要看最终输出

has_content检查原始notes,但notes在后面被过滤了。入口看到的和出口输出的不是同一回事。

2. localStorage是域名级别的,不是设备级别的

多设备共用的Web应用,UI状态必须考虑跨设备兼容。用window.innerWidth判断设备类型是最简单的方案。

3. HTML结构改了,CSS选择器必须同步grep

CSS选择器是"盲"的——DOM结构变了,选择器失效,样式静默丢失。改HTML后第一件事:grep所有引用该class的CSS文件。

Logo

Agent 垂直技术社区,欢迎活跃、内容共建。

更多推荐