40 KiB
统一定时任务执行与投递设计
状态:设计完成,尚未实施
目标数据库版本:v11
适用范围:scheduler、Scheduled Agent、Cron tools、SQLite、WebUI 任务页
1. 背景
当前定时任务同时存在四组互相耦合的概念:
JobKind::Task/JobKind::Monitor;DeliveryPolicy::Direct/Always/OnAlert/Never;- Agent 自己调用
send_message的旧执行路径,以及 Scheduler 托管投递的新执行路径; - 通过
NO_REPLY、NO_REPLY[INFO]、NO_REPLY[FAIL]、NO_REPLY[REFUSE]等字符串猜测运行结果的协议。
这导致任务“做什么”和“是否投递”没有形成正交模型。特别是 NO_REPLY[INFO] XXXX、Markdown 包裹、缺少冒号或模型附加解释时,字符串解析会把本应静默的结果当作普通内容投递。继续放宽正则只会扩大不确定协议,无法从根本上解决问题。
本设计把所有 AI 定时任务统一成一种 Scheduled Run:任务配置只声明调度、执行 Agent、完整 prompt、目标和投递策略;每次运行必须通过运行时注入的终结工具提交结构化结果;Scheduler 根据结构化结果和投递策略做确定性决策。
2. 设计目标
- 删除
task/monitor运行类型,任务语义只由 prompt 表达。 - 删除所有
NO_REPLY[...]魔法字符串和自然语言结果解析。 - 删除 Agent 自行投递的
Direct路径,所有投递由 Scheduler 拥有。 - 统一普通通知、异常巡检和后台维护的执行路径。
- 定时任务可选择 Root 或一个命名 Agent;命名 Agent 的 Provider、模型、工具、Skills 和委托边以 AgentCatalog 为准。
- Scheduled Run 不产生脱离当前执行的后台子 Agent;请求后台委托时自动按前台委托执行。
- 执行结果先持久化,再投递;进程重启后可以恢复未完成投递。
- 无法确认是否完成的执行标记为
unknown,不盲目重跑同一 occurrence。 - 使用一次性、原子、可失败回滚的 SQLite v11 自动迁移;迁移后运行时代码只认识新 schema 和新枚举。
3. 非目标
- 不提供分布式 Scheduler 或跨节点共识。
- 不承诺外部渠道 exactly-once。渠道发送成功但本地确认前进程崩溃时,仍可能重复投递。
- 不保存 Scheduled Run 的用户聊天历史;每次执行默认是隔离上下文。
- 不自动把上一次执行的工具结果带到下一次执行。
- 不增加
shell、Webhook 等第二种 Job 执行类型;本期所有任务仍是 Agent 任务。 - 不保留旧字段、旧枚举、旧工具参数或旧字符串协议的运行时兼容分支。
4. 参考项目取舍
本设计采用以下参考经验:
- ZeroClaw:Cron 明确归属于某个 Agent,复用该 Agent 的身份、模型和权限;Cron 本身是一项顶层运行,而不是子 Agent 完成消息。
- Hermes:有限生命周期调用方不能接收后台完成结果时,委托回退为同步;进程崩溃后把副作用不确定的执行标记为
unknown,不自动重放。 - PicoBot:保留现有 SQLite 租约、Scheduler 集中投递、MessageBus 目标有序发送和命名 AgentCatalog。
明确不采用:
- Hermes 大量平台特例、JSON Job 主存储和多套 Scheduler provider;
- ZeroClaw 仅清除过期锁后重新执行的恢复方式;
- pi 的子进程式、无持久化 Subagent 示例;
- 把 Cron 结果伪装成后台 Agent Inbox 事件。
5. 核心模型
5.1 ScheduledJob
ScheduledJob 只回答五个问题:
何时执行:schedule
由谁执行:agent_id
执行什么:prompt
发到哪里:channel + chat_id
何时投递:delivery_policy
目标 Rust 类型:
pub struct ScheduledJob {
pub id: String,
pub name: String,
pub schedule: Schedule,
pub prompt: String,
/// None 表示 Root;Some(id) 表示当前 AgentCatalog 中的命名 Agent。
pub agent_id: Option<String>,
pub channel: String,
pub chat_id: String,
pub delivery_policy: DeliveryPolicy,
pub enabled: bool,
pub next_run_at: i64,
pub last_run_at: Option<i64>,
pub last_outcome: Option<ScheduledOutcomeKind>,
pub created_at: i64,
pub updated_at: i64,
// durable claim
pub locked_at: Option<i64>,
pub lock_owner: Option<String>,
pub lease_until: Option<i64>,
}
删除以下字段:
job_kind:与delivery_policy重复;model:当前并未实际应用,且会绕开命名 Agent 的 Provider/Model 定义;delete_after_run:当前工具不开放且始终写false;Schedule::At完成领取后统一禁用,用户显式删除即可。
5.2 DeliveryPolicy
只保留三个值:
pub enum DeliveryPolicy {
Always,
OnAlert,
Never,
}
always:任何终态都投递;on_alert:仅alert、failed、refused、unknown投递;never:任何终态都不投递,只保留执行记录。
删除 Direct。Agent 不再有自行完成最终投递的职责。
5.3 ScheduledOutcome
Agent 可以主动提交四种结果,运行时恢复还可以产生 unknown:
pub enum ScheduledOutcome {
Ok { message: String },
Alert { message: String },
Failed { message: String },
Refused { message: String },
Unknown { message: String }, // 仅运行时生成,模型不能提交
}
语义:
ok:任务成功完成,没有需要用户关注的异常;alert:任务成功完成并发现需要用户关注的事实;failed:检查或任务没有可靠完成;refused:安全策略、授权或 Agent 自身约束拒绝执行;unknown:进程在持久化终态前退出,无法判断外部副作用是否发生。
投递矩阵:
| Outcome | always |
on_alert |
never |
|---|---|---|---|
ok |
投递 | 静默 | 静默 |
alert |
投递 | 投递 | 静默 |
failed |
投递 | 投递 | 静默 |
refused |
投递 | 投递 | 静默 |
unknown |
投递 | 投递 | 静默 |
Agent 只报告事实,不能通过工具参数提供 notify=true/false。投递权始终属于 Scheduler。
5.4 ScheduledRunStatus
运行状态只描述生命周期,不表达业务是否正常:
pub enum ScheduledRunStatus {
Claimed,
Running,
Completed,
Failed,
TimedOut,
Cancelled,
Interrupted,
Unknown,
}
成功调用终结工具后,生命周期状态是 completed,业务结果由 outcome 表达;例如 completed + failed 表示 Agent 正常结束并明确报告检查失败。Provider 错误或结果协议缺失则是 failed + failed。
6. 总体架构
flowchart TD
Tick[Scheduler tick] --> Claim[Storage claim occurrence]
Claim --> RunRow[(job_runs: claimed)]
Claim --> Advance[提前推进 next_run / 禁用 At]
RunRow --> Resolve[ScheduledAgentRunner]
Resolve --> Catalog[Root config / AgentCatalog]
Resolve --> Agent[AgentLoop]
Agent --> Tools[受限工具 + complete_scheduled_run]
Tools --> Outcome[ScheduledOutcome]
Outcome --> Commit[原子提交 Run 终态与 delivery_status]
Commit -->|suppressed / not_requested| Done[完成]
Commit -->|pending| Delivery[Scheduler delivery drain]
Delivery --> Bus[MessageBus / OutboundDispatcher]
Bus --> Channel[Channel]
Channel --> Ack[delivery_status = delivered]
组件职责:
| 组件 | 职责 | 明确不负责 |
|---|---|---|
Scheduler |
tick、领取、并发上限、执行超时、恢复、投递 drain | 解释模型自然语言 |
ScheduledAgentRunner |
解析 Root/命名 Agent、构造隔离上下文、运行 Agent | 渠道发送、next-run 计算 |
AgentLoop |
模型/工具循环、识别终结工具已提交 | Job 状态或渠道策略 |
complete_scheduled_run |
Schema 校验并提交一次结构化 Outcome | 消息发送、数据库写入 |
Storage |
occurrence、租约、终态、投递状态的原子转换 | Provider 和 Channel I/O |
OutboundDispatcher |
目标有序、瞬态错误重试、渠道调用、类型化投递回执 | Scheduled Run 业务分类 |
7. 结构化终结协议
7.1 工具定义
所有 Scheduled Run 都运行时注入同一个工具:
{
"name": "complete_scheduled_run",
"parameters": {
"type": "object",
"properties": {
"outcome": {
"type": "string",
"enum": ["ok", "alert", "failed", "refused"]
},
"message": {
"type": "string",
"minLength": 1,
"maxLength": 16384
}
},
"required": ["outcome", "message"],
"additionalProperties": false
}
}
约束:
runtime_injected() == true,Agent definition 的tools不得声明它;- 只在
ToolExecutionContext带 Scheduled completion sink 时可用; exclusive() == true,不与其他工具并行;- 每次运行只接受一次;
unknown不出现在模型可见 schema 中;- message 去除首尾空白后必须非空,并在字符边界安全截断到上限。
7.2 AgentLoop 终结行为
ToolExecutionContext 增加可选的 ScheduledCompletionSink。终结工具用一次性 compare-and-set 写入 Outcome。AgentLoop 在每个工具调用之后检查 sink:
- 未提交:继续普通工具循环;
- 已提交:把本次终结工具调用和结果写入 Agent transcript;
- 同一 Provider 消息中排在终结工具之后的工具调用统一归约为
Cancelled,原因是 Scheduled Run 已完成; - 不再调用 Provider,立即返回结构化 Scheduled 结果。
终结工具必须是最后的语义动作,但运行时不依赖模型遵守这一提示来保证结束。
7.3 Fail-closed
只有合法的结构化提交才能得到 ok 并可能静默。以下情况统一生成 failed,不读取文本猜测:
- Agent 输出普通最终文本但没有调用终结工具;
- 输出任何
NO_REPLY变体; - 工具参数不合法且模型未修正;
- 达到最大工具迭代次数;
- Provider 错误;
- Agent 返回空结果;
- 执行超时。
普通最终文本可以截断后保存在 diagnostic,但不得作为通知正文。用户通知由 Scheduler 生成,例如:
定时任务「生产站点巡检」未能完成:Agent 未提交结构化运行结果。
8. Agent 解析与权限
8.1 Root 任务
agent_id = NULL 表示使用当前 Root Provider、模型、上下文窗口和 Skills。工具从 Root registry 派生,但移除:
send_message;cron_add、cron_update、cron_remove、cron_enable、cron_disable;reload_config;- 只服务于交互会话或后台收件箱控制的工具。
再注入 complete_scheduled_run。Root Scheduled Run 不加载用户聊天历史。
8.2 命名 Agent 任务
agent_id = Some(id) 必须从当前不可变 AgentCatalog 解析:
- Provider profile、模型、上下文窗口来自 Agent definition;
- 工具和 Skills 完全按 definition allowlist;
complete_scheduled_run作为固有运行时工具额外注入,不构成权限扩张;- 委托边按 definition 的
delegates; - Job 不提供 per-job tools 或 model override。
创建和更新任务时校验当前 agent_id。如果之后重载删除或禁用了该 Agent,Gateway 不因一个 Job 无法启动;该 occurrence 记录为 failed,并按投递策略通知。Health 页面同时报告悬空 Agent 引用。
8.3 AgentRun 记录
Scheduled 顶层执行仍复用 agent_runs 审计和 transcript,但不增加新的 AgentRunMode:
mode = foreground,因为 Scheduler 同步等待它;caller_agent_id = SCHEDULER;caller_scope_id = scheduled:<job_id>;root_session_id = scheduled-run:<job_run_id>,它是审计 scope,不是可接收 Inbox 的 Session;completion_slot_reserved = false;job_runs.agent_run_id关联顶层 AgentRun。
这是执行来源的区别,不是第三种并发模式,因此不扩展 foreground/background 枚举。
9. 子 Agent 语义
Scheduled Agent 可以调用 delegate,但所有子任务必须在本次 Scheduled Run 内收敛:
delegate(mode=foreground) → 正常执行
delegate(mode=background) → 自动改为 foreground,并在工具结果中说明降级
理由:
scheduled-run:<id>不是用户 Session,不能消费 durable inbox continuation;- Scheduler 必须在一次 run 内得到完整 Outcome;
- 避免创建无法投递的
cron:<id>completion; - 前台子 Agent 仍可并行批量执行并由父 Agent 汇总。
Scheduled Agent 不能使用 emit_signal 向用户 Session 建立旁路。子 Agent 的最终结果作为普通工具结果返回父 Scheduled Agent,只有父 Agent 可以调用 complete_scheduled_run。
ScheduledCompletionSink 是顶层运行能力,不随 ToolExecutionContext 克隆给子 Agent。Coordinator 构造子 Agent context 时必须显式清空该能力,子 Agent registry 也不得注入终结工具;否则子 Agent 会越过父 Agent 汇总直接结束整次任务。
Root Scheduled Agent 的第一次委托必须走 AgentCatalog 的 root_can_delegate() 规则,不能把合成的 SCHEDULER 或 ROOT 身份误当作命名 Agent 传给 can_delegate()。命名 Scheduled Agent 的后续委托仍走 can_delegate(caller, target)。这一区分只影响委托边校验,不创建第二条 Scheduled 执行路径。
10. occurrence、领取和崩溃恢复
10.1 领取事务
claim_due_scheduled_jobs 改为 claim_due_scheduled_runs。一次领取在同一事务中:
- 选择
enabled=1 AND next_run_at<=now且租约为空/过期的 Job; - 条件更新租约;
- 插入唯一的
job_runs(status=claimed, delivery_status=awaiting_result)行,并把其自增id作为本次 occurrence 的稳定 ID,同时快照 Agent、投递策略和目标; - 对
Every/Cron把next_run_at推进到领取时刻之后的第一个未来时间; - 对
At立即设置enabled=0; - 提交后返回
ClaimedScheduledRun { job_snapshot, run_id, owner }。
领取条件、Run 插入和 Job 推进处于同一写事务;SQLite 的写串行化和条件租约保证一次到期状态只能提交一个 Run。无需额外维护 occurrence key 或 schedule revision。推进下次时间发生在执行之前,因此进程崩溃不会使本次 occurrence 被自动重放;用户之后重新启用一次性任务会建立一个新的 Run,不会与历史运行冲突。错过的多个历史 tick 不逐个补跑,只执行当前到期 occurrence,并计算下一个未来时间。
Job 执行租约固定为 execution_timeout + shutdown_grace,执行本身必须在 execution_timeout 内终止,因此不增加心跳续租任务。终态提交同时校验 job_run_id + lock_owner + 非终态 status;租约过期后迟到的旧执行不能修改新 Run 或 Job 摘要。
10.2 状态转换
claimed → running → completed
├→ failed
├→ timed_out
├→ cancelled
└→ interrupted
claimed/running --process restart--> unknown
所有终态不可重写。Job 的租约 owner 必须匹配才能提交终态,旧执行不得覆盖新领取。
10.3 启动恢复
Gateway 启动、Scheduler admission 尚未开放前:
- 找到所有
job_runs.status IN ('claimed','running'); - 标记为
unknown,写入固定诊断; - 根据该 run 快照的 delivery policy 设置
pending或not_requested; - 清除关联 Job 的旧租约;
- 不回退已推进的
next_run_at,也不重跑 occurrence; - 开放 Scheduler admission 后先 drain pending delivery,再领取新任务。
优雅关停由 TaskSupervisor 先停止新领取,再取消/限时等待运行;能够得到明确取消结果时记录 interrupted,只有硬崩溃才在下次启动归为 unknown。
11. 投递设计
11.1 先提交再发送
执行完成事务负责:
- 提交 AgentRun 终态;
- 提交 JobRun status、outcome、message、diagnostic 和 duration;
- 更新 ScheduledJob 的
last_run_at、last_outcome; - 按矩阵把 delivery status 设置为:
pending:需要投递;suppressed:on_alert + ok;not_requested:never。
- 释放 Job 租约。
任何渠道 I/O 都发生在事务之后。
11.2 复用 job_runs 作为轻量 outbox
不新增通用消息队列表。JobRun 自带目标快照和投递状态:
awaiting_result → pending / suppressed / not_requested
pending → delivering → delivered
├→ pending(瞬态失败、退避后重试)
└→ failed(永久失败或次数耗尽)
Scheduler 在每次 tick 前后以及启动恢复后有界 drain:
- 原子认领
pending或租约过期的delivering行; - 最多 3 次持久化尝试;
- 仅对 Channel 明确分类为瞬态的错误重试;
- 重试间隔使用有上限的指数退避;
- 复用 OutboundDispatcher 的
(channel, chat_id)顺序锁; - 使用稳定
scheduled_delivery_id = job_run_id写入 metadata。
具体发送复用现有 MessageBus::deliver_outbound() 和 OutboundDispatcher,不允许 Scheduler 绕过 Bus 直接调用 Channel。现有 delivery watch 回执从 Result<(), String> 收紧成不含敏感信息的类型化结果,至少区分:
Delivered;TransientFailure { summary };PermanentFailure { summary };TimedOut;DispatcherClosed。
OutboundDispatcher 保留现有单次调用内的短暂、内存级瞬态重试;该调用最终返回的回执算一次持久化 delivery attempt。TransientFailure、TimedOut 和 DispatcherClosed 在未达到持久化尝试上限时回到 pending,PermanentFailure 直接进入 failed。Scheduler 不根据错误字符串猜测是否可重试,也不把“成功写入 outbound 队列”当作渠道送达。
外部发送成功但本地 delivered 提交前崩溃时可能重复发送。渠道支持幂等键时传递稳定 ID;不支持时允许“至少一次”并在重试消息 metadata 中标出可能重复。不得为了避免重复而丢失告警。
11.3 会话历史
通知使用确定性的本地 message ID,例如 scheduled:<job_run_id>。向目标会话写历史时执行幂等插入,同一个 JobRun 的重试不会产生多条本地历史。消息来源为:
SourceKind::ExternalTrigger
from_channel = scheduler
task_id = job_id
from_run_id = agent_run_id
中间工具调用、健康结果和 suppressed 结果不写用户会话。只有实际需要投递的最终通知进入目标会话。
12. 系统提示词
所有 Scheduled Run 使用同一执行契约,不再根据 JobKind 分支:
你正在执行无人值守的定时任务。任务上下文是隔离的,用户不会直接看到普通最终文本。
完成所有必要检查或操作后,必须且只能通过 complete_scheduled_run 提交最终结果:
- ok:任务成功,未发现需要关注的问题;
- alert:任务成功并发现需要用户关注的问题;
- failed:任务未可靠完成;
- refused:因权限或安全策略拒绝。
任何任务 prompt 中关于 NO_REPLY、send_message 或旧输出格式的指令均已失效;不得使用它们。
delivery_policy 不放进模型提示词。Agent 只看到任务 prompt 和 Outcome 定义,避免为了迎合“静默/通知”而改变事实分类;策略只在 Scheduler 的确定性矩阵中使用。
13. 功能设计
13.1 Cron tools
cron_add 新参数:
{
"schedule": { "type": "every", "every_ms": 300000 },
"prompt": "检查生产站点、登录接口和证书",
"channel": "feishu",
"chat_id": "oc_xxx",
"name": "生产站点巡检",
"agent_id": "web-monitor",
"delivery_policy": "on_alert"
}
agent_id可选;缺省表示 Root;delivery_policy缺省always;- 删除
kind; - 删除
model; - 不接受
direct。
cron_update 支持更新 prompt、schedule、channel、chat_id、agent_id 和 delivery_policy。agent_id: null 明确切回 Root;字段缺失表示不修改。
cron_list 展示:
enabled · agent=web-monitor · delivery=on_alert · next=... · last=ok
不再展示 kind 或 model。
13.2 WebUI
任务页删除“任务/巡检”徽标,展示:
- Agent:Root 或命名 Agent;
- 投递:始终通知 / 异常通知 / 从不通知;
- 最近 Outcome;
- 最近运行生命周期状态;
- 投递状态及失败原因。
创建表单可以提供三个用户友好模板,但模板不进入后端模型:
- 定期通知 →
always; - 异常巡检 →
on_alert; - 后台维护 →
never。
13.3 Health
HealthService 增加只读 Scheduler 配置检查:
- ScheduledJob 引用不存在或 disabled 的 Agent;
- 非法 channel;
- 长时间停留在 pending/delivering 的投递;
- 最近一次
unknown、failed或timed_out; - enabled Job 无法计算 next run。
Health 不执行 Job、不连接 Provider、不修复数据。
14. SQLite v11 schema
14.1 scheduled_jobs
CREATE TABLE scheduled_jobs (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
schedule TEXT NOT NULL,
prompt TEXT NOT NULL,
agent_id TEXT,
channel TEXT NOT NULL,
chat_id TEXT NOT NULL,
delivery_policy TEXT NOT NULL
CHECK (delivery_policy IN ('always','on_alert','never')),
enabled INTEGER NOT NULL DEFAULT 1 CHECK (enabled IN (0,1)),
next_run_at INTEGER NOT NULL,
last_run_at INTEGER,
last_outcome TEXT CHECK (
last_outcome IS NULL OR
last_outcome IN ('ok','alert','failed','refused','unknown')
),
locked_at INTEGER,
lock_owner TEXT,
lease_until INTEGER,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
CHECK (length(trim(id)) > 0),
CHECK (length(trim(name)) > 0),
CHECK (length(trim(prompt)) > 0),
CHECK (length(trim(channel)) > 0),
CHECK (length(trim(chat_id)) > 0),
CHECK (agent_id IS NULL OR length(trim(agent_id)) > 0),
CHECK (
(locked_at IS NULL AND lock_owner IS NULL AND lease_until IS NULL) OR
(locked_at IS NOT NULL AND lock_owner IS NOT NULL AND lease_until IS NOT NULL)
)
);
CREATE INDEX idx_jobs_claimable
ON scheduled_jobs(enabled, next_run_at, lease_until);
14.2 job_runs
CREATE TABLE job_runs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
job_id TEXT NOT NULL
REFERENCES scheduled_jobs(id) ON DELETE CASCADE,
scheduled_for INTEGER NOT NULL,
agent_run_id TEXT UNIQUE
REFERENCES agent_runs(id) ON DELETE SET NULL,
-- claim-time immutable snapshot
agent_id TEXT,
delivery_policy TEXT NOT NULL
CHECK (delivery_policy IN ('always','on_alert','never')),
target_channel TEXT NOT NULL,
target_chat_id TEXT NOT NULL,
started_at INTEGER,
finished_at INTEGER,
status TEXT NOT NULL CHECK (status IN (
'claimed','running','completed','failed',
'timed_out','cancelled','interrupted','unknown'
)),
outcome TEXT CHECK (
outcome IS NULL OR
outcome IN ('ok','alert','failed','refused','unknown')
),
message TEXT,
diagnostic TEXT,
duration_ms INTEGER,
delivery_status TEXT NOT NULL CHECK (delivery_status IN (
'awaiting_result','not_requested','suppressed','pending',
'delivering','delivered','failed'
)),
delivery_attempts INTEGER NOT NULL DEFAULT 0,
delivery_next_attempt_at INTEGER,
delivery_lease_owner TEXT,
delivery_lease_until INTEGER,
delivery_error TEXT,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
CHECK (agent_id IS NULL OR length(trim(agent_id)) > 0),
CHECK (length(trim(target_channel)) > 0),
CHECK (length(trim(target_chat_id)) > 0),
CHECK (delivery_attempts >= 0),
CHECK (duration_ms IS NULL OR duration_ms >= 0),
CHECK (
(delivery_lease_owner IS NULL AND delivery_lease_until IS NULL) OR
(delivery_lease_owner IS NOT NULL AND delivery_lease_until IS NOT NULL)
)
);
CREATE INDEX idx_job_runs_job_finished
ON job_runs(job_id, finished_at DESC);
CREATE INDEX idx_job_runs_recovery
ON job_runs(status, updated_at);
CREATE INDEX idx_job_runs_delivery
ON job_runs(delivery_status, delivery_next_attempt_at, delivery_lease_until);
不为 AgentCatalog 建数据库外键;Agent 定义是配置运行代资源,不是 SQLite 行。agent_id 和 definition hash 的最终审计值保存在关联 AgentRun 中。
15. 自动迁移设计
15.1 总体原则
SCHEMA_VERSION从 10 增加到 11;- Storage 在 Gateway 启动后台任务之前执行迁移;
- migration 使用一个专用连接和
BEGIN IMMEDIATE,在单个 SQLite 事务中完成; - 任一步失败则整体回滚,
user_version保持原值,Gateway 拒绝启动; - v11 运行时代码不读取旧列、不解析旧枚举、不调用旧函数;
- 旧 schema 知识只存在于
storage/migrations/v11_scheduled.rs; - v11 数据库由旧版本二进制打开时,沿用现有“数据库版本过新”拒绝策略;
- 不支持自动降级。
BEGIN IMMEDIATE 必须在配置的 SQLite busy timeout 内取得写锁;若另一个 PicoBot 进程仍在使用同一数据库且无法取得锁,启动直接报错,不等待后台任务运行后再迁移。部署流程必须先停旧进程再启动新版本。
15.2 初始化顺序调整
当前 init_scheduler_schema() 在 migrate_schema() 之前执行,会让新旧 DDL 的所有权混乱。实施后:
migrate_schema()成为 Scheduler 表 DDL 的唯一入口;- 全新数据库直接创建 v11 表;
user_version=0但已有旧表的数据库先执行现有 legacy→v10 规范化,再执行 v10→v11;- 测试不再直接调用可绕过 migration 的
init_scheduler_schema(),统一通过Storage::new或专用 latest-schema fixture。
15.3 v10 预检
在创建新表前读取并验证全部旧行:
- schedule JSON 必须能反序列化;
- delivery policy 必须是
direct/always/on_alert/never; - Job ID、channel、chat_id 和 prompt 必须满足非空约束;
- 旧 JobRun 外键必须能找到 Job;
- Job lease 字段必须全部为空或全部非空;
- 发现损坏数据时返回包含 Job ID 的 migration error,不做默认修复。
15.4 Job 字段映射
| v10 | v11 | 规则 |
|---|---|---|
job_kind |
删除 | 不读取其运行语义 |
delivery_policy=direct |
always |
新代码统一中央投递 |
always/on_alert/never |
原值 | 保留 |
model |
删除 | 当前执行路径未应用该值;迁移日志报告被删除的非空数量 |
delete_after_run |
删除 | At 任务改为完成领取后禁用 |
无 agent_id |
NULL |
使用 Root Scheduled Agent |
last_status=ok |
last_outcome=ok |
仅作列表摘要 |
其他非空 last_status |
last_outcome=failed |
详细旧信息仍在历史 run |
内置 picobot-routine-maintenance 的 prompt 在迁移中按固定 Job ID 改成不含 NO_REPLY 的任务描述。用户自定义 prompt 不做不安全的字符串替换;新的系统级 Scheduled 契约明确忽略其中关于 NO_REPLY、send_message 和旧输出格式的指令。
15.5 历史 JobRun 映射
历史行只用于审计,绝不因升级重新投递:
| v10 值 | v11 值 |
|---|---|
status=ok |
status=completed |
status=timeout |
status=timed_out |
status=error/delivery_error |
status=failed |
| 其他状态 | status=unknown |
result_kind=quiet |
outcome=ok |
result_kind=content 且 Job 为 on_alert |
outcome=alert |
result_kind=content 其他情况 |
outcome=ok |
result_kind=reported_failure |
outcome=failed |
result_kind=refused |
outcome=refused |
| 无法映射 | outcome=unknown |
delivery_status=direct/delivered |
delivered |
suppressed/skipped |
suppressed |
failed 或存在 delivery_error |
failed |
| 其他 | not_requested |
其他字段:
scheduled_for = started_at;message = output;diagnostic = error;target_*、delivery_policy从迁移时的 Job 行快照;agent_run_id = NULL;- 所有 delivery lease 字段为空。
15.6 迁移时发现旧执行锁
v10 只有 Job lease,没有预先建立的 JobRun。若迁移时发现 lock_owner 非空或 lease 尚未清理,说明旧进程可能在终态提交前退出:
- 为它创建一条
status=unknown/outcome=unknown的 JobRun; - message 固定说明升级时发现未完成执行;
always/on_alert设置 deliverypending,never设置not_requested;- recurring Job 的
next_run_at推进到迁移时刻之后; AtJob 设为 disabled;- 清除旧租约。
这条未知通知可能与崩溃前已经送达但未提交的通知重复,但不会静默掩盖不确定状态。
15.7 SQLite 表重建
SQLite 删除列和修改 CHECK 约束采用 canonical table rebuild:
CREATE TABLE scheduled_jobs_v11 ...;- 写入转换后的 Job;
CREATE TABLE job_runs_v11 ... REFERENCES scheduled_jobs_v11;- 写入转换后的历史 Run 和旧锁恢复 Run;
- 删除旧
job_runs; - 删除旧
scheduled_jobs; - rename
scheduled_jobs_v11、job_runs_v11; - 重建索引;
- 执行
PRAGMA foreign_key_check并要求零行; - 最后设置
PRAGMA user_version = 11并提交。
表重建必须在同一连接、同一事务中完成。不得用多个 pool connection 分散 DDL,也不得在事务提交前启动 Scheduler。
15.8 “不在代码层面兼容过去”的准确含义
允许且必须存在:
- 一次性 migration 对 v10 表和旧枚举的读取与转换;
- migration tests 的 v10 fixture;
- 迁移日志和损坏数据诊断。
明确禁止:
row_to_job同时尝试新旧列;DeliveryPolicy::parse("direct");- 保留
JobKind但在新路径忽略; - 保留
handle_cron_message作为 fallback; - 继续解析任意
NO_REPLY文本; - 新旧工具参数并存;
- 根据
user_version在 Scheduler 运行期分支。
迁移成功后,进程内只有 v11 类型和 v11 SQL。
16. 旧代码清理清单
src/scheduler/mod.rs
删除:
ScheduledDisposition;parse_scheduled_disposition();managed/Direct双路径;job_kind == Monitor分支;- Agent 返回普通文本后再分类的代码;
- 先发送、后写 JobRun 的顺序。
替换为:claim occurrence → ScheduledAgentRunner → typed outcome → terminal commit → delivery drain。
src/session/session.rs
删除:
create_cron_agent();create_managed_scheduled_agent();handle_cron_message();handle_managed_scheduled_message();- Cron 专用
NO_REPLY/send_messageprompt。
Scheduled Agent 构造迁移到 Agent/Coordinator 边界,SessionManager 不再执行 Cron Agent。
src/storage/scheduler.rs
删除:
JobKind及 parser;DeliveryPolicy::Direct;model、job_kind、delete_after_run映射;set_scheduled_job_behavior();- 完成时才插入 JobRun 的旧事务。
新增严格 typed parser、occurrence claim、终态提交、未知恢复和持久化 delivery claim。
src/storage/mod.rs 与 src/storage/migrations/
把 SCHEMA_VERSION 一次提升到 11,删除 migration 之前调用 Scheduler 旧 DDL 初始化函数的顺序。新增唯一的 v11 Scheduler migration 模块和跨表终态事务 API;迁移完成后通用 Storage 查询不得包含任何 v10 列名。
src/tools/cron.rs
删除 kind、model 参数与所有默认联动;默认 delivery_policy=always。新增可选 agent_id,更新 list 输出和测试。
src/tools
新增 complete_scheduled_run.rs。它是 runtime-injected、exclusive、Scheduled context-only 的无外部副作用控制工具。
src/agent
新增 ScheduledCompletionSink 和 AgentCoordinator::execute_scheduled();复用 foreground AgentRun 持久化,不创建 inbox completion。AgentLoop 在终结工具成功后停止。
src/session/messenger.rs
把 Scheduled 通知改成稳定 message ID 的幂等写入;删除任何依赖 cron:<id> 作为可消费会话的行为。
src/bus
保留 MessageBus::deliver_outbound() 的等待回执入口,把 OutboundMessage.delivery 和 BusError 的字符串结果改为类型化、可判定 retry class 的安全回执。OutboundDispatcher 仍是唯一 Channel 调用方;普通无回执消息继续使用 publish_outbound()。
Gateway / WebUI / Protocol
- API JSON 删除
job_kind、model、delete_after_run、result_kind; - 增加
agent_id、outcome、delivery attempts/状态; webui/src/pages/TasksPage.svelte删除“巡检/任务”判断;- Health 与任务详情展示新的结构化状态。
文档与内置知识
实施时同步更新:
README.md;docs/ARCHITECTURE.md;AGENTS.md;resources/skills/about-picobot/references/architecture.md;resources/skills/about-picobot/references/config.md;resources/skills/about-picobot/references/db-schema.md;resources/skills/about-picobot/references/tools.md;- 内置维护任务 prompt。
全仓库应不存在运行时 NO_REPLY、JobKind、DeliveryPolicy::Direct 或 handle_cron_message 引用。
17. 并发与生命周期不变量
- 一个 Job 同时最多有一个持租约 occurrence;短周期任务不会重叠执行。
- occurrence 的 JobRun 在 Agent 启动前持久化;同一个到期状态只能提交一个 Run。
next_run_at在 claim 事务中推进;执行失败和进程崩溃不会重放同一 occurrence。- 只有 lease owner 可以把 run 从非终态推进到终态。
- 所有终态不可重写;迟到 Agent 结果必须丢弃并记录日志。
- Outcome 和投递策略都使用 claim-time snapshot;运行过程中编辑 Job 只影响后续 occurrence。
- Outcome、Job 摘要和初始 delivery status 在同一事务提交。
- 渠道 I/O 不发生在 SQLite 事务或 Session mutex 内。
- Scheduled Run 不预留 Agent Inbox slot,也不向合成 session 投递 completion。
- 后台委托在模型可见参数不变,但 Scheduled context 强制变为 foreground。
never严格不通知,包括失败、拒绝、超时和 unknown;这些状态仍可通过管理页面和 Health 查看。- 静默只来源于结构化
ok与策略矩阵,不能来源于普通文本。
18. 错误处理矩阵
| 场景 | Run status | Outcome | 投递内容来源 |
|---|---|---|---|
Agent 提交 ok |
completed | ok | Agent message |
Agent 提交 alert |
completed | alert | Agent message |
Agent 提交 failed |
completed | failed | Agent message |
Agent 提交 refused |
completed | refused | Agent message |
| Agent 未调用终结工具 | failed | failed | Scheduler 固定协议错误 |
| Provider 失败 | failed | failed | 安全归一化错误,不含响应正文 |
| 运行超时 | timed_out | failed | Scheduler 固定超时说明 |
| Gateway 优雅取消 | interrupted | failed | Scheduler 固定中断说明 |
| 硬崩溃恢复 | unknown | unknown | Scheduler 固定 unknown 说明 |
| Agent 定义不存在 | failed | failed | Agent ID 和修复建议 |
| 渠道发送永久失败 | 原 run 不变 | 原 outcome | delivery_status=failed |
投递失败不改变已经确定的执行 Outcome;它只改变 delivery status。
19. 实施顺序
本设计应在一个功能版本中完成,不能长期保留两套路径:
- 增加 v11 migration 和新 Storage 类型、测试;
- 增加 Scheduled completion sink、终结工具和 AgentLoop 终止语义;
- 增加
AgentCoordinator::execute_scheduled(); - 改写 Scheduler claim、执行、恢复和 delivery drain;
- 切换 Cron tools/API/WebUI;
- 更新内置维护任务和文档;
- 删除所有旧类型、旧函数和魔法字符串;
- 运行全量验证后再提交,并按功能变化增加产品中段版本号一次。
代码合并点只允许新路径。迁移代码可以先写,但最终提交中不允许 Scheduler 通过 feature flag 或 schema 判断走旧路径。
20. 测试设计
单元测试
DeliveryPolicy × ScheduledOutcome全矩阵;- 终结工具 schema、空 message、额外字段、重复调用;
- 终结工具之后的同批工具归约为 Cancelled;
- 普通文本、所有
NO_REPLY变体都不能产生ok; - named/root Agent 解析和工具收窄;
- Scheduled background delegate 自动前台化;
- 两个并发 Scheduler 对同一到期 Job 只有一个 claim 成功;
- At claim 后禁用;
- recurring claim 时推进到未来;
- lease owner 条件提交;
- 迟到结果不能覆盖 unknown/timeout;
- delivery claim、瞬态重试、永久失败和尝试上限;
deliver_outbound只有收到 Channel 成功回执才返回 Delivered,入队成功不算送达;- 回执保留 transient/permanent 分类且不泄露渠道敏感响应;
- 本地历史稳定 message ID 去重。
Migration 测试
- 空数据库直接得到 v11;
- 真实 v10 fixture 保留所有 Job 和历史 Run;
task/monitor列被物理删除;direct全部转为always,运行时无法解析 direct;- 非空 model 计数被记录且列被删除;
- delete_after_run 列被删除;
- 历史 Run 不产生 pending delivery;
- 旧锁生成 unknown run 并按策略决定 pending/not_requested;
- 内置维护 prompt 不含
NO_REPLY; - 损坏 schedule/policy 使迁移原子失败且
user_version不变; PRAGMA foreign_key_check为空;- v11 重启迁移幂等;
- v12+ 数据库被拒绝。
集成测试
always + ok实际投递;on_alert + ok静默但 JobRun 可查询;on_alert + alert/failed/refused实际投递;never在所有 Outcome 下均不投递;- Provider 普通文本完成被转为协议失败通知;
- Gateway 在结果提交后、发送前重启,pending 在启动后恢复;
- Gateway 在发送后、ack 前重启,允许有标识的重复但不丢失;
- Gateway 在 Agent 执行中退出,恢复为 unknown 且不重跑同一 occurrence;
- 命名 Agent 被删除后 Job 明确失败且 Gateway 仍能启动;
- Cron 内 foreground 子 Agent 汇总成功,background 参数不会产生 Inbox 事件。
必跑验证
cargo test --lib
cargo test --test test_scheduler
cargo test --test test_request_format
cargo clippy --all-targets --all-features -- -D warnings
cd webui && npm run check && npm run build
cargo build
git diff --check
21. 验收标准
- 新建和更新任务的 API/工具中没有
kind、monitor或model字段。 - 数据库
scheduled_jobs不存在job_kind、model、delete_after_run。 DeliveryPolicy只有always/on_alert/never。- 所有 Scheduled Run 都必须调用
complete_scheduled_run;无调用时 fail-closed。 - 全仓库运行时代码不再出现
NO_REPLY结果协议。 - Scheduler 只有一条 Agent 执行和一条中央投递路径。
on_alert + ok不投递,其余矩阵行为与本设计一致。- 结果提交后崩溃不会丢失待投递消息。
- 执行中崩溃产生
unknown,同一 occurrence 不自动重跑。 - v10 数据库首次启动自动迁移到 v11,失败时完整回滚;迁移后没有运行时兼容代码。