发布日期:2026-11-01 | 环境:DSH Desktop 0.2.1-rc.5 / Windows 11 | 结论先行:我做了个让 DSH agent"无人值守自动完成任务"的 skill(挂机门禁 + 心跳保活 + 看门狗崩溃自愈),实测中发现一个隐藏问题——任务做完后还会空转几十轮,于是又补了个 128 行小插件把它清零。

一、痛点:我想睡觉,但它得替我干活

真正的痛点是场景,不是 bug:我人不在设备前——睡觉、通勤、开会、出门——但有一批任务等着做(写测试、生成文档、跑数据、修一批小 bug)。我希望能把任务清单往那一放,说一句"我走了",然后第二天回来收割成果。

DSH 本身是个很强的 agent,但它默认是"对话式"的:你在,你说,它做。你走了,它凭什么继续跑?

普通做法全都不行:

常规做法为什么不行
人守着,做一步说一句场景本身就是"人不在",守着就没意义
一次说完所有任务让它连续做跑到一半进程崩了/网络断了,没人知道,也没东西把它拉起来;而且清单在对话里,每轮都在重付上下文
只靠"模型自觉"让它自由发挥,会跳过验证、跳过记录、写一半就"我觉得好了"——无人核对,产物是垃圾

所以我要的不是"更强的模型",是一整套无人值守的流程规范:它自己验收、自己决策、自己扛崩溃、最后还给我一份能看懂的晨间简报。

二、这个 skill 做了什么:把"怎么跑夜班"写成规范

dsh-night-shift 就是一个纯 markdown 的 skill,本质是一堆行为规范 + 一个主机级看门狗脚本。它要解决的核心问题有四个,每个对应一条设计:

设计 1:任务清单必须落文件,不能写进对话

对话里列的任务,每轮 turn 都在重付一遍上下文(清单成了历史的一部分),而且我半夜改不了。所以 skill 强制:任务清单写进文件(项目里的 BUG-BOARD.md / TODO.md,或我指定的路径),agent 按需读取、未读不花钱,我能中途增删。状态用四标记:[ ] 待办 / [x] 完成 / [~] 有残留 / [!] blocked。

设计 2:验收门禁是唯一裁判

无人值守最怕的就是"ai 觉得自己修好了"。skill 的一条总原则是:测试/验证证据绿了才算完成,"我觉得"不算。每个任务收尾必须贴出可复现的验证命令与结果。

设计 3:不等人,拿不准就按默认处置

人不在场,问了也白问。所以 skill 给了习惯决策树(三档):拿不准的事按默认处置 → 记进 DECISIONS.md → 继续推进。流程绝不为"等人工晨检"暂停。

设计 4:扛得住崩溃——心跳保活 + 看门狗

这是整套东西里最关键的工程部分,也是后面"空转问题"的来源。

  • 心跳保活:开工时用 schedule_create 建一个周期提醒(必须用 every_seconds 周期语义,不是一次性)。宿主重启后提醒表还在,到点会恢复冷会话并投递,agent 接着干。
  • 看门狗:一个主机级的 Windows 计划任务,只在"有活跃 run 登记 且 DSH 进程不在"时把 DSH 拉起来。进程活着它绝不动手(不误杀),没跑夜班它就退出(不无条件开机自启)。
  • 运行登记表:runs/<sessionId>.json,每个会话一条,多项目并发互不覆盖。

还有一条"挂机门禁":我睡前说"我走了"之后,它必须先逐项实测(Goal 挂上了没 / 续行启用了没 / 心跳建了没 / run 登记了没),全绿才让我安心离开——否则明确告诉我"现在挂机不会自恢复"。

三、三句总原则

几乎决定了它所有的行为:

  1. 验收门禁是唯一裁判:证据绿了才算完成。
  2. 不等人:拿不准按默认处置、记账、继续。
  3. 三条红线永不越:git push、C 级禁区(系统目录/凭据/注册表)、修复类任务装新依赖——任何模式下都禁止。

四、发现了问题:任务做完了,它还在"自言自语"

skill 跑通了。几轮实测下来,"门禁 → 崩溃 → 看门狗拉起 → 心跳续跑 → 任务续完"这条链是通的。

但我盯日志时发现一个怪事:任务清单全部打勾、简报也写好、心跳也删了,它却还连着开十几到几十轮 turn——每轮约 10 秒、0 次工具调用,只吐一句"心跳空转:已收尾,无需动作"。

一次实测:删除心跳之后,又连开了 17 轮空转。会话没死、任务没错,但每一轮都在重新付一遍完整的会话历史。整夜下来,这就是一笔真实的 token 账单。而且是可复现的。

五、定位:积压不在 schedule,在 agent 收件箱

一开始我以为是"删得不够干净",在"间隔"这个参数上折腾了半天,全错了。

真正的转折是把 DSH 的 dsh-schedule 源码从头读了一遍。心跳的投递链是这样的:

schedule_create(every_seconds=N)
   ↓ 每 N 秒触发(drive 扫描到期任务)
resolveEveryOccurrence(...)   ← 把错过周期折叠成一次,scheduledAt 推到未来
   ↓
agent.followup(message)       ← ★ 消息被推进「agent 收件箱」
   ↓ 模型忙(长 turn)时堆在收件箱
模型空闲后,收件箱里每条消息各起一轮 turn

关键在最后两步。schedule 自身没有队列——每次触发立刻投递、然后把下一次推到未来。真正堆积的是 agent.followup() 推进去的 agent 收件箱(agent.inbox)。

于是 schedule_delete 的能力边界就清楚了:

  • 能:删任务行 → 不再产生新的提醒。
  • 不能:收回已经进了收件箱的消息。

平台自己的 API 文档在 schedule_delete 里写得很直白:"leaving queued messages intact"(保留排队中的消息)。这不是 bug,是设计。我之前一直以为要清"schedule 里排队的 occurrence",其实要清的是收件箱。

量化公式:积压轮数 ≈ 任务连续工作时长 ÷ 心跳间隔。60 秒 + 干 2 小时 ≈ 120 轮。

六、解决:给 skill 配一个 128 行的小插件

想清楚根因,问题就变成"怎么清收件箱"。两条路:让 skill 自己想办法,或写个工具直接删。

为什么 skill 自己做不到: 收件箱是 agent 内部状态,模型没有任何工具能动它。我把它全部 17 个内置工具名逐个查过——没有一个是"清空收件箱"的。模型连"发现收件箱里有几条"都做不到。

但源码里 Inbox 接口其实公开支持删除:

export interface Inbox {
  get nextTurn(): readonly UserMessage[]
  get nextStep(): readonly UserMessage[]
  get hasPending(): boolean
  clear(): void                          // 持久取消全部待处理
  remove(messageId: MessageId): boolean   // 按 id 删一条
  // append / prepend / replace / splice ...
}

remove / clear 都是持久取消(落 agent/inbox/spliced 事件,重启不复活)。问题只是:这套接口只有插件能调,模型调不到。 所以做成插件,给 skill 加一个工具——这是唯一能把内部接口安全暴露成"模型可调用工具"的方式。

关键决策

① 只删心跳消息,绝不碰用户真消息。 收件箱里可能混着用户输入,一刀 clear() 会误删。心跳消息带来源标记 source.kind === 'schedule',所以工具只挑这类删:

const agent = exec.agent
for (const m of [...agent.inbox.nextStep, ...agent.inbox.nextTurn]) {
  if (m.source?.kind === 'schedule') agent.inbox.remove(m.id)
}

② 间隔从 60 秒改到 300 秒,而不是回到 900 秒。 有了插件兜底"完成后的空转",间隔的价值回到"任务推进节奏":太小(60s)堆内存、测试版常崩崩得更勤;太大(900s)任务间隙白等 15 分钟。折中 300 秒,6 小时最多堆 ~72 条。

七、时间线 / 量化

阶段干了什么产出
做 skill无人值守流程规范:清单落文件、验收门禁、习惯决策、心跳保活、看门狗能挂机跑完整夜班
实测跑真机夜班,崩溃恢复链路全通但暴露"完成后空转"问题
定位读 dsh-schedule 源码,追 followup → agent.inbox根因:积压在收件箱,不在 schedule
定方案查 17 个 tool 包确认模型无工具可删;读 Inbox 接口确认插件可删决策:写插件
实现src/index.ts 128 行,注册 sweep_schedule_inbox 工具lib/index.js 4614 字节
测试4 个单元测试(只删 schedule / 保留 user / dry_run 不改 / 无 agent 抛错)4/4 通过
真机验证完整夜班:杀进程 → 看门狗拉起 → 心跳续跑 → 完成 → sweep见下节

八、验证:空转真的被清零了吗

用一次完整夜班(会话 f0b2a2cb)全程实测:

  • 门禁信号:聊天里弹出 present 卡片 + 磁盘上出现 GATE.md(确认"可以离开")。
  • 崩溃恢复:16:18 杀 DSH → 看门狗 3 分钟内拉起 → 心跳 16:21 叫醒会话续跑。
  • 任务续完:update_goal complete + === DONE ===。
  • 收尾顺序:schedule_delete → 删 run → sweep_schedule_inbox。

最关键的一行证据——sweep 调用后,日志里出现三条持续取消的 splice 事件:

[324] sweep_schedule_inbox
[325] agent/inbox/spliced {"target":"next-turn","removedCount":1,"outcome":"canceled"}
[326] agent/inbox/spliced {"target":"next-turn","removedCount":1,"outcome":"canceled"}
[327] agent/inbox/spliced {"target":"next-turn","removedCount":1,"outcome":"canceled"}
→ 之后 0 条新 reminder、0 个新 turn

3 条排队心跳被持久取消,之后零空转。 skill 的无人值守闭环 + 插件的积压清零,到这里才真正完整。

(诚实说明:单元测试覆盖四类;真机验证覆盖一轮完整夜班。跨版本/跨操作系统兼容性未实测,只用了公开接口推断。)

九、抄作业:怎么用起来

skill 本体(纯 markdown,无需安装):

配套插件(可选,消除完成后空转):

dsh plugin --profile desktop add https://github.com/firstsaofan/dsh-plugin-schedule-sweep

装完把 dsh-plugin-schedule-sweep 加进 profile 的 dsh.profile.bundles,重启 DSH。不装插件 skill 也能用,只是完成后会多几轮空转。

先决条件:DSH Desktop(实测 0.2.1-rc.5 / Windows 11);心跳只在桌面 GUI(Web 宿主)可用,headless 不支持 schedule。

十、红线 / 速查表

  1. 任务清单落文件,别写进对话——对话里的清单每轮重付上下文。
  2. 验收靠证据,不靠"我觉得"——无人值守的唯一裁判。
  3. 积压不在 schedule,在 agent 收件箱——遇到"删了还在响",先想到收件箱。
  4. 心跳间隔:装了插件用 300s,没装用 900s(默认),60s 只用于测试。
  5. 看门狗只判"进程在不在",进程活着绝不动手,不误杀。

十一、结语

完整时间线一行流:想睡觉但任务没做完 → 做一个无人值守 skill(清单落文件 + 验收门禁 + 习惯决策 + 心跳保活 + 看门狗崩溃自愈)→ 实测发现任务做完还空转 17 轮 → 读源码定位"积压在 agent 收件箱而非 schedule" → 写 128 行插件只删心跳消息 → 真机夜班 3 条积压 canceled、空转归零。

这件事让我改了一个习惯:遇到"删了还不停",别先调参数,先问"这个东西到底存在哪一层"。 我一开始对着"间隔"较劲了半天,方向错了;真正一读源码,答案就在 followup → inbox 这一行里。无人值守的价值不在模型多强,在于把这些"你以为它懂、其实靠不住"的地方,一条条变成硬规则。


原文首发:www.firstsaofan.top

本文首发于我的个人博客,其他平台为同步发布。若样式丢失或图片不显示,请到站内阅读完整版。