diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index fe4e9ad..f38257c 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -2,7 +2,7 @@ 本文档描述 PicoBot 当前实现的运行时边界、数据流、并发模型和演进约束。它面向维护者和后续参与改进的 Agent,是代码架构的主入口;行为细节仍以代码和测试为最终依据。 -流式模型输出、reasoning 展示、活动 Turn 快照和 Channel 实时投递的详细设计与取舍见 [STREAMING_TURN_DESIGN.md](STREAMING_TURN_DESIGN.md)。用户输入路由、Session 执行拆分、终态投递确认和历史增量校准的重构方案见 [MESSAGE_FLOW_REFACTOR_DESIGN.md](MESSAGE_FLOW_REFACTOR_DESIGN.md)。已实施的 checkpoint 上下文压缩、pi 风格 reserve 阈值、统一编排和 overflow 失败语义见 [CONTEXT_COMPACTION_DESIGN.md](CONTEXT_COMPACTION_DESIGN.md)。配置运行代、重载边界和失败语义见 [CONFIG_HOT_RELOAD_DESIGN.md](CONFIG_HOT_RELOAD_DESIGN.md)。具名子 Agent、委托图、后台收件箱、结果传递机制与 `queue`/`steer` 信号的设计见 [SUB_AGENT_DESIGN.md](SUB_AGENT_DESIGN.md)。 +流式模型输出、reasoning 展示、活动 Turn 快照和 Channel 实时投递的详细设计与取舍见 [STREAMING_TURN_DESIGN.md](STREAMING_TURN_DESIGN.md)。用户输入路由、Session 执行拆分、终态投递确认和历史增量校准的重构方案见 [MESSAGE_FLOW_REFACTOR_DESIGN.md](MESSAGE_FLOW_REFACTOR_DESIGN.md)。已实施的 checkpoint 上下文压缩、pi 风格 reserve 阈值、统一编排和 overflow 失败语义见 [CONTEXT_COMPACTION_DESIGN.md](CONTEXT_COMPACTION_DESIGN.md)。配置运行代、重载边界和失败语义见 [CONFIG_HOT_RELOAD_DESIGN.md](CONFIG_HOT_RELOAD_DESIGN.md)。具名子 Agent、委托图、后台收件箱、结果传递机制与 `queue`/`steer` 信号的设计见 [SUB_AGENT_DESIGN.md](SUB_AGENT_DESIGN.md)。计划中的统一 Scheduled Run、结构化终结协议、中央投递和 v11 数据库迁移见 [SCHEDULED_RUN_DESIGN.md](SCHEDULED_RUN_DESIGN.md)。 ## 1. 设计目标 diff --git a/docs/SCHEDULED_RUN_DESIGN.md b/docs/SCHEDULED_RUN_DESIGN.md new file mode 100644 index 0000000..a7168cd --- /dev/null +++ b/docs/SCHEDULED_RUN_DESIGN.md @@ -0,0 +1,961 @@ +# 统一定时任务执行与投递设计 + +状态:设计完成,尚未实施 + +目标数据库版本: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. 设计目标 + +1. 删除 `task` / `monitor` 运行类型,任务语义只由 prompt 表达。 +2. 删除所有 `NO_REPLY[...]` 魔法字符串和自然语言结果解析。 +3. 删除 Agent 自行投递的 `Direct` 路径,所有投递由 Scheduler 拥有。 +4. 统一普通通知、异常巡检和后台维护的执行路径。 +5. 定时任务可选择 Root 或一个命名 Agent;命名 Agent 的 Provider、模型、工具、Skills 和委托边以 AgentCatalog 为准。 +6. Scheduled Run 不产生脱离当前执行的后台子 Agent;请求后台委托时自动按前台委托执行。 +7. 执行结果先持久化,再投递;进程重启后可以恢复未完成投递。 +8. 无法确认是否完成的执行标记为 `unknown`,不盲目重跑同一 occurrence。 +9. 使用一次性、原子、可失败回滚的 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 只回答五个问题: + +```text +何时执行:schedule +由谁执行:agent_id +执行什么:prompt +发到哪里:channel + chat_id +何时投递:delivery_policy +``` + +目标 Rust 类型: + +```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, + pub channel: String, + pub chat_id: String, + pub delivery_policy: DeliveryPolicy, + pub enabled: bool, + pub next_run_at: i64, + pub last_run_at: Option, + pub last_outcome: Option, + pub created_at: i64, + pub updated_at: i64, + // durable claim + pub locked_at: Option, + pub lock_owner: Option, + pub lease_until: Option, +} +``` + +删除以下字段: + +- `job_kind`:与 `delivery_policy` 重复; +- `model`:当前并未实际应用,且会绕开命名 Agent 的 Provider/Model 定义; +- `delete_after_run`:当前工具不开放且始终写 `false`;`Schedule::At` 完成领取后统一禁用,用户显式删除即可。 + +### 5.2 DeliveryPolicy + +只保留三个值: + +```rust +pub enum DeliveryPolicy { + Always, + OnAlert, + Never, +} +``` + +- `always`:任何终态都投递; +- `on_alert`:仅 `alert`、`failed`、`refused`、`unknown` 投递; +- `never`:任何终态都不投递,只保留执行记录。 + +删除 `Direct`。Agent 不再有自行完成最终投递的职责。 + +### 5.3 ScheduledOutcome + +Agent 可以主动提交四种结果,运行时恢复还可以产生 `unknown`: + +```rust +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 + +运行状态只描述生命周期,不表达业务是否正常: + +```rust +pub enum ScheduledRunStatus { + Claimed, + Running, + Completed, + Failed, + TimedOut, + Cancelled, + Interrupted, + Unknown, +} +``` + +成功调用终结工具后,生命周期状态是 `completed`,业务结果由 `outcome` 表达;例如 `completed + failed` 表示 Agent 正常结束并明确报告检查失败。Provider 错误或结果协议缺失则是 `failed + failed`。 + +## 6. 总体架构 + +```mermaid +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 都运行时注入同一个工具: + +```json +{ + "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: + +1. 未提交:继续普通工具循环; +2. 已提交:把本次终结工具调用和结果写入 Agent transcript; +3. 同一 Provider 消息中排在终结工具之后的工具调用统一归约为 `Cancelled`,原因是 Scheduled Run 已完成; +4. 不再调用 Provider,立即返回结构化 Scheduled 结果。 + +终结工具必须是最后的语义动作,但运行时不依赖模型遵守这一提示来保证结束。 + +### 7.3 Fail-closed + +只有合法的结构化提交才能得到 `ok` 并可能静默。以下情况统一生成 `failed`,不读取文本猜测: + +- Agent 输出普通最终文本但没有调用终结工具; +- 输出任何 `NO_REPLY` 变体; +- 工具参数不合法且模型未修正; +- 达到最大工具迭代次数; +- Provider 错误; +- Agent 返回空结果; +- 执行超时。 + +普通最终文本可以截断后保存在 `diagnostic`,但不得作为通知正文。用户通知由 Scheduler 生成,例如: + +```text +定时任务「生产站点巡检」未能完成: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:`; +- `root_session_id = scheduled-run:`,它是审计 scope,不是可接收 Inbox 的 Session; +- `completion_slot_reserved = false`; +- `job_runs.agent_run_id` 关联顶层 AgentRun。 + +这是执行来源的区别,不是第三种并发模式,因此不扩展 `foreground/background` 枚举。 + +## 9. 子 Agent 语义 + +Scheduled Agent 可以调用 `delegate`,但所有子任务必须在本次 Scheduled Run 内收敛: + +```text +delegate(mode=foreground) → 正常执行 +delegate(mode=background) → 自动改为 foreground,并在工具结果中说明降级 +``` + +理由: + +- `scheduled-run:` 不是用户 Session,不能消费 durable inbox continuation; +- Scheduler 必须在一次 run 内得到完整 Outcome; +- 避免创建无法投递的 `cron:` 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`。一次领取在同一事务中: + +1. 选择 `enabled=1 AND next_run_at<=now` 且租约为空/过期的 Job; +2. 条件更新租约; +3. 插入唯一的 `job_runs(status=claimed, delivery_status=awaiting_result)` 行,并把其自增 `id` 作为本次 occurrence 的稳定 ID,同时快照 Agent、投递策略和目标; +5. 对 `Every/Cron` 把 `next_run_at` 推进到领取时刻之后的第一个未来时间; +6. 对 `At` 立即设置 `enabled=0`; +7. 提交后返回 `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 状态转换 + +```text +claimed → running → completed + ├→ failed + ├→ timed_out + ├→ cancelled + └→ interrupted + +claimed/running --process restart--> unknown +``` + +所有终态不可重写。Job 的租约 owner 必须匹配才能提交终态,旧执行不得覆盖新领取。 + +### 10.3 启动恢复 + +Gateway 启动、Scheduler admission 尚未开放前: + +1. 找到所有 `job_runs.status IN ('claimed','running')`; +2. 标记为 `unknown`,写入固定诊断; +3. 根据该 run 快照的 delivery policy 设置 `pending` 或 `not_requested`; +4. 清除关联 Job 的旧租约; +5. 不回退已推进的 `next_run_at`,也不重跑 occurrence; +6. 开放 Scheduler admission 后先 drain pending delivery,再领取新任务。 + +优雅关停由 TaskSupervisor 先停止新领取,再取消/限时等待运行;能够得到明确取消结果时记录 `interrupted`,只有硬崩溃才在下次启动归为 `unknown`。 + +## 11. 投递设计 + +### 11.1 先提交再发送 + +执行完成事务负责: + +1. 提交 AgentRun 终态; +2. 提交 JobRun status、outcome、message、diagnostic 和 duration; +3. 更新 ScheduledJob 的 `last_run_at`、`last_outcome`; +4. 按矩阵把 delivery status 设置为: + - `pending`:需要投递; + - `suppressed`:`on_alert + ok`; + - `not_requested`:`never`。 +5. 释放 Job 租约。 + +任何渠道 I/O 都发生在事务之后。 + +### 11.2 复用 job_runs 作为轻量 outbox + +不新增通用消息队列表。JobRun 自带目标快照和投递状态: + +```text +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:`。向目标会话写历史时执行幂等插入,同一个 JobRun 的重试不会产生多条本地历史。消息来源为: + +```text +SourceKind::ExternalTrigger +from_channel = scheduler +task_id = job_id +from_run_id = agent_run_id +``` + +中间工具调用、健康结果和 suppressed 结果不写用户会话。只有实际需要投递的最终通知进入目标会话。 + +## 12. 系统提示词 + +所有 Scheduled Run 使用同一执行契约,不再根据 JobKind 分支: + +```text +你正在执行无人值守的定时任务。任务上下文是隔离的,用户不会直接看到普通最终文本。 +完成所有必要检查或操作后,必须且只能通过 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` 新参数: + +```json +{ + "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` 展示: + +```text +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 + +```sql +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 + +```sql +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 的所有权混乱。实施后: + +1. `migrate_schema()` 成为 Scheduler 表 DDL 的唯一入口; +2. 全新数据库直接创建 v11 表; +3. `user_version=0` 但已有旧表的数据库先执行现有 legacy→v10 规范化,再执行 v10→v11; +4. 测试不再直接调用可绕过 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 尚未清理,说明旧进程可能在终态提交前退出: + +1. 为它创建一条 `status=unknown/outcome=unknown` 的 JobRun; +2. message 固定说明升级时发现未完成执行; +3. `always/on_alert` 设置 delivery `pending`,`never` 设置 `not_requested`; +4. recurring Job 的 `next_run_at` 推进到迁移时刻之后; +5. `At` Job 设为 disabled; +6. 清除旧租约。 + +这条未知通知可能与崩溃前已经送达但未提交的通知重复,但不会静默掩盖不确定状态。 + +### 15.7 SQLite 表重建 + +SQLite 删除列和修改 CHECK 约束采用 canonical table rebuild: + +1. `CREATE TABLE scheduled_jobs_v11 ...`; +2. 写入转换后的 Job; +3. `CREATE TABLE job_runs_v11 ... REFERENCES scheduled_jobs_v11`; +4. 写入转换后的历史 Run 和旧锁恢复 Run; +5. 删除旧 `job_runs`; +6. 删除旧 `scheduled_jobs`; +7. rename `scheduled_jobs_v11`、`job_runs_v11`; +8. 重建索引; +9. 执行 `PRAGMA foreign_key_check` 并要求零行; +10. 最后设置 `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_message` prompt。 + +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:` 作为可消费会话的行为。 + +### `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. 并发与生命周期不变量 + +1. 一个 Job 同时最多有一个持租约 occurrence;短周期任务不会重叠执行。 +2. occurrence 的 JobRun 在 Agent 启动前持久化;同一个到期状态只能提交一个 Run。 +3. `next_run_at` 在 claim 事务中推进;执行失败和进程崩溃不会重放同一 occurrence。 +4. 只有 lease owner 可以把 run 从非终态推进到终态。 +5. 所有终态不可重写;迟到 Agent 结果必须丢弃并记录日志。 +6. Outcome 和投递策略都使用 claim-time snapshot;运行过程中编辑 Job 只影响后续 occurrence。 +7. Outcome、Job 摘要和初始 delivery status 在同一事务提交。 +8. 渠道 I/O 不发生在 SQLite 事务或 Session mutex 内。 +9. Scheduled Run 不预留 Agent Inbox slot,也不向合成 session 投递 completion。 +10. 后台委托在模型可见参数不变,但 Scheduled context 强制变为 foreground。 +11. `never` 严格不通知,包括失败、拒绝、超时和 unknown;这些状态仍可通过管理页面和 Health 查看。 +12. 静默只来源于结构化 `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. 实施顺序 + +本设计应在一个功能版本中完成,不能长期保留两套路径: + +1. 增加 v11 migration 和新 Storage 类型、测试; +2. 增加 Scheduled completion sink、终结工具和 AgentLoop 终止语义; +3. 增加 `AgentCoordinator::execute_scheduled()`; +4. 改写 Scheduler claim、执行、恢复和 delivery drain; +5. 切换 Cron tools/API/WebUI; +6. 更新内置维护任务和文档; +7. 删除所有旧类型、旧函数和魔法字符串; +8. 运行全量验证后再提交,并按功能变化增加产品中段版本号一次。 + +代码合并点只允许新路径。迁移代码可以先写,但最终提交中不允许 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 事件。 + +### 必跑验证 + +```bash +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. 验收标准 + +1. 新建和更新任务的 API/工具中没有 `kind`、`monitor` 或 `model` 字段。 +2. 数据库 `scheduled_jobs` 不存在 `job_kind`、`model`、`delete_after_run`。 +3. `DeliveryPolicy` 只有 `always/on_alert/never`。 +4. 所有 Scheduled Run 都必须调用 `complete_scheduled_run`;无调用时 fail-closed。 +5. 全仓库运行时代码不再出现 `NO_REPLY` 结果协议。 +6. Scheduler 只有一条 Agent 执行和一条中央投递路径。 +7. `on_alert + ok` 不投递,其余矩阵行为与本设计一致。 +8. 结果提交后崩溃不会丢失待投递消息。 +9. 执行中崩溃产生 `unknown`,同一 occurrence 不自动重跑。 +10. v10 数据库首次启动自动迁移到 v11,失败时完整回滚;迁移后没有运行时兼容代码。