定时任务与闲时任务

模型能用的 Cron 与 OffPeak 工具及其参数,任务定义存进桌面端的 tasks-index.sqlite,由 Electron 常驻调度进程每 20 秒认领派发;执行轮为何隐藏写工具,独立 CLI 为何没有调度,闲时任务怎样取号、轮询与续跑,错过与失败怎样处理。

作者 David更新于 29 篇(共 47 篇)

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.tsoff-peak.ts六个工具的声明与 handler
apps/zcode-cli/packages/bootstrap/src/zcode-protocol/automation-port.tsoffpeak-port.ts把工具调用转成对客户端的协议请求
packages/services/src/session/automation*.ts定时任务的存储、cron 计算、间隔承载
packages/services/src/session/offPeak*.ts闲时任务的存储、取号与轮询、服务端客户端
packages/desktop/src/scheduler/常驻调度进程
packages/desktop/src/main/desktopCronScheduler.tspackages/desktop/src/host/index.ts拉起调度进程;把到期任务变成一次会话输入

谁拿得到这些工具

Cron 四个工具只在运行时有 automationPort 时注册,闲时两个工具只在有 offPeakPort 时注册,子 Agent 一律拿不到(apps/zcode-cli/packages/core/src/runtime/helpers/runtime-tools.ts:66runtime-tools.ts:69)。这两个端口只有协议服务端在创建会话时才会装上:automationPort 每个会话都有,offPeakPort 要宿主下发 offPeakToolEnabledapps/zcode-cli/packages/bootstrap/src/zcode-protocol/server-operations.ts:3370server-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):

工具参数审批
CronCreatecrondelayMinutes 二选一;prompttitle 必填;recurringmaxRunsintervalUnitinterval 成对需要
CronList不需要
CronUpdateidtitle 必填,其余字段只传要改的需要
CronDeleteid需要,标为破坏性

参数规则写在 apps/zcode-cli/packages/contracts/src/tools/automation.ts:34cron 是本地时区的五段表达式;delayMinutes 是 1 到 525600 的整数,表示“从现在起多少分钟后”,给了它就不能再给 cronrecurring: truemaxRunsrecurring 缺省为真,maxRuns 只能配 recurring: falseintervalUnitminutehourlydailyweeklymonthlyyearlyinterval 是 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 模式存成 buildautomation-port.ts:130)。于是模型在对话里建的定时任务,以后每次触发都回到这个会话里接着跑,不另开会话。成功后,端口还会把当前会话标题固定成任务标题,防止旁路生成的标题把它改掉(automation-port.ts:157)。

闲时任务工具

OffPeakCreate 的参数是 titleprompt,以及三个只在用户明确要求时才填的可选项:permissionModemodelthoughtLevelapps/zcode-cli/packages/contracts/src/tools/off-peak.ts:13)。缺省值由宿主补:模式 yolozcodeAgentService.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.

也就是说,闲时任务在创建它的会话里继续跑,带着完整历史,但什么时候开始由服务端决定;说明还要求模型:凡是按时间、按周期的需求改用 CronCreatehandlers/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:187paths.ts:33)。

内容
automations定义与调度状态:cron_exprschedule_rulenext_run_atretry_atrunningdispatch_attempts、绑定会话 target_task_id
automation_runs每次触发一行,也是 runId 的幂等台账
off_peak_tasks闲时任务:本地状态、服务端票据 ID、排队位次、是否可派发

几条数字与约定:整个本地索引最多保留 20 条定时任务,所有生命周期状态都算,计数和插入放在同一个 BEGIN IMMEDIATE 事务里(packages/shared/src/automation-types.ts:9automationRepo.ts:324);runIdautomationId: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:33automationCron.ts:343)。几种写法怎样变成规则:

  • 每 N 个单位intervalUnitinterval 是一个“间隔承载”,服务层校验配对与 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 分钟,写库前拒绝并提示改用 delayMinutesautomationCron.ts:7automationCron.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):

图表加载中…
  • 认领claimDueBEGIN IMMEDIATE 里先把过了截止日期的任务转为完成、回收认领超过 10 分钟的僵尸锁,再挑出 enabled 且没在跑、next_run_atretry_at 已到的任务,原子地把 running 从 0 改成 1(automationRepo.ts:689,常量见 automationRepo.ts:40)。同一个任务因此不会被两轮同时派发。
  • 转发:主进程把请求交给窗口 Host 表里的第一个;没有窗口 Host、或者应用正在退出,就回一个 transient 失败,让调度进程退避重试(desktopCronScheduler.ts:107desktopCronScheduler.ts:119,选 Host 见 packages/desktop/src/main/index.ts:657)。
  • 执行:Host 的 dispatchCronRun 先把本次运行的模型选择固定下来,同一次运行重试时不再重新解析;任务绑定了会话就恢复那个会话并应用保存的模式与模型,没绑定就新建一个会话;然后以 runId 作为 traceId、带上 automationId 发出任务 prompt(packages/desktop/src/host/index.ts:849host/index.ts:932)。发送成功即算“已派发”,这一轮 Agent 跑得怎样另行跟踪,只用于展示,跑完把会话标为未读(host/index.ts:794)。
  • 立即运行:界面上的“立即运行”先写一条 manual 运行、占住同一把锁,再由 Host 直接派发,不经过调度进程;它不改 next_run_at、次数上限和生命周期,调度进程只在认领超时后兜底(zcodeAgentService.ts:4310automationRepo.ts:591)。

执行轮为什么禁用写工具

定时任务到点跑的那一轮,模型拿到的只是任务 prompt。如果这一轮还能调用 CronCreateCronUpdateCronDelete,任务就能改写自己的定义,或者再派生新的定时任务,形成一条无人值守、越滚越多的调度链。闲时任务同理,再加上一层计费上的顾虑。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:23turn-loop-state.ts:34:定时执行轮隐藏 CronCreateCronUpdateCronDelete;闲时执行轮隐藏 OffPeakCreateSendMessageWorkflow。后两个进名单的理由写在注释里:它们会在本轮的单次执行约束之外重新拉起子 Agent,按父会话常驻的模型选择建模型,请求就落到用户的套餐上(turn-loop-state.ts:25SendMessage 一侧的说明见 apps/zcode-cli/packages/core/src/tool/handlers/send-message.ts:16)。定时执行轮刻意放行 OffPeakCreate,允许“定时派生一个闲时任务”。怎样认出执行轮有三重信号:显式的 automationIdoffPeakTaskId;查询 ID 以 automation-offpeak- 开头;本轮拒绝名单里已经有这些工具(turn-loop-state.ts:130turn-loop-state.ts:145)。

其余几道

位置做法出处
工具说明要求 prompt 直接写最终要做的事,不许让执行轮再建任务cron.ts:214handlers/off-peak.ts:198
handler执行轮里即使收到调用也直接拒绝,不碰端口cron.ts:39handlers/off-peak.ts:39
桌面端协议信封automationId 的输入合并 Cron 写工具的拒绝名单,带 offPeakTaskId 的合并 OffPeakCreatezcodeAgentService.ts:3298
闲时派发发送 prompt 时显式拒绝 CronCreateOffPeakCreatehost/index.ts:653
协议端口本轮由定时任务派发就拒绝建新任务;当前会话已绑定定时任务也拒绝;查询失败按拒绝处理automation-port.ts:47automation-port.ts:95

最后一道补的是另一个口子:桌面端的交互输入直连 CLI,不经过注入拒绝名单的那一层,用户在一个已经属于某个定时任务的会话里继续聊天时,CronCreate 仍然可见。端口因此按会话绑定再查一次,一个会话只能挂一个定时任务,注释强调归属查询失败时“未知不能等同于未绑定”(automation-port.ts:52automation-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:137offPeakServerClient.ts:165):

接口用途
GET /ticket/availability查当前能否取号,不能时给出最早恢复时间
POST /ticket取号,task_id 就是本地任务 ID
POST /ticket/status批量查票据状态,一次最多 100 张
POST /ticket/:id/settle终态核销,幂等

这几个接口只发任务 ID、票据 ID 之类的标识(offPeakServerClient.ts:227offPeakServerClient.ts:251),不带任务提示词;NOTICE 也专门说明,实际的模型内容另走模型链路(NOTICE.md:47)。

  • 创建:先取号,取号成功才落库,取号时票据若已经是 ready 就立即唤醒调度进程(offPeakTaskService.ts:167)。对话里建的任务,若本会话已有未结束的闲时任务,取号之前就拒绝,免得白耗额度(offPeakTaskService.ts:198)。
  • 轮询:只要有未结束的任务,就批量查状态,间隔听服务端的 next_poll_after,钳在 5 秒到 5 分钟之间;失败时从 10 秒起翻倍退避(offPeakTaskService.ts:30offPeakTaskService.ts:528)。票据转为 ready 时任务标记为可派发并唤醒调度进程;排队中的票据过期,就用同一个任务 ID 重新取号(offPeakTaskService.ts:596)。
  • 派发:调度进程按排队时间先后认领可派发的 queued 任务(packages/services/src/session/offPeakTaskRepo.ts:552),Host 分三种情况执行:表单建的任务首次运行时新建会话;对话里建的任务首次运行时恢复绑定会话,发原 prompt;已经跑过的,恢复同一会话,发一段“接着上次中断的地方做”的续跑提示(packages/desktop/src/host/offPeakDispatchPlan.ts:3host/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:9offPeakTaskService.ts:656)。开发时设置环境变量 ZCODE_OFFPEAK_MOCK=1,会在本机起一个模拟网关,取号、放行、过期都在本地模拟,但模型请求仍转发到用户自己的 Coding Plan 端点(packages/services/src/session/offPeakMockGateway.ts:67offPeakMockGateway.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:36automationRepo.ts:982同样的退避公式,不限次数;退避表在内存里,调度进程重启即清零(packages/desktop/src/scheduler/offPeakDispatchSettlement.ts:73
确定性失败permanent 直接转 failed 并停用(automationRepo.ts:971缺模型、缺凭证、绑定会话被删等转 failedoffPeakDispatchSettlement.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:1879main/index.ts:1883)。桌面端的进程分工见桌面应用

下一篇:专家工作流与图调度器——core/src/workflow 里固定八个阶段的长任务流水线、按依赖推进的图调度器,以及它在当前版本里的入口。

本页目录