跳到主要内容

内容阅读

web-schedule:dsh 会话内的定时、提醒与自动化

web-schedule:dsh 会话内的定时、提醒与自动化

web-schedule 是一层挂在 dsh web 上的会话内自动化 overlay:给模型三个工具,在当前会话里安排延迟的后续动作。一个提醒的本质是一段被推迟的 follow-up turn,记录写在会话日志里,等根 agent 完全空闲之后,在那个对话里排队一条普通消息。它不发浏览器通知、不发系统通知、不发邮件短信,到点时你合着笔记本,什么都不会发生。 两条最值得记住的纪律:时间上没有 session 默认时区,at 必须带明确时区,落库只存算出来的 UTC 目标;定时器是进程内的、记录是持久的,进程关了定时器停但记录还在,重开会话恢复等待并补投过期的提醒,错过的间隔不积压。

先纠正一个预期

看到 schedule 和 reminder 这两个词,多数人脑子里浮现的是一个通知系统:定个时间,到点了弹浏览器通知、推系统消息、发封邮件。web-schedule 不是这个东西,把它当这个东西用,第一天就会失望。

它是一层 overlay,让一个 dsh web 进程拥有会话内的提醒能力。启动命令是 dsh web --patch examples/web-schedule/cordis.yml,不改默认的 Web 组合。加载之后,模型得到三个工具:schedule_createschedule_listschedule_delete,每个工具的返回结果都会把投递方式标识为 session-local

session-local 这个词是理解全部行为的钥匙。一个提醒到点后的投递,是这个会话的根 agent 等到完全空闲,然后在自己所在的那个对话里排队一个普通的 follow-up turn。它绝不打断 agent 正在做的事,没有单独的提醒卡片,没有特殊 UI,就是一条普通消息进了对话,agent 像处理任何用户消息一样处理它。

这个设计的直接后果必须说透:你要让那个 dsh web 进程开着、那个会话的 agent 空闲着,到点了提醒才会出现在对话里。你合上笔记本、杀掉进程,到点了什么都不会发生,没有后台守护进程替你盯着。等下次重开会话,过期的提醒才补投进来。这不是缺陷,是画死的边界:它解决的是"在 agent 会话内部安排后续动作",不是"给人发定时通知"。想给真人发通知,得在会话外面另配一套系统,那是另一个问题。

为什么把边界画这么死?因为一旦承诺外部通知,这层 overlay 就得管通知渠道的认证、重试、去重、静默时段,复杂度翻几倍,而它要解决的核心问题一个都没变。把"到点后在对话里插一句话"做扎实,比把"到点后想尽办法找到你"做一半,对长期挂着的开发助手场景有用得多。

三个工具,三种触发

模型侧的接口小得可以在一段话里讲完。schedule_create 创建提醒,支持三种触发方式:after_seconds 是正整数秒数,从创建那一刻起等;at 是绝对时间目标;every_seconds 是固定频率间隔,最小 300 秒。schedule_list 列出当前提醒,schedule_delete 删除。

三种触发方式覆盖了三类真实需求:等一会再查(相对延迟)、到点做事(绝对时刻)、周期性巡检(固定频率)。没有列出的同样重要:没有 cron 表达式,没有 calendar 表达式。想表达"每个工作日早上九点"这种规则,这个工具表达不了,你得自己算成一组 at 目标,或者接受它不是干这个的。

every_seconds 的 300 秒下限是一道防滥用闸。没有这道闸,模型可以给自己安排每秒一次的自我唤醒,一个失控的循环就能把 token 消耗和日志膨胀同时点燃。有了这道闸,schedule 被锁定在"人尺度的提醒"上:每五分钟检查一次构建可以,每秒轮询一个接口不行。真需要高频轮询,那是事件循环或定时任务的活,不是提醒系统的活。

创建和删除的返回时机有个容易被忽略的细节:两者都在会话持久化确认了对应的事件前缀之后,才向模型确认成功。换句话说,工具说"创建成功"的时候,提醒的事件已经确实写进了会话日志,不是只在内存里立了个牌位。这堵住了一个撕裂场景:工具返回成功、进程随即崩溃、重开后提醒却不见了。在这套实现里,返回成功和日志里有记录是同一件事,中间没有窗口。

一个提醒的解剖:记录、定时器、follow-up

把一个提醒拆开,它是三个部件的组合,三个部件的生死周期各不相同。

第一个部件是记录。提醒被创建时,它作为事件写进会话日志。会话日志是这个会话一切交互历史的唯一事实来源,消息历史从日志派生、从不单独存储,提醒记录也一样寄居在这份日志里。记录是持久的,跟着日志落盘,进程死了它不死。

第二个部件是定时器。进程内的内存状态,负责"等到时间到"这件事。它活在那个 dsh web 进程里,进程关了它就没了。定时器和记录的分离是整个恢复语义的地基:定时器可以死,但提醒的意图不会丢,因为它记在日志里,重开会话时扫一遍日志里未投递的记录,就能把等待重新建立起来。

第三个部件是投递,也就是到点后发生的那个动作:根 agent 完全空闲后,在那个对话里排队一个普通的 follow-up turn。这里值得看清楚它复用了什么。会话机制里,一个 turn 是一段只能追加的日志,由 turn 开始和结束事件框住;循环在 turn 开始之后认领排队中的输入,被认领的消息连同注入的上下文一起记录成用户消息事件。空闲注入有个明确的规则:它会保持待定,直到一次唤醒式投递把下一个 turn 打开。提醒走的正是这条路:它不是特权通道,它是排队输入里的一条,和用户亲手敲进去的消息走同一扇门。

这个复用解释了为什么"根 agent 完全空闲"是硬条件。提醒绝不能 steering 当前正在做的工作,而排队输入的认领本来就发生在 turn 边界,注入的内容要等一次唤醒式投递开起新 turn 才真正进入循环。schedule 没有造一套自己的消息系统,它把"到点了"翻译成"现在往排队输入里放一条消息",剩下的全部搭会话机制的便车。

投递本身还有一道谦逊的边界:一次持久化的派发只记录"follow-up 已排队",不确认模型处理成功,也不确认用户收到。理由是结构性的:一旦排队进对话,它就是一次普通的 turn,做没做好取决于 agent 的能力,不取决于 schedule。让 schedule 替 agent 的后续表现背书,既做不到,也没意义。

时间的绝对权威

时间处理是这层 overlay 最讲究的部分,也是最容易踩坑的部分。它的立场可以用一句话概括:自然语言的解释可以宽松,机器字段的存储必须绝对。

宽松的一半在自然语言层。浏览器会给每个 prompt 附上自己的 IANA 时区,时间上下文告诉模型:遇到没带时区的日期时间,按这个请求的浏览器时区解释。用户说"下午三点",模型用浏览器时区把它落成一个具体时刻。这一步允许推断,因为用户的话本来就是本地口语。

严格的一半在 schedule_create.at 这个机器字段上。它只接受两种形态:要么是一个严格的 RFC 3339 日期时间,带 Z 或数字偏移;要么是日期、时间加时区的三件组,时区必须是明确的 UTC 或 IANA 的区域写法。没有第三种。想靠上下文猜时区,字段层直接不收。

比字段校验更狠的一条:Schedule 不保留也不推断 session 默认时区。每次创建都得带明确时区,不存在"这个会话默认用东八区"这种隐式状态。为什么把隐式状态赶尽杀绝?因为它是真实的 bug 温床。设想用户在上海创建了会话,第二天飞到东京,浏览器的时区报的是东京时间。如果存在会话默认时区,同一个提醒的解释会随用户所在位置漂移;如果存的是"按创建时的本地时间"之类的东西,夏令时一来触发时刻还会漂。这套实现把歧义在创建那一刻就消解掉:强制显式时区,算出一个 UTC 目标,落库只留这个 UTC 值,原始时区信息不留。之后无论用户在哪、时区规则怎么改,触发时刻都是创建那一刻定死的。

夏令时是这个立场的试金石。春季拨快一小时,凌晨两点到三点这段时间不存在,本地时间定在这个空档里的,创建直接拒绝,不猜你是想要三点前还是三点后。秋季拨慢一小时,凌晨两点到三点出现两次,定在这个重叠里的,选第一个瞬间,也不猜。两种情况都不给"智能修正",因为修正就是猜测,猜测就会在某次夏令时切换后错一个小时。宁可创建时多一次往返,不留运行时的定时炸弹。

拿具体值走一遍两种合法形态。RFC 3339 形态:2026-09-01T09:00:00+08:00,偏移写死,天下无双解。三件组形态:日期 2026-09-01、时间 09:00、时区 Asia/Shanghai,IANA 区域名必须完整,Asia 加斜杠加城市,缩写和固定名词都不收。两种形态殊途同归,都先算出唯一的 UTC 瞬间再落库。一个容易犯的错是拿浏览器时区去填三件组:模型可以参考浏览器时区解释你的口语,但填进字段的时区是你声明的意图,两边不一致时以字段为准,字段说什么就是什么。

再算一遍跨时区漂移,看这套设计挡掉了什么。你在上海晚上八点创建一个"明早九点"的提醒,浏览器时区是 Asia/Shanghai,落库目标是 UTC 一点。第二天你人在东京,浏览器时区变成了 Asia/Tokyo。如果实现里存的是"明早九点"这个本地表述,或者存了个会话默认时区,重开后的解释就可能变成东京九点,比原意早一小时触发。这套实现里没有这种可能性:落库那一刻就只剩 UTC 一点,你在哪、浏览报什么,都动不了它。

回过头看,整个时间设计是一条单向阀门:口语进来时可以模糊,落成记录之前必须收敛成唯一的 UTC 瞬间,之后只认这个瞬间。提醒的对齐永远基于创建时间,不受后续时区变化或夏令时变化影响。

冷热恢复:定时器会死,记录不死

一个提醒从创建到投递之间可能隔很久,中间进程会关、会崩、会话会变冷。这层 overlay 对这些情况的回答,全部建立在"定时器和记录分离"上。

正常的生命周期是这样的:创建,记录落日志,进程内立起定时器;到点,agent 空闲,follow-up 排队,投递完成,记录标记已投递。平滑世界里的路径就这么长。

不平滑的世界从关闭进程开始。关闭进程或让会话变冷,内存里的定时器停了,但记录原封不动躺在日志里。下次重开同一个会话,等待被恢复,过期的提醒补投。注意"重开"和"读"是两回事:只是打开冷历史看日志,永远不激活提醒。看日志的是读者,不是活的 agent;一个会话真正活过来,是它被重新拉起、从存储的日志做种、续写新事件的时候。会话机制里有个精致的细节可以佐证这条边界:一个被种的会话把结束标记作为自己的第一笔活跃写入,而一个原样重开、什么都没发生的会话不会在日志里堆叠新标记,读历史不产生副作用。

崩溃走的也是同一条路。进程崩了,崩之前记录进日志的事件完好,重开后恢复等待。这套韧性不是 schedule 自己发明的,它直接继承会话的崩溃恢复语义:被崩溃孤立的 turn 会被合上,之前记录的事件不丢。schedule 只是把自己的记录放在了正确的地方。

还有一个反直觉的规则:fork 不继承父会话的提醒。要看懂这条,得先看 fork 是什么:fork 从源日志选择一段事件前缀,要求这段前缀结束在打开的 turn 之外,子会话拿到这段深拷贝的种子事件,加上自己的父会话、种子长度等元数据,从此是一个新的会话身份。提醒的所有权挂在原始会话的日志上,而 fork 出来的对话是另一个会话。即便种子事件里躺着提醒的创建记录,等待和投递也不会在新会话里发生。这个设计防的是一个混乱场景:如果不禁止继承,一个被 fork 出来的会话也到点提醒,两个对话同时醒来说同一句话,用户和日志都会困惑。一个提醒一个家,家是创建它的那个会话。

补投的算术

补投规则值得单独讲,因为朴素实现在这里几乎必然翻车。

先看一次性提醒。设一个十点的提醒,到点时进程关着,十一点重开。补投规则是投它,因为它最新该到。它过期多久不影响投递次数,投一次就完。

再看固定频率的提醒,这里才是朴素实现的重灾区。设一个每五分钟的提醒,十点创建,进程十点零五关了,十一点零五重开。朴素做法会把错过的时间补齐:十点零五、十点一十、一直到十一点,十二次提醒一次不落,对话被刷爆,每一次还都是一次模型调用。这里的规则是只呈现最新该到的那一次,错过的间隔不积压。下次目标仍按原始序列算:十一点一十就是十一点一十,不因为十一点零五补投过一次就顺延成十一点一十再加五分钟。

这里的技术选择是固定频率而不是固定延迟,两者的区别在于锚点。固定延迟从上次投递起算,投递一晚,整个序列就往后蹭;固定频率锚在创建时刻的序列上,投递晚了,序列不漂。对"每五分钟检查一次构建"这种意图,固定频率才是对的语义:你要的是采样率,不是节奏感。

多条提醒同时过期时还有一条合并规则:在同一个空闲决策点上,所有不同的、同时过期的固定频率记录,合并成一个 follow-up,每条出现一次。十个巡检提醒同时在等你,醒来看到的是一条汇总消息,不是十条轰炸。顺序上也有讲究:到期的一次性提醒在固定频率批次之前跑,先办单次的事,再处理周期的。

把这几条规则放在一起看,它们服务于同一个目标:长时间离线之后的回归体验可控。你休假一周回来重开会话,看到的是每个提醒最新该到的那一次、合并成尽量少的消息,而不是一周的积压清单。代价是"错过了几次"这个信息被压掉了,做统计的人会不满,但做统计本来就该看日志而不是看投递。

一次完整投递走查

把整条链路从头到尾串一遍,用一个具体例子。

上午十点,你在 dsh web 会话里让 agent"二十分钟后检查一下测试跑完没有"。模型调 schedule_createafter_seconds 给 1200。提醒事件写进会话日志,持久化确认了事件前缀,工具返回成功,结果里投递方式写着 session-local。进程内立起定时器。这中间所有步骤都是普通会话机制:事件落日志、持久化经统一的检查点确认,schedule 没有自己的存储。

十点二十,定时器到点。此时 agent 正在跑一个长任务,提醒不会打断它,排队输入里那条消息安静等着。agent 干完手头的活,turn 合上,完全空闲,下一次唤醒式投递开起新 turn,循环认领排队输入,那条"二十分钟到了"的消息连同上下文被记录成本 turn 的用户消息。同时一笔持久化派发记录写下去,内容只是"follow-up 已排队",到此 schedule 的责任结束。agent 读到消息,去查测试,回复你,这一切是普通 turn 的事。

换一个剧本看恢复。十点零五你合上电脑,设了每五分钟的巡检提醒。十一点零五回来重开会话:会话从日志做种活过来,扫描未投递的提醒,发现这条固定频率记录的目标序列已经走到十一点零五,把最新该到的这一次投进对话,一条消息,不是十二条。序列继续按原始锚点走,十一点一十是下一个。你要是只是开了冷历史看看日志,agent 没活,什么都不投。

两个剧本覆盖了这套机制的自白:活着的时候,它是排队输入里的一条普通消息;死过一回的时候,它是日志里一条没丢的记录。两种形态之间靠会话的冷热语义切换,schedule 自己没有发明任何状态机。

和外部定时器的分界

把 schedule 放到候选方案里比一比,边界更清楚。

系统级定时器,crontab 或 systemd timer,管的是"到点在操作系统里跑一条命令"。它的触发不依赖任何进程活着,精度高、语法成熟。但它叫醒的是命令,不是对话:到点的动作如果要经过模型,你得自己写一层胶水把命令的输出喂回 agent 会话,会话的上下文、日志、审批全都对不上。schedule 反过来,触发就发生在对话内部,醒来自带全部上下文。

操作系统通知和推送服务管的是"到点找到这个人"。它解决人机之间的最后一米,不解决 agent 内部的后续动作。如果你的真实需求是"别让我忘了三点开会",正确答案是日历软件,不是给 agent 加提醒;agent 在不在运行、会话开没开,不该是这类提醒的先决条件。

外部编排器管的是"到点调用一个 API"。适合已经服务化的工作流,headless 的 SDK 调用也能接。但如果到点之后要做的事依赖这个会话里积累的上下文,先前任性的排查记录、读过的文件、讨论过的方案,从外面重新喂一遍上下文,成本和失真都不可控。schedule 的价值恰好在免掉这一步:动作和上下文在同一处。

分界线画出来就是一句话:动作属于对话,用 schedule;动作属于操作系统,用定时器;动作属于人,用日历;动作属于服务,用编排器。四件事经常被一个"提醒"混着说,拆开之后每个工具的适用范围都很窄,窄是对的。

overlay 而非核心

最后看它挂载的方式,这里体现的是 harness 一贯的取舍。

默认的 dsh web 没有 schedule 能力,模型看不到这三个工具。想要,就 --patch examples/web-schedule/cordis.yml 把这层 overlay 叠上去。overlay 注册三个工具、挂上定时器逻辑、接上会话的 follow-up 机制,就完成了。仓库的示例总览把它定位成可运行的扩展点演示:主接口和扩展点的活例子,一个 durable、Session-local 提醒的 Web overlay。

这个安排让默认组合保持精简。一个 headless 的 CI runner、一个只做代码生成的部署,不需要定时能力,也就不会被它的复杂度拖累;需要长期挂着会话做巡检的开发助手,一条命令把能力叠上。能力按需出现,核心不为低频需求买单,这是插件化在这个具体功能上的落点。

还要认清它的身份:它是 example,不是一等公民子系统。没有 patch 这层 overlay 的部署里,模型根本没有 schedule 工具,也不存在"隐藏的定时器还在跑"的残留。能力的有无和文件的加载严格一致,这比"核心里带一个默认关闭的开关"更干净,后者迟早出现配置漂移和"为什么这里有个死开关"的考古题。

权衡

它的局限要一条条说清,每条都是设计选择而不是疏忽。

它不是通用定时任务系统。没有 cron、没有 calendar 表达式、有最小间隔下限、没有外部通知。拿它当 crontab 用,第一天就会撞上这些墙。它只解决"在 agent 会话内安排一个延迟的后续动作"这个窄场景,并且把这个场景做到了零新增基础设施:没有独立的通知系统,没有独立的消息通道,没有独立的持久化,提醒就是日志事件加一次普通的 follow-up,崩溃恢复直接继承会话语义。

投递依赖进程开着和 agent 空闲。对长期挂着的开发助手,这合理;对偶尔打开用一下的场景,意味着提醒总会迟到,虽然重开必补投。迟到是常态而不是异常,接受这一点再用。

补投只呈现最新该到的那次。想知道"我错过了几次",投递里没有答案,答案在日志里。对调试和统计,这是个额外步骤。

时区必须显式。防住了跨时区漂移的 bug,代价是忘了带时区的创建会被拒。模型侧有浏览器时区做自然语言兜底,但机器字段上没有宽容。一次拒绝换来的是之后永不漂移,这笔账在提醒这种"创建一次、长期生效"的场景里是划算的。

它是 example 不是核心。仓库不给它子系统的文档待遇,行为契约全在示例 README 里。把它用于生产,等于接受"示例级支持"的事实,出了问题先读 README 再读代码。

结论

web-schedule 用一层 overlay 给会话加了定时能力,内核是一个三部件结构:记录进会话日志、定时器活在进程里、投递是根 agent 空闲后的一条普通 follow-up。时间上绝对权威,显式时区、只存 UTC、夏令时空档拒绝重叠取第一;恢复上定时器和记录分离,重开补投、错过的间隔不积压、同时过期的合并、fork 不继承。它不是通知系统也不是 crontab,是"agent 会话内的延迟后续动作",用窄场景换来了零新增基础设施。

这个小例子把 harness 的几条主线捏在了一个功能里:能力做成按需 overlay 而不是核心开关;会话日志是唯一事实来源,连提醒这种边角能力都寄居其上;新功能复用既有机制而不是另起炉灶,follow-up 走排队输入的老路;显式状态优于隐式默认,时区问题在创建时就地消解。读别的 harness 功能设计时,拿这四条去对照,往往能提前看出它会把复杂度藏在哪里。

延伸阅读

上一篇:子 Agent 与多智能体:dsh 怎么调度另一个 agent 下一篇:MCP 协议在 dsh 中的位置:一个通用客户端,一份记忆服务器接入手册


GitHub 原文:31-web-schedule-timer-automation.md

评论

0
登录后可以参与评论和讨论。

EMPTY

还没有评论

欢迎留下第一条评论,帮助这篇内容更快形成讨论。