发布日期: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 登记了没),全绿才让我安心离开——否则明确告诉我"现在挂机不会自恢复"。
三、三句总原则
几乎决定了它所有的行为:
- 验收门禁是唯一裁判:证据绿了才算完成。
- 不等人:拿不准按默认处置、记账、继续。
- 三条红线永不越:
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,无需安装):
- 仓库:https://github.com/firstsaofan/dsh-night-shift
- 用法:把任务写进
BUG-BOARD.md/TODO.md→ 对 DSH 说"按 night-shift 挂机,我走了"。
配套插件(可选,消除完成后空转):
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。
十、红线 / 速查表
- 任务清单落文件,别写进对话——对话里的清单每轮重付上下文。
- 验收靠证据,不靠"我觉得"——无人值守的唯一裁判。
- 积压不在 schedule,在 agent 收件箱——遇到"删了还在响",先想到收件箱。
- 心跳间隔:装了插件用 300s,没装用 900s(默认),60s 只用于测试。
- 看门狗只判"进程在不在",进程活着绝不动手,不误杀。
十一、结语
完整时间线一行流:想睡觉但任务没做完 → 做一个无人值守 skill(清单落文件 + 验收门禁 + 习惯决策 + 心跳保活 + 看门狗崩溃自愈)→ 实测发现任务做完还空转 17 轮 → 读源码定位"积压在 agent 收件箱而非 schedule" → 写 128 行插件只删心跳消息 → 真机夜班 3 条积压 canceled、空转归零。
这件事让我改了一个习惯:遇到"删了还不停",别先调参数,先问"这个东西到底存在哪一层"。 我一开始对着"间隔"较劲了半天,方向错了;真正一读源码,答案就在 followup → inbox 这一行里。无人值守的价值不在模型多强,在于把这些"你以为它懂、其实靠不住"的地方,一条条变成硬规则。
- 夜班无人全自动 skill:https://github.com/firstsaofan/dsh-night-shift
- 配套插件:https://github.com/firstsaofan/dsh-plugin-schedule-sweep
- License:MIT | 作者:firstsaofan
原文首发:www.firstsaofan.top
本文首发于我的个人博客,其他平台为同步发布。若样式丢失或图片不显示,请到站内阅读完整版。
DSH 全自动无人值守 agent 自动完成任务
https://www.firstsaofan.top/archives/dsh-unattended-night-shift-agent-plugin-2
评论