定时任务与闲时任务
模型能用的 Cron 与 OffPeak 工具及其参数,任务定义存进桌面端的 tasks-index.sqlite,由 Electron 常驻调度进程每 20 秒认领派发;执行轮为何隐藏写工具,独立 CLI 为何没有调度,闲时任务怎样取号、轮询与续跑,错过与失败怎样处理。
ZCode 有两类“以后再跑”的任务:定时任务(代码里叫 automation,模型侧工具叫 Cron)按时钟触发;闲时任务(off-peak)不看时钟,先向服务端取号排队,等服务端放出闲时算力再跑,不占套餐额度。两者共用一个调度进程和一条派发管道,但数据表、消息类型和状态机各自独立(packages/shared/src/off-peak-types.ts:6)。
这是桌面端的能力。Agent CLI 这边只有工具、转发用的端口和对执行轮的限制,任务怎么存、谁来到点叫醒,全在桌面端的 host 与 services 里。根目录 NOTICE.md 的说法是(NOTICE.md:22):
不能保证关机后仍运行或错过的任务一定补跑;独立 CLI 也不因包含 Cron 工具就自动具备持久调度服务。
下文会看到代码与这句话一致。账号侧的闲时计划(哪些账号、哪些模型可用)见账号、Coding Plan 与闲时计划,这里只讲任务。
| 位置 | 职责 |
|---|---|
apps/zcode-cli/packages/core/src/tool/handlers/cron.ts、off-peak.ts | 六个工具的声明与 handler |
apps/zcode-cli/packages/bootstrap/src/zcode-protocol/automation-port.ts、offpeak-port.ts | 把工具调用转成对客户端的协议请求 |
packages/services/src/session/automation*.ts | 定时任务的存储、cron 计算、间隔承载 |
packages/services/src/session/offPeak*.ts | 闲时任务的存储、取号与轮询、服务端客户端 |
packages/desktop/src/scheduler/ | 常驻调度进程 |
packages/desktop/src/main/desktopCronScheduler.ts、packages/desktop/src/host/index.ts | 拉起调度进程;把到期任务变成一次会话输入 |
谁拿得到这些工具
Cron 四个工具只在运行时有 automationPort 时注册,闲时两个工具只在有 offPeakPort 时注册,子 Agent 一律拿不到(apps/zcode-cli/packages/core/src/runtime/helpers/runtime-tools.ts:66、runtime-tools.ts:69)。这两个端口只有协议服务端在创建会话时才会装上:automationPort 每个会话都有,offPeakPort 要宿主下发 offPeakToolEnabled(apps/zcode-cli/packages/bootstrap/src/zcode-protocol/server-operations.ts:3370、server-operations.ts:3373)。TUI 和 -p 无头模式不传这两个端口,工具面里根本没有它们。
即使在协议模式下,端口也只是转发:CronCreate 最终变成一次发给客户端的 automation/create 请求(apps/zcode-cli/packages/bootstrap/src/zcode-protocol/automation-port.ts:114),由桌面端 services 里的 AutomationService 落库(packages/services/src/zcode-agent/zcodeAgentService.ts:2481)。调度进程只在桌面端主进程里拉起(packages/desktop/src/main/index.ts:1895),代码里找不到 Web 服务端或 CLI 拉起它的地方。所以 NOTICE 那句话是准确的:CLI 自己既不存任务,也不会到点醒来。
Cron 工具
四个工具的超时都是 30000 毫秒,结果上限 32000 字节(apps/zcode-cli/packages/core/src/tool/handlers/cron.ts:36):
| 工具 | 参数 | 审批 |
|---|---|---|
CronCreate | cron 或 delayMinutes 二选一;prompt、title 必填;recurring、maxRuns;intervalUnit 与 interval 成对 | 需要 |
CronList | 无 | 不需要 |
CronUpdate | id、title 必填,其余字段只传要改的 | 需要 |
CronDelete | id | 需要,标为破坏性 |
参数规则写在 apps/zcode-cli/packages/contracts/src/tools/automation.ts:34:cron 是本地时区的五段表达式;delayMinutes 是 1 到 525600 的整数,表示“从现在起多少分钟后”,给了它就不能再给 cron、recurring: true 或 maxRuns;recurring 缺省为真,maxRuns 只能配 recurring: false;intervalUnit 取 minute、hourly、daily、weekly、monthly、yearly,interval 是 1 到 200。CronUpdate 的说明列了它改不了的东西:工作区、会话绑定、模型、Provider、模式、思考档位、运行次数、历史和启停状态(cron.ts:308)。
工具说明花了大量篇幅纠正模型对时间的误判。相对时间一律走 delayMinutes,注释解释过原因:模型对“现在”几点的认识常常是旧的,自己算出来的一次性时刻一旦刚好过去,就会被悄悄滚到明年(cron.ts:197):
For any schedule expressed as a delay from now — 'in 3 minutes' sets delayMinutes=3, '8分钟后' sets delayMinutes=8, 'in 2 hours' sets delayMinutes=120, 'later'/'稍后' — set delayMinutes to the total whole minutes, omit cron, set recurring=false, and omit maxRuns.
模型和会话不由参数决定:CronCreate 取当前运行时的模型,并把当前会话 ID 作为绑定会话一起交给端口(cron.ts:104);协议端口再读会话此刻的模型选择和权限模式一并保存,auto 模式存成 build(automation-port.ts:130)。于是模型在对话里建的定时任务,以后每次触发都回到这个会话里接着跑,不另开会话。成功后,端口还会把当前会话标题固定成任务标题,防止旁路生成的标题把它改掉(automation-port.ts:157)。
闲时任务工具
OffPeakCreate 的参数是 title、prompt,以及三个只在用户明确要求时才填的可选项:permissionMode、model、thoughtLevel(apps/zcode-cli/packages/contracts/src/tools/off-peak.ts:13)。缺省值由宿主补:模式 yolo(zcodeAgentService.ts:2620),模型取闲时允许列表的最后一个(zcodeAgentService.ts:1030),档位取最高。工具说明开宗明义(apps/zcode-cli/packages/core/src/tool/handlers/off-peak.ts:188):
it takes a queue ticket immediately and later runs unattended in THIS session (with the full conversation history) when the server grants off-peak compute, at no plan-quota cost. There is no guaranteed start time.
也就是说,闲时任务在创建它的会话里继续跑,带着完整历史,但什么时候开始由服务端决定;说明还要求模型:凡是按时间、按周期的需求改用 CronCreate(handlers/off-peak.ts:191)。创建会消耗一次免费取号额度,失败时 handler 按分类给模型一句可转述的话,不让它盲目重试(handlers/off-peak.ts:81):
| 分类或错误码 | 含义 |
|---|---|
quota_3103 | 免费额度用完 |
eligibility_3101 | 当前账号没有可用于闲时任务的 Coding Plan 连接 |
model_not_allowed | 指定的模型不在闲时允许列表里 |
session_bound | 本会话已有一个未结束的闲时任务 |
offpeak_disabled | 账号当前没开通这个功能 |
network | 取号服务不可达 |
OffPeakList 只读,返回本工作区最近创建的 20 条(zcodeAgentService.ts:2677)。
任务定义存在哪
定时任务与闲时任务都存在桌面端的 tasks-index.sqlite 里,与会话索引同库,WAL 模式、可多进程读写(packages/services/src/session/automationRepo.ts:219)。库在数据根目录的 v2 下,默认是 ~/.zcode/v2/tasks-index.sqlite,根目录可由设置或环境变量 ZCODE_DATA_BASE_DIR 改(packages/services/src/paths.ts:187、paths.ts:33)。
| 表 | 内容 |
|---|---|
automations | 定义与调度状态:cron_expr、schedule_rule、next_run_at、retry_at、running、dispatch_attempts、绑定会话 target_task_id 等 |
automation_runs | 每次触发一行,也是 runId 的幂等台账 |
off_peak_tasks | 闲时任务:本地状态、服务端票据 ID、排队位次、是否可派发 |
几条数字与约定:整个本地索引最多保留 20 条定时任务,所有生命周期状态都算,计数和插入放在同一个 BEGIN IMMEDIATE 事务里(packages/shared/src/automation-types.ts:9、automationRepo.ts:324);runId 是 automationId:scheduledAt,手动运行是 automationId:manual:<uuid>(automation-types.ts:129);定时任务 ID 以 automation- 开头(automationRepo.ts:317),闲时任务 ID 以 offpeak- 开头(packages/services/src/session/offPeakTaskService.ts:212)。
cron 表达式用 croner 库(packages/services/package.json:25)解析和校验,但它只负责“解析加算下一次”,不负责调度(packages/services/src/session/automationCron.ts:51)。任务带 scheduleRule 时下一次时间以它为准,cronExpr 只作兼容展示;没有规则时才按 cronExpr 算(automation-types.ts:33、automationCron.ts:343)。几种写法怎样变成规则:
- 每 N 个单位:
intervalUnit加interval是一个“间隔承载”,服务层校验配对与 1 到 200 的范围(packages/services/src/session/automationIntervalCarrier.ts:25),再把兼容 cron 里的时、分、日、星期拆出来,组装成以创建时刻为锚点的规则(automationCron.ts:291)。这样“每 50 小时”“每 40 天”这类 cron 写不出来的间隔也有真实语义。 - 相对延迟:服务层用创建时的真实时钟换算,锚点不按整分钟取整,
cronExpr只是顺带生成的展示值(automationCron.ts:69)。 */N分钟:标准 cron 会对齐墙钟刻度,11:46 建的“每 10 分钟”会在 11:50 先跑一次;服务层补一个以创建时刻为锚点的规则,让它从创建时起算(packages/services/src/session/automationService.ts:216)。- 写死月日的一次性任务:如果目标时刻刚过去不到 1 分钟,立即补跑;过去了 1 到 30 分钟,写库前拒绝并提示改用
delayMinutes(automationCron.ts:7、automationCron.ts:90)。
调度:Electron 里的常驻进程
主进程在本地数据库准备好之后,用 Electron 的 utilityProcess.fork 拉起一个服务名为 zcode-cron-scheduler 的子进程(packages/desktop/src/main/desktopCronScheduler.ts:61,时机见 main/index.ts:1893)。这个进程只读写 tasks-index.sqlite,不碰界面也不碰 Agent 运行时,每 20000 毫秒做一轮(packages/desktop/src/scheduler/index.ts:33):
- 认领:
claimDue在BEGIN IMMEDIATE里先把过了截止日期的任务转为完成、回收认领超过 10 分钟的僵尸锁,再挑出enabled且没在跑、next_run_at或retry_at已到的任务,原子地把running从 0 改成 1(automationRepo.ts:689,常量见automationRepo.ts:40)。同一个任务因此不会被两轮同时派发。 - 转发:主进程把请求交给窗口 Host 表里的第一个;没有窗口 Host、或者应用正在退出,就回一个
transient失败,让调度进程退避重试(desktopCronScheduler.ts:107、desktopCronScheduler.ts:119,选 Host 见packages/desktop/src/main/index.ts:657)。 - 执行:Host 的
dispatchCronRun先把本次运行的模型选择固定下来,同一次运行重试时不再重新解析;任务绑定了会话就恢复那个会话并应用保存的模式与模型,没绑定就新建一个会话;然后以runId作为 traceId、带上automationId发出任务 prompt(packages/desktop/src/host/index.ts:849、host/index.ts:932)。发送成功即算“已派发”,这一轮 Agent 跑得怎样另行跟踪,只用于展示,跑完把会话标为未读(host/index.ts:794)。 - 立即运行:界面上的“立即运行”先写一条
manual运行、占住同一把锁,再由 Host 直接派发,不经过调度进程;它不改next_run_at、次数上限和生命周期,调度进程只在认领超时后兜底(zcodeAgentService.ts:4310、automationRepo.ts:591)。
执行轮为什么禁用写工具
定时任务到点跑的那一轮,模型拿到的只是任务 prompt。如果这一轮还能调用 CronCreate、CronUpdate、CronDelete,任务就能改写自己的定义,或者再派生新的定时任务,形成一条无人值守、越滚越多的调度链。闲时任务同理,再加上一层计费上的顾虑。ZCode 为此设了好几道闸。
对模型隐藏。回合循环在构造本轮发给模型的工具清单时统一隐藏(apps/zcode-cli/packages/core/src/runtime/methods/turn-loop.ts:221):
function buildTurnDisallowedTools(state: RegularTurnLoopState): Set<string> | null {
const tools = new Set(state.toolDisallowlist ?? []);
if (isAutomationMutationRestrictedTurn(state)) {
// 定时任务执行轮只应运行任务 prompt,不能反过来管理自己的定义。
// 保留 CronList 供只读查询;所有 mutation 在 provider 请求边界统一隐藏。
for (const toolName of AUTOMATION_MUTATION_TOOL_NAMES) {
tools.add(toolName);
}
}
if (isOffPeakCreateRestrictedTurn(state)) {
// 闲时执行轮禁止再创建闲时任务(防递归自我派生);OffPeakList 只读保留。
// 注意 automation 执行轮不进此分支——cron turn 放行 OffPeakCreate。
for (const toolName of OFF_PEAK_MUTATION_TOOL_NAMES) {
tools.add(toolName);
}
}
return tools.size > 0 ? tools : null;
}两份名单定义在 apps/zcode-cli/packages/core/src/runtime/methods/turn-loop-state.ts:23 与 turn-loop-state.ts:34:定时执行轮隐藏 CronCreate、CronUpdate、CronDelete;闲时执行轮隐藏 OffPeakCreate、SendMessage、Workflow。后两个进名单的理由写在注释里:它们会在本轮的单次执行约束之外重新拉起子 Agent,按父会话常驻的模型选择建模型,请求就落到用户的套餐上(turn-loop-state.ts:25,SendMessage 一侧的说明见 apps/zcode-cli/packages/core/src/tool/handlers/send-message.ts:16)。定时执行轮刻意放行 OffPeakCreate,允许“定时派生一个闲时任务”。怎样认出执行轮有三重信号:显式的 automationId 或 offPeakTaskId;查询 ID 以 automation- 或 offpeak- 开头;本轮拒绝名单里已经有这些工具(turn-loop-state.ts:130、turn-loop-state.ts:145)。
其余几道:
| 位置 | 做法 | 出处 |
|---|---|---|
| 工具说明 | 要求 prompt 直接写最终要做的事,不许让执行轮再建任务 | cron.ts:214、handlers/off-peak.ts:198 |
| handler | 执行轮里即使收到调用也直接拒绝,不碰端口 | cron.ts:39、handlers/off-peak.ts:39 |
| 桌面端协议信封 | 带 automationId 的输入合并 Cron 写工具的拒绝名单,带 offPeakTaskId 的合并 OffPeakCreate | zcodeAgentService.ts:3298 |
| 闲时派发 | 发送 prompt 时显式拒绝 CronCreate 与 OffPeakCreate | host/index.ts:653 |
| 协议端口 | 本轮由定时任务派发就拒绝建新任务;当前会话已绑定定时任务也拒绝;查询失败按拒绝处理 | automation-port.ts:47、automation-port.ts:95 |
最后一道补的是另一个口子:桌面端的交互输入直连 CLI,不经过注入拒绝名单的那一层,用户在一个已经属于某个定时任务的会话里继续聊天时,CronCreate 仍然可见。端口因此按会话绑定再查一次,一个会话只能挂一个定时任务,注释强调归属查询失败时“未知不能等同于未绑定”(automation-port.ts:52、automation-port.ts:91)。闲时任务的端口同样拒绝在闲时轮里再建闲时任务(apps/zcode-cli/packages/bootstrap/src/zcode-protocol/offpeak-port.ts:43),会话绑定上则只拒绝“本会话已有未结束的闲时任务”,任务结束后可以再建(offpeak-port.ts:26)。
闲时任务:取号、轮询与续跑
闲时任务的生命周期横跨三方:桌面端的 OffPeakTaskService 负责取号和同步状态,调度进程负责认领派发,服务端决定什么时候放行。服务端接口在 /api/v1/off-peak 下,每次请求带 JWT、Coding Plan Key 和计划身份头,超时 10000 毫秒(packages/services/src/session/offPeakServerClient.ts:137、offPeakServerClient.ts:165):
| 接口 | 用途 |
|---|---|
GET /ticket/availability | 查当前能否取号,不能时给出最早恢复时间 |
POST /ticket | 取号,task_id 就是本地任务 ID |
POST /ticket/status | 批量查票据状态,一次最多 100 张 |
POST /ticket/:id/settle | 终态核销,幂等 |
这几个接口只发任务 ID、票据 ID 之类的标识(offPeakServerClient.ts:227、offPeakServerClient.ts:251),不带任务提示词;NOTICE 也专门说明,实际的模型内容另走模型链路(NOTICE.md:47)。
- 创建:先取号,取号成功才落库,取号时票据若已经是
ready就立即唤醒调度进程(offPeakTaskService.ts:167)。对话里建的任务,若本会话已有未结束的闲时任务,取号之前就拒绝,免得白耗额度(offPeakTaskService.ts:198)。 - 轮询:只要有未结束的任务,就批量查状态,间隔听服务端的
next_poll_after,钳在 5 秒到 5 分钟之间;失败时从 10 秒起翻倍退避(offPeakTaskService.ts:30、offPeakTaskService.ts:528)。票据转为ready时任务标记为可派发并唤醒调度进程;排队中的票据过期,就用同一个任务 ID 重新取号(offPeakTaskService.ts:596)。 - 派发:调度进程按排队时间先后认领可派发的
queued任务(packages/services/src/session/offPeakTaskRepo.ts:552),Host 分三种情况执行:表单建的任务首次运行时新建会话;对话里建的任务首次运行时恢复绑定会话,发原 prompt;已经跑过的,恢复同一会话,发一段“接着上次中断的地方做”的续跑提示(packages/desktop/src/host/offPeakDispatchPlan.ts:3、host/index.ts:357)。
发送时带上本次执行专用的约束(host/index.ts:645):
await zcodeTaskService.sendPrompt({
taskId,
traceId,
content: promptContent,
clientMode: "desktop-continuous",
// Bug 原因:闲时自动 turn 以前只注入 idle plan,没有限制工具面,模型可在后台创建
// 持久化定时任务。首跑与续跑在此收敛,显式隐藏 CronCreate 且不伪造 cron automation 归属。
// 闲时轮同时隐藏 OffPeakCreate,OffPeakList 只读保留。
toolDenylist: ["CronCreate", "OffPeakCreate"],
modelSelection: idleSelection,
modelExecution: {
// 闲时执行凭据只服务主 Turn;完成后不再派生自动 Memory 请求。
memoryExtraction: "skip",
selectionScope: "execution",
requestAuth,
subagents: {
foregroundModel: "submission",
background: "deny",
},
},
offPeakTaskId: request.offPeakTaskId,
offPeakRunType,
});闲时模型只用于这一轮,不写回会话的模型选择;本轮不做项目记忆抽取;子 Agent 只能前台运行,并沿用这一轮的闲时模型,后台子 Agent 会被拒绝(见子 Agent)。每个模型请求都带 X-Off-Peak-Ticket-ID 头(packages/services/src/session/offPeakRuntimeModel.ts:256)。
模型适配层对闲时请求有两条特判(apps/zcode-cli/packages/adapters/src/model/offpeak-retry.ts:30):HTTP 429 或业务码 3105 是“还在排队”,等 Retry-After,最多 5 分钟一次,不限次数地重试;业务码 3102 表示票据失效,即运行满 3 小时或 ready 后 5 分钟没发请求(off-peak-types.ts:32),这一轮以一个固定标记失败。Host 看到这个标记不把任务记为失败,而是把它放回队列、用同一个任务 ID 重新取号,下次放行时恢复同一会话继续(host/index.ts:458)。
暂停只停本地派发,票据还在服务端排着(offPeakTaskService.ts:298);任务进入终态后要去服务端核销,失败的留到下一个轮询周期补报(offPeakTaskService.ts:9、offPeakTaskService.ts:656)。开发时设置环境变量 ZCODE_OFFPEAK_MOCK=1,会在本机起一个模拟网关,取号、放行、过期都在本地模拟,但模型请求仍转发到用户自己的 Coding Plan 端点(packages/services/src/session/offPeakMockGateway.ts:67、offPeakMockGateway.ts:7)。
错过与失败
| 情形 | 定时任务 | 闲时任务 |
|---|---|---|
| 错过触发时刻 | 首次认领时计划时间已早于 5 分钟前,记一次 skipped,原因 computer_asleep_or_app_not_running,不补跑;循环任务排到下一次,一次性任务直接结束(scheduler/index.ts:138) | 不看时钟,没有错过一说,一直顺延(scheduler/index.ts:8) |
| 派发暂时失败 | 退避 30 秒乘 2 的 n−1 次方,封顶 15 分钟;第 5 次仍失败,循环任务放弃这一轮,有限任务转 failed 并停用(automationRepo.ts:36、automationRepo.ts:982) | 同样的退避公式,不限次数;退避表在内存里,调度进程重启即清零(packages/desktop/src/scheduler/offPeakDispatchSettlement.ts:73) |
| 确定性失败 | permanent 直接转 failed 并停用(automationRepo.ts:971) | 缺模型、缺凭证、绑定会话被删等转 failed(offPeakDispatchSettlement.ts:52) |
| 进程崩溃或退出 | 退出时释放在途认领;来不及释放的,10 分钟后当作僵尸锁回收 | 调度进程启动时把残留的 running 改回 queued,保留会话以便续跑(scheduler/index.ts:420) |
调度进程判定错过的那段代码(scheduler/index.ts:138):
// misfire:首轮(非重试)且计划触发时间已远早于 now → 认定错过窗口,跳过不补跑。
const missed =
!isRetry && automation.nextRunAt != null && automation.nextRunAt <= now - MISFIRE_GRACE_MS;
if (missed) {
// 纯一次性任务(如 delayMinutes 落成的 minute scheduleRule)错过窗口后,
// 通用重算会给出 anchorAt + k*interval 的下一周期,让“只跑一次”的提醒在后续周期
// 继续执行。一次性语义是确定的目标时刻,错过即终态,不得再排程新的执行承诺。
const finalize = isOneShotAutomation(automation);
const nextRunAt = finalize ? null : computeAutomationNextRunAt(automation, now);
await repo.skipAndReschedule({
automationId: automation.automationId,
runId,
workspaceKey,
scheduledAt,
reason: "computer_asleep_or_app_not_running",
nextRunAt,
finalize,
});这也解释了 NOTICE 里的限定:窗口只是隐藏到托盘、它的 Host 进程还在时,任务照常触发;一个窗口 Host 都没有时,派发按暂时失败退避,第 5 次后循环任务放弃这一轮、有限任务转为失败;机器休眠或应用退出超过 5 分钟,错过的那一次就没了。主进程有一个 keepAwakeWhileRunning 设置,开启后持有系统的防睡眠锁,缺省关闭(packages/desktop/src/main/index.ts:1879、main/index.ts:1883)。桌面端的进程分工见桌面应用。
下一篇:专家工作流与图调度器——core/src/workflow 里固定八个阶段的长任务流水线、按依赖推进的图调度器,以及它在当前版本里的入口。