用户看到什么
提交一次性任务,等待低峰执行;无论中间续跑多少段,任务卡片和 History 都保持一个身份。
从用户提交到服务端取号,从本地认领到 Agent 分段执行,再到 History 和核销: 完整解释 Task、Session、Ticket 三种身份如何协作。
Off-Peak 不是单纯的定时任务。它把一次用户任务拆成产品身份、本地控制、 Agent 执行和服务端准入四个层次;每层回答的问题不同。
提交一次性任务,等待低峰执行;无论中间续跑多少段,任务卡片和 History 都保持一个身份。
SQLite 保存聚合当前态;Service 同步票据;Scheduler 负责认领、派发、超时回收和退避。
首段创建 Session,后续段恢复同一 Session;每次模型请求都必须携带当段有效 ticket。
服务端维护 FIFO、并发和 ticket 生命周期。客户端只观察结果,不复制服务端排队真相。
每一行代表一个跨多段执行仍保持稳定身份的逻辑任务。服务端 ticket 可以反复更换, Session 始终复用,History 只做一次执行摘要。
off_peak_tasks 是当前态聚合根,同时兼任调度锁、服务端快照、 核销 outbox 和 History tombstone。
任务是稳定聚合根,Session 是连续上下文,Ticket 是每一执行分段的准入凭证, History 则只是面向用户的派生投影。
用户只创建一次。task_id、Prompt、工作区、产品状态和最终结果都挂在这里;
暂停、取消、删除 History 也以它为入口。
首段 create,续跑 resume。它保留上下文,但不是 Off-Peak 调度状态的真源。
每个最多约 3 小时的准入分段一张票。当前本地聚合行只留“现在这张”,旧票会被覆盖。
只有真正启动过才出现;删除只写 tombstone,不删除任务、Session 或执行字段。
Renderer 不直接调度;Host 负责取号和同步;Scheduler 只读写 SQLite;Main 只路由; Agent 的每一次模型请求再经过服务端细粒度准入。
User
│ create task
▼
Renderer ─RPC─▶ Host Service ─take/status/settle─▶ Server Ticket DB
│ │
│ persist current snapshot │ Promote timer
▼ ▼
off_peak_tasks ◀──── queued / ready / active / expired
│
│ claim ready task
▼
Scheduler ─dispatch─▶ Workspace Host ─create/resume─▶ Session
│
│ sendPrompt + ticket overlay
▼
Agent Loop ─each model call─▶ Gateway
│ │
└──── result / 3102 / 429 ──┘
▲
└──── task terminal or continuation ────
取票和核销由 Host Service 负责;真正的模型准入发生在 CLI 请求模型时。 把这两层混在一起,会误以为“ready 就等于已经执行”。
读取当前用户能否创建新 ticket;不可创建时返回精确的 next_take_at,供 UI 提前置灰并到点重查。
task_id 换取当前 ticket_id。服务端可能立即 queued,也可能返回额度、资格或网络错误。
批量读取最多 100 张票,按 ticket_id 回写 queued / ready / active / expired / settled / not_found 投影。
每一次模型请求携带 X-Off-Peak-Ticket-ID。Agent 已启动不代表后续每个 turn 都天然获准。
任务终态或分段结束后核销。客户端先落本地 pending,再异步发送,避免一次网络失败丢失终态意图。
本地轴描述“任务执行到哪一步”;服务端轴描述“当前 ticket 获得了什么准入资格”。 本地表不会保存服务端完整 state,只保存面向调度的投影。
| 本地状态 | 可能对应的服务端状态 | 为什么不一一对应 |
|---|---|---|
| queued | queued / ready / expired / not_found | ready 快照到 scheduler 派发之间有窗口;废票重取也有短暂窗口。 |
| paused | queued / ready / expired | Pause 只停本地派发,不退出服务端 FIFO。 |
| running | ready / active / expired | 当前实现先写 running,首个模型请求可能仍在 429 细阀等待。 |
| terminal | active / settled | 本地先落终态,服务端资源通过 settle outbox 异步释放。 |
用搜索框可按字段名或语义过滤。内部调度字段不会进入 UI 领域对象,但和业务字段共处一行, 这是该表既是聚合根又是基础设施状态表的直接体现。
每个折叠块是一段具有独立失败语义的业务链路。默认展开关键路径,可一键查看全部分支。
先决定入口是否存在,再由服务端决定请求能否真正取号。
+client/configs: exposure + models
-> GET /ticket/availability
-> false: disable until next_take_at, then refresh
-> true/error: allow create
-> POST /ticket
-> success: create local task
-> 3103: show error + refresh availability
POST /ticket remains the final quota authority
先取号,成功才 INSERT;创建阶段不会预建 Session。
+random offPeakTaskId
→ POST /ticket
→ failure: no row
→ success: INSERT queued
先 flush settle outbox,再批量读取最多 100 张非终态 ticket。
+outbox → nonTerminal → missing ticket: retake → batchStatus ≤ 100 → apply by ticketId → schedule next cycle
Pause 只停本地,Continue 决定复用旧票还是显式消耗额度重取。
+queued --pause--> paused paused --continue--> queued ├─ alive ticket: reuse └─ dead ticket: retake
BEGIN IMMEDIATE 保证单任务 single-flight;任务间没有本地并发上限。
+BEGIN IMMEDIATE SELECT due UPDATE claim_running = 1 COMMIT dispatch → main → host
Session 常驻模型不被污染,idle plan 只活在自动派发的当前 turn。
+first: createTask → send original prompt resume: resumeTask → send continuation both: turnRuntimeModel = idle plan
细阀可在每一次模型请求发生;排队不消耗普通 API 重试预算。
+429/3105 → queued hold → retry ∞ 3102 → marker → requeue + retake other → normal retry → failed?
当前代码只复用普通 Session 的 permission / elicitation 交互,聚合态保持 running。
+permission requested → session waits for response → off_peak_tasks stays running → same turn keeps idle plan
逻辑任务和 Session 不变,只替换当前 ticket;History 仍是一条完整执行。
+task-A + ticket-1 + session-X
↓ 3102
task-A + ticket-2 + session-X
本地终态先落库,服务端资源释放通过异步 settle outbox 完成。
+local terminal
→ unread + history status
→ settle current ticket
├─ ack: settled_at
└─ retryable: outbox
三个入口分别是终止、物理删除和视觉隐藏,数据后果完全不同。
+cancel = terminal state delete = physical row delete history = visibility tombstone
Off-Peak 没有 misfire skip;中断统一回队并复用 Session 接续。
+recoverInterrupted:
running / awaiting
→ queued
→ resume same session later
没有独立 runs 表。UI 直接从任务聚合行计算一条摘要,因此多张 ticket、多段执行对用户保持透明。
纯 queued / paused,没有 startedAt,History 显示空态。
started_at IS NULL
startedAt 存在且未隐藏,展示 Prompt、Triggered、Status、Duration 和 Session 入口。
started_at IS NOT NULL
只写 tombstone;任务后续完成、失败或核销都不会让该行重新出现。
history_deleted_at IS NOT NULL
| 本地任务状态 | History 文案 | Duration 终点 |
|---|---|---|
| running | In progress | Date.now() |
| completed | Succeeded | endedAt |
| failed | Failure | endedAt |
| cancelled after start | Skipped | endedAt |
普通 Automation 的同一定义会按周期产生很多次独立触发,runs 表既是幂等账本也是历史。 Off-Peak 当前只有一次逻辑运行,重复发生的是 ticket 分段。
最少的表和写路径,足够完成一次性任务、分段续跑与 0/1 History。
如果一条任务仍然只允许一次逻辑执行,那么 task 与 run 永远接近 1:1,只会把同一状态拆写两遍。
让聚合表继续回答“任务现在怎样”,子表专门记录“每张票怎样流转与核销”。产品 History 仍保持 0/1。
id · title · prompt · workspace
status · started_at · ended_at
session_id · history_deleted_at
current_ticket_id · current snapshot
scheduler claim · task-level failure
回答产品与调度问题:这项任务是谁、现在在哪、是否终态、用户是否隐藏历史。
ticket_id PK · task_id FK
ordinal · server_state · taken_at
ready_at · active_at · expires_at
segment_started_at · segment_ended_at
end_reason · last_error
settle_state · settle_attempts · settled_at
回答可靠性问题:经历过哪些准入分段、哪张票待核销、一次续跑为什么结束。
采用纯增量迁移,不改现有任务主键、不立刻删除旧字段。新旧代码在一段时间内都能读取
off_peak_tasks 完成核心流程。
使用 CREATE TABLE IF NOT EXISTS 创建 ticket ledger 和索引;原任务表、原 RPC、原 UI 全部不动。
每个任务最多回填当前 server_ticket_id,标记为 legacy backfill。已经被覆盖的历史票无法凭空恢复。
Take、status、segment end、settle 同时写旧聚合字段与新子表;对账后再把诊断和 outbox 查询切到子表。
先切 settle 重试,再切本地额度与诊断;任务卡片和 0/1 History 继续从聚合表读取,避免产品语义漂移。
新版本首次启动自动建表并回填当前票。旧任务照常运行;没有历史票时只影响审计完整度,不影响执行。
保留聚合字段即可让旧版本继续跑;旧版本期间产生的新票不会写 ledger,重新升级后无法补回完整链路。
ON DELETE CASCADE。
任务删除后至少保留未核销票据和最小任务标识,直到 acknowledged 或明确放弃。
以下项目是从代码全链路得出的事实,包含已经闭环的不变量,也包含文档目标和当前实现之间的差异。
所有终态写入都带非终态 WHERE 守卫,Cancel 可以稳定压住迟到 stopped 回写。
BEGIN IMMEDIATE + claim_running 原子认领,多个任务之间不做错误的本地串行化。
Permission / elicitation 只走普通 Session;Off-Peak 聚合保持 running,不暴露不可达状态。
sendPrompt 受理后即 markRunning,因此细阀 429 期间本地已经显示 In progress。
普通同步对 not_found 不做重取;queued 任务可能持续携带旧票,只有 Continue 会处理。
字段会写库,但同步仍是 Service 级公共 timer,不按每条任务的 next_poll_at 过滤;即使无任务,finally 仍会约 5 分钟后再排一次 timer。
D39 删除本地 task.createdAt 与静态 limit 预判,改由 availability 返回实时 can_take_number 与 next_take_at。
共享配置只控制曝光和模型列表;服务端内部 limit/window 不再泄漏为客户端推断规则。
客户端保存的是带 next_take_at 的短期服务端快照,不保存 quotaExhausted boolean;到点重查,查询失败则保持禁入直到成功刷新。
行被物理删除后,如果异步 settle 遇到网络/5xx,已经没有本地记录可继续补报。
每个窗口 Host 都启动 sync;写入和接口幂等,但批量状态请求和 settle 扫描会放大。
服务端有 ticket 真源和审计,但本地旧 ticket 会被覆盖;离线诊断无法还原每段耗时、结束原因与 settle 重试。
当前服务端由独立 timer 推进 queued → ready;若客户端仍假定 status 查询会触发 Promote,轮询语义和故障判断会失真。
一眼判断一个需求是否还能继续塞进当前表,还是已经应该拆出 runs / tickets / events。