import { guardStaleCtx, toErrorMessage } from '@zhushanwen/pi-ext-guards' import { getLogger } from '@zhushanwen/pi-extension-logger' import type { SchedulerBackend } from './backend.js' import { autoName, generateTaskId } from './format.js' import { computeNextRunAt, MS_PER_DAY, MS_PER_MINUTE, parseDuration } from './parsing.js' import { appendExecutionRecord, toTaskSnapshot } from './types.js' import type { AddOptions, ScheduledTask, SchedulerEntryOp, ScheduleSpec, } from './types.js' const logger = getLogger('scheduler') const MAX_TASKS = 50 const RATE_LIMIT_PER_MINUTE = 6 // tick 间隔(30s)。导出供测试 import 复用(fake timers 推进量与生产节奏同源——禁 // 测试本地重造 30_000 魔数,改值即测试锚同步漂移);ack 写盘自检为事件驱动 //(maybeStartAck 由任务创建回调触发,见 index.ts 创建汇聚点;message_start / // turn_end 挂点只承载覆写武装与安全网注销),不引用本常量。 export const TICK_INTERVAL_MS = 30_000 const DEFAULT_EXPIRY_DAYS = 7 const DEFAULT_EXPIRY_MS = DEFAULT_EXPIRY_DAYS * MS_PER_DAY // 7 days // U4 dispatch 模型切换(设计 D3 修订版 + D1 归属简化 + ADR-0122 事实查询翻转): // - dispatch 注入消息的 customType 标记前缀(sendMessage 的消息标记,供运维从 session 流 // 识别 scheduler dispatch 注入;与 ack 触发器前缀 pi-scheduler-ack: 避让) // - 恢复判定锚点 = isIdle() 同步事实查询(判定层级最高):agent_settled 确定性事件是 // 提前触发的加速器(事件到达即查询并兑现);每 tick 开头对未决记录做一次同步事实 // 查询兜正确性(事件丢失时由查询收敛——拉保证正确、事件只优化延迟)。原 2-tick // 计数对账(时间窗猜测事件丢失)已按 ADR-0122 删除。 const DISPATCH_CUSTOM_TYPE_PREFIX = 'pi-scheduler:' /** * 模型控制面(U4 dispatch 模型切换,依赖反转):runtime 只见 'provider/id' 字符串 ref * (与 ScheduledTask.model 同域,tool.ts draft 组装 modelRef 的产出形态),pi 的 Model * 对象解析(modelRegistry.find → setModel)封在生产装配闭包(index.ts session_start)。 * * 契约:setModelByRef 不 throw、失败(解析无命中 / pi setModel false / 意外异常)返回 false。 * * 缺省(不注入)= 无模型切换能力:task.model 非空时降级为照常 dispatch(日志,模型字段 * 不阻塞核心调度),纯 runtime 单测与旧装配路径行为不变。 */ export interface SchedulerModelOps { /** 会话当前模型的 'provider/id' 引用(ctx.model;undefined = 会话无模型) */ getCurrentModelRef(): string | undefined /** 切换会话模型;false = 解析失败 / pi setModel false(无可用 API key 等) */ setModelByRef(ref: string): Promise /** 会话是否 idle(not streaming,ctx.isIdle) */ isIdle(): boolean } /** * 未决模型切换记录(纯内存态)。模型语义异常不进持久化词表 * (replay 守卫对 advance.status 硬校验 'success',旁路字段会炸重放推进)。 * * 生命周期(ADR-0122 事实查询翻转后):dispatch 受理 → 记录创建(in-flight 等价语义: * 等 run 落定);恢复兑现锚点 = isIdle() 同步事实查询(agent_settled 事件提前触发 + * tick 每轮查询兜正确性)→ 恢复成功 / restore-failed(setModel(原) false)→ 记录清除 * (终态只留日志)。记录存在 + idle 即恢复,无 phase 区分、无时间窗计数。 */ interface PendingModelSwitch { taskId: string /** 会话期望模型(dispatch 切换前抓取的原模型 'provider/id') */ expectedModelRef: string targetModelRef: string } // HISTORY_LIMIT 单点在 types.ts(ext-simplify-08 L5)——与 replay.ts 的 advance 折叠共用 // STALE_CTX_MARKER(文案兜底分诊词)已迁移到 ext-guards 共享守卫(guardStaleCtx 内部 // 引用,本文件不再直接持有)。语义:G1 模块级代际检测(isCtxStale)为主判;文案子串 // 覆盖代际盲区——显式 reload / cwd 变化触发 clearExtensionCache 后 jiti 重新 import 产生 // 全新模块环境,旧闭包引用的模块级代数冻结不再递增,isCtxStale 恒 false,只剩错误文案 // 能识别 stale。pi 非契约 API(Error message 非稳定接口):文案由 docs/pi-semantics.json // PS-30 探针随 pi 版本门禁自动重验;pi 升级仍需回归 runtime.test.ts 的 U1 / G1-d 文案 // 锚定用例。文案变更时此兜底失效,后果为 timer 泄漏 + 每 30s warn(不 crash)。 export class SchedulerRuntime { private tasks: Map = new Map() private backend: SchedulerBackend private tickTimer: ReturnType | null = null private dispatchTimestamps: number[] = [] private onAfterTickCallback: (() => void) | null = null private readonly isCtxStale: (() => boolean) | undefined // R3-S1:同任务 dispatch 在途标记(Set),见 dispatchTask 注释 private readonly dispatchesInFlight = new Set() // U4 dispatch 模型切换(设计 D3 修订版):模型控制面(缺省 undefined = 无切换能力,降级) private readonly modelOps: SchedulerModelOps | undefined // 未决模型切换记录(同时至多一条——互斥窗口 + 模型 op 串行队列共同保证「切换→dispatch→ // 恢复」周期单在途、单 setModel 在途) private pendingModelSwitch: PendingModelSwitch | null = null // 模型 op 串行队列尾(MF-2 收口):调度器发起的全部 setModel(dispatch 切换 / 期望模型 // 恢复 / 错误路径恢复)单点经 serializeModelOp 入队——「setModel 起步 → 记录创建 / // 恢复收口」全程持队,任何后继 setModel 发起点先排队前置等待,两个 setModel 并发 // 结构性不可能(完成顺序不定 → turn 静默跑错模型的交错被消除)。链条引用恒吞错 // 自愈(见 serializeModelOp),前序 op 失败不阻断后继。 private modelOpChain: Promise = Promise.resolve() /** * 依赖反转构造:backend 承担 appendEntry/pi.sendMessage/时间源,runtime 只持有内存态。 * 不触碰任何 FS / session JSONL(测试可用 MockSchedulerBackend 零副作用注入)。 * * isCtxStale(G1 代际检测,S9/R3-M1):返回 true 表示本 runtime 建立时的 session 已被 * 替换。index.ts 装配点注入(模块级代数比对,R3-M1),使 stale 分诊不依赖 pi 错误文案; * 缺省(不注入)恒视为非 stale——纯 runtime 单测与旧装配路径行为不变。 * * modelOps(U4):模型控制面,session_start 装配点注入;缺省 = 无模型切换能力 * (task.model 非空时降级照常 dispatch)。 */ constructor(backend: SchedulerBackend, isCtxStale?: () => boolean, modelOps?: SchedulerModelOps) { this.backend = backend this.isCtxStale = isCtxStale this.modelOps = modelOps // U6c:session_tree(树回退)重折叠接线——生产 backend 在 pi.on('session_tree') // 时按活跃路径重折叠任务集并回调;落点 = 本 tasks Map(loadTasks 换集)。 // 纯重建:不 dispatch、不 append op、不动 tickTimer 启停(常驻循环照常消费 // 新任务集,被撤子树任务不在集内即到点不触发)也不动 pendingModelSwitch // (撤回编排前置 = 会话空闲,无在途模型切换记录)。测试替身不实现 // onSessionTree 时 ?. 订阅为 no-op,既有单测路径不变。 backend.onSessionTree?.((tasks) => this.loadTasks(tasks)) } // ── 任务 CRUD ── async addTask(prompt: string, schedule: ScheduleSpec, options: AddOptions = {}): Promise { if (this.tasks.size >= MAX_TASKS) { throw new Error(`Task limit reached (${MAX_TASKS}). Delete a task first.`) } const id = generateTaskId() const now = this.backend.now() const kind = options.kind ?? 'recurring' const name = options.name ?? autoName(prompt) let expiresAt: number | undefined if (options.expires === 'never') { expiresAt = undefined } else if (kind === 'recurring') { const expiryMs = options.expires ? (parseDuration(options.expires) ?? DEFAULT_EXPIRY_MS) : DEFAULT_EXPIRY_MS expiresAt = now + expiryMs } // 统一 nextRunAt 计算:interval → now + intervalMs;cron → 下次命中(D2 后同步) const nextRunAt = computeNextRunAt(schedule, now) if (nextRunAt === undefined) { // 创建时校验失败报错给用户(仅 cron 可能 undefined,interval 恒有值) const expr = schedule.mode === 'cron' ? schedule.cronExpression : '' throw new Error(`Invalid cron expression: ${expr}`) } const task: ScheduledTask = { id, name, prompt, kind, schedule, model: options.model, enabled: true, createdAt: now, nextRunAt, expiresAt, runCount: 0, history: [], } this.tasks.set(id, task) // append-only:写 upsert op 到 owner session JSONL(ER-APPEND-FAIL catch,内存态已更新) this.appendEntrySafe({ op: 'upsert', taskId: id, // getSessionFile() 在 --no-session 模式返回 undefined → '' 兜底(该模式 appendEntry 无 owner 不落盘) ownerSessionFile: this.backend.getSessionFile() ?? '', task: toTaskSnapshot(task), }) return task } listTasks(): ScheduledTask[] { return Array.from(this.tasks.values()).sort((a, b) => a.nextRunAt - b.nextRunAt) } getTask(id: string): ScheduledTask | undefined { return this.tasks.get(id) } async toggleTask(id: string, enabled: boolean): Promise { const task = this.tasks.get(id) if (!task) return false task.enabled = enabled // enable 重算到未来后的新 nextRunAt;携带到 toggle op 持久化, // 防 resume 重放从 upsert 快照回退到旧过期 nextRunAt(P1 跨 session 持久化) let recalcedNext: number | undefined // enable 时若 nextRunAt 已过期,重算,避免 enable 瞬间立即触发 if (enabled && task.nextRunAt < this.backend.now()) { const next = computeNextRunAt(task.schedule, this.backend.now()) if (next === undefined) { this.disableForInvalidCron(task) } else { task.nextRunAt = next recalcedNext = next // MF-1:重算到未来后清除残留 pending。pending 是「到期待 dispatch」标记, // 由 busy tick 的 step2 置位(W4 跨 tick 重试保留)。nextRunAt 已推到未来则该标记过期, // 否则下个 tick step3 `pending && enabled` 会在重算的未来时间点之前提前 dispatch, // 违背上方注释「避免 enable 瞬间立即触发」承诺。 task.pending = false } } // 全部 mutation 完成后 append toggle:确保 append 的 enabled 是最终值 // (LOW4:cron-invalid 回退 enabled=false 的路径,append enabled=false 而非入参 true) // P1:nextRunAt 仅 enable 重算到未来时携带——持久化重算值,防 resume 重放回退到 upsert 快照的旧过期值。 // 普通 toggle / cron 失效回退(recalcedNext=undefined)不带,重放时保持 upsert 快照值。 this.appendEntrySafe({ op: 'toggle', taskId: id, enabled: task.enabled, ...(recalcedNext !== undefined && { nextRunAt: recalcedNext }), }) return true } deleteTask(id: string): boolean { const deleted = this.tasks.delete(id) if (deleted) { this.appendEntrySafe({ op: 'delete', taskId: id }) } return deleted } async runTaskNow(id: string): Promise { const task = this.tasks.get(id) if (!task) return false // gap3:持久化由 dispatchTask 成功后 append advance(recurring)/delete(once) 隐式覆盖, // 不在此重复 append(advance 已在 dispatchTask chokepoint) return await this.dispatchTask(task) } // ── 调度 ── startScheduler(): void { if (this.tickTimer) return this.tickTimer = setInterval(() => { // 三件套语义等价迁移到共享守卫 guardStaleCtx(crash-resilience D1 / u1-ext-guard; // 守卫语义 = 本处原地实现的泛化,迁移对照逐条可证): // 1. G1 前置检查(S9):守卫的 isCtxStale 命中 → onStale(= retireStaleTimer)且 // tickScheduler 不执行——与迁移前「前置分支 return」等价:本 runtime 所属 session // 已被替换 → timer 属泄漏资源自停退场,不触碰捕获的 stale ctx,不依赖 pi 错误文案。 // 主防线仍是 F1(session_start 停旧 timer),此处覆盖 F1 未能触达的泄漏路径。 // 2. F2 catch 分诊(防御兜底):守卫对 tickScheduler 的 rejection 挂同一分诊谓词 // `isCtxStale() || 文案含 STALE_CTX_MARKER`(字面不变)——stale → retireStaleTimer; // 非 stale → 原样 reject,由下方 .catch 仅告警不终止调度('tick error' 文案不变, // U2/G1-c 锚定)。fire-and-forget 的 tick 链路必须自带 catch——tick 内任何异常 // (典型:泄漏 timer 的 onAfterTick → refreshWidget 访问 stale ctx.ui 抛错)无人接住 // 即 unhandledRejection,直接崩掉 pi 主进程(E1 同机制)。 // 3. retireStaleTimer 自停:未改('tick stopped' warn 口径与幂等 stopScheduler 原样, // U1/G1-b/G1-d 锚定)。 // `?.catch`:前置检查命中时守卫返回 undefined(fn 未执行、无 Promise、无 rejection // 可接——retire 已由 onStale 完成);非 stale 时返回 Promise,非 stale rejection // 流到 .catch 仅告警。 void guardStaleCtx(() => this.tickScheduler(), { isCtxStale: this.isCtxStale, onStale: () => this.retireStaleTimer(), })?.catch((err: unknown) => { logger.warn('tick error', { error: toErrorMessage(err) }) }) }, TICK_INTERVAL_MS) } stopScheduler(): void { if (this.tickTimer) { clearInterval(this.tickTimer) this.tickTimer = null } } /** * stale 自停退场(G1 前置检查与 F2 catch 分诊共用):warn 观测口径与 crash-fix 一致 * (含 "tick stopped",U1 断言锚定)+ stopScheduler(幂等)。timer 自停后调度由 * session_start 重建的新一代 runtime 接管。 */ private retireStaleTimer(): void { logger.warn('tick stopped: stale extension ctx (session replaced); timer self-retired') this.stopScheduler() } /** * 注册 tick 后回调(W2)。index.ts 注册 refreshWidget 替代独立 widgetTimer—— * 每次 tickScheduler 末尾调用,对齐 TICK_INTERVAL_MS 刷新 widget。 */ onAfterTick(callback: () => void): void { this.onAfterTickCallback = callback } async tickScheduler(): Promise { // U4 未决模型切换记录的事实查询结算(严格先于本 tick 的 dispatch 循环)。顺序约束: // 否则出现「B dispatch 先切 Y → 结算持 A 旧记录把 Y 静默切回」的交错(Y 从未生效、 // 全序列无报错,G4 被绕过)。恢复执行时 await 完成,保证恢复 setModel 完成后 dispatch // 循环才开 setModel;更广的「任意两个 setModel 不并发」由模型 op 串行队列(modelOpChain) // 结构保证——含 agent_settled 恢复 fire-and-forget 挂点,dispatch 模型分支排在其后。 // 无未决记录(或非 idle)的绝大多数 tick 同步返回、不引入 microtask 断点——dispatch // 的同步段契约(in-flight 守卫在 tickScheduler() 未 await 时同步可见)保持不变。 const settling = this.settlePendingIfIdle('tick-reconcile') if (settling) await settling const now = this.backend.now() // 1. 过期清理(must-fix 2 / CL9:append delete 抵消残留 upsert,防 resume 复活已过期任务) // append-only 下 upsert entry 永久残留 JSONL(D10 不裁剪),若无 delete entry 抵消, // resume 时 replayFoldEntries 会从 upsert 重放出已过期任务 → 每 resume 复活直到首个 tick。 for (const [id, task] of this.tasks) { if (task.expiresAt && now >= task.expiresAt) { this.tasks.delete(id) this.appendEntrySafe({ op: 'delete', taskId: id }) } } // 2. 标记到期(pending 是运行时标记,与 enabled 正交) for (const task of this.tasks.values()) { if (task.enabled && now >= task.nextRunAt) { task.pending = true } } // 3. dispatch pending 任务(按 nextRunAt 排序)。W4:显式 +t.enabled, // 防御标记后到 dispatch 之间被 toggle disabled 的竞态(pending 与 enabled 正交) const pending = [...this.tasks.values()] .filter(t => t.pending && t.enabled) .sort((a, b) => a.nextRunAt - b.nextRunAt) for (const task of pending) { if (task.pending) { await this.dispatchTask(task) } } // W2:tick 完成后刷新 widget(index.ts 注册 refreshWidget) this.onAfterTickCallback?.() } // ── dispatch ── /** * dispatch 单个任务。返回 true 表示消息已发出(pi.sendMessage 受理),false 表示 * no-op(task disabled / 已有同任务在途 / rate-limited)。 * * R3-S1 in-flight 守卫:tick 为 fire-and-forget,若 tick1 的 `await backend.sendMessage` * 挂起超过 TICK_INTERVAL_MS(如 pi 卡死),tick2 的 step2 会再标 pending、step3 对同一 * task 并发第二个 dispatch → 同一 prompt 双注入。参照 subagent-workflow resumesInFlight * 模式:入口同步置位、finally 清除(覆盖 gate / rate-limit / sendMessage 抛错 / 成功推进 * 全部退出路径);命中时 skip 本轮并 warn(不 throw——tick 继续处理其他任务,本任务 * pending 保留到下轮重试)。 */ async dispatchTask(task: ScheduledTask): Promise { if (!task.enabled) return false if (this.dispatchesInFlight.has(task.id)) { logger.warn('dispatch already in flight, skipping this tick', { taskId: task.id }) return false } this.dispatchesInFlight.add(task.id) try { return await this.dispatchTaskInner(task) } finally { this.dispatchesInFlight.delete(task.id) } } /** * dispatch 本体(dispatchTask 守卫置位后执行;runTaskNow 与 tick step3 共用入口, * 手动 run-now 与挂起中的 tick dispatch 并发时同样被守卫拦截)。 * steer 直投(scheduler-steer-direct-dispatch 设计):{deliverAs:'steer', triggerTurn:true} * 在 pi 侧的两分支——busy 时 steer 插入当前 turn(立即被模型看到)、idle 时开新 turn。 * 受理即记账:pi extension API sendMessage 是 fire-and-forget(返回 void,错误走 * pi 内部 emitError 通道),await 立即通过,无「入队未终态」窗口——nextRunAt 调用即推进, * tick 不重标 pending,无需防重标记。 * sendMessage 抛错(同步异常,session 关闭等)时记录 failed 状态但不 rethrow, * 让 tick 继续处理其他任务。 * * U4 模型切换(设计 D3 修订版):task.model 非空且 ≠ 会话当前模型时,sendMessage 前 * 按序执行「互斥校验 → setModel(task.model) → 记未决切换记录 → sendMessage」,切换段 * 整体入模型 op 串行队列(modelOpChain——与恢复 setModel 互斥,MF-2)。busy 亦照常 setModel * (P-MODEL-② 证伪「busy 切换不生效」——steer 消息消费时在同一 run 内开新 turn,turn * 开始从 ctx.model 取当前模型,setModel 对 steer turn 生效。pi 实装锚点(1.0.0 复核): * dist/core/agent-session.js setModel 同步写 agent.state.model + 每个 turn * 准备时从 agent.state.model 取模型快照——setModel 先于下一 turn 准备即生效)。 * 失败降级(分级,不阻塞核心 * 调度):modelOps 缺省 / 会话无当前模型 / setModel false → 日志 + 放弃切换,照常 dispatch。 * * 持久化(append-only):recurring 成功推进 nextRunAt → append advance(status='success' CL8); * once 成功 → append delete。失败 dispatch 不 append(CL7 重试语义,transient 失败 nextRunAt 未推进)。 * 模型切换语义不进任何持久化词表(U4 (c):appendExecutionRecord/advance/delete 行为不变)。 */ private async dispatchTaskInner(task: ScheduledTask): Promise { // 检查速率限制(U4 (b):rate-limit 拒绝路径保持在任何 setModel 之前,无模型残留) if (!this.hasDispatchCapacity(this.backend.now())) return false const modelOps = this.modelOps if (task.model && modelOps && task.model !== modelOps.getCurrentModelRef()) { // 串行段闭包内使用:task.model 的属性收窄不进异步闭包,入口处捕获为 const const targetModelRef = task.model let skipped = false if (this.pendingModelSwitch) { // 互斥窗口(设计 D3,理由 = 切换秩序):未决记录未关闭时,其他需切模型任务命中即 // skip 本轮 + pending 留待下 tick 重试(复用现状 skip 语义,不建真队列;不需切模型 // 的任务不受互斥影响——检查只在本分支)。「dispatch 写新记录前先结算旧记录」 // 的落地:本分支结构性永不写新记录(杜绝新旧叠写),skip 前做一次 isIdle() 事实 // 查询结算(idle 即恢复收口),保证新任务不被旧记录无限阻塞。 // 结算的恢复必须 await(顺序约束,与 tickScheduler 的结算 await 同构):恢复 // setModel(原) 完成后才放行 skip——恢复与紧随的需切模型任务 setModel(目标) 不得 // 并发(串行由模型 op 队列结构保证,本 await 是调用方顺序语义:skip 放行前恢复 // 已收口);本分支随后即 skip,await 无额外延迟代价。 await this.settlePendingIfIdle('mutex-settle') skipped = true } else { // 模型 op 串行段(MF-2 收口,互斥窗口全程覆盖「setModel 起步 → 记录创建」): // 前序模型 op(agent_settled 恢复 / 前序 dispatch 切换 / 错误路径恢复)可能仍在途—— // 排队等待期间未决记录与当前模型均可能变化,禁止按排队前快照盲切(旧快照的 // expectedModelRef 会把恢复锚点记错),串行段内重新校验后再切换。后来的需切模型 // 任务同样排队、出队时重新校验命中互斥 skip——双记录叠写 / 两个 setModel 并发 // 结构性不可能。 await this.serializeModelOp(async () => { const currentNow = modelOps.getCurrentModelRef() if (currentNow === undefined) { // 会话无当前模型:期望模型无处记录、恢复不可定义 → 放弃切换照常 dispatch(分级降级) logger.warn('cannot record expected model for switch (session has no current model)', { taskId: task.id, targetModelRef, }) return } if (this.pendingModelSwitch) { // 排队期间前序 dispatch 已建未决记录 → 互斥命中:skip 留待下 tick,不叠写 skipped = true return } if (targetModelRef === currentNow) { // 排队期间恢复已兑现(会话已回期望模型 = 本任务目标)→ 无需切换 return } const switched = await modelOps.setModelByRef(targetModelRef) if (switched) { // setModel 受理 → 记未决切换记录(会话期望模型 + 在途标记,内存态); // sendMessage 受理后等 run 落定(agent_settled + isIdle 复核)兑现恢复。 // 记录创建收在同一串行段内:互斥窗口对后继模型任务即时可见,无「已切未记」 // 的放行缺口。 this.pendingModelSwitch = { taskId: task.id, expectedModelRef: currentNow, targetModelRef, } } else { // setModel false(无可用 API key 等)→ 按「恢复失败」同族处理:日志 + 放弃本次 // 切换,任务照常 dispatch(模型字段不阻塞核心调度) logger.warn('model switch failed (setModel false), dispatch continues with current model', { taskId: task.id, targetModelRef, }) } }) } if (skipped) { logger.warn('model switch in progress, skipping this tick (pending retry next tick)', { taskId: task.id, targetModelRef, }) return false } } try { await this.backend.sendMessage( { content: task.prompt, customType: `${DISPATCH_CUSTOM_TYPE_PREFIX}dispatched`, display: true }, { deliverAs: 'steer', triggerTurn: true }, ) } catch { // U4 (a):接管副作用——同步抛错路径先恢复原模型(异常路径不得把会话留在目标任务 // 模型上),再走现状 failed 记账。记录先关后恢复(防 await 期间并发重入)。 const ps = this.pendingModelSwitch if (ps && modelOps) { this.pendingModelSwitch = null // 恢复 setModel 入模型 op 串行队列(与其他 setModel 发起点互斥,不并发);排队后 // 复核「当前 == 期望」:等待期间已切回则不动作 await this.serializeModelOp(async () => { if (modelOps.getCurrentModelRef() === ps.expectedModelRef) return const restored = await modelOps.setModelByRef(ps.expectedModelRef) if (!restored) { logger.warn( 'model restore failed after send error (restore-failed): manual switch back required', { expectedModelRef: ps.expectedModelRef, targetModelRef: ps.targetModelRef, taskId: ps.taskId }, ) } }) } task.lastStatus = 'failed' task.pending = false appendExecutionRecord(task, this.backend.now(), 'failed') return false } return this.onDispatchSuccess(task) } /** * dispatch 成功后的状态更新与持久化(dispatchTaskInner 受理成功后调用)。 */ private async onDispatchSuccess(task: ScheduledTask): Promise { const now = this.backend.now() task.runCount++ task.lastRunAt = now task.lastStatus = 'success' task.pending = false task.lastError = undefined appendExecutionRecord(task, now, 'success') if (task.kind === 'once') { this.tasks.delete(task.id) this.appendEntrySafe({ op: 'delete', taskId: task.id }) } else { const next = computeNextRunAt(task.schedule, now) if (next === undefined) { this.disableForInvalidCron(task) } else { task.nextRunAt = next this.appendEntrySafe({ op: 'advance', taskId: task.id, nextRunAt: next, at: now, status: 'success', }) } } this.dispatchTimestamps.push(now) return true } private hasDispatchCapacity(now: number): boolean { const oneMinuteAgo = now - MS_PER_MINUTE this.dispatchTimestamps = this.dispatchTimestamps.filter(t => t > oneMinuteAgo) return this.dispatchTimestamps.length < RATE_LIMIT_PER_MINUTE } // ── 模型切换:agent_settled 恢复与 tick 事实查询(U4,D3 修订版 + D1 归属简化 // + ADR-0122 事实查询翻转)── /** * 未决模型切换记录的事实查询结算:记录存在 + 会话 idle(isIdle() 同步查询, * 判定层级最高的事实来源)→ 兑现恢复;非 idle → 不动作(不能把在途 run 的模型 * 换掉),记录保留等下一查询点。无记录时同步返回 undefined(调用方跳过 await, * 保住 tick 无记录路径的同步契约——dispatch 的 in-flight 守卫在 tickScheduler() * 未 await 时同步可见)。 * * 调用点两处:① tickScheduler 开头(严格先于 dispatch 循环——顺序约束:否则 * 「B dispatch 先切 Y → 对账持 A 旧记录把 Y 静默切回」;agent_settled 事件丢失时 * 本查询点是正确性兜底,事件只作提前触发加速器);② dispatchTaskInner 互斥分支 * (skip 前尽量把旧记录收口,恢复 setModel 与后续新任务的 setModel(目标) 不得并发 * ——串行由模型 op 队列结构保证,本 await 是调用方顺序语义)。 */ private settlePendingIfIdle(reason: string): Promise | undefined { const ps = this.pendingModelSwitch if (!ps) return undefined if (this.isCtxStale?.()) { // stale 代际失效(session 替换):未决记录随代际丢弃,不触碰旧 ctx(同现有守卫路径) this.pendingModelSwitch = null return undefined } const ops = this.modelOps if (!ops) return undefined if (!ops.isIdle()) return undefined return this.restoreExpectedModel(reason) } /** * 模型 op 入队(串行化原语,MF-2):op 排到 modelOpChain 尾,前序 op 完成(含失败) * 后才执行;返回本 op 的结果 Promise 供调用方 await。链尾引用恒吞错自愈(重置为 * resolved),保证前序 op 失败不阻断后继、也不向链条泄漏 rejection(失败只回给本 op * 调用方)。op 内禁止再入队(会死锁)——现有 op(切换 / 恢复)只调 * modelOps.setModelByRef,无重入。 */ private serializeModelOp(op: () => Promise): Promise { const result = this.modelOpChain.then(op) this.modelOpChain = result.then( () => undefined, () => undefined, ) return result } /** * 执行恢复(切回会话期望模型)并关闭未决记录(在途标记生命周期收口): * 当前模型已等于期望(用户已手动切回)→ 清记录不动作;setModel(原) false → * restore-failed 终态(日志含恢复动作:会话停留在任务模型,需手动切回)+ 清记录。 * 记录先关后恢复——防 await setModel 期间 agent_settled 与 tick 对账并发重入。 * 恢复 setModel 本体经模型 op 串行队列(serializeModelOp,MF-2):agent_settled 挂点的 * fire-and-forget 调用由此变为安全——在途恢复持队,后续任何 setModel 发起点 * (dispatch 切换 / 对账恢复)排队等待,两个 setModel 并发结构性不可能(完成顺序 * 不定 → turn 静默跑错模型的交错被消除)。「当前 == 期望」复核移入串行段:排队 * 等待期间用户已手动切回则不动作(与既有清记录不动作语义一致)。 */ private restoreExpectedModel(reason: string): Promise { const ps = this.pendingModelSwitch const ops = this.modelOps if (!ps || !ops) return Promise.resolve() this.pendingModelSwitch = null return this.serializeModelOp(async () => { if (ops.getCurrentModelRef() === ps.expectedModelRef) return const restored = await ops.setModelByRef(ps.expectedModelRef) if (!restored) { logger.warn('model restore failed (restore-failed): session stays on task model, manual switch back required', { taskId: ps.taskId, expectedModelRef: ps.expectedModelRef, targetModelRef: ps.targetModelRef, reason, }) } }) } // 模型恢复事件入口(index.ts 的 pi.on('agent_settled') 转发;方法内不触碰捕获的 pi/ctx // ——恢复动作经构造注入的 modelOps,stale 代际由 isCtxStale 入口检查拦下:清状态机、 // 不执行恢复)。 /** * agent_settled:模型恢复的事件触发点(D1 归属简化——原 turnIndex 归属:4 状态字段 + * 6 事件挂点 + customType 前缀匹配,绕路自造对 2-tick 对账的依赖)。settled = run * 完全落定(无 retry/compaction/queued continuation;pi 实装 agent-session.js * _emitAgentSettled 先置 _isAgentRunActive = false 再 emit),dispatched turn 必已 * 结束,无需 turnIndex 归属识别——事件到达即做 isIdle() 事实查询: * - idle → 兑现恢复(事件是提前触发的加速器,恢复延迟 = 事件即时) * - 非 idle(settled 与用户新 run 交错)→ 不能切:会把新 run 的模型换掉 * (P-MODEL-② 同族形态)。记录保留,由 tick 的 isIdle() 查询在 idle 重入时兑现。 * 不挂 agent_end 恢复:end 后仍可能有自动续跑 turn,此时切回会把续跑 turn 的模型换掉 * (P-MODEL-② 同族形态)——恢复只认 settled。 */ handleRunSettled(): void { if (this.isCtxStale?.()) return if (!this.pendingModelSwitch || !this.modelOps) return if (this.modelOps.isIdle()) { void this.restoreExpectedModel('agent-settled') } } /** * ERR-2 fallback(ext-simplify-17 B3 抽取):cron 表达式失效 → 停用任务并记录失败原因 * (toggle enable 重算与 dispatch 成功推进两处共用)。禁止 `?? now()` 类 fallback * (会使 nextRunAt=now,下个 tick 立即重算 → 死循环);nextRunAt 保留原值—— * enabled=false 后 tick 不再触发。 */ private disableForInvalidCron(task: ScheduledTask): void { task.enabled = false task.lastStatus = 'failed' task.lastError = 'cron expression invalid' } // ── 装配与回调 ── /** * 任务集换装入口(装配点 session_start 注入初始数组 + U6c session_tree 重折叠共用; * 读盘/重放由 backend 完成,runtime 只持有内存态)。 */ loadTasks(tasks: ScheduledTask[]): void { this.tasks = new Map(tasks.map(t => [t.id, t])) } // ── append-only 持久化辅助 ── /** * 委托 backend.appendEntry。失败 → logger.warn + 不 rethrow(ER-APPEND-FAIL)。 * 内存态已先行更新(at-least-once 已知恶化窗口:append 失败则该 op 丢失,resume 重放回退)。 * 不再设 task.lastError='persist failed'(append 失败是 transient,不应污染业务态)。 */ private appendEntrySafe(op: SchedulerEntryOp): void { try { this.backend.appendEntry(op) } catch (err) { // best-effort 降级(ER-APPEND-FAIL):append-only 模型下 append 失败仅丢失该 op 的持久化, // 内存态已先行更新、不 rethrow,业务流程继续。at-least-once 已知恶化窗口(resume 重放回退)。 logger.warn('appendEntry failed', { error: toErrorMessage(err) }) } } }