# 定时任务与闲时任务

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

- 作者：David（道雾轩）
- 专栏：ZCode 源码解读（https://daiw.org/manual/zcode.md）
- 最后更新：2026-09-21
- 原文：https://daiw.org/manual/zcode/cron-offpeak
- 转载与引用：请注明出处并附原文链接（https://daiw.org/about/copyright）

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 与闲时计划](https://daiw.org/manual/zcode/accounts-plans)，这里只讲任务。

| 位置 | 职责 |
| --- | --- |
| `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`）：

```mermaid
sequenceDiagram
  participant S as 调度进程
  participant D as tasks-index.sqlite
  participant M as 主进程
  participant H as 窗口 Host
  participant A as Agent 会话
  S->>D: claimDue，事务内认领到期任务
  S->>S: 超过宽限 5 分钟则记为错过
  S->>D: 写一行 automation_runs，状态 claimed
  S->>M: cron-dispatch-request
  M->>H: 转给第一个窗口 Host，消息 CronRun
  H->>A: 新建会话或恢复绑定会话，再 sendPrompt
  H-->>M: CronRunResult
  M-->>S: cron-dispatch-result
  S->>D: 结算，算下一次 next_run_at 或安排重试
```

- **认领**：`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`）：

```ts
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`）：

```ts
    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](https://daiw.org/manual/zcode/subagents)）。每个模型请求都带 `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`）。

```mermaid
stateDiagram-v2
  [*] --> queued: 取号成功
  queued --> paused: 暂停
  paused --> queued: 继续，票据失效则重新取号
  queued --> running: 服务端放行后派发成功
  running --> queued: 票据失效或应用重启
  running --> completed: 这一轮成功
  running --> failed: 这一轮失败
  running --> cancelled: 用户停止
  queued --> cancelled: 取消
  paused --> cancelled: 取消
```

暂停只停本地派发，票据还在服务端排着（`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`）：

```ts
  // 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`）。桌面端的进程分工见[桌面应用](https://daiw.org/manual/zcode/desktop)。

下一篇：[专家工作流与图调度器](https://daiw.org/manual/zcode/expert-workflow)——`core/src/workflow` 里固定八个阶段的长任务流水线、按依赖推进的图调度器，以及它在当前版本里的入口。
