@x-otto/schedule 0.0.1-alpha.2 → 0.0.1-alpha.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","names":["SNAPSHOT_VERSION"],"sources":["../src/registry.ts","../src/cron-parser.ts","../src/schedule-store.ts","../src/fire-ownership.ts","../src/schedule-lease-migrations.ts","../src/remote-schedule-store.ts","../src/remote-fire-ownership.ts","../src/scheduler.ts","../src/schedule-capability.ts"],"sourcesContent":["import type { ScheduledTask } from './types'\n\n// schedule/src/registry.ts — In-memory schedule task registry (Map-backed CRUD).\nexport class ScheduleRegistry {\n private tasks: Map<string, ScheduledTask>\n\n constructor() {\n this.tasks = new Map()\n }\n\n add(task: ScheduledTask): void {\n if (this.tasks.has(task.id)) {\n throw new Error(`ScheduleRegistry: duplicate task id \"${task.id}\"`)\n }\n this.tasks.set(task.id, task)\n }\n\n remove(id: string): boolean {\n return this.tasks.delete(id)\n }\n\n update(\n id: string,\n patch: Partial<\n Pick<\n ScheduledTask,\n | 'enabled'\n | 'lastFiredAt'\n | 'nextFireAt'\n | 'maxDurationMs'\n | 'maxRetries'\n | 'lastFireFailed'\n | 'lastFireError'\n | 'lastFireCancelled'\n >\n >,\n ): boolean {\n const task = this.tasks.get(id)\n if (!task) return false\n\n if (patch.enabled !== undefined) task.enabled = patch.enabled\n if (patch.lastFiredAt !== undefined) task.lastFiredAt = patch.lastFiredAt\n if (patch.nextFireAt !== undefined) task.nextFireAt = patch.nextFireAt\n if (patch.maxDurationMs !== undefined) task.maxDurationMs = patch.maxDurationMs\n if (patch.maxRetries !== undefined) task.maxRetries = patch.maxRetries\n if (patch.lastFireFailed !== undefined) task.lastFireFailed = patch.lastFireFailed\n if (patch.lastFireError !== undefined) task.lastFireError = patch.lastFireError\n if (patch.lastFireCancelled !== undefined) task.lastFireCancelled = patch.lastFireCancelled\n\n return true\n }\n\n get(id: string): ScheduledTask | undefined {\n return this.tasks.get(id)\n }\n\n list(): ScheduledTask[] {\n return [...this.tasks.values()].sort((a, b) => a.nextFireAt - b.nextFireAt)\n }\n\n /**\n * Find tasks that are due for execution.\n * Returns all enabled tasks (recurring or one-shot) whose nextFireAt <= now.\n * The 'firing' guard is a scheduler concern (managed via a separate Set).\n */\n findDue(now: number): ScheduledTask[] {\n const due: ScheduledTask[] = []\n for (const task of this.tasks.values()) {\n if (task.enabled && task.nextFireAt <= now) {\n due.push(task)\n }\n }\n due.sort((a, b) => a.nextFireAt - b.nextFireAt)\n return due\n }\n\n count(): number {\n return this.tasks.size\n }\n\n has(id: string): boolean {\n return this.tasks.has(id)\n }\n}\n","// schedule/src/cron-parser.ts — 5-field cron expression parser.\n// Uses local Date API (DST-safe). Search capped at 366 days forward.\n\ntype FieldSet = Set<number>\n\n/**\n * Parse a 5-field cron expression (minute hour dom month dow) and return the next\n * fire time >= from (inclusive). All times in local timezone.\n *\n * Throws on invalid expressions.\n *\n * Field syntax: wildcard (*), single value (5), step (e.g. \\*\\/15 or 5\\/15),\n * range (1-5), list (1,15,30), range with step (1-10\\/2).\n *\n * Unsupported: L, W, ?, #, name aliases (MON, JAN).\n *\n * ## DoM∧DoW semantics\n *\n * If both day-of-month and day-of-week are constrained (not a bare *),\n * a date matches if **EITHER** field matches (standard vixie-cron OR behaviour).\n *\n * ## Day-of-week mapping\n *\n * 0 = Sunday, 1–6 = Monday–Saturday, 7 = also Sunday.\n */\nexport function parseCron(expr: string, from?: Date): Date {\n const { validMinute, validHour, validDom, validMonth, validDow, domConstrained, dowConstrained } =\n parseExpression(expr)\n\n const startTime = from ? new Date(from.getTime()) : new Date()\n startTime.setSeconds(0, 0)\n\n const maxTime = new Date(startTime.getTime() + 366 * 24 * 60 * 60 * 1000)\n let current = new Date(startTime.getTime())\n\n while (current.getTime() <= maxTime.getTime()) {\n // --- minute ---\n const minute = current.getMinutes()\n const nextMin = nextInSet(validMinute, minute)\n if (nextMin === null) {\n current.setMinutes(firstInSet(validMinute))\n current.setHours(current.getHours() + 1)\n current.setSeconds(0, 0)\n continue\n }\n if (nextMin !== minute) {\n current.setMinutes(nextMin)\n current.setSeconds(0, 0)\n continue\n }\n\n // --- hour ---\n const hour = current.getHours()\n const nextHr = nextInSet(validHour, hour)\n if (nextHr === null) {\n current.setHours(firstInSet(validHour))\n current.setMinutes(firstInSet(validMinute))\n current.setDate(current.getDate() + 1)\n current.setSeconds(0, 0)\n continue\n }\n if (nextHr !== hour) {\n current.setHours(nextHr)\n current.setMinutes(firstInSet(validMinute))\n current.setSeconds(0, 0)\n continue\n }\n\n // --- day (DoM ∨ DoW) ---\n const dom = current.getDate()\n const dow = current.getDay()\n const domMatch = validDom.has(dom)\n const dowMatch = validDow.has(dow)\n\n let dayMatch: boolean\n if (domConstrained && dowConstrained) {\n dayMatch = domMatch || dowMatch\n } else if (domConstrained) {\n dayMatch = domMatch\n } else if (dowConstrained) {\n dayMatch = dowMatch\n } else {\n dayMatch = true\n }\n\n if (!dayMatch) {\n current.setDate(current.getDate() + 1)\n current.setHours(firstInSet(validHour))\n current.setMinutes(firstInSet(validMinute))\n current.setSeconds(0, 0)\n continue\n }\n\n // --- month ---\n const month = current.getMonth() + 1 // 1-based\n const nextMo = nextInSet(validMonth, month)\n if (nextMo === null) {\n // Wrap to next year\n current.setMonth(firstInSet(validMonth) - 1)\n current.setFullYear(current.getFullYear() + 1)\n current.setDate(1)\n current.setHours(firstInSet(validHour))\n current.setMinutes(firstInSet(validMinute))\n current.setSeconds(0, 0)\n continue\n }\n if (nextMo !== month) {\n current.setMonth(nextMo - 1)\n current.setDate(1)\n current.setHours(firstInSet(validHour))\n current.setMinutes(firstInSet(validMinute))\n current.setSeconds(0, 0)\n continue\n }\n\n // All fields matched\n return current\n }\n\n throw new Error(`No matching time found for cron \"${expr}\" within 366 days of ${startTime.toISOString()}`)\n}\n\n// ---------------------------------------------------------------------------\n// Internal helpers\n// ---------------------------------------------------------------------------\n\ninterface ParsedExpression {\n validMinute: FieldSet\n validHour: FieldSet\n validDom: FieldSet\n validMonth: FieldSet\n validDow: FieldSet\n domConstrained: boolean\n dowConstrained: boolean\n}\n\nfunction parseExpression(expr: string): ParsedExpression {\n if (!expr || !expr.trim()) {\n throw new Error('Invalid cron expression: empty string')\n }\n\n const fields = expr.trim().split(/\\s+/)\n if (fields.length !== 5) {\n throw new Error(`Invalid cron expression: expected 5 fields, got ${fields.length} (${expr})`)\n }\n\n const [minField, hourField, domField, monthField, dowField] = fields as [string, string, string, string, string]\n\n const validMinute = parseField(minField, 0, 59)\n const validHour = parseField(hourField, 0, 23)\n const validDom = parseField(domField, 1, 31)\n const validMonth = parseField(monthField, 1, 12)\n const validDow = parseField(dowField, 0, 7)\n\n // 7 = Sunday, also maps to 0 for Date.getDay() compatibility\n if (validDow.has(7)) {\n validDow.add(0)\n }\n\n // A field is \"constrained\" if the user explicitly specified values (not bare *)\n // 架构 review 2026-08-12 S12:`*/1` 语义等价 `*`(覆盖全域、步长 1),此前按裸字符串\n // 比较被判为 constrained,触发 DoM∧DoW 的 OR 语义——dow 单侧约束被误升为双约束。\n // 全域等价判定只认两种写法(`*` 与 `*/1`);`1-31`/`0-6` 这类显式全域范围仍判\n // constrained(写全范围的作者意图是显式约束,不替用户臆测)。\n const isWildcardEquivalent = (field: string): boolean => field === '*' || field === '*/1'\n const domConstrained = !isWildcardEquivalent(domField)\n const dowConstrained = !isWildcardEquivalent(dowField)\n\n return { validMinute, validHour, validDom, validMonth, validDow, domConstrained, dowConstrained }\n}\n\n/**\n * Parse a single cron field into a set of valid integer values.\n *\n * Supported sub-syntax:\n * - * -> all values in [min, max]\n * - 5 -> single value\n * - *\\/15 or 5\\/15 -> step (start/step)\n * - 1-5 -> inclusive range\n * - 1,15,30 -> comma-separated list\n * - 1-10/2 -> range with step\n *\n * Throws on unsupported syntax (letters, L, W, ?, # etc.) or out-of-range values.\n */\nfunction parseField(field: string, min: number, max: number): FieldSet {\n validateFieldSyntax(field)\n\n const result = new Set<number>()\n const parts = field.split(',')\n\n for (const part of parts) {\n const { range, step } = splitStep(part)\n validateStep(step, part)\n\n if (range === '*') {\n for (let i = min; i <= max; i += step) {\n result.add(i)\n }\n } else if (range.includes('-')) {\n const [startStr, endStr] = range.split('-') as [string, string]\n const start = parseFieldInt(startStr, part)\n const end = parseFieldInt(endStr, part)\n if (start < min || end > max || start > end) {\n throw new Error(`Invalid cron field \"${field}\": range [${start},${end}] out of bounds [${min},${max}]`)\n }\n for (let i = start; i <= end; i += step) {\n result.add(i)\n }\n } else {\n const val = parseFieldInt(range, part)\n if (val < min || val > max) {\n throw new Error(\n `Invalid cron field \"${field}\": value ${val} out of range [${min},${max}]`,\n )\n }\n if (step > 1) {\n // \"N/step\" — start at N, step to max (vixie-cron semantics, e.g. 5/15 → 5,20,35,50)\n for (let i = val; i <= max; i += step) {\n result.add(i)\n }\n } else {\n result.add(val)\n }\n }\n }\n\n return result\n}\n\nfunction validateFieldSyntax(field: string): void {\n // Allow only digits, *, /, -, comma, and whitespace\n const cleaned = field.replace(/[\\d*\\/\\-,]/g, '')\n if (cleaned.length > 0) {\n throw new Error(`Invalid cron field \"${field}\": unsupported syntax`)\n }\n}\n\nfunction splitStep(part: string): { range: string; step: number } {\n const slashIdx = part.indexOf('/')\n if (slashIdx === -1) {\n return { range: part, step: 1 }\n }\n return {\n range: part.substring(0, slashIdx),\n step: parseInt(part.substring(slashIdx + 1), 10),\n }\n}\n\nfunction validateStep(step: number, part: string): void {\n if (isNaN(step) || step < 1) {\n throw new Error(`Invalid cron field: invalid step in \"${part}\"`)\n }\n}\n\nfunction parseFieldInt(s: string, contextPart: string): number {\n const n = parseInt(s, 10)\n if (isNaN(n)) {\n throw new Error(`Invalid cron field: expected number, got \"${s}\" in \"${contextPart}\"`)\n }\n return n\n}\n\nfunction nextInSet(set: FieldSet, current: number): number | null {\n let best: number | null = null\n for (const v of set) {\n if (v >= current && (best === null || v < best)) {\n best = v\n }\n }\n return best\n}\n\nfunction firstInSet(set: FieldSet): number {\n let best = Infinity\n for (const v of set) {\n if (v < best) best = v\n }\n return best\n}\n","// schedule/src/schedule-store.ts — Snapshot-per-id storage via Persistence<ScheduledTask>.\n// RFC-169 D1: partitioned by workspaceKey. Fail-open: read errors → [].\nimport { createPersistence, type Persistence, type Snapshot } from '@x-otto/persistence'\nimport { OTTO_HOME, isShadowModeEnabled } from '@x-otto/env'\nimport { resolve } from 'node:path'\n\nimport type { ScheduledTask } from './types'\n\nconst SNAPSHOT_VERSION = 1\n\n/** Per-workspace schedules directory: `${OTTO_HOME}/schedules/<workspaceKey>/`. */\nfunction schedulesDir(workspaceKey: string): string {\n return resolve(OTTO_HOME, 'schedules', workspaceKey)\n}\n\nexport interface ScheduleStore {\n saveTask(task: ScheduledTask): Promise<void>\n loadAllTasks(): Promise<ScheduledTask[]>\n deleteTask(id: string): Promise<boolean>\n}\n\nexport interface ScheduleStoreOptions {\n persistence?: Persistence<ScheduledTask>\n /** Logger (injected, replaces bare console.error). */\n logger?: { error: (msg: string | Error, ...args: unknown[]) => void }\n}\n\n/**\n * Create a ScheduleStore backed by FilePersistence in ${OTTO_HOME}/schedules/<workspaceKey>/.\n *\n * `workspaceKey` is required (RFC-169 D1) — every call site must explicitly state which\n * workspace's schedules it wants, so a caller can never silently fall back to a shared\n * global path. Remote mode (RemoteScheduleStore, RFC-169 D3) is a separate implementation\n * selected by the caller based on `--session-url`, not by this factory.\n */\nexport function createScheduleStore(workspaceKey: string, options: ScheduleStoreOptions = {}): ScheduleStore {\n // RFC-345:影子模式(OTTO_SHADOW)下默认持久化切到 memory 后端——`/schedule add`\n // 等交互路径只写内存、零磁盘落盘,符合影子态「写静默吞」(§D3)语义。调用方显式\n // 注入 options.persistence 时不受影子态控制。\n //\n // 已知折衷:影子态下 `/schedule list` 只含本进程内存任务,既有磁盘任务不可见。\n // 读穿透叠加 merge 的复杂度不值得为一个短暂影子会话引入,故不做。\n const store =\n options.persistence ??\n (isShadowModeEnabled()\n ? createPersistence<ScheduledTask>({ type: 'memory' })\n : createPersistence<ScheduledTask>({ type: 'file', baseDir: schedulesDir(workspaceKey) }))\n const logger = options.logger\n\n return {\n async saveTask(task: ScheduledTask): Promise<void> {\n const snapshot: Snapshot<ScheduledTask> = {\n version: SNAPSHOT_VERSION,\n id: task.id,\n data: task,\n metadata: {\n createdAt: task.createdAt,\n updatedAt: Date.now(),\n tags: ['schedule'],\n },\n }\n await store.save(snapshot)\n },\n\n async loadAllTasks(): Promise<ScheduledTask[]> {\n let ids: string[]\n try {\n ids = await store.list()\n } catch (err) {\n logger?.error('[schedules] Failed to list schedule snapshots:', err)\n return []\n }\n\n const tasks: ScheduledTask[] = []\n for (const id of ids) {\n try {\n const snapshot = await store.load(id)\n if (snapshot) {\n tasks.push(snapshot.data)\n }\n } catch (err) {\n logger?.error(`[schedules] Failed to load schedule \"${id}\":`, err)\n // skip corrupted record, continue loading rest\n }\n }\n\n tasks.sort((a, b) => a.nextFireAt - b.nextFireAt)\n return tasks\n },\n\n async deleteTask(id: string): Promise<boolean> {\n try {\n return await store.delete(id)\n } catch (err) {\n logger?.error(`[schedules] Failed to delete schedule \"${id}\":`, err)\n return false\n }\n },\n }\n}\n","// schedule/src/fire-ownership.ts — Per-fire lease acquisition (async tryAcquire).\n// RFC-169 D1: partitioned by workspaceKey.\n// RFC-171 D2/D5: lease format token:pid:hostname + liveness probe.\nimport {\n closeSync,\n mkdirSync,\n openSync,\n writeSync,\n readFileSync,\n statSync,\n renameSync,\n rmSync,\n unlinkSync,\n utimesSync,\n} from 'node:fs'\nimport { resolve } from 'node:path'\nimport { randomUUID } from 'node:crypto'\n\nimport {\n OTTO_HOME,\n currentHostname,\n decodeLeaseToken,\n encodeLeaseToken,\n isProcessAlive,\n isShadowModeEnabled,\n} from '@x-otto/env'\n\n/** Per-workspace lock file: `${OTTO_HOME}/schedule-owner-<workspaceKey>.lock`. */\nfunction lockFilePath(workspaceKey: string): string {\n return resolve(OTTO_HOME, `schedule-owner-${workspaceKey}.lock`)\n}\n\nconst STALE_MS = 5 * 60_000 // 5-minute stale threshold (must exceed renew interval)\n\nexport interface FireOwnership {\n tryAcquire(): Promise<boolean>\n\n renew(): Promise<void>\n\n release(): Promise<void>\n\n lastFailureReason?(): 'occupied' | 'network-error' | undefined\n\n /** Whether this process currently holds the lock (verified against the token). */\n isOwner(): boolean\n}\n\n/**\n * Create a local token-based O_EXCL FireOwnership, scoped to `workspaceKey` (RFC-169 D1).\n * Only the lock holder runs the ticker and fires tasks.\n */\nexport function createLocalFireOwnership(workspaceKey: string): FireOwnership {\n const token = randomUUID()\n const LOCK_FILE = lockFilePath(workspaceKey)\n const myHostname = currentHostname()\n let owned = false\n\n /** Create the lockfile with our token (encoded as `token:pid:hostname`, RFC-171 D2). Returns false if it already exists. */\n const writeLock = (): boolean => {\n try {\n // OTTO_HOME may not exist yet on a fresh install — otto doesn't self-create it\n // ahead of first write. mkdirSync recursive is a cheap no-op once it does.\n mkdirSync(OTTO_HOME, { recursive: true })\n const fd = openSync(LOCK_FILE, 'wx') // O_EXCL\n try {\n writeSync(fd, encodeLeaseToken(token, process.pid, myHostname))\n } finally {\n closeSync(fd)\n }\n owned = true\n return true\n } catch (err) {\n if ((err as NodeJS.ErrnoException).code !== 'EEXIST') throw err\n return false\n }\n }\n\n /** Read + decode the lockfile currently written, or null if unreadable. */\n const readDecoded = (): { token: string; pid?: number; host?: string } | null => {\n try {\n const raw = readFileSync(LOCK_FILE, 'utf-8').trim()\n return decodeLeaseToken(raw)\n } catch {\n return null\n }\n }\n\n /** Read only the token field (fencing comparisons). */\n const readToken = (): string | null => readDecoded()?.token ?? null\n\n /** RFC-171 D2: confirmed-dead check — same hostname + pid not alive. Cross-host/malformed → false (safe fallback to stale-timeout). */\n const isConfirmedDead = (decoded: { pid?: number; host?: string }): boolean => {\n if (decoded.pid === undefined || decoded.host === undefined) return false\n if (decoded.host !== myHostname) return false\n return !isProcessAlive(decoded.pid)\n }\n\n /** RFC-171 D2: confirmed-alive check — same hostname + pid alive. Used to reject takeover even past STALE_MS. */\n const isConfirmedAlive = (decoded: { pid?: number; host?: string }): boolean => {\n if (decoded.pid === undefined || decoded.host === undefined) return false\n if (decoded.host !== myHostname) return false\n return isProcessAlive(decoded.pid)\n }\n\n // RFC-169 D3/D3b: FireOwnership methods are Promise-returning so this local O_EXCL\n // implementation and the remote HTTP one (remote-fire-ownership.ts) satisfy the same\n // interface. All work below is genuinely synchronous fs I/O — wrapped in an\n // immediately-resolved Promise, not an actual async boundary.\n const tryAcquireSync = (): boolean => {\n if (owned) return true\n // RFC-345:影子态不参与 fire ownership 竞选(§D9)——不建 schedule-owner-*.lock、\n // 不报失败原因。影子会话加载任务列表只读展示,fire 由真实 owner(或影子会话内部的\n // 其它路径)承担。这样影子进程既零写盘,也不会因竞选失败而降级走 STALE_MS 等\n // 既有的健康检查路径。\n if (isShadowModeEnabled()) return false\n if (writeLock()) return true\n\n // Lock exists — try liveness probe first (RFC-171 D2), fall back to mtime stale-timeout.\n const decoded = readDecoded()\n let mtime: number\n try {\n mtime = statSync(LOCK_FILE).mtimeMs\n } catch {\n // Vanished between calls — retry create.\n return writeLock()\n }\n\n if (decoded !== null && isConfirmedAlive(decoded)) {\n // Confirmed same-host owner is alive — reject even past STALE_MS (hung-process\n // trade-off; see module header + session-write-lease.ts D2 for the rationale).\n return false\n }\n\n const confirmedDead = decoded !== null && isConfirmedDead(decoded)\n if (!confirmedDead && Date.now() - mtime <= STALE_MS) return false\n\n // Stale or confirmed dead: atomically claim it away, then recreate with our token.\n const claimPath = `${LOCK_FILE}.stale-${randomUUID()}`\n try {\n renameSync(LOCK_FILE, claimPath)\n rmSync(claimPath, { force: true })\n } catch {\n return false // raced another taker — let the winner keep it\n }\n return writeLock()\n }\n\n return {\n async tryAcquire(): Promise<boolean> {\n return tryAcquireSync()\n },\n\n async renew(): Promise<void> {\n if (!owned) return\n // Only touch the lock if it is still ours (guards against a takeover).\n if (readToken() !== token) {\n owned = false\n return\n }\n try {\n const t = new Date()\n utimesSync(LOCK_FILE, t, t)\n } catch {\n owned = false // lock vanished under us\n }\n },\n\n async release(): Promise<void> {\n if (!owned) return\n // Only unlink if the lock is still ours (never delete a successor's lock).\n if (readToken() === token) {\n try {\n unlinkSync(LOCK_FILE)\n } catch {\n // best-effort cleanup\n }\n }\n owned = false\n },\n\n isOwner(): boolean {\n return owned && readToken() === token\n },\n }\n}\n","/**\n * schedule-lease-migrations.ts —— RFC-169 D3a: schedule_lease 表的独立 migration 段.\n *\n * `schedule_lease` is a schedule-domain table (server-arbitrated fire-ownership lease,\n * RFC-169 D3), not a generic blob-snapshot table — it must not join `BLOB_MIGRATIONS`\n * (@x-otto/persistence's generic storage kernel schema), per the \"generic storage kernel\n * does not hardcode domain schema\" principle already established by RFC-074 M5-02b\n * (session tables were carved out of the shared migration set for the same reason).\n *\n * Consumed by `persistenced-app.ts` via `acquireDb(leaseDbPath, wal, SCHEDULE_LEASE_MIGRATIONS)`\n * — deliberately a SEPARATE db file from the blob-snapshot `storage.db` (acquireDb's\n * fail-fast guard rejects injecting two different migration sets into the same db path,\n * `sqlite-db-cache.ts:134-140`).\n */\nimport type { Migration } from '@x-otto/persistence'\n\nexport const SCHEDULE_LEASE_MIGRATIONS: Migration[] = [\n {\n version: 1,\n up: (db) => {\n db.exec(`\n CREATE TABLE IF NOT EXISTS schedule_lease (\n ws_key TEXT PRIMARY KEY,\n token TEXT NOT NULL,\n renewed_at INTEGER NOT NULL\n );\n `)\n },\n },\n]\n","/**\n * remote-schedule-store.ts —— RFC-169 D3: 远程模式的 ScheduleStore 实现。\n *\n * `extends RemotePersistence<ScheduledTask>`(@x-otto/persistence 既有抽象基类,已有\n * RemoteSessionPersistence/PanelState 两个先例)——复用 wsKey 路径分区、鉴权 header 注入、\n * 超时/连接失败错误文案,不重新实现 HTTP 客户端。`ScheduleStore` 接口形状(saveTask/\n * loadAllTasks/deleteTask)与本地 `createScheduleStore` 一致,`setupScheduleService` 可\n * 按 remoteSessionConfigured 无痛切换两种实现(RFC-169 §4 重要事项规则 6)。\n */\nimport { RemotePersistence } from '@x-otto/persistence'\nimport type { RemotePersistenceOptions } from '@x-otto/persistence'\n\nimport type { ScheduleStore } from './schedule-store'\nimport type { ScheduledTask } from './types'\n\nconst SNAPSHOT_VERSION = 1\n\ninterface ScheduleSnapshot {\n version: number\n id: string\n data: ScheduledTask\n}\n\nclass RemoteScheduleStoreImpl extends RemotePersistence<ScheduleSnapshot> implements ScheduleStore {\n protected override get serviceLabel(): string {\n return 'schedule 远程服务'\n }\n protected override get endpointFlag(): string {\n return '--session-url'\n }\n\n protected extractId(snapshot: ScheduleSnapshot): string {\n return snapshot.id\n }\n\n async saveTask(task: ScheduledTask): Promise<void> {\n await this.save({ version: SNAPSHOT_VERSION, id: task.id, data: task })\n }\n\n /**\n * M3 里程碑完成审核记录的已知架构成本:list()+load() 是 1+N 次 HTTP 请求(先拿 id 列表,\n * 再逐个拉快照),非单次批量端点。可接受——schedule 任务集合天然小(MAX_TASKS=50,\n * scheduler.ts),且 loadAllTasks 只在 SchedulerService.start()/定期 reloadFromStore()\n * 调用,不在 tick 热路径。真要优化需要 persistenced 加批量读端点,超出本 RFC 范围。\n */\n async loadAllTasks(): Promise<ScheduledTask[]> {\n const ids = await this.list()\n const tasks: ScheduledTask[] = []\n for (const id of ids) {\n const snapshot = await this.load(id)\n if (snapshot) tasks.push(snapshot.data)\n }\n tasks.sort((a, b) => a.nextFireAt - b.nextFireAt)\n return tasks\n }\n\n async deleteTask(id: string): Promise<boolean> {\n return this.delete(id)\n }\n}\n\nexport interface RemoteScheduleStoreOptions {\n /**\n * `--session-url` 的原始值(如 `http://host:3001/api/storage/sessions`)——**不是**裸\n * origin,该值本身是完整资源路径(对齐 `otto serve` 后端约定)。内部用\n * `new URL(sessionUrl).origin` 提取协议+主机+端口,再拼接 `/api/storage/schedules`——\n * 与 `remote-fire-ownership.ts` 的同一提取逻辑保持一致,不能直接拿 sessionUrl 当前缀用。\n */\n sessionUrl: string\n getAuth: () => Promise<{ token: string }>\n fetch?: typeof globalThis.fetch\n timeoutMs?: number\n wsKey?: string | (() => string | undefined)\n}\n\n/**\n * Create a ScheduleStore backed by the persistenced `/api/storage/schedules` namespace's\n * generic storage route (`storage.ts`, `namespace='schedules'`) — distinct from the\n * lease sub-route (`schedule-lease.ts`, `/api/schedules/...`) which handles fire-ownership\n * arbitration only.\n */\nexport function createRemoteScheduleStore(options: RemoteScheduleStoreOptions): ScheduleStore {\n const origin = new URL(options.sessionUrl).origin\n const remoteOptions: RemotePersistenceOptions<ScheduleSnapshot> = {\n baseUrl: `${origin}/api/storage/schedules`,\n getAuth: options.getAuth,\n fetch: options.fetch ?? globalThis.fetch,\n timeoutMs: options.timeoutMs ?? 10_000,\n wsKey: options.wsKey,\n }\n return new RemoteScheduleStoreImpl(remoteOptions)\n}\n","// schedule/src/remote-fire-ownership.ts — Remote fire ownership via HTTP storage host.\n// RFC-169 D3c: 30s heartbeat, 180s stale.\n// D3d: fallback to tryAcquire on renew failure.\n// F1: in-flight dedup via inFlightAcquire.\nimport { randomUUID } from 'node:crypto'\n\nimport type { FireOwnership } from './fire-ownership'\n\nconst HEARTBEAT_MS = 30_000\nconst STALE_MS = 180_000\n\nexport interface RemoteFireOwnershipOptions {\n /**\n * `--session-url` 的原始值(如 `http://host:3001/api/storage/sessions`)——**不是**裸\n * origin。该值本身是完整资源路径(对齐 `otto serve` 后端约定,见 RFC-146 §1.3 两条远程\n * 产品线对照表),本构造函数内部用 `new URL(sessionUrl).origin` 提取协议+主机+端口,\n * 再拼接 `/api/schedules/...` lease 路径——不能直接拿 `sessionUrl` 当 baseUrl 使用。\n */\n sessionUrl: string\n wsKey: string\n getAuth: () => Promise<{ token: string }>\n fetch?: typeof globalThis.fetch\n timeoutMs?: number\n now?: () => number\n}\n\ntype LeaseAction = 'acquire' | 'renew' | 'release'\n\n/**\n * F2 (RFC-169 终局 review 2026-07-15): `callLease` 此前把\"服务端明确拒绝\"(HTTP 200 但\n * `acquired:false`/`renewed:false`)和\"请求本身没打到/没打通\"(网络错误/超时/非 2xx HTTP\n * 状态)统一折叠成同一个 `null` 返回值——消费方因此无法区分\"另一个终端真的持有租约\"和\n * \"我这边压根没连上服务端、连判定结果都没拿到\",导致 TUI 只读提示在网络故障时显示一句\n * 具体但错误的诊断(\"另一终端持有触发权\")。现在用一个可辨识返回类型显式区分三态:\n * `{ ok:true, data }`(服务端已应答,无论 acquired/renewed 是 true 还是 false,这本身\n * 就是一次成功的判定往返)、`{ ok:false, reason:'network-error' }`(请求本身失败)。\n * `response.ok===false`(如 403/500)也归为 network-error——那不是\"lease 被占用\"这个\n * 业务语义上的拒绝,是请求层面出了问题(鉴权/服务端错误),同样不该说成\"被占用\"。\n *\n * 已知精度局限(本次 review 复核发现,暂不展开为独立修复):`network-error` 这个名字\n * 把\"真连不上网络\"和\"鉴权失败(403 token 不对)\"两种不同性质的请求层失败合并成一类\n * ——两者的正确排查方向不同(前者查网络连通性,后者查 token 配置),但当前 TUI 提示\n * 文案统一说\"连接远程服务失败\",对鉴权失败场景不够精确。之所以不现在拆出独立的\n * `'auth-error'` 三态:① token 由 `getAuth()` 单一配置源注入,生产场景下 401/403 出现\n * 概率极低(配置错误通常在首次连接就会暴露,不会隐蔽到运行时才触发);② 拆分收益不足以\n * 覆盖新增一个枚举值分支的代码/文档维护成本,属于本次 review 判断\"可保留观察\"的一项。\n */\ntype LeaseCallResult =\n | { ok: true; data: Record<string, unknown> }\n | { ok: false; reason: 'network-error' }\n\n/** Create a server-arbitrated FireOwnership backed by persistenced's lease endpoints. */\nexport function createRemoteFireOwnership(options: RemoteFireOwnershipOptions): FireOwnership {\n const token = randomUUID()\n const fetchImpl = options.fetch ?? globalThis.fetch\n const timeoutMs = options.timeoutMs ?? 10_000\n const baseUrl = new URL(options.sessionUrl).origin\n const now = options.now ?? Date.now\n let owned = false\n /** Last time an actual HTTP renew/acquire heartbeat was sent (throttling — see header). */\n let lastHeartbeatAt = 0\n /** F1: dedupe concurrent tryAcquire() calls onto one in-flight request/state-write. */\n let inFlightAcquire: Promise<boolean> | undefined\n /** F2: reason for the most recent non-owner outcome, for TUI diagnostics. */\n let lastFailureReason: 'occupied' | 'network-error' | undefined\n\n const callLease = async (action: LeaseAction): Promise<LeaseCallResult> => {\n const auth = await options.getAuth()\n const controller = new AbortController()\n const timeout = setTimeout(() => controller.abort(), timeoutMs)\n try {\n const response = await fetchImpl(\n `${baseUrl}/api/schedules/${encodeURIComponent(options.wsKey)}/lease/${action}`,\n {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n Authorization: `Bearer ${auth.token}`,\n },\n body: JSON.stringify({ token }),\n signal: controller.signal,\n },\n )\n if (!response.ok) return { ok: false, reason: 'network-error' }\n return { ok: true, data: (await response.json()) as Record<string, unknown> }\n } catch {\n // Network error / timeout / abort — treated as \"did not succeed\", never thrown.\n // The scheduler's tick loop tolerates transient failures and retries next heartbeat.\n return { ok: false, reason: 'network-error' }\n } finally {\n clearTimeout(timeout)\n }\n }\n\n const tryAcquire = (): Promise<boolean> => {\n // F1: if a tryAcquire() is already in flight, share its result instead of firing a\n // second concurrent HTTP request whose (possibly stale-ordered) response could\n // clobber `owned` after the first call already set it. Both callers get the same\n // outcome, which is the only sane semantics for \"try to acquire\" called concurrently.\n if (inFlightAcquire) return inFlightAcquire\n\n const promise = (async (): Promise<boolean> => {\n lastHeartbeatAt = now()\n const result = await callLease('acquire')\n if (!result.ok) {\n owned = false\n lastFailureReason = 'network-error'\n return false\n }\n const acquired = result.data['acquired'] === true\n owned = acquired\n lastFailureReason = acquired ? undefined : 'occupied'\n return acquired\n })()\n\n inFlightAcquire = promise\n promise.finally(() => {\n if (inFlightAcquire === promise) inFlightAcquire = undefined\n })\n return promise\n }\n\n return {\n tryAcquire,\n\n async renew(): Promise<void> {\n // Throttle: SchedulerService.tick() calls renew() every second, but a real HTTP\n // heartbeat only needs to fire every HEARTBEAT_MS (30s, D3c) — everything in between\n // is a free no-op. The very first call after construction always fires (lastHeartbeatAt\n // starts at 0), matching the local implementation's \"renew is safe to call immediately\".\n if (now() - lastHeartbeatAt < HEARTBEAT_MS) return\n\n if (!owned) {\n // D3d: not currently owner (lost it, or never acquired) — every heartbeat is an\n // acquire attempt so a healed network partition or an expired competitor's lease\n // can be reclaimed without waiting for a full process restart.\n await tryAcquire()\n return\n }\n lastHeartbeatAt = now()\n const result = await callLease('renew')\n if (!result.ok) {\n // F2: renew failed at the transport level (not a server-side \"superseded\"\n // rejection) — still downgrade to non-owner (D3d semantics unchanged), but record\n // the accurate reason so the TUI doesn't claim \"another terminal holds it\".\n owned = false\n lastFailureReason = 'network-error'\n return\n }\n const renewed = result.data['renewed'] === true\n if (!renewed) {\n // D3d: lost the lease (superseded, or this renew call itself failed/timed out).\n // Don't distinguish \"genuinely superseded\" from \"network blip\" here — both cases\n // downgrade to non-owner and let the next heartbeat's tryAcquire() sort it out.\n owned = false\n lastFailureReason = 'occupied'\n } else {\n lastFailureReason = undefined\n }\n },\n\n async release(): Promise<void> {\n if (!owned) return\n await callLease('release')\n owned = false\n },\n\n isOwner(): boolean {\n return owned\n },\n\n lastFailureReason(): 'occupied' | 'network-error' | undefined {\n return owned ? undefined : lastFailureReason\n },\n }\n}\n\nexport { HEARTBEAT_MS as REMOTE_FIRE_OWNERSHIP_HEARTBEAT_MS, STALE_MS as REMOTE_FIRE_OWNERSHIP_STALE_MS }\n","// schedule/src/scheduler.ts — Schedule lifecycle: start→tick→fire→stop.\n// Fire logs → ${OTTO_HOME}/schedule-logs/. Clock: Date.now() by default, injectable for testing.\n\nimport { writeFileSync, readFileSync, mkdirSync, readdirSync, unlinkSync } from 'node:fs'\nimport { resolve } from 'node:path'\n\nimport { OTTO_HOME, isShadowModeEnabled } from '@x-otto/env'\nimport type { AgentJobRegistry } from '@x-otto/runtime'\n\nimport { ScheduleRegistry } from './registry'\nimport { parseCron } from './cron-parser'\nimport type { ScheduleStore } from './schedule-store'\nimport type { FireOwnership } from './fire-ownership'\nimport type { CancelResult, ScheduledTask, ScheduledTaskInput, ScheduleSubscriber } from './types'\n\n// ---------------------------------------------------------------------------\n// Constants\n// ---------------------------------------------------------------------------\n\nconst MAX_TASKS = 50\nconst SEVEN_DAYS = 7 * 24 * 60 * 60 * 1000\nconst JITTER_WINDOW_MS = 30 * 60 * 1000 // 30 minutes (recurring)\nconst ONE_SHOT_JITTER_WINDOW_MS = 90 * 1000 // 90 seconds (one-shot, RFC-087 §D3 — review S2)\nconst DEFAULT_MAX_DURATION_MS = 300_000 // 5 minutes\nconst DEFAULT_MAX_RETRIES = 0\nconst TICK_INTERVAL_MS = 1000\nconst NEEDS_INPUT_MAX_EXTENSION_FACTOR = 3 // review S8: hard cap on needs_input total wait\nconst MAX_FIRE_LOGS_PER_TASK = 20 // review S7: cap schedule-logs/<taskId>-*.log retention per task\nconst RELOAD_INTERVAL_TICKS = 30 // review S5: owner re-syncs from store every ~30s\nconst RECOVERY_RETRY_INTERVAL_MS = 30_000 // N1: re-acquire retry cadence when ownership lost mid-session\n\n// ---------------------------------------------------------------------------\n// Dependencies\n// ---------------------------------------------------------------------------\n\nexport interface SchedulerDeps {\n registry: ScheduleRegistry\n store: ScheduleStore\n ownership: FireOwnership\n jobRegistry: AgentJobRegistry\n /**\n * Dispatch an agent job. Mirrors AgentJobService.start() shape:\n * synchronously returns a record with at least { id }.\n */\n startJob: (input: { title: string; prompt: string; sessionId: string; origin: 'user' | 'main' }) => { id: string }\n getSessionId: () => string\n /**\n * RFC-327 D2:向订阅者投递到点通知的窄端口(缺省 = 不支持订阅者,此类任务 fire 时\n * 记日志跳过而非崩溃)。\n *\n * 返回是否真的送达——`false` 表示服务未运行/已停机。scheduler 据此写 fire 日志,\n * 但**不重试、不报错**:定时通知是尽力而为的旁路信号(at-least-once 的\"至少\"由\n * 下一次 cron 周期保证,不是靠即时重试)。\n *\n * 端口刻意只接受纯数据标识:scheduler 不知道插件服务的存在形式,也不持有其句柄。\n */\n notifySubscriber?: (\n subscriber: ScheduleSubscriber,\n tick: { scheduleId: string; occurrenceId: string; occurredAt: number },\n ) => boolean\n /**\n * RFC-357 D1/D2:`target==='session'` 任务到点后的投递端口——把 `prompt` 注入\n * `boundSessionId` 对应的当前会话(经 `AgentSession.prompt(text,{streamingBehavior:'followUp'})`,\n * busy 时排队、idle 时直接起新回合)。缺省 = 不支持 session 投递(此类任务 fire 时记日志\n * 跳过而非崩溃,同 `notifySubscriber` 缺省行为的纹理)。\n *\n * 返回值语义(调用方——CLI wiring——必须自行捕获全部失效原因,统一收敛为 boolean):\n * `true` = 已成功注入;`false` = 失效(会话身份不匹配/已销毁/archived/paused 等),\n * scheduler 不重试、不报错——同 notifySubscriber 一样是尽力而为的旁路信号。\n */\n followUpCurrentSession?: (\n task: { prompt: string; boundSessionId: string; name: string; origin: 'user' | 'model' },\n ) => boolean\n now?: () => number\n logger?: { error: (msg: string | Error, ...args: unknown[]) => void }\n /**\n * RFC-230:任务失败重试之间的固定延迟(ms)注入端口。缺省真实 `setTimeout`,测试可注入假\n * 实现避免真实等待。`retryBackoffMs`(见下)为 0 时仍会调用(`sleep(0)`,一次微任务调度\n * 点而非跳过——与当前\"循环内无 await 直接进下一轮\"存在细微时序差异,但不影响任何可观察\n * 行为,见 RFC-230 §3 D6 Scheduler 小节)。\n */\n sleep?: (ms: number) => Promise<void>\n /**\n * RFC-230:每次重试之间的固定延迟(ms)。默认 0(保持现状:不改变既有 per-task 重试\n * 节奏,除非显式配置非零值)。真实值来自 `app.getResilienceConfig().schedule.retryBackoffMs`,\n * 由真实构造点(`packages/cli/src/commands/interactive-schedule.ts`)注入——不是\n * `packages/coding` App 内部构造 `SchedulerService`(二轮评审 P2 修正的装配点误解)。\n */\n retryBackoffMs?: number\n}\n\n// ---------------------------------------------------------------------------\n// SchedulerService\n// ---------------------------------------------------------------------------\n\nexport class SchedulerService {\n private readonly deps: SchedulerDeps\n private readonly registry: ScheduleRegistry\n private ticker: ReturnType<typeof setInterval> | null = null\n private recoveryTimer: ReturnType<typeof setInterval> | null = null\n private readonly firing: Set<string> = new Set()\n private tickInProgress = false\n private running = false\n private tickCount = 0\n\n // RFC-229 M1: cancel support\n private readonly runningJobsByTaskId: Map<string, { jobId: string; fireToken: number; attempt: number }> = new Map()\n private readonly cancelRequested: Set<string> = new Set()\n private _nextFireToken = 0\n\n constructor(deps: SchedulerDeps) {\n this.deps = deps\n this.registry = deps.registry\n }\n\n /**\n * RFC-169 D3d: whether this process currently holds fire ownership (ticker actively\n * fires due tasks) vs read-only (loaded the task list but a different live process —\n * local: another workspace-scoped otto instance; remote: another terminal sharing the\n * same wsKey — is the one that will actually fire). Consumed by `/schedule list` to show\n * a \"read-only, another terminal holds fire ownership\" hint (RFC-169 D3d TUI indicator).\n */\n isFireOwner(): boolean {\n return this.deps.ownership.isOwner()\n }\n\n /**\n * F2 (RFC-169 终局 review 2026-07-15): when `isFireOwner()` is false, why. `undefined`\n * from the underlying `FireOwnership` (e.g. the local O_EXCL implementation, which never\n * has a \"can't reach the lock\" failure mode — fs ops either succeed or throw) means the\n * distinction genuinely doesn't apply; callers should fall back to the generic\n * \"another terminal holds it\" wording. Only the remote implementation can return\n * `'network-error'`.\n */\n fireOwnerFailureReason(): 'occupied' | 'network-error' | undefined {\n return this.deps.ownership.lastFailureReason?.()\n }\n\n // ── Lifecycle ──\n\n /** Load persisted tasks and start the ticker (if fire owner). */\n async start(): Promise<void> {\n if (this.running) return\n\n // Try to acquire fire ownership (MF-5: only owner fires)\n const isOwner = await this.deps.ownership.tryAcquire()\n if (!isOwner) {\n // Not the owner: load tasks for read-only list but don't start ticker\n try {\n const tasks = await this.deps.store.loadAllTasks()\n for (const task of tasks) {\n try { this.registry.add(task) } catch { /* skip duplicate */ }\n }\n } catch (err) {\n this.deps.logger?.error('[schedules] Failed to load schedules (read-only mode):', err)\n }\n return\n }\n\n // This process is the fire owner — load tasks and start ticker\n try {\n const now = this.deps.now?.() ?? Date.now()\n const tasks = await this.deps.store.loadAllTasks()\n for (const task of tasks) {\n // H1: recompute a missed recurring nextFireAt from cron so a downtime\n // gap doesn't fire every missed occurrence at once on startup.\n if (task.recurring && task.nextFireAt < now) {\n try {\n const nextBase = parseCron(task.cronExpression, new Date(now + 60_000))\n task.nextFireAt = this.applyJitter(task.id, nextBase, true)\n } catch {\n /* keep stored nextFireAt if cron no longer parses */\n }\n }\n try { this.registry.add(task) } catch { /* skip duplicate */ }\n }\n } catch (err) {\n this.deps.logger?.error('[schedules] Failed to load schedules:', err)\n }\n\n this.running = true\n this.ticker = setInterval(() => {\n void this.tick()\n }, TICK_INTERVAL_MS)\n this.ticker.unref()\n }\n\n /**\n * review S5: incremental re-sync from the store — add tasks the owner doesn't know about\n * yet (created by a non-owner process), remove tasks that vanished from the store\n * (deleted by a non-owner process), and pick up field edits (enable/disable, cron, prompt)\n * made elsewhere. Never touches a task currently `firing` (avoid clobbering in-flight\n * dispatch state) and never re-applies jitter to tasks the owner already knows about\n * (their nextFireAt is the owner's own scheduling decision, not the store's).\n */\n private async reloadFromStore(): Promise<void> {\n let tasks: ScheduledTask[]\n try {\n tasks = await this.deps.store.loadAllTasks()\n } catch (err) {\n this.deps.logger?.error('[schedules] Failed to reload schedules from store:', err)\n return\n }\n\n const storeIds = new Set(tasks.map((t) => t.id))\n\n // Pick up new tasks + field edits from other processes.\n // Safety net: non-recurring tasks that have already fired (lastFiredAt set) are\n // zombies — a prior deleteOne failed, leaving the task in the store after the\n // in-memory registry already removed it. Delete them from the store instead of\n // re-adding to the registry, preventing perpetual resurrection.\n for (const task of tasks) {\n if (this.firing.has(task.id)) continue // don't clobber in-flight dispatch state\n if (!task.recurring && task.lastFiredAt != null && !this.registry.get(task.id)) {\n void this.deps.store.deleteTask(task.id).catch((err) => {\n this.deps.logger?.error(`[schedules] Failed to delete zombie one-shot ${task.id}:`, err)\n })\n continue\n }\n const existing = this.registry.get(task.id)\n if (!existing) {\n try { this.registry.add(task) } catch { /* raced with a concurrent add */ }\n } else {\n // Merge remote edits (enable/disable, cron, prompt, name) without touching\n // nextFireAt/lastFiredAt — those are the owner's own scheduling state.\n //\n // Known trade-off (N7, RFC-169 终局复审 2026-07-23): when a non-owner process\n // changes cronExpression, the owner accepts the new expression but does NOT\n // recalculate nextFireAt from it — that stays on the old cron's schedule for\n // one more fire. The task will self-correct on the next tick's findDue+fire\n // cycle (scheduler.ts parseCron fires from the expression now stored in\n // `task.cronExpression`). One-stale-fire window is an accepted trade-off\n // vs the risk of the owner's scheduling state being clobbered by a stale\n // store read.\n existing.enabled = task.enabled\n existing.name = task.name\n existing.prompt = task.prompt\n existing.cronExpression = task.cronExpression\n existing.maxDurationMs = task.maxDurationMs\n existing.maxRetries = task.maxRetries\n }\n }\n\n // Remove tasks deleted elsewhere (but never one currently firing).\n //\n // RFC-357 D2/D4(自查发现的实现细节,非次要边角):session-local 任务(target==='session')\n // never 写入 store(见 saveOne()),所以永远不在 storeIds 里——若不显式跳过,这个\"消失即\n // 删除\"同步会在每次 reloadFromStore(每 ~30s,见 tick() 的 RELOAD_INTERVAL_TICKS)把它们\n // 当作\"被其他进程删除的任务\"清除,导致 session-local 任务创建后 ≤30s 内被误删。\n for (const task of this.registry.list()) {\n if (task.target === 'session') continue\n if (!storeIds.has(task.id) && !this.firing.has(task.id)) {\n this.registry.remove(task.id)\n }\n }\n }\n\n /** Clear ticker, release ownership, and persist current state. */\n async stop(): Promise<void> {\n if (!this.running && !this.deps.ownership.isOwner()) return\n\n this.stopRecovery()\n\n if (this.ticker) {\n clearInterval(this.ticker)\n this.ticker = null\n }\n\n // Release the lock before flushAll() so a fast process exit still frees it — otherwise\n // the next start waits out the stale window (A4, local=5min / remote=180s per D3c).\n // RFC-169 D3: release() is now Promise-returning (remote implementation genuinely\n // awaits an HTTP round-trip; local implementation's body is still fully synchronous,\n // so awaiting here changes nothing for that path but is required for the remote one).\n this.running = false\n await this.deps.ownership.release()\n await this.flushAll()\n }\n\n /**\n * N1 (RFC-169 终局复审 2026-07-23): when S4 detects ownership loss mid-session, stop\n * the ticker (prevents double-fire) but enter recovery mode — a low-frequency\n * re-acquire loop that can reclaim ownership when a network partition heals or the\n * competing process exits. Without this, a single transient heartbeat failure in\n * remote mode permanently degrades the process to read-only (contradicting D3d's\n * auto-recovery promise) and local suspend-then-resume recovery requires a full\n * process restart.\n */\n private startRecovery(): void {\n if (this.recoveryTimer) return // already in recovery\n this.deps.logger?.error('[schedules] Lost fire ownership — entering recovery mode')\n this.recoveryTimer = setInterval(() => {\n void this.recoveryAttempt()\n }, RECOVERY_RETRY_INTERVAL_MS)\n this.recoveryTimer.unref()\n }\n\n private async recoveryAttempt(): Promise<void> {\n try {\n const acquired = await this.deps.ownership.tryAcquire()\n if (acquired && this.deps.ownership.isOwner()) {\n this.stopRecovery()\n this.running = true\n this.ticker = setInterval(() => {\n void this.tick()\n }, TICK_INTERVAL_MS)\n this.ticker.unref()\n this.deps.logger?.error('[schedules] Re-acquired fire ownership — recovery complete')\n }\n } catch {\n // tryAcquire failures during recovery are expected — suppress.\n }\n }\n\n private stopRecovery(): void {\n if (this.recoveryTimer) {\n clearInterval(this.recoveryTimer)\n this.recoveryTimer = null\n }\n }\n\n // ── Public CRUD ──\n\n /**\n * Create a scheduled task.\n *\n * - Generates an 8-char id via crypto.randomUUID()\n * - Parses the cron expression to compute nextFireAt + jitter\n * - Enforces a max of 50 tasks (throws if exceeded)\n */\n add(input: ScheduledTaskInput): ScheduledTask {\n if (this.registry.count() >= MAX_TASKS) {\n throw new Error(`Schedule limit reached (max ${MAX_TASKS}). Remove some tasks before adding more.`)\n }\n\n // RFC-357 D4:subscriber 与 target:'session' 互斥,subscriber 优先——不静默忽略\n // target,而是把它当作从未被指定过(不写入 task,fire() 天然走 subscriber 分支)。\n const wantsSession = input.target === 'session' && !input.subscriber\n\n // RFC-357 D2(独立评审 R3):非 fire owner 进程不跑 ticker,session-local 任务若在\n // 此创建会出现在 list 里却永远不被这个进程 fire(owner 进程的 reloadFromStore 又看\n // 不到它,因为它不落盘)——必须在创建时拒绝,不能让用户对着一个静默失效的任务发呆。\n if (wantsSession && !this.deps.ownership.isOwner()) {\n throw new Error(\n 'Cannot create a session-local scheduled task: this terminal does not hold fire ownership. '\n + 'Session-local tasks must be created in the terminal that will actually fire them.',\n )\n }\n\n const id = crypto.randomUUID().slice(0, 8)\n const now = this.deps.now?.() ?? Date.now()\n const nextBase = parseCron(input.cronExpression, new Date(now + 60_000))\n const nextFireAt = this.applyJitter(id, nextBase, input.recurring)\n\n const task: ScheduledTask = {\n id,\n name: input.name,\n prompt: input.prompt,\n cronExpression: input.cronExpression,\n enabled: true,\n recurring: input.recurring,\n origin: input.origin,\n createdAt: now,\n nextFireAt,\n maxDurationMs: input.maxDurationMs ?? DEFAULT_MAX_DURATION_MS,\n maxRetries: input.maxRetries ?? DEFAULT_MAX_RETRIES,\n // RFC-327 D2:缺省不写该字段——既有任务与其序列化形态零变化(前向兼容)。\n ...(input.subscriber ? { subscriber: input.subscriber } : {}),\n // RFC-357 D2/D4:target:'session' 时绑定创建时刻的 sessionId,且不写 target\n // 字段除非真的走 session 路径(与 subscriber 同款\"缺省不写\"前向兼容纪律)。\n ...(wantsSession ? { target: 'session' as const, boundSessionId: this.deps.getSessionId() } : {}),\n }\n\n this.registry.add(task)\n this.saveOne(task)\n return task\n }\n\n /** Remove a task by id. Returns true if the task existed and was removed. */\n remove(id: string): boolean {\n const removed = this.registry.remove(id)\n if (removed) {\n void this.deleteOne(id)\n }\n return removed\n }\n\n /**\n * Update mutable fields of a task.\n *\n * If cronExpression changes, nextFireAt is recalculated with jitter.\n * Fields not in the patch are left unchanged.\n */\n update(\n id: string,\n patch: Partial<Pick<ScheduledTask, 'name' | 'prompt' | 'cronExpression' | 'enabled'>>,\n ): boolean {\n const task = this.registry.get(id)\n if (!task) return false\n\n if (patch.name !== undefined) {\n task.name = patch.name\n }\n if (patch.prompt !== undefined) {\n task.prompt = patch.prompt\n }\n if (patch.enabled !== undefined) {\n task.enabled = patch.enabled\n }\n if (patch.cronExpression !== undefined) {\n task.cronExpression = patch.cronExpression\n const now = this.deps.now?.() ?? Date.now()\n const nextBase = parseCron(task.cronExpression, new Date(now + 60_000))\n task.nextFireAt = this.applyJitter(task.id, nextBase, task.recurring)\n }\n\n this.saveOne(task)\n return true\n }\n\n /** Return all tasks, ordered by nextFireAt ascending. */\n list(): ScheduledTask[] {\n return this.registry.list()\n }\n\n /**\n * RFC-229 M2: return the set of task IDs that currently have a running job.\n * Used by the plugin panel to show [R] status indicators.\n */\n getRunningTaskIds(): Set<string> {\n return new Set(this.runningJobsByTaskId.keys())\n }\n\n /**\n * RFC-229 M3: read the most recent fire log entries for a task.\n * Returns up to `maxEntries` lines, newest first. Each entry is { timestamp, status }.\n */\n readFireLogs(taskId: string, maxEntries: number = 20): Array<{ timestamp: string; status: string }> {\n const logDir = resolve(OTTO_HOME, 'schedule-logs')\n const prefix = `${taskId}-`\n try {\n const files = readdirSync(logDir)\n .filter((f) => f.startsWith(prefix) && f.endsWith('.log'))\n .sort()\n .reverse() // newest first\n const entries: Array<{ timestamp: string; status: string }> = []\n for (let i = 0; i < files.length && entries.length < maxEntries; i++) {\n try {\n const content = readFileSync(resolve(logDir, files[i]!), 'utf-8').trim()\n // Format: \"2026-07-23T09:23:12.000Z completed (attempt 1)\"\n const spaceIdx = content.indexOf(' ')\n const timestamp = spaceIdx > 0 ? content.slice(0, spaceIdx) : content\n const status = spaceIdx > 0 ? content.slice(spaceIdx + 1) : ''\n entries.push({ timestamp, status })\n } catch {\n // skip unreadable file\n }\n }\n return entries\n } catch {\n return []\n }\n }\n\n /**\n * Force-execute a task immediately, regardless of its nextFireAt.\n * Does NOT advance nextFireAt — this is an extra execution.\n *\n * review S3: guarded against a concurrent tick-driven fire() for the same task — without\n * this, a manual runNow() while tick's fire() await is in flight (fire() only adds to\n * `firing` synchronously before awaiting startJob) could dispatch two jobs for one task.\n */\n runNow(id: string): void {\n const task = this.registry.get(id)\n if (!task) {\n throw new Error(`Schedule task \"${id}\" not found`)\n }\n if (!task.enabled) {\n throw new Error(`Schedule task \"${id}\" is disabled`)\n }\n if (this.firing.has(task.id)) {\n throw new Error(`Schedule task \"${id}\" is already firing`)\n }\n this.firing.add(task.id)\n void this.fire(task)\n }\n\n /**\n * RFC-229 M1: cancel the currently running job for a schedule task.\n *\n * Only cancels jobs in `running` or `needs_input` status — a job that has already\n * produced a diff (`ready`) is deliberately left untouched so the user can `/job apply`.\n *\n * Does NOT delete the task definition or alter the cron schedule. The task will fire\n * again at its next cron-triggered time.\n *\n * Sets a cancel intent in `cancelRequested` so the in-flight `fire()` loop stops\n * retrying — it does NOT prematurely remove the task from `firing` (that belongs to\n * `fire().finally`), avoiding a race window where the task appears idle but `fire()` is\n * still running (RFC-229 R10).\n */\n cancel(taskId: string): CancelResult {\n const task = this.registry.get(taskId)\n if (!task) return { ok: false, reason: 'task_not_found' }\n\n // Find the running job — prefer the in-memory tracking map, fallback to scanning\n // jobRegistry (the map is empty after a process restart, RFC-229 D9).\n let jobId: string | undefined\n const record = this.runningJobsByTaskId.get(taskId)\n if (record) {\n jobId = record.jobId\n } else {\n // Fallback: scan jobRegistry for a still-running job. We can't reliably\n // associate a job back to its taskId, so only use this if we find at most one\n // running job — a best-effort recovery after process restart.\n const runningJobs = this.deps.jobRegistry.list().filter(\n j => j.status === 'running' || j.status === 'needs_input',\n )\n if (runningJobs.length === 1) {\n jobId = runningJobs[0]!.id\n }\n }\n if (!jobId) return { ok: false, reason: 'not_running' }\n\n // Guard: only cancel jobs that are genuinely still running or waiting for input.\n // A job that already produced a diff (ready) must not be canceled — the user may\n // still want to `/job apply` (RFC-229 R9).\n const job = this.deps.jobRegistry.get(jobId)\n if (!job) return { ok: false, reason: 'job_not_found' }\n if (job.status === 'ready') return { ok: false, reason: 'job_is_ready' }\n if (job.status === 'applied') return { ok: false, reason: 'job_already_applied' }\n if (job.status === 'failed') return { ok: false, reason: 'job_already_failed' }\n if (job.status === 'canceled') return { ok: false, reason: 'job_already_canceled' }\n\n // Set cancel intent — the in-flight `fire()` loop checks this before each retry\n // attempt and will stop without calling jobRegistry.cancel() itself (RFC-229 R10).\n this.cancelRequested.add(task.id)\n\n // Cancel the job. This calls e.abort() + removeWorktree + update(status:'canceled')\n // inside AgentJobRegistry.cancel(), which will trigger onExit('canceled') that\n // waitForCompletion picks up as the 'canceled' outcome.\n this.deps.jobRegistry.cancel(jobId)\n\n return { ok: true }\n }\n\n // ── Internal: tick ──\n\n /**\n * Heartbeat: evict expired recurring tasks, find & fire due tasks.\n * Reentry guard (MF-1) prevents overlapping ticks.\n */\n private async tick(): Promise<void> {\n if (this.tickInProgress) return\n this.tickInProgress = true\n\n try {\n // A3: heartbeat the ownership lock so a live owner never looks stale.\n // RFC-169 D3d: the remote FireOwnership implementation internally switches this call\n // to an acquire-attempt once it has lost ownership (network partition healed, etc.) —\n // SchedulerService stays uniform across local/remote, no branching here.\n await this.deps.ownership.renew()\n\n // review S4: if renew() detected a takeover (lock token no longer ours — e.g. this\n // process was suspended past STALE_MS and a successor claimed the lock), stop firing\n // but enter recovery mode — periodically re-attempt acquire so the process can\n // reclaim ownership when the competitor exits or a network partition heals (N1,\n // RFC-169 终局复审 2026-07-23: the original S4 fix permanently killed the ticker\n // with no re-acquire path, contradicting D3d's auto-recovery promise for remote\n // mode and making local-mode suspend-then-resume recovery impossible).\n if (!this.deps.ownership.isOwner()) {\n this.running = false\n if (this.ticker) {\n clearInterval(this.ticker)\n this.ticker = null\n }\n this.startRecovery()\n return\n }\n\n // review S5: the owner only loaded tasks once at start() — a task added/removed via\n // a non-owner process's `/schedule add` (writing directly to the store) was invisible\n // to the owner's in-memory registry until restart, so it could never fire. Periodically\n // re-sync from the store (every RELOAD_INTERVAL_TICKS ticks — cheap relative to\n // TICK_INTERVAL_MS=1s, and store reads are fail-open/best-effort).\n this.tickCount++\n if (this.tickCount % RELOAD_INTERVAL_TICKS === 0) {\n await this.reloadFromStore()\n }\n\n const now = this.deps.now?.() ?? Date.now()\n\n // Evict recurring tasks whose *next* fire is still >7 days stale (review S1: using\n // lastFiredAt/createdAt as the basis wrongly evicted low-frequency recurring tasks —\n // e.g. a monthly cron got deleted 7 days after its first fire, before it could ever\n // fire again. nextFireAt reflects the task's own cadence, so a monthly task's\n // nextFireAt is always <30 days out and never spuriously exceeds the 7-day threshold).\n for (const task of this.registry.list()) {\n if (task.recurring && now - task.nextFireAt > SEVEN_DAYS) {\n this.registry.remove(task.id)\n void this.deleteOne(task.id)\n }\n }\n\n // Fire due tasks\n const due = this.registry.findDue(now)\n for (const task of due) {\n if (this.firing.has(task.id)) continue\n\n // S6 regression guard: a failed one-shot is kept (not deleted) with a *stale*\n // nextFireAt in the past, so on the very next tick findDue() would see it as \"due\"\n // again and the ticker would auto-refire it — turning a single failure into an\n // infinite auto-retry loop. One-shot tasks only ever get ONE ticker-driven attempt;\n // once marked lastFireFailed, only an explicit runNow() may retry it.\n //\n // RFC-229 M1: same guard applies to one-shot tasks canceled by the user —\n // lastFireCancelled is set instead of lastFireFailed (cancel is not a failure),\n // but the ticker still must not auto-refire.\n if (!task.recurring && (task.lastFireFailed || task.lastFireCancelled)) continue\n\n // Advance nextFireAt before dispatching (MF-1); persist just this one (A2)\n //\n // RFC-327 D2:**先捕获本次触发时刻再推进**——`registry.update` 就地修改 task 对象,\n // 推进后 `task.nextFireAt` 已是下一次的时刻。订阅者的 occurrenceId 必须由**本次**\n // 计划触发时刻派生,否则同一次触发的重投会拿到\"下一次\"的 id,且相邻两次触发的\n // id 会串位——幂等键彻底失效。\n const scheduledFor = task.nextFireAt\n const nextBase = parseCron(task.cronExpression, new Date(now + 60_000))\n const jittered = this.applyJitter(task.id, nextBase, task.recurring)\n this.registry.update(task.id, { nextFireAt: jittered })\n this.saveOne(task)\n\n this.firing.add(task.id)\n void this.fire(task, scheduledFor)\n }\n } finally {\n this.tickInProgress = false\n }\n }\n\n // ── Internal: fire ──\n\n /**\n * Execute a task: start a job, monitor completion via registry events,\n * retry on failure up to maxRetries. Handles one-shot cleanup.\n *\n * review S4 residual risk (accepted): fire() runs independent of the ticker — if this\n * process is suspended mid-fire and loses ownership (S4 stops the ticker on the next\n * tick, but does not abort in-flight fire() calls), this in-flight call can still resolve\n * and write a stale lastFiredAt/nextFireAt back to the store after a successor process\n * has taken over. Harmless one-time overwrite (this process's ticker is already stopped,\n * so no repeated corruption) — needs 5-min suspend + in-flight fire + a same-window write\n * race with the new owner to manifest. Revisit if it proves to matter in practice.\n */\n /**\n * RFC-327 D2:向订阅者投递一次到点通知。\n *\n * `occurrenceId` 由 `{taskId, 本次计划触发时刻}` 派生——**同一次计划触发的重复投递\n * 得到同一个 id**,这正是幂等键的意义:service 重启后收到重投也能识别\"这次我处理过\"。\n * 若用随机 id,重复投递会被当成两次不同触发,幂等失效。\n *\n * 返回是否送达;未送达(服务未运行/宿主未接线)只记日志,不抛错、不重试。\n */\n private fireSubscriber(task: ScheduledTask, scheduledFor?: number): boolean {\n const subscriber = task.subscriber\n if (!subscriber) return false\n\n const notify = this.deps.notifySubscriber\n if (!notify) {\n this.writeFireLog(task.id, 'subscriber tick skipped: host has no notifySubscriber port')\n return false\n }\n\n const occurredAt = this.deps.now?.() ?? Date.now()\n // 以**本次**计划触发时刻(非实际投递时刻、非已推进的 nextFireAt)派生,保证重投同 id。\n // ticker 路径由调用方传入(推进 nextFireAt 之前捕获);runNow 是计划外的额外执行,\n // 没有\"计划时刻\",故用实际触发时刻——它天然唯一,符合\"额外执行不参与幂等去重\"的语义。\n const occurrenceId = `${task.id}@${scheduledFor ?? occurredAt}`\n\n let delivered = false\n try {\n delivered = notify(subscriber, { scheduleId: task.id, occurrenceId, occurredAt })\n } catch (err) {\n // 插件侧异常绝不能让调度器崩溃或让本任务卡在 firing 集合里。\n this.deps.logger?.error(`[schedules] subscriber notify threw for task ${task.id}:`, err)\n this.writeFireLog(task.id, `subscriber tick error: ${err instanceof Error ? err.message : String(err)}`)\n return false\n }\n\n this.writeFireLog(\n task.id,\n delivered\n ? `subscriber tick delivered (${subscriber.pluginId}:${subscriber.serviceId}, occurrence ${occurrenceId})`\n : `subscriber tick not delivered (${subscriber.pluginId}:${subscriber.serviceId} not running)`,\n )\n return delivered\n }\n\n /**\n * RFC-357 D1/D2:把一个 `target==='session'` 任务的 `prompt` 注入其 `boundSessionId`\n * 对应的当前会话。不建 worktree、不派 AgentJob、不进 `runningJobsByTaskId`/重试循环\n * (`cancel()` 对 session-local 任务的行为见 `cancel()` 自身的 not_running 分支)。\n *\n * 返回是否注入成功;失败(无注入端口/会话身份不匹配/会话已销毁等——由端口内部判断并\n * 折叠为 boolean)只记日志,不抛错、不重试——同 `fireSubscriber` 的旁路信号纪律。\n */\n private fireSession(task: ScheduledTask): boolean {\n if (!task.boundSessionId) {\n this.writeFireLog(task.id, 'session tick skipped: task has no boundSessionId (invalid state)')\n return false\n }\n\n const followUp = this.deps.followUpCurrentSession\n if (!followUp) {\n this.writeFireLog(task.id, 'session tick skipped: host has no followUpCurrentSession port')\n return false\n }\n\n // RFC-357 G5:每次 session-local 投递都带可见来源标记(纯文本前缀,不发明结构化\n // 元数据键——复用 RFC-142 红线纪律,防止被模型误学成\"工具参数 schema\")。使其在\n // 对话流中与用户正常输入可区分——这条消息不是用户刚敲的,是定时器代发的。\n //\n // D3 新增约束1:模型创建的 session-local 任务额外在首次 fire 加一段更详细的提示。\n // `task.lastFiredAt` 在本次 fire 成功之前仍是上一状态(finally 块在 fireSession()\n // 返回之后才写入),故此处判空即\"这是首次触发\"。用户创建的任务(origin==='user')\n // 不加首次提示——用户自己知道自己排了什么,只需要 G5 的通用来源标记。\n const isFirstModelFire = task.origin === 'model' && task.lastFiredAt == null\n const marker = `⏰ scheduled: ${task.name}`\n const prompt = isFirstModelFire\n ? `${marker} [created by the model — first fire, use /schedule cancel ${task.id} to stop it]\\n\\n${task.prompt}`\n : `${marker}\\n\\n${task.prompt}`\n\n let delivered = false\n try {\n delivered = followUp({\n prompt,\n boundSessionId: task.boundSessionId,\n name: task.name,\n origin: task.origin,\n })\n } catch (err) {\n // 宿主侧异常绝不能让调度器崩溃或让本任务卡在 firing 集合里。\n this.deps.logger?.error(`[schedules] session followUp threw for task ${task.id}:`, err)\n this.writeFireLog(task.id, `session tick error: ${err instanceof Error ? err.message : String(err)}`)\n return false\n }\n\n this.writeFireLog(\n task.id,\n delivered\n ? `session tick delivered (session ${task.boundSessionId})`\n : `session tick not delivered (session ${task.boundSessionId} unavailable — evicted/disposed/archived/paused/mismatched)`,\n )\n return delivered\n }\n\n private async fire(task: ScheduledTask, scheduledFor?: number): Promise<void> {\n let outcome: 'success' | 'failed' = 'failed'\n let outcomeCancelled = false\n let lastError: string | undefined\n const fireToken = ++this._nextFireToken\n\n try {\n // RFC-327 D2:订阅者任务不派 AgentJob——到点只投递一条通知,插件服务若要产生工作\n // 须自己走 capability-gated startJob(timer 不绕过审批/origin/worktree 约束)。\n //\n // 不进重试循环:通知是 at-least-once 的旁路信号,\"至少送到\"由下一个 cron 周期承担,\n // 即时重试只会在服务未起来时空转(且重复投递同一 occurrenceId 反而加重幂等负担)。\n if (task.subscriber) {\n outcome = this.fireSubscriber(task, scheduledFor) ? 'success' : 'failed'\n return\n }\n\n // RFC-357 D1/D2/D4:session-local 任务不派 AgentJob——不建 worktree、不进重试循环、\n // 不进 runningJobsByTaskId(fire()一次性尝试,失败/成功都不重试;一次性 session-local\n // 任务同 recurring 一样在 finally 块里按 outcome 处理,见下方 outcome 赋值)。\n if (task.target === 'session') {\n const sessionDelivered = this.fireSession(task)\n outcome = sessionDelivered ? 'success' : 'failed'\n if (sessionDelivered) {\n // 同 job 路径的成功记账语义(:805)——lastFiredAt 既用于\"这是不是首次触发\"\n // 的判断(D3 新增约束1 的提示逻辑),也是既有 /schedule list 展示字段。\n // 不落盘(session-local 任务永不 saveOne),只更新内存态。\n this.registry.update(task.id, {\n lastFiredAt: this.deps.now?.() ?? Date.now(),\n lastFireFailed: false,\n lastFireError: undefined,\n })\n }\n return\n }\n\n const origin: 'user' | 'main' = task.origin === 'model' ? 'main' : 'user'\n const sessionId = this.deps.getSessionId()\n\n for (let attempt = 0; attempt <= task.maxRetries; attempt++) {\n // RFC-229 M1: cancel() sets a cancel intent in `cancelRequested` and calls\n // jobRegistry.cancel() — the canceled job will resolve waitForCompletion as\n // 'canceled'. But if this loop hasn't yet entered waitForCompletion (e.g. the\n // cancel happened between retry attempts), the cancel intent flag blocks the next\n // attempt entirely without launching a new job.\n if (this.cancelRequested.has(task.id)) {\n this.writeFireLog(task.id, `canceled by user (attempt ${attempt + 1})`)\n outcomeCancelled = true\n break\n }\n\n const job = this.deps.startJob({\n title: task.name,\n prompt: task.prompt,\n sessionId,\n origin,\n })\n\n // RFC-229 M1: record the running job so cancel() can look it up\n this.runningJobsByTaskId.set(task.id, { jobId: job.id, fireToken, attempt })\n\n const result = await this.waitForCompletion(job.id, task.maxDurationMs)\n\n if (result === 'success') {\n this.registry.update(task.id, {\n lastFiredAt: this.deps.now?.() ?? Date.now(),\n lastFireFailed: false,\n lastFireError: undefined,\n })\n this.saveOne(task)\n // review I3: when the job produced a diff (status 'ready'), the schedule fire log\n // reads \"completed\" while a human may see nothing has actually landed — surface\n // the pending review explicitly so the log itself explains \"why nothing changed\".\n const finishedJob = this.deps.jobRegistry.get(job.id)\n const pendingApply = finishedJob?.status === 'ready' && !!finishedJob.diff?.trim()\n this.writeFireLog(\n task.id,\n pendingApply\n ? `completed (attempt ${attempt + 1}) — diff ready, /job apply ${job.id}`\n : `completed (attempt ${attempt + 1})`,\n )\n outcome = 'success'\n return\n }\n\n // RFC-229 M1: canceled is distinct from failed — the user deliberately aborted the\n // job, so we must NOT enter the retry loop. Log it and stop.\n if (result === 'canceled') {\n this.writeFireLog(task.id, `canceled by user (attempt ${attempt + 1})`)\n outcomeCancelled = true\n break\n }\n\n lastError = result === 'timeout' ? 'timed out' : 'job failed'\n // Failed/timeout — will retry if attempts remain\n\n // RFC-230:重试之间的固定延迟——只在还有剩余 attempt 时等待(非最后一次失败,\n // 避免多余等待)。retryBackoffMs 默认 0(保持现状行为,`sleep(0)` 仍是一次\n // 微任务调度点)。\n if (attempt < task.maxRetries) {\n const backoffMs = this.deps.retryBackoffMs ?? 0\n const sleep = this.deps.sleep ?? ((ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms)))\n await sleep(backoffMs)\n }\n }\n\n // Exhausted all retries (only reached when outcomeCancelled is false — the cancel\n // branch breaks without reaching here)\n if (!outcomeCancelled) {\n this.writeFireLog(task.id, `failed after ${task.maxRetries + 1} attempt(s)`)\n }\n } catch (err) {\n lastError = err instanceof Error ? err.message : String(err)\n this.deps.logger?.error(`[schedules] fire failed for task ${task.id}:`, err)\n this.writeFireLog(task.id, `error: ${lastError}`)\n } finally {\n this.firing.delete(task.id)\n this.runningJobsByTaskId.delete(task.id)\n\n // RFC-229 M1: `cancelRequested` prevents the finally block from overwriting the\n // cancel state with conflicting data (e.g. lastFireFailed: true). When cancel() is\n // called, it sets cancelRequested and calls jobRegistry.cancel() — the finally block\n // must honour that intent and skip the regular failed-case saveOne.\n const cancelled = this.cancelRequested.has(task.id)\n this.cancelRequested.delete(task.id)\n\n if (!task.recurring) {\n if (outcome === 'success') {\n this.registry.remove(task.id)\n await this.deleteOne(task.id)\n } else if (outcomeCancelled) {\n // RFC-229 M1: a canceled one-shot is kept (not deleted) so the user can see it\n // and optionally retry via runNow. Mark lastFireCancelled (not lastFireFailed)\n // so the ticker's one-shot guard skips it and the UI renders \"canceled\" not \"failed\".\n this.registry.update(task.id, {\n lastFireCancelled: true,\n lastFireError: undefined,\n })\n this.saveOne(task)\n } else if (cancelled) {\n // Cancel intent was set (by cancel()) but the job never emitted 'canceled'\n // through onExit — e.g. it was killed externally. Don't write any conflicting\n // state; leave the task as-is.\n } else {\n // review S6: a failed one-shot is no longer silently deleted — keep it (marked\n // lastFireFailed) so it's visible in `/schedule list`/`schedule_list` and can be\n // retried via runNow. Previously the finally block unconditionally removed\n // one-shot tasks regardless of outcome, leaving only a best-effort log file as\n // evidence of failure.\n this.registry.update(task.id, { lastFireFailed: true, lastFireError: lastError })\n this.saveOne(task)\n }\n }\n }\n }\n\n // ── Internal: event-based completion monitor ──\n\n /**\n * Subscribe to AgentJobRegistry onUpdate / onExit and resolve when the\n * job reaches a conclusion:\n *\n * 'success': ready (diff produced) or applied\n * 'failed': failed\n * 'timeout': maxDurationMs elapsed while still running (triggers cancel)\n * 'canceled': the job was canceled (deliberately, by user via cancel(), not a failure)\n *\n * ready is NOT terminal in the engine, but for the schedule it's a success\n * (the diff exists, the user can /job apply later).\n *\n * needs_input extends the timeout — the agent is blocked on user input\n * and should not be killed while waiting.\n */\n private waitForCompletion(jobId: string, maxDurationMs: number): Promise<'success' | 'failed' | 'timeout' | 'canceled'> {\n return new Promise((resolve) => {\n let settled = false\n let timeout: ReturnType<typeof setTimeout> | undefined\n let unsubExit: (() => void) | undefined\n let unsubUpdate: (() => void) | undefined\n\n // review S8: needs_input previously reset the *full* maxDurationMs window on every\n // occurrence — an agent that repeatedly hits needs_input (e.g. a tool that keeps\n // asking for confirmation) could extend the wait indefinitely and never be canceled.\n // Cap the total wall-clock budget at a hard multiple of maxDurationMs; needs_input\n // still extends the window, but only up to this ceiling.\n const startedAt = this.deps.now?.() ?? Date.now()\n const hardDeadline = startedAt + maxDurationMs * NEEDS_INPUT_MAX_EXTENSION_FACTOR\n\n const cleanup = () => {\n if (timeout) clearTimeout(timeout)\n unsubExit?.()\n unsubUpdate?.()\n }\n\n const finish = (result: 'success' | 'failed' | 'timeout' | 'canceled') => {\n if (settled) return\n settled = true\n cleanup()\n resolve(result)\n }\n\n const armTimeout = (ms: number) => {\n if (timeout) clearTimeout(timeout)\n timeout = setTimeout(() => {\n this.deps.jobRegistry.cancel(jobId)\n finish('failed')\n }, ms)\n }\n\n // onUpdate: watch for ready (terminal for schedule) and needs_input (extend timeout)\n unsubUpdate = this.deps.jobRegistry.onUpdate((job) => {\n if (job.id !== jobId) return\n\n if (job.status === 'ready') {\n // Diff produced — success. Don't cancel; user may /job apply.\n finish('success')\n } else if (job.status === 'needs_input') {\n // Agent blocked on user input — extend timeout instead of killing, but never\n // past hardDeadline (S8).\n const now = this.deps.now?.() ?? Date.now()\n const remaining = Math.min(maxDurationMs, hardDeadline - now)\n if (remaining <= 0) {\n this.deps.jobRegistry.cancel(jobId)\n finish('failed')\n return\n }\n armTimeout(remaining)\n }\n })\n\n // onExit: terminal states\n unsubExit = this.deps.jobRegistry.onExit((job) => {\n if (job.id !== jobId) return\n\n if (job.status === 'failed') {\n finish('failed')\n } else if (job.status === 'canceled') {\n // RFC-229 M1: distinguish deliberate cancel from a genuine failure — the\n // fire() retry loop must not retry a canceled job.\n finish('canceled')\n } else if (job.status === 'applied') {\n finish('success')\n }\n })\n\n // Only a still-running job hits the timeout: cancel it and resolve as\n // failed (retry path), not relying solely on the cancel→onExit event.\n armTimeout(maxDurationMs)\n })\n }\n\n // ── Internal: jitter ──\n\n /**\n * Apply a deterministic per-task jitter to spread cron fires. Recurring tasks use a\n * 30-min window (spreads periodic load); one-shot tasks use a 90s window (review S2 —\n * RFC-087 §D3 intends one-shot \"in ~30s/~90s\", not \"delayed by up to 30 min\"; a 30-min\n * window made \"remind me in 30 minutes\" arrive up to ~61 minutes later).\n * Uses djb2 hash of the task id for reproducibility across restarts.\n */\n private applyJitter(taskId: string, baseTime: Date, recurring: boolean): number {\n let hash = 0\n for (let i = 0; i < taskId.length; i++) {\n hash = ((hash << 5) - hash) + taskId.charCodeAt(i)\n hash |= 0\n }\n const window = recurring ? JITTER_WINDOW_MS : ONE_SHOT_JITTER_WINDOW_MS\n const jitter = Math.abs(hash) % window\n return baseTime.getTime() + jitter\n }\n\n // ── Internal: persistence ──\n\n /**\n * Persist a single task (fire-and-forget). Snapshot-per-id means we never\n * rewrite the whole collection and never delete-by-diff — that would let one\n * process delete another process's schedules (A2).\n */\n /**\n * RFC-357 D2(独立评审 R2 命中的咽喉点方案):`target==='session'` 任务永不落盘——\n * 这是唯一的判断点,`add()`/`update()`/tick 推进/fire 成功/fire 失败-取消/`flushAll()`\n * 全部调用点共享此拦截,不逐点打补丁(否则新增调用点时会漏防,正是原设计的漏洞)。\n */\n private saveOne(task: ScheduledTask): void {\n if (task.target === 'session') return\n void this.deps.store.saveTask(task).catch((err) => {\n this.deps.logger?.error(`[schedules] Failed to save schedule ${task.id}:`, err)\n })\n }\n\n /**\n * Delete a single task snapshot (awaited — must confirm removal to prevent zombies on\n * store reload). RFC-357 D2:session-local 任务从不落盘,删除时直接视为成功(无需真的\n * 调用 store——它本就不在 store 里),避免对不存在的记录发起一次多余的 IO。\n */\n private async deleteOne(id: string): Promise<boolean> {\n const task = this.registry.get(id)\n if (task?.target === 'session') return true\n try {\n return await this.deps.store.deleteTask(id)\n } catch (err) {\n this.deps.logger?.error(`[schedules] Failed to delete schedule ${id}:`, err)\n return false\n }\n }\n\n /**\n * Flush every in-memory task to the store (used on stop; bounded, no diff-delete).\n * RFC-357 D2(独立评审 R2):跳过 `target==='session'` 任务——否则进程正常退出时会把\n * 内存中的 session-local 任务写进 store,下次 `start()` 从 store 加载后在**新进程的新\n * sessionRef** 上\"复活\",直接击穿\"不落盘\"这条防线(本 RFC 最初设计遗漏的关键路径)。\n */\n private async flushAll(): Promise<void> {\n try {\n for (const task of this.registry.list()) {\n if (task.target === 'session') continue\n await this.deps.store.saveTask(task)\n }\n } catch (err) {\n this.deps.logger?.error('[schedules] Failed to flush schedules:', err)\n }\n }\n\n // ── Internal: fire log ──\n\n /**\n * Write a one-line fire log to ${OTTO_HOME}/schedule-logs/<taskId>-<epoch>.log, then prune\n * older logs for this task beyond MAX_FIRE_LOGS_PER_TASK (review S7: a `* * * * *` task\n * fires 1440 times/day with no prior retention — the directory grew unbounded). Best-effort\n * throughout — swallows errors silently (logging is non-critical).\n */\n private writeFireLog(taskId: string, status: string): void {\n // RFC-345:影子态(OTTO_SHADOW)零磁盘写入——fire log 是纯本机持久化产物,\n // 影子进程跳过不批。读回放(readFireLogs)不受影响。\n if (isShadowModeEnabled()) return\n const logDir = resolve(OTTO_HOME, 'schedule-logs')\n const logFile = resolve(logDir, `${taskId}-${Date.now()}.log`)\n try {\n mkdirSync(logDir, { recursive: true })\n writeFileSync(logFile, `${new Date().toISOString()} ${status}\\n`, 'utf-8')\n this.pruneFireLogs(logDir, taskId)\n } catch {\n // best-effort logging\n }\n }\n\n /** Keep only the newest MAX_FIRE_LOGS_PER_TASK log files for a given taskId. */\n private pruneFireLogs(logDir: string, taskId: string): void {\n try {\n const prefix = `${taskId}-`\n const files = readdirSync(logDir)\n .filter((f) => f.startsWith(prefix) && f.endsWith('.log'))\n .sort() // filenames embed epoch ms — lexical sort == chronological\n const excess = files.length - MAX_FIRE_LOGS_PER_TASK\n if (excess <= 0) return\n for (const f of files.slice(0, excess)) {\n try {\n unlinkSync(resolve(logDir, f))\n } catch {\n // best-effort — a single stale/locked file must not block the rest\n }\n }\n } catch {\n // best-effort — directory listing failure is non-critical\n }\n }\n}\n","/**\n * schedule-capability.ts —— Binds a SchedulerService to the shape the schedule_* model\n * tools consume (ToolCallbacksDeps.schedule).\n *\n * `origin` is pinned to 'model' HERE (the model-vs-user distinction lives in\n * wiring, not tool execute — ToolCallContext has no origin field). The scheduler\n * is resolved lazily so this capability object can exist at tool-registration\n * time (the `when: Boolean(c.schedule)` gate) even before the interactive scheduler\n * is constructed; it is populated via App.setScheduleService during interactive\n * setup (RFC-087 A1).\n */\nimport type { SchedulerService } from './scheduler'\n\nexport interface ScheduleCapability {\n create: (input: {\n cron: string\n prompt: string\n recurring: boolean\n name?: string\n /** review I2: optional override for the default 5-min single-fire timeout. */\n maxDurationMs?: number\n /**\n * RFC-357 D3 (M2): where the prompt fires. Default 'job' (existing behavior, zero\n * change). 'session' injects into the CURRENT conversation via followUp instead of\n * dispatching an isolated worktree AgentJob — see `create.ts`'s tool description for\n * the model-facing guidance on when to pick which.\n */\n target?: 'job' | 'session'\n }) => {\n taskId: string\n nextFireAt: number\n name: string\n /** RFC-357 D3: echoes the resolved target so callers (e.g. first-fire notice, M2) can react. */\n target?: 'job' | 'session'\n }\n list: () => Array<{\n id: string\n name: string\n cronExpression: string\n enabled: boolean\n recurring: boolean\n nextFireAt: number\n lastFiredAt?: number\n lastFireFailed?: boolean\n lastFireError?: string\n lastFireCancelled?: boolean\n }>\n delete: (taskId: string) => boolean\n run: (taskId: string) => void\n cancel: (taskId: string) => { ok: boolean; reason?: string }\n}\n\nexport function createScheduleCapability(\n getScheduler: () => SchedulerService | undefined,\n): ScheduleCapability {\n return {\n create: (input) => {\n const scheduler = getScheduler()\n if (!scheduler) {\n throw new Error('Schedule scheduler is not available in this session.')\n }\n const task = scheduler.add({\n name: input.name ?? 'schedule',\n prompt: input.prompt,\n cronExpression: input.cron,\n recurring: input.recurring,\n origin: 'model', // pinned by wiring — schedule_create is always model-originated\n maxDurationMs: input.maxDurationMs,\n // RFC-357 D3: model may request target:'session'. Non-owner rejection and\n // boundSessionId binding happen inside scheduler.add() (RFC-357 M1) — this\n // capability layer does not duplicate that logic.\n target: input.target,\n })\n return { taskId: task.id, nextFireAt: task.nextFireAt, name: task.name, target: task.target }\n },\n list: () =>\n (getScheduler()?.list() ?? []).map((t) => ({\n id: t.id,\n name: t.name,\n cronExpression: t.cronExpression,\n enabled: t.enabled,\n recurring: t.recurring,\n nextFireAt: t.nextFireAt,\n lastFiredAt: t.lastFiredAt,\n lastFireFailed: t.lastFireFailed,\n lastFireError: t.lastFireError,\n lastFireCancelled: t.lastFireCancelled,\n })),\n delete: (taskId) => getScheduler()?.remove(taskId) ?? false,\n run: (taskId) => {\n getScheduler()?.runNow(taskId)\n },\n cancel: (taskId) => {\n return getScheduler()?.cancel(taskId) ?? { ok: false, reason: 'Schedule scheduler is not available.' }\n },\n }\n}\n"],"mappings":"kgBAGA,IAAa,EAAb,KAA8B,CAC5B,MAEA,aAAc,CACZ,KAAK,MAAQ,IAAI,IAGnB,IAAI,EAA2B,CAC7B,GAAI,KAAK,MAAM,IAAI,EAAK,GAAG,CACzB,MAAU,MAAM,wCAAwC,EAAK,GAAG,GAAG,CAErE,KAAK,MAAM,IAAI,EAAK,GAAI,EAAK,CAG/B,OAAO,EAAqB,CAC1B,OAAO,KAAK,MAAM,OAAO,EAAG,CAG9B,OACE,EACA,EAaS,CACT,IAAM,EAAO,KAAK,MAAM,IAAI,EAAG,CAY/B,OAXK,GAED,EAAM,UAAY,IAAA,KAAW,EAAK,QAAU,EAAM,SAClD,EAAM,cAAgB,IAAA,KAAW,EAAK,YAAc,EAAM,aAC1D,EAAM,aAAe,IAAA,KAAW,EAAK,WAAa,EAAM,YACxD,EAAM,gBAAkB,IAAA,KAAW,EAAK,cAAgB,EAAM,eAC9D,EAAM,aAAe,IAAA,KAAW,EAAK,WAAa,EAAM,YACxD,EAAM,iBAAmB,IAAA,KAAW,EAAK,eAAiB,EAAM,gBAChE,EAAM,gBAAkB,IAAA,KAAW,EAAK,cAAgB,EAAM,eAC9D,EAAM,oBAAsB,IAAA,KAAW,EAAK,kBAAoB,EAAM,mBAEnE,IAXW,GAcpB,IAAI,EAAuC,CACzC,OAAO,KAAK,MAAM,IAAI,EAAG,CAG3B,MAAwB,CACtB,MAAO,CAAC,GAAG,KAAK,MAAM,QAAQ,CAAC,CAAC,MAAM,EAAG,IAAM,EAAE,WAAa,EAAE,WAAW,CAQ7E,QAAQ,EAA8B,CACpC,IAAM,EAAuB,EAAE,CAC/B,IAAK,IAAM,KAAQ,KAAK,MAAM,QAAQ,CAChC,EAAK,SAAW,EAAK,YAAc,GACrC,EAAI,KAAK,EAAK,CAIlB,OADA,EAAI,MAAM,EAAG,IAAM,EAAE,WAAa,EAAE,WAAW,CACxC,EAGT,OAAgB,CACd,OAAO,KAAK,MAAM,KAGpB,IAAI,EAAqB,CACvB,OAAO,KAAK,MAAM,IAAI,EAAG,GCxD7B,SAAgB,EAAU,EAAc,EAAmB,CACzD,GAAM,CAAE,cAAa,YAAW,WAAU,aAAY,WAAU,iBAAgB,kBAC9E,EAAgB,EAAK,CAEjB,EAAY,EAAO,IAAI,KAAK,EAAK,SAAS,CAAC,CAAG,IAAI,KACxD,EAAU,WAAW,EAAG,EAAE,CAE1B,IAAM,EAAU,IAAI,KAAK,EAAU,SAAS,CAAG,IAAM,GAAK,GAAK,GAAK,IAAK,CACrE,EAAU,IAAI,KAAK,EAAU,SAAS,CAAC,CAE3C,KAAO,EAAQ,SAAS,EAAI,EAAQ,SAAS,EAAE,CAE7C,IAAM,EAAS,EAAQ,YAAY,CAC7B,EAAU,EAAU,EAAa,EAAO,CAC9C,GAAI,IAAY,KAAM,CACpB,EAAQ,WAAW,EAAW,EAAY,CAAC,CAC3C,EAAQ,SAAS,EAAQ,UAAU,CAAG,EAAE,CACxC,EAAQ,WAAW,EAAG,EAAE,CACxB,SAEF,GAAI,IAAY,EAAQ,CACtB,EAAQ,WAAW,EAAQ,CAC3B,EAAQ,WAAW,EAAG,EAAE,CACxB,SAIF,IAAM,EAAO,EAAQ,UAAU,CACzB,EAAS,EAAU,EAAW,EAAK,CACzC,GAAI,IAAW,KAAM,CACnB,EAAQ,SAAS,EAAW,EAAU,CAAC,CACvC,EAAQ,WAAW,EAAW,EAAY,CAAC,CAC3C,EAAQ,QAAQ,EAAQ,SAAS,CAAG,EAAE,CACtC,EAAQ,WAAW,EAAG,EAAE,CACxB,SAEF,GAAI,IAAW,EAAM,CACnB,EAAQ,SAAS,EAAO,CACxB,EAAQ,WAAW,EAAW,EAAY,CAAC,CAC3C,EAAQ,WAAW,EAAG,EAAE,CACxB,SAIF,IAAM,EAAM,EAAQ,SAAS,CACvB,EAAM,EAAQ,QAAQ,CACtB,EAAW,EAAS,IAAI,EAAI,CAC5B,EAAW,EAAS,IAAI,EAAI,CAE9B,EAWJ,GAVA,AAOE,EAPE,GAAkB,EACT,GAAY,EACd,EACE,EACF,EACE,EAEA,GAGT,CAAC,EAAU,CACb,EAAQ,QAAQ,EAAQ,SAAS,CAAG,EAAE,CACtC,EAAQ,SAAS,EAAW,EAAU,CAAC,CACvC,EAAQ,WAAW,EAAW,EAAY,CAAC,CAC3C,EAAQ,WAAW,EAAG,EAAE,CACxB,SAIF,IAAM,EAAQ,EAAQ,UAAU,CAAG,EAC7B,EAAS,EAAU,EAAY,EAAM,CAC3C,GAAI,IAAW,KAAM,CAEnB,EAAQ,SAAS,EAAW,EAAW,CAAG,EAAE,CAC5C,EAAQ,YAAY,EAAQ,aAAa,CAAG,EAAE,CAC9C,EAAQ,QAAQ,EAAE,CAClB,EAAQ,SAAS,EAAW,EAAU,CAAC,CACvC,EAAQ,WAAW,EAAW,EAAY,CAAC,CAC3C,EAAQ,WAAW,EAAG,EAAE,CACxB,SAEF,GAAI,IAAW,EAAO,CACpB,EAAQ,SAAS,EAAS,EAAE,CAC5B,EAAQ,QAAQ,EAAE,CAClB,EAAQ,SAAS,EAAW,EAAU,CAAC,CACvC,EAAQ,WAAW,EAAW,EAAY,CAAC,CAC3C,EAAQ,WAAW,EAAG,EAAE,CACxB,SAIF,OAAO,EAGT,MAAU,MAAM,oCAAoC,EAAK,uBAAuB,EAAU,aAAa,GAAG,CAiB5G,SAAS,EAAgB,EAAgC,CACvD,GAAI,CAAC,GAAQ,CAAC,EAAK,MAAM,CACvB,MAAU,MAAM,wCAAwC,CAG1D,IAAM,EAAS,EAAK,MAAM,CAAC,MAAM,MAAM,CACvC,GAAI,EAAO,SAAW,EACpB,MAAU,MAAM,mDAAmD,EAAO,OAAO,IAAI,EAAK,GAAG,CAG/F,GAAM,CAAC,EAAU,EAAW,EAAU,EAAY,GAAY,EAExD,EAAc,EAAW,EAAU,EAAG,GAAG,CACzC,EAAY,EAAW,EAAW,EAAG,GAAG,CACxC,EAAW,EAAW,EAAU,EAAG,GAAG,CACtC,EAAa,EAAW,EAAY,EAAG,GAAG,CAC1C,EAAW,EAAW,EAAU,EAAG,EAAE,CAGvC,EAAS,IAAI,EAAE,EACjB,EAAS,IAAI,EAAE,CAQjB,IAAM,EAAwB,GAA2B,IAAU,KAAO,IAAU,MAIpF,MAAO,CAAE,cAAa,YAAW,WAAU,aAAY,WAAU,eAH1C,CAAC,EAAqB,EAAS,CAG2B,eAF1D,CAAC,EAAqB,EAAS,CAE2C,CAgBnG,SAAS,EAAW,EAAe,EAAa,EAAuB,CACrE,EAAoB,EAAM,CAE1B,IAAM,EAAS,IAAI,IACb,EAAQ,EAAM,MAAM,IAAI,CAE9B,IAAK,IAAM,KAAQ,EAAO,CACxB,GAAM,CAAE,QAAO,QAAS,EAAU,EAAK,CAGvC,GAFA,EAAa,EAAM,EAAK,CAEpB,IAAU,IACZ,IAAK,IAAI,EAAI,EAAK,GAAK,EAAK,GAAK,EAC/B,EAAO,IAAI,EAAE,SAEN,EAAM,SAAS,IAAI,CAAE,CAC9B,GAAM,CAAC,EAAU,GAAU,EAAM,MAAM,IAAI,CACrC,EAAQ,EAAc,EAAU,EAAK,CACrC,EAAM,EAAc,EAAQ,EAAK,CACvC,GAAI,EAAQ,GAAO,EAAM,GAAO,EAAQ,EACtC,MAAU,MAAM,uBAAuB,EAAM,YAAY,EAAM,GAAG,EAAI,mBAAmB,EAAI,GAAG,EAAI,GAAG,CAEzG,IAAK,IAAI,EAAI,EAAO,GAAK,EAAK,GAAK,EACjC,EAAO,IAAI,EAAE,KAEV,CACL,IAAM,EAAM,EAAc,EAAO,EAAK,CACtC,GAAI,EAAM,GAAO,EAAM,EACrB,MAAU,MACR,uBAAuB,EAAM,WAAW,EAAI,iBAAiB,EAAI,GAAG,EAAI,GACzE,CAEH,GAAI,EAAO,EAET,IAAK,IAAI,EAAI,EAAK,GAAK,EAAK,GAAK,EAC/B,EAAO,IAAI,EAAE,MAGf,EAAO,IAAI,EAAI,EAKrB,OAAO,EAGT,SAAS,EAAoB,EAAqB,CAGhD,GADgB,EAAM,QAAQ,cAAe,GAAG,CACpC,OAAS,EACnB,MAAU,MAAM,uBAAuB,EAAM,uBAAuB,CAIxE,SAAS,EAAU,EAA+C,CAChE,IAAM,EAAW,EAAK,QAAQ,IAAI,CAIlC,OAHI,IAAa,GACR,CAAE,MAAO,EAAM,KAAM,EAAG,CAE1B,CACL,MAAO,EAAK,UAAU,EAAG,EAAS,CAClC,KAAM,SAAS,EAAK,UAAU,EAAW,EAAE,CAAE,GAAG,CACjD,CAGH,SAAS,EAAa,EAAc,EAAoB,CACtD,GAAI,MAAM,EAAK,EAAI,EAAO,EACxB,MAAU,MAAM,wCAAwC,EAAK,GAAG,CAIpE,SAAS,EAAc,EAAW,EAA6B,CAC7D,IAAM,EAAI,SAAS,EAAG,GAAG,CACzB,GAAI,MAAM,EAAE,CACV,MAAU,MAAM,6CAA6C,EAAE,QAAQ,EAAY,GAAG,CAExF,OAAO,EAGT,SAAS,EAAU,EAAe,EAAgC,CAChE,IAAI,EAAsB,KAC1B,IAAK,IAAM,KAAK,EACV,GAAK,IAAY,IAAS,MAAQ,EAAI,KACxC,EAAO,GAGX,OAAO,EAGT,SAAS,EAAW,EAAuB,CACzC,IAAI,EAAO,IACX,IAAK,IAAM,KAAK,EACV,EAAI,IAAM,EAAO,GAEvB,OAAO,EC1QT,SAAS,EAAa,EAA8B,CAClD,OAAO,EAAQ,EAAW,YAAa,EAAa,CAuBtD,SAAgB,EAAoB,EAAsB,EAAgC,EAAE,CAAiB,CAO3G,IAAM,EACJ,EAAQ,cACP,GAAqB,CAClB,EAAiC,CAAE,KAAM,SAAU,CAAC,CACpD,EAAiC,CAAE,KAAM,OAAQ,QAAS,EAAa,EAAa,CAAE,CAAC,EACvF,EAAS,EAAQ,OAEvB,MAAO,CACL,MAAM,SAAS,EAAoC,CACjD,IAAM,EAAoC,CACxC,QAASA,EACT,GAAI,EAAK,GACT,KAAM,EACN,SAAU,CACR,UAAW,EAAK,UAChB,UAAW,KAAK,KAAK,CACrB,KAAM,CAAC,WAAW,CACnB,CACF,CACD,MAAM,EAAM,KAAK,EAAS,EAG5B,MAAM,cAAyC,CAC7C,IAAI,EACJ,GAAI,CACF,EAAM,MAAM,EAAM,MAAM,OACjB,EAAK,CAEZ,OADA,GAAQ,MAAM,iDAAkD,EAAI,CAC7D,EAAE,CAGX,IAAM,EAAyB,EAAE,CACjC,IAAK,IAAM,KAAM,EACf,GAAI,CACF,IAAM,EAAW,MAAM,EAAM,KAAK,EAAG,CACjC,GACF,EAAM,KAAK,EAAS,KAAK,OAEpB,EAAK,CACZ,GAAQ,MAAM,wCAAwC,EAAG,IAAK,EAAI,CAMtE,OADA,EAAM,MAAM,EAAG,IAAM,EAAE,WAAa,EAAE,WAAW,CAC1C,GAGT,MAAM,WAAW,EAA8B,CAC7C,GAAI,CACF,OAAO,MAAM,EAAM,OAAO,EAAG,OACtB,EAAK,CAEZ,OADA,GAAQ,MAAM,0CAA0C,EAAG,IAAK,EAAI,CAC7D,KAGZ,CCtEH,SAAS,EAAa,EAA8B,CAClD,OAAO,EAAQ,EAAW,kBAAkB,EAAa,OAAO,CAsBlE,SAAgB,EAAyB,EAAqC,CAC5E,IAAM,EAAQ,GAAY,CACpB,EAAY,EAAa,EAAa,CACtC,EAAa,GAAiB,CAChC,EAAQ,GAGN,MAA2B,CAC/B,GAAI,CAGF,EAAU,EAAW,CAAE,UAAW,GAAM,CAAC,CACzC,IAAM,EAAK,EAAS,EAAW,KAAK,CACpC,GAAI,CACF,EAAU,EAAI,EAAiB,EAAO,QAAQ,IAAK,EAAW,CAAC,QACvD,CACR,EAAU,EAAG,CAGf,MADA,GAAQ,GACD,SACA,EAAK,CACZ,GAAK,EAA8B,OAAS,SAAU,MAAM,EAC5D,MAAO,KAKL,MAA2E,CAC/E,GAAI,CAEF,OAAO,EADK,EAAa,EAAW,QAAQ,CAAC,MAAM,CACvB,MACtB,CACN,OAAO,OAKL,MAAiC,GAAa,EAAE,OAAS,KAGzD,EAAmB,GACnB,EAAQ,MAAQ,IAAA,IAAa,EAAQ,OAAS,IAAA,IAC9C,EAAQ,OAAS,EAAmB,GACjC,CAAC,EAAe,EAAQ,IAAI,CAI/B,EAAoB,GACpB,EAAQ,MAAQ,IAAA,IAAa,EAAQ,OAAS,IAAA,IAC9C,EAAQ,OAAS,EAAmB,GACjC,EAAe,EAAQ,IAAI,CAO9B,MAAgC,CACpC,GAAI,EAAO,MAAO,GAKlB,GAAI,GAAqB,CAAE,MAAO,GAClC,GAAI,GAAW,CAAE,MAAO,GAGxB,IAAM,EAAU,GAAa,CACzB,EACJ,GAAI,CACF,EAAQ,EAAS,EAAU,CAAC,aACtB,CAEN,OAAO,GAAW,CAUpB,GAPI,IAAY,MAAQ,EAAiB,EAAQ,EAO7C,EADkB,IAAY,MAAQ,EAAgB,EAAQ,GAC5C,KAAK,KAAK,CAAG,GAAS,IAAU,MAAO,GAG7D,IAAM,EAAY,GAAG,EAAU,SAAS,GAAY,GACpD,GAAI,CACF,EAAW,EAAW,EAAU,CAChC,EAAO,EAAW,CAAE,MAAO,GAAM,CAAC,MAC5B,CACN,MAAO,GAET,OAAO,GAAW,EAGpB,MAAO,CACL,MAAM,YAA+B,CACnC,OAAO,GAAgB,EAGzB,MAAM,OAAuB,CACtB,KAEL,IAAI,GAAW,GAAK,EAAO,CACzB,EAAQ,GACR,OAEF,GAAI,CACF,IAAM,EAAI,IAAI,KACd,EAAW,EAAW,EAAG,EAAE,MACrB,CACN,EAAQ,MAIZ,MAAM,SAAyB,CACxB,KAEL,IAAI,GAAW,GAAK,EAClB,GAAI,CACF,EAAW,EAAU,MACf,EAIV,EAAQ,KAGV,SAAmB,CACjB,OAAO,GAAS,GAAW,GAAK,GAEnC,CCvKH,MAAa,EAAyC,CACpD,CACE,QAAS,EACT,GAAK,GAAO,CACV,EAAG,KAAK;;;;;;QAMN,EAEL,CACF,CCND,IAAM,EAAN,cAAsC,CAA6D,CACjG,IAAuB,cAAuB,CAC5C,MAAO,gBAET,IAAuB,cAAuB,CAC5C,MAAO,gBAGT,UAAoB,EAAoC,CACtD,OAAO,EAAS,GAGlB,MAAM,SAAS,EAAoC,CACjD,MAAM,KAAK,KAAK,CAAE,QAAS,EAAkB,GAAI,EAAK,GAAI,KAAM,EAAM,CAAC,CASzE,MAAM,cAAyC,CAC7C,IAAM,EAAM,MAAM,KAAK,MAAM,CACvB,EAAyB,EAAE,CACjC,IAAK,IAAM,KAAM,EAAK,CACpB,IAAM,EAAW,MAAM,KAAK,KAAK,EAAG,CAChC,GAAU,EAAM,KAAK,EAAS,KAAK,CAGzC,OADA,EAAM,MAAM,EAAG,IAAM,EAAE,WAAa,EAAE,WAAW,CAC1C,EAGT,MAAM,WAAW,EAA8B,CAC7C,OAAO,KAAK,OAAO,EAAG,GAwB1B,SAAgB,EAA0B,EAAoD,CAS5F,OAAO,IAAI,EAPuD,CAChE,QAAS,GAFI,IAAI,IAAI,EAAQ,WAAW,CAAC,OAEtB,wBACnB,QAAS,EAAQ,QACjB,MAAO,EAAQ,OAAS,WAAW,MACnC,UAAW,EAAQ,WAAa,IAChC,MAAO,EAAQ,MAChB,CACgD,CCtCnD,SAAgB,EAA0B,EAAoD,CAC5F,IAAM,EAAQ,GAAY,CACpB,EAAY,EAAQ,OAAS,WAAW,MACxC,EAAY,EAAQ,WAAa,IACjC,EAAU,IAAI,IAAI,EAAQ,WAAW,CAAC,OACtC,EAAM,EAAQ,KAAO,KAAK,IAC5B,EAAQ,GAER,EAAkB,EAElB,EAEA,EAEE,EAAY,KAAO,IAAkD,CACzE,IAAM,EAAO,MAAM,EAAQ,SAAS,CAC9B,EAAa,IAAI,gBACjB,EAAU,eAAiB,EAAW,OAAO,CAAE,EAAU,CAC/D,GAAI,CACF,IAAM,EAAW,MAAM,EACrB,GAAG,EAAQ,iBAAiB,mBAAmB,EAAQ,MAAM,CAAC,SAAS,IACvE,CACE,OAAQ,OACR,QAAS,CACP,eAAgB,mBAChB,cAAe,UAAU,EAAK,QAC/B,CACD,KAAM,KAAK,UAAU,CAAE,QAAO,CAAC,CAC/B,OAAQ,EAAW,OACpB,CACF,CAED,OADK,EAAS,GACP,CAAE,GAAI,GAAM,KAAO,MAAM,EAAS,MAAM,CAA8B,CADpD,CAAE,GAAI,GAAO,OAAQ,gBAAiB,MAEzD,CAGN,MAAO,CAAE,GAAI,GAAO,OAAQ,gBAAiB,QACrC,CACR,aAAa,EAAQ,GAInB,MAAqC,CAKzC,GAAI,EAAiB,OAAO,EAE5B,IAAM,GAAW,SAA8B,CAC7C,EAAkB,GAAK,CACvB,IAAM,EAAS,MAAM,EAAU,UAAU,CACzC,GAAI,CAAC,EAAO,GAGV,MAFA,GAAQ,GACR,EAAoB,gBACb,GAET,IAAM,EAAW,EAAO,KAAK,WAAgB,GAG7C,MAFA,GAAQ,EACR,EAAoB,EAAW,IAAA,GAAY,WACpC,KACL,CAMJ,MAJA,GAAkB,EAClB,EAAQ,YAAc,CAChB,IAAoB,IAAS,EAAkB,IAAA,KACnD,CACK,GAGT,MAAO,CACL,aAEA,MAAM,OAAuB,CAK3B,GAAI,GAAK,CAAG,EAAA,IAAgC,OAE5C,GAAI,CAAC,EAAO,CAIV,MAAM,GAAY,CAClB,OAEF,EAAkB,GAAK,CACvB,IAAM,EAAS,MAAM,EAAU,QAAQ,CACvC,GAAI,CAAC,EAAO,GAAI,CAId,EAAQ,GACR,EAAoB,gBACpB,OAEc,EAAO,KAAK,UAAe,GAQzC,EAAoB,IAAA,IAHpB,EAAQ,GACR,EAAoB,aAMxB,MAAM,SAAyB,CACxB,AAEL,KADA,MAAM,EAAU,UAAU,CAClB,KAGV,SAAmB,CACjB,OAAO,GAGT,mBAA8D,CAC5D,OAAO,EAAQ,IAAA,GAAY,GAE9B,CC3JH,MAMM,EAAmB,IAsEzB,IAAa,EAAb,KAA8B,CAC5B,KACA,SACA,OAAwD,KACxD,cAA+D,KAC/D,OAAuC,IAAI,IAC3C,eAAyB,GACzB,QAAkB,GAClB,UAAoB,EAGpB,oBAA2G,IAAI,IAC/G,gBAAgD,IAAI,IACpD,eAAyB,EAEzB,YAAY,EAAqB,CAC/B,KAAK,KAAO,EACZ,KAAK,SAAW,EAAK,SAUvB,aAAuB,CACrB,OAAO,KAAK,KAAK,UAAU,SAAS,CAWtC,wBAAmE,CACjE,OAAO,KAAK,KAAK,UAAU,qBAAqB,CAMlD,MAAM,OAAuB,CACvB,SAAK,QAIT,IAAI,CADY,MAAM,KAAK,KAAK,UAAU,YAAY,CACxC,CAEZ,GAAI,CACF,IAAM,EAAQ,MAAM,KAAK,KAAK,MAAM,cAAc,CAClD,IAAK,IAAM,KAAQ,EACjB,GAAI,CAAE,KAAK,SAAS,IAAI,EAAK,MAAS,SAEjC,EAAK,CACZ,KAAK,KAAK,QAAQ,MAAM,yDAA0D,EAAI,CAExF,OAIF,GAAI,CACF,IAAM,EAAM,KAAK,KAAK,OAAO,EAAI,KAAK,KAAK,CACrC,EAAQ,MAAM,KAAK,KAAK,MAAM,cAAc,CAClD,IAAK,IAAM,KAAQ,EAAO,CAGxB,GAAI,EAAK,WAAa,EAAK,WAAa,EACtC,GAAI,CACF,IAAM,EAAW,EAAU,EAAK,eAAgB,IAAI,KAAK,EAAM,IAAO,CAAC,CACvE,EAAK,WAAa,KAAK,YAAY,EAAK,GAAI,EAAU,GAAK,MACrD,EAIV,GAAI,CAAE,KAAK,SAAS,IAAI,EAAK,MAAS,UAEjC,EAAK,CACZ,KAAK,KAAK,QAAQ,MAAM,wCAAyC,EAAI,CAGvE,KAAK,QAAU,GACf,KAAK,OAAS,gBAAkB,CACzB,KAAK,MAAM,EACf,EAAiB,CACpB,KAAK,OAAO,OAAO,EAWrB,MAAc,iBAAiC,CAC7C,IAAI,EACJ,GAAI,CACF,EAAQ,MAAM,KAAK,KAAK,MAAM,cAAc,OACrC,EAAK,CACZ,KAAK,KAAK,QAAQ,MAAM,qDAAsD,EAAI,CAClF,OAGF,IAAM,EAAW,IAAI,IAAI,EAAM,IAAK,GAAM,EAAE,GAAG,CAAC,CAOhD,IAAK,IAAM,KAAQ,EAAO,CACxB,GAAI,KAAK,OAAO,IAAI,EAAK,GAAG,CAAE,SAC9B,GAAI,CAAC,EAAK,WAAa,EAAK,aAAe,MAAQ,CAAC,KAAK,SAAS,IAAI,EAAK,GAAG,CAAE,CACzE,KAAK,KAAK,MAAM,WAAW,EAAK,GAAG,CAAC,MAAO,GAAQ,CACtD,KAAK,KAAK,QAAQ,MAAM,gDAAgD,EAAK,GAAG,GAAI,EAAI,EACxF,CACF,SAEF,IAAM,EAAW,KAAK,SAAS,IAAI,EAAK,GAAG,CAC3C,GAAK,EAcH,EAAS,QAAU,EAAK,QACxB,EAAS,KAAO,EAAK,KACrB,EAAS,OAAS,EAAK,OACvB,EAAS,eAAiB,EAAK,eAC/B,EAAS,cAAgB,EAAK,cAC9B,EAAS,WAAa,EAAK,gBAlB3B,GAAI,CAAE,KAAK,SAAS,IAAI,EAAK,MAAS,GA4B1C,IAAK,IAAM,KAAQ,KAAK,SAAS,MAAM,CACjC,EAAK,SAAW,WAChB,CAAC,EAAS,IAAI,EAAK,GAAG,EAAI,CAAC,KAAK,OAAO,IAAI,EAAK,GAAG,EACrD,KAAK,SAAS,OAAO,EAAK,GAAG,CAMnC,MAAM,MAAsB,CACtB,CAAC,KAAK,SAAW,CAAC,KAAK,KAAK,UAAU,SAAS,GAEnD,KAAK,cAAc,CAEnB,AAEE,KAAK,UADL,cAAc,KAAK,OAAO,CACZ,MAQhB,KAAK,QAAU,GACf,MAAM,KAAK,KAAK,UAAU,SAAS,CACnC,MAAM,KAAK,UAAU,EAYvB,eAA8B,CACxB,KAAK,gBACT,KAAK,KAAK,QAAQ,MAAM,2DAA2D,CACnF,KAAK,cAAgB,gBAAkB,CAChC,KAAK,iBAAiB,EAC1B,IAA2B,CAC9B,KAAK,cAAc,OAAO,EAG5B,MAAc,iBAAiC,CAC7C,GAAI,CACe,MAAM,KAAK,KAAK,UAAU,YAAY,EACvC,KAAK,KAAK,UAAU,SAAS,GAC3C,KAAK,cAAc,CACnB,KAAK,QAAU,GACf,KAAK,OAAS,gBAAkB,CACzB,KAAK,MAAM,EACf,EAAiB,CACpB,KAAK,OAAO,OAAO,CACnB,KAAK,KAAK,QAAQ,MAAM,6DAA6D,OAEjF,GAKV,cAA6B,CAC3B,AAEE,KAAK,iBADL,cAAc,KAAK,cAAc,CACZ,MAazB,IAAI,EAA0C,CAC5C,GAAI,KAAK,SAAS,OAAO,EAAI,GAC3B,MAAU,MAAM,yEAAmF,CAKrG,IAAM,EAAe,EAAM,SAAW,WAAa,CAAC,EAAM,WAK1D,GAAI,GAAgB,CAAC,KAAK,KAAK,UAAU,SAAS,CAChD,MAAU,MACR,8KAED,CAGH,IAAM,EAAK,OAAO,YAAY,CAAC,MAAM,EAAG,EAAE,CACpC,EAAM,KAAK,KAAK,OAAO,EAAI,KAAK,KAAK,CACrC,EAAW,EAAU,EAAM,eAAgB,IAAI,KAAK,EAAM,IAAO,CAAC,CAClE,EAAa,KAAK,YAAY,EAAI,EAAU,EAAM,UAAU,CAE5D,EAAsB,CAC1B,KACA,KAAM,EAAM,KACZ,OAAQ,EAAM,OACd,eAAgB,EAAM,eACtB,QAAS,GACT,UAAW,EAAM,UACjB,OAAQ,EAAM,OACd,UAAW,EACX,aACA,cAAe,EAAM,eAAiB,IACtC,WAAY,EAAM,YAAc,EAEhC,GAAI,EAAM,WAAa,CAAE,WAAY,EAAM,WAAY,CAAG,EAAE,CAG5D,GAAI,EAAe,CAAE,OAAQ,UAAoB,eAAgB,KAAK,KAAK,cAAc,CAAE,CAAG,EAAE,CACjG,CAID,OAFA,KAAK,SAAS,IAAI,EAAK,CACvB,KAAK,QAAQ,EAAK,CACX,EAIT,OAAO,EAAqB,CAC1B,IAAM,EAAU,KAAK,SAAS,OAAO,EAAG,CAIxC,OAHI,GACG,KAAK,UAAU,EAAG,CAElB,EAST,OACE,EACA,EACS,CACT,IAAM,EAAO,KAAK,SAAS,IAAI,EAAG,CAClC,GAAI,CAAC,EAAM,MAAO,GAWlB,GATI,EAAM,OAAS,IAAA,KACjB,EAAK,KAAO,EAAM,MAEhB,EAAM,SAAW,IAAA,KACnB,EAAK,OAAS,EAAM,QAElB,EAAM,UAAY,IAAA,KACpB,EAAK,QAAU,EAAM,SAEnB,EAAM,iBAAmB,IAAA,GAAW,CACtC,EAAK,eAAiB,EAAM,eAC5B,IAAM,EAAM,KAAK,KAAK,OAAO,EAAI,KAAK,KAAK,CACrC,EAAW,EAAU,EAAK,eAAgB,IAAI,KAAK,EAAM,IAAO,CAAC,CACvE,EAAK,WAAa,KAAK,YAAY,EAAK,GAAI,EAAU,EAAK,UAAU,CAIvE,OADA,KAAK,QAAQ,EAAK,CACX,GAIT,MAAwB,CACtB,OAAO,KAAK,SAAS,MAAM,CAO7B,mBAAiC,CAC/B,OAAO,IAAI,IAAI,KAAK,oBAAoB,MAAM,CAAC,CAOjD,aAAa,EAAgB,EAAqB,GAAkD,CAClG,IAAM,EAAS,EAAQ,EAAW,gBAAgB,CAC5C,EAAS,GAAG,EAAO,GACzB,GAAI,CACF,IAAM,EAAQ,EAAY,EAAO,CAC9B,OAAQ,GAAM,EAAE,WAAW,EAAO,EAAI,EAAE,SAAS,OAAO,CAAC,CACzD,MAAM,CACN,SAAS,CACN,EAAwD,EAAE,CAChE,IAAK,IAAI,EAAI,EAAG,EAAI,EAAM,QAAU,EAAQ,OAAS,EAAY,IAC/D,GAAI,CACF,IAAM,EAAU,EAAa,EAAQ,EAAQ,EAAM,GAAI,CAAE,QAAQ,CAAC,MAAM,CAElE,EAAW,EAAQ,QAAQ,IAAI,CAC/B,EAAY,EAAW,EAAI,EAAQ,MAAM,EAAG,EAAS,CAAG,EACxD,EAAS,EAAW,EAAI,EAAQ,MAAM,EAAW,EAAE,CAAG,GAC5D,EAAQ,KAAK,CAAE,YAAW,SAAQ,CAAC,MAC7B,EAIV,OAAO,OACD,CACN,MAAO,EAAE,EAYb,OAAO,EAAkB,CACvB,IAAM,EAAO,KAAK,SAAS,IAAI,EAAG,CAClC,GAAI,CAAC,EACH,MAAU,MAAM,kBAAkB,EAAG,aAAa,CAEpD,GAAI,CAAC,EAAK,QACR,MAAU,MAAM,kBAAkB,EAAG,eAAe,CAEtD,GAAI,KAAK,OAAO,IAAI,EAAK,GAAG,CAC1B,MAAU,MAAM,kBAAkB,EAAG,qBAAqB,CAE5D,KAAK,OAAO,IAAI,EAAK,GAAG,CACnB,KAAK,KAAK,EAAK,CAiBtB,OAAO,EAA8B,CACnC,IAAM,EAAO,KAAK,SAAS,IAAI,EAAO,CACtC,GAAI,CAAC,EAAM,MAAO,CAAE,GAAI,GAAO,OAAQ,iBAAkB,CAIzD,IAAI,EACE,EAAS,KAAK,oBAAoB,IAAI,EAAO,CACnD,GAAI,EACF,EAAQ,EAAO,UACV,CAIL,IAAM,EAAc,KAAK,KAAK,YAAY,MAAM,CAAC,OAC/C,GAAK,EAAE,SAAW,WAAa,EAAE,SAAW,cAC7C,CACG,EAAY,SAAW,IACzB,EAAQ,EAAY,GAAI,IAG5B,GAAI,CAAC,EAAO,MAAO,CAAE,GAAI,GAAO,OAAQ,cAAe,CAKvD,IAAM,EAAM,KAAK,KAAK,YAAY,IAAI,EAAM,CAgB5C,OAfK,EACD,EAAI,SAAW,QAAkB,CAAE,GAAI,GAAO,OAAQ,eAAgB,CACtE,EAAI,SAAW,UAAkB,CAAE,GAAI,GAAO,OAAQ,sBAAuB,CAC7E,EAAI,SAAW,SAAkB,CAAE,GAAI,GAAO,OAAQ,qBAAsB,CAC5E,EAAI,SAAW,WAAmB,CAAE,GAAI,GAAO,OAAQ,uBAAwB,EAInF,KAAK,gBAAgB,IAAI,EAAK,GAAG,CAKjC,KAAK,KAAK,YAAY,OAAO,EAAM,CAE5B,CAAE,GAAI,GAAM,EAfF,CAAE,GAAI,GAAO,OAAQ,gBAAiB,CAwBzD,MAAc,MAAsB,CAC9B,SAAK,eACT,MAAK,eAAiB,GAEtB,GAAI,CAcF,GATA,MAAM,KAAK,KAAK,UAAU,OAAO,CAS7B,CAAC,KAAK,KAAK,UAAU,SAAS,CAAE,CAClC,KAAK,QAAU,GACf,AAEE,KAAK,UADL,cAAc,KAAK,OAAO,CACZ,MAEhB,KAAK,eAAe,CACpB,OAQF,KAAK,YACD,KAAK,UAAY,IAA0B,GAC7C,MAAM,KAAK,iBAAiB,CAG9B,IAAM,EAAM,KAAK,KAAK,OAAO,EAAI,KAAK,KAAK,CAO3C,IAAK,IAAM,KAAQ,KAAK,SAAS,MAAM,CACjC,EAAK,WAAa,EAAM,EAAK,WAAa,SAC5C,KAAK,SAAS,OAAO,EAAK,GAAG,CACxB,KAAK,UAAU,EAAK,GAAG,EAKhC,IAAM,EAAM,KAAK,SAAS,QAAQ,EAAI,CACtC,IAAK,IAAM,KAAQ,EAAK,CAYtB,GAXI,KAAK,OAAO,IAAI,EAAK,GAAG,EAWxB,CAAC,EAAK,YAAc,EAAK,gBAAkB,EAAK,mBAAoB,SAQxE,IAAM,EAAe,EAAK,WACpB,EAAW,EAAU,EAAK,eAAgB,IAAI,KAAK,EAAM,IAAO,CAAC,CACjE,EAAW,KAAK,YAAY,EAAK,GAAI,EAAU,EAAK,UAAU,CACpE,KAAK,SAAS,OAAO,EAAK,GAAI,CAAE,WAAY,EAAU,CAAC,CACvD,KAAK,QAAQ,EAAK,CAElB,KAAK,OAAO,IAAI,EAAK,GAAG,CACnB,KAAK,KAAK,EAAM,EAAa,SAE5B,CACR,KAAK,eAAiB,KA2B1B,eAAuB,EAAqB,EAAgC,CAC1E,IAAM,EAAa,EAAK,WACxB,GAAI,CAAC,EAAY,MAAO,GAExB,IAAM,EAAS,KAAK,KAAK,iBACzB,GAAI,CAAC,EAEH,OADA,KAAK,aAAa,EAAK,GAAI,6DAA6D,CACjF,GAGT,IAAM,EAAa,KAAK,KAAK,OAAO,EAAI,KAAK,KAAK,CAI5C,EAAe,GAAG,EAAK,GAAG,GAAG,GAAgB,IAE/C,EAAY,GAChB,GAAI,CACF,EAAY,EAAO,EAAY,CAAE,WAAY,EAAK,GAAI,eAAc,aAAY,CAAC,OAC1E,EAAK,CAIZ,OAFA,KAAK,KAAK,QAAQ,MAAM,gDAAgD,EAAK,GAAG,GAAI,EAAI,CACxF,KAAK,aAAa,EAAK,GAAI,0BAA0B,aAAe,MAAQ,EAAI,QAAU,OAAO,EAAI,GAAG,CACjG,GAST,OANA,KAAK,aACH,EAAK,GACL,EACI,8BAA8B,EAAW,SAAS,GAAG,EAAW,UAAU,eAAe,EAAa,GACtG,kCAAkC,EAAW,SAAS,GAAG,EAAW,UAAU,eACnF,CACM,EAWT,YAAoB,EAA8B,CAChD,GAAI,CAAC,EAAK,eAER,OADA,KAAK,aAAa,EAAK,GAAI,mEAAmE,CACvF,GAGT,IAAM,EAAW,KAAK,KAAK,uBAC3B,GAAI,CAAC,EAEH,OADA,KAAK,aAAa,EAAK,GAAI,gEAAgE,CACpF,GAWT,IAAM,EAAmB,EAAK,SAAW,SAAW,EAAK,aAAe,KAClE,EAAS,gBAAgB,EAAK,OAC9B,EAAS,EACX,GAAG,EAAO,4DAA4D,EAAK,GAAG,kBAAkB,EAAK,SACrG,GAAG,EAAO,MAAM,EAAK,SAErB,EAAY,GAChB,GAAI,CACF,EAAY,EAAS,CACnB,SACA,eAAgB,EAAK,eACrB,KAAM,EAAK,KACX,OAAQ,EAAK,OACd,CAAC,OACK,EAAK,CAIZ,OAFA,KAAK,KAAK,QAAQ,MAAM,+CAA+C,EAAK,GAAG,GAAI,EAAI,CACvF,KAAK,aAAa,EAAK,GAAI,uBAAuB,aAAe,MAAQ,EAAI,QAAU,OAAO,EAAI,GAAG,CAC9F,GAST,OANA,KAAK,aACH,EAAK,GACL,EACI,mCAAmC,EAAK,eAAe,GACvD,uCAAuC,EAAK,eAAe,6DAChE,CACM,EAGT,MAAc,KAAK,EAAqB,EAAsC,CAC5E,IAAI,EAAgC,SAChC,EAAmB,GACnB,EACE,EAAY,EAAE,KAAK,eAEzB,GAAI,CAMF,GAAI,EAAK,WAAY,CACnB,EAAU,KAAK,eAAe,EAAM,EAAa,CAAG,UAAY,SAChE,OAMF,GAAI,EAAK,SAAW,UAAW,CAC7B,IAAM,EAAmB,KAAK,YAAY,EAAK,CAC/C,EAAU,EAAmB,UAAY,SACrC,GAIF,KAAK,SAAS,OAAO,EAAK,GAAI,CAC5B,YAAa,KAAK,KAAK,OAAO,EAAI,KAAK,KAAK,CAC5C,eAAgB,GAChB,cAAe,IAAA,GAChB,CAAC,CAEJ,OAGF,IAAM,EAA0B,EAAK,SAAW,QAAU,OAAS,OAC7D,EAAY,KAAK,KAAK,cAAc,CAE1C,IAAK,IAAI,EAAU,EAAG,GAAW,EAAK,WAAY,IAAW,CAM3D,GAAI,KAAK,gBAAgB,IAAI,EAAK,GAAG,CAAE,CACrC,KAAK,aAAa,EAAK,GAAI,6BAA6B,EAAU,EAAE,GAAG,CACvE,EAAmB,GACnB,MAGF,IAAM,EAAM,KAAK,KAAK,SAAS,CAC7B,MAAO,EAAK,KACZ,OAAQ,EAAK,OACb,YACA,SACD,CAAC,CAGF,KAAK,oBAAoB,IAAI,EAAK,GAAI,CAAE,MAAO,EAAI,GAAI,YAAW,UAAS,CAAC,CAE5E,IAAM,EAAS,MAAM,KAAK,kBAAkB,EAAI,GAAI,EAAK,cAAc,CAEvE,GAAI,IAAW,UAAW,CACxB,KAAK,SAAS,OAAO,EAAK,GAAI,CAC5B,YAAa,KAAK,KAAK,OAAO,EAAI,KAAK,KAAK,CAC5C,eAAgB,GAChB,cAAe,IAAA,GAChB,CAAC,CACF,KAAK,QAAQ,EAAK,CAIlB,IAAM,EAAc,KAAK,KAAK,YAAY,IAAI,EAAI,GAAG,CAC/C,EAAe,GAAa,SAAW,SAAW,CAAC,CAAC,EAAY,MAAM,MAAM,CAClF,KAAK,aACH,EAAK,GACL,EACI,sBAAsB,EAAU,EAAE,6BAA6B,EAAI,KACnE,sBAAsB,EAAU,EAAE,GACvC,CACD,EAAU,UACV,OAKF,GAAI,IAAW,WAAY,CACzB,KAAK,aAAa,EAAK,GAAI,6BAA6B,EAAU,EAAE,GAAG,CACvE,EAAmB,GACnB,MASF,GANA,EAAY,IAAW,UAAY,YAAc,aAM7C,EAAU,EAAK,WAAY,CAC7B,IAAM,EAAY,KAAK,KAAK,gBAAkB,EAE9C,MADc,KAAK,KAAK,QAAW,GAAe,IAAI,QAAe,GAAY,WAAW,EAAS,EAAG,CAAC,GAC7F,EAAU,EAMrB,GACH,KAAK,aAAa,EAAK,GAAI,gBAAgB,EAAK,WAAa,EAAE,aAAa,OAEvE,EAAK,CACZ,EAAY,aAAe,MAAQ,EAAI,QAAU,OAAO,EAAI,CAC5D,KAAK,KAAK,QAAQ,MAAM,oCAAoC,EAAK,GAAG,GAAI,EAAI,CAC5E,KAAK,aAAa,EAAK,GAAI,UAAU,IAAY,QACzC,CACR,KAAK,OAAO,OAAO,EAAK,GAAG,CAC3B,KAAK,oBAAoB,OAAO,EAAK,GAAG,CAMxC,IAAM,EAAY,KAAK,gBAAgB,IAAI,EAAK,GAAG,CACnD,KAAK,gBAAgB,OAAO,EAAK,GAAG,CAE/B,EAAK,YACJ,IAAY,WACd,KAAK,SAAS,OAAO,EAAK,GAAG,CAC7B,MAAM,KAAK,UAAU,EAAK,GAAG,EACpB,GAIT,KAAK,SAAS,OAAO,EAAK,GAAI,CAC5B,kBAAmB,GACnB,cAAe,IAAA,GAChB,CAAC,CACF,KAAK,QAAQ,EAAK,EACT,IAUT,KAAK,SAAS,OAAO,EAAK,GAAI,CAAE,eAAgB,GAAM,cAAe,EAAW,CAAC,CACjF,KAAK,QAAQ,EAAK,IAuB1B,kBAA0B,EAAe,EAA+E,CACtH,OAAO,IAAI,QAAS,GAAY,CAC9B,IAAI,EAAU,GACV,EACA,EACA,EAQE,GADY,KAAK,KAAK,OAAO,EAAI,KAAK,KAAK,EAChB,EAAgB,EAE3C,MAAgB,CAChB,GAAS,aAAa,EAAQ,CAClC,KAAa,CACb,KAAe,EAGX,EAAU,GAA0D,CACpE,IACJ,EAAU,GACV,GAAS,CACT,EAAQ,EAAO,GAGX,EAAc,GAAe,CAC7B,GAAS,aAAa,EAAQ,CAClC,EAAU,eAAiB,CACzB,KAAK,KAAK,YAAY,OAAO,EAAM,CACnC,EAAO,SAAS,EACf,EAAG,EAIR,EAAc,KAAK,KAAK,YAAY,SAAU,GAAQ,CAChD,KAAI,KAAO,EAEf,IAAI,EAAI,SAAW,QAEjB,EAAO,UAAU,SACR,EAAI,SAAW,cAAe,CAGvC,IAAM,EAAM,KAAK,KAAK,OAAO,EAAI,KAAK,KAAK,CACrC,EAAY,KAAK,IAAI,EAAe,EAAe,EAAI,CAC7D,GAAI,GAAa,EAAG,CAClB,KAAK,KAAK,YAAY,OAAO,EAAM,CACnC,EAAO,SAAS,CAChB,OAEF,EAAW,EAAU,IAEvB,CAGF,EAAY,KAAK,KAAK,YAAY,OAAQ,GAAQ,CAC5C,EAAI,KAAO,IAEX,EAAI,SAAW,SACjB,EAAO,SAAS,CACP,EAAI,SAAW,WAGxB,EAAO,WAAW,CACT,EAAI,SAAW,WACxB,EAAO,UAAU,GAEnB,CAIF,EAAW,EAAc,EACzB,CAYJ,YAAoB,EAAgB,EAAgB,EAA4B,CAC9E,IAAI,EAAO,EACX,IAAK,IAAI,EAAI,EAAG,EAAI,EAAO,OAAQ,IACjC,GAAS,GAAQ,GAAK,EAAQ,EAAO,WAAW,EAAE,CAClD,GAAQ,EAGV,IAAM,EAAS,KAAK,IAAI,EAAK,EADd,EAAY,KAAmB,KAE9C,OAAO,EAAS,SAAS,CAAG,EAe9B,QAAgB,EAA2B,CACrC,EAAK,SAAW,WACf,KAAK,KAAK,MAAM,SAAS,EAAK,CAAC,MAAO,GAAQ,CACjD,KAAK,KAAK,QAAQ,MAAM,uCAAuC,EAAK,GAAG,GAAI,EAAI,EAC/E,CAQJ,MAAc,UAAU,EAA8B,CAEpD,GADa,KAAK,SAAS,IAAI,EAAG,EACxB,SAAW,UAAW,MAAO,GACvC,GAAI,CACF,OAAO,MAAM,KAAK,KAAK,MAAM,WAAW,EAAG,OACpC,EAAK,CAEZ,OADA,KAAK,KAAK,QAAQ,MAAM,yCAAyC,EAAG,GAAI,EAAI,CACrE,IAUX,MAAc,UAA0B,CACtC,GAAI,CACF,IAAK,IAAM,KAAQ,KAAK,SAAS,MAAM,CACjC,EAAK,SAAW,WACpB,MAAM,KAAK,KAAK,MAAM,SAAS,EAAK,OAE/B,EAAK,CACZ,KAAK,KAAK,QAAQ,MAAM,yCAA0C,EAAI,EAY1E,aAAqB,EAAgB,EAAsB,CAGzD,GAAI,GAAqB,CAAE,OAC3B,IAAM,EAAS,EAAQ,EAAW,gBAAgB,CAC5C,EAAU,EAAQ,EAAQ,GAAG,EAAO,GAAG,KAAK,KAAK,CAAC,MAAM,CAC9D,GAAI,CACF,EAAU,EAAQ,CAAE,UAAW,GAAM,CAAC,CACtC,EAAc,EAAS,GAAG,IAAI,MAAM,CAAC,aAAa,CAAC,GAAG,EAAO,IAAK,QAAQ,CAC1E,KAAK,cAAc,EAAQ,EAAO,MAC5B,GAMV,cAAsB,EAAgB,EAAsB,CAC1D,GAAI,CACF,IAAM,EAAS,GAAG,EAAO,GACnB,EAAQ,EAAY,EAAO,CAC9B,OAAQ,GAAM,EAAE,WAAW,EAAO,EAAI,EAAE,SAAS,OAAO,CAAC,CACzD,MAAM,CACH,EAAS,EAAM,OAAS,GAC9B,GAAI,GAAU,EAAG,OACjB,IAAK,IAAM,KAAK,EAAM,MAAM,EAAG,EAAO,CACpC,GAAI,CACF,EAAW,EAAQ,EAAQ,EAAE,CAAC,MACxB,QAIJ,KCziCZ,SAAgB,EACd,EACoB,CACpB,MAAO,CACL,OAAS,GAAU,CACjB,IAAM,EAAY,GAAc,CAChC,GAAI,CAAC,EACH,MAAU,MAAM,uDAAuD,CAEzE,IAAM,EAAO,EAAU,IAAI,CACzB,KAAM,EAAM,MAAQ,WACpB,OAAQ,EAAM,OACd,eAAgB,EAAM,KACtB,UAAW,EAAM,UACjB,OAAQ,QACR,cAAe,EAAM,cAIrB,OAAQ,EAAM,OACf,CAAC,CACF,MAAO,CAAE,OAAQ,EAAK,GAAI,WAAY,EAAK,WAAY,KAAM,EAAK,KAAM,OAAQ,EAAK,OAAQ,EAE/F,UACG,GAAc,EAAE,MAAM,EAAI,EAAE,EAAE,IAAK,IAAO,CACzC,GAAI,EAAE,GACN,KAAM,EAAE,KACR,eAAgB,EAAE,eAClB,QAAS,EAAE,QACX,UAAW,EAAE,UACb,WAAY,EAAE,WACd,YAAa,EAAE,YACf,eAAgB,EAAE,eAClB,cAAe,EAAE,cACjB,kBAAmB,EAAE,kBACtB,EAAE,CACL,OAAS,GAAW,GAAc,EAAE,OAAO,EAAO,EAAI,GACtD,IAAM,GAAW,CACf,GAAc,EAAE,OAAO,EAAO,EAEhC,OAAS,GACA,GAAc,EAAE,OAAO,EAAO,EAAI,CAAE,GAAI,GAAO,OAAQ,uCAAwC,CAEzG"}
1
+ {"version":3,"file":"index.js","names":["SNAPSHOT_VERSION"],"sources":["../src/cron-parser.ts","../src/registry.ts","../src/fire-target.ts","../src/fire-executor.ts","../src/fire-logger.ts","../src/schedule-config.ts","../src/tick-loop.ts","../src/schedule-persistence.ts","../src/scheduler.ts","../src/schedule-store.ts","../src/remote-schedule-store.ts","../src/schedule-lease-migrations.ts","../src/fire-ownership.ts","../src/remote-fire-ownership.ts","../src/fire-targets/job-fire-target.ts","../src/fire-targets/session-fire-target.ts","../src/fire-targets/subscriber-fire-target.ts","../src/triggers/in-process-event-trigger.ts","../src/schedule-capability.ts"],"sourcesContent":["// schedule/src/cron-parser.ts — 5-field cron expression parser.\n// Uses local Date API (DST-safe). Search capped at 366 days forward.\n\ntype FieldSet = Set<number>\n\n/**\n * Parse a 5-field cron expression (minute hour dom month dow) and return the next\n * fire time >= from (inclusive). All times in local timezone.\n *\n * Throws on invalid expressions.\n *\n * Field syntax: wildcard (*), single value (5), step (e.g. \\*\\/15 or 5\\/15),\n * range (1-5), list (1,15,30), range with step (1-10\\/2).\n *\n * Unsupported: L, W, ?, #, name aliases (MON, JAN).\n *\n * ## DoM∧DoW semantics\n *\n * If both day-of-month and day-of-week are constrained (not a bare *),\n * a date matches if **EITHER** field matches (standard vixie-cron OR behaviour).\n *\n * ## Day-of-week mapping\n *\n * 0 = Sunday, 1–6 = Monday–Saturday, 7 = also Sunday.\n */\nexport function parseCron(expr: string, from?: Date): Date {\n const { validMinute, validHour, validDom, validMonth, validDow, domConstrained, dowConstrained } =\n parseExpression(expr)\n\n const startTime = from ? new Date(from.getTime()) : new Date()\n startTime.setSeconds(0, 0)\n\n const maxTime = new Date(startTime.getTime() + 366 * 24 * 60 * 60 * 1000)\n const current = new Date(startTime.getTime())\n\n while (current.getTime() <= maxTime.getTime()) {\n // --- minute ---\n const minute = current.getMinutes()\n const nextMin = nextInSet(validMinute, minute)\n if (nextMin === null) {\n current.setMinutes(firstInSet(validMinute))\n current.setHours(current.getHours() + 1)\n current.setSeconds(0, 0)\n continue\n }\n if (nextMin !== minute) {\n current.setMinutes(nextMin)\n current.setSeconds(0, 0)\n continue\n }\n\n // --- hour ---\n const hour = current.getHours()\n const nextHr = nextInSet(validHour, hour)\n if (nextHr === null) {\n current.setHours(firstInSet(validHour))\n current.setMinutes(firstInSet(validMinute))\n current.setDate(current.getDate() + 1)\n current.setSeconds(0, 0)\n continue\n }\n if (nextHr !== hour) {\n current.setHours(nextHr)\n current.setMinutes(firstInSet(validMinute))\n current.setSeconds(0, 0)\n continue\n }\n\n // --- day (DoM ∨ DoW) ---\n const dom = current.getDate()\n const dow = current.getDay()\n const domMatch = validDom.has(dom)\n const dowMatch = validDow.has(dow)\n\n let dayMatch: boolean\n if (domConstrained && dowConstrained) {\n dayMatch = domMatch || dowMatch\n } else if (domConstrained) {\n dayMatch = domMatch\n } else if (dowConstrained) {\n dayMatch = dowMatch\n } else {\n dayMatch = true\n }\n\n if (!dayMatch) {\n current.setDate(current.getDate() + 1)\n current.setHours(firstInSet(validHour))\n current.setMinutes(firstInSet(validMinute))\n current.setSeconds(0, 0)\n continue\n }\n\n // --- month ---\n const month = current.getMonth() + 1 // 1-based\n const nextMo = nextInSet(validMonth, month)\n if (nextMo === null) {\n // Wrap to next year\n current.setMonth(firstInSet(validMonth) - 1)\n current.setFullYear(current.getFullYear() + 1)\n current.setDate(1)\n current.setHours(firstInSet(validHour))\n current.setMinutes(firstInSet(validMinute))\n current.setSeconds(0, 0)\n continue\n }\n if (nextMo !== month) {\n current.setMonth(nextMo - 1)\n current.setDate(1)\n current.setHours(firstInSet(validHour))\n current.setMinutes(firstInSet(validMinute))\n current.setSeconds(0, 0)\n continue\n }\n\n // All fields matched\n return current\n }\n\n throw new Error(`No matching time found for cron \"${expr}\" within 366 days of ${startTime.toISOString()}`)\n}\n\n// ---------------------------------------------------------------------------\n// Internal helpers\n// ---------------------------------------------------------------------------\n\ninterface ParsedExpression {\n validMinute: FieldSet\n validHour: FieldSet\n validDom: FieldSet\n validMonth: FieldSet\n validDow: FieldSet\n domConstrained: boolean\n dowConstrained: boolean\n}\n\nfunction parseExpression(expr: string): ParsedExpression {\n if (!expr || !expr.trim()) {\n throw new Error('Invalid cron expression: empty string')\n }\n\n const fields = expr.trim().split(/\\s+/)\n if (fields.length !== 5) {\n throw new Error(`Invalid cron expression: expected 5 fields, got ${fields.length} (${expr})`)\n }\n\n const [minField, hourField, domField, monthField, dowField] = fields as [string, string, string, string, string]\n\n const validMinute = parseField(minField, 0, 59)\n const validHour = parseField(hourField, 0, 23)\n const validDom = parseField(domField, 1, 31)\n const validMonth = parseField(monthField, 1, 12)\n const validDow = parseField(dowField, 0, 7)\n\n // 7 = Sunday, also maps to 0 for Date.getDay() compatibility\n if (validDow.has(7)) {\n validDow.add(0)\n }\n\n // A field is \"constrained\" if the user explicitly specified values (not bare *)\n // 架构 review 2026-08-12 S12:`*/1` 语义等价 `*`(覆盖全域、步长 1),此前按裸字符串\n // 比较被判为 constrained,触发 DoM∧DoW 的 OR 语义——dow 单侧约束被误升为双约束。\n // 全域等价判定只认两种写法(`*` 与 `*/1`);`1-31`/`0-6` 这类显式全域范围仍判\n // constrained(写全范围的作者意图是显式约束,不替用户臆测)。\n const isWildcardEquivalent = (field: string): boolean => field === '*' || field === '*/1'\n const domConstrained = !isWildcardEquivalent(domField)\n const dowConstrained = !isWildcardEquivalent(dowField)\n\n return { validMinute, validHour, validDom, validMonth, validDow, domConstrained, dowConstrained }\n}\n\n/**\n * Parse a single cron field into a set of valid integer values.\n *\n * Supported sub-syntax:\n * - * -> all values in [min, max]\n * - 5 -> single value\n * - *\\/15 or 5\\/15 -> step (start/step)\n * - 1-5 -> inclusive range\n * - 1,15,30 -> comma-separated list\n * - 1-10/2 -> range with step\n *\n * Throws on unsupported syntax (letters, L, W, ?, # etc.) or out-of-range values.\n */\nfunction parseField(field: string, min: number, max: number): FieldSet {\n validateFieldSyntax(field)\n\n const result = new Set<number>()\n const parts = field.split(',')\n\n for (const part of parts) {\n const { range, step } = splitStep(part)\n validateStep(step, part)\n\n if (range === '*') {\n for (let i = min; i <= max; i += step) {\n result.add(i)\n }\n } else if (range.includes('-')) {\n const [startStr, endStr] = range.split('-') as [string, string]\n const start = parseFieldInt(startStr, part)\n const end = parseFieldInt(endStr, part)\n if (start < min || end > max || start > end) {\n throw new Error(`Invalid cron field \"${field}\": range [${start},${end}] out of bounds [${min},${max}]`)\n }\n for (let i = start; i <= end; i += step) {\n result.add(i)\n }\n } else {\n const val = parseFieldInt(range, part)\n if (val < min || val > max) {\n throw new Error(\n `Invalid cron field \"${field}\": value ${val} out of range [${min},${max}]`,\n )\n }\n if (step > 1) {\n // \"N/step\" — start at N, step to max (vixie-cron semantics, e.g. 5/15 → 5,20,35,50)\n for (let i = val; i <= max; i += step) {\n result.add(i)\n }\n } else {\n result.add(val)\n }\n }\n }\n\n return result\n}\n\nfunction validateFieldSyntax(field: string): void {\n // Allow only digits, *, /, -, comma, and whitespace\n const cleaned = field.replace(/[\\d*/\\-,]/g, '')\n if (cleaned.length > 0) {\n throw new Error(`Invalid cron field \"${field}\": unsupported syntax`)\n }\n}\n\nfunction splitStep(part: string): { range: string; step: number } {\n const slashIdx = part.indexOf('/')\n if (slashIdx === -1) {\n return { range: part, step: 1 }\n }\n return {\n range: part.substring(0, slashIdx),\n step: parseInt(part.substring(slashIdx + 1), 10),\n }\n}\n\nfunction validateStep(step: number, part: string): void {\n if (isNaN(step) || step < 1) {\n throw new Error(`Invalid cron field: invalid step in \"${part}\"`)\n }\n}\n\nfunction parseFieldInt(s: string, contextPart: string): number {\n const n = parseInt(s, 10)\n if (isNaN(n)) {\n throw new Error(`Invalid cron field: expected number, got \"${s}\" in \"${contextPart}\"`)\n }\n return n\n}\n\nfunction nextInSet(set: FieldSet, current: number): number | null {\n let best: number | null = null\n for (const v of set) {\n if (v >= current && (best === null || v < best)) {\n best = v\n }\n }\n return best\n}\n\nfunction firstInSet(set: FieldSet): number {\n let best = Infinity\n for (const v of set) {\n if (v < best) best = v\n }\n return best\n}\n","import type { ScheduledTask } from './types'\n\n// schedule/src/registry.ts — In-memory schedule task registry (Map-backed CRUD).\nexport class ScheduleRegistry {\n private tasks: Map<string, ScheduledTask>\n\n constructor() {\n this.tasks = new Map()\n }\n\n add(task: ScheduledTask): void {\n if (this.tasks.has(task.id)) {\n throw new Error(`ScheduleRegistry: duplicate task id \"${task.id}\"`)\n }\n this.tasks.set(task.id, task)\n }\n\n remove(id: string): boolean {\n return this.tasks.delete(id)\n }\n\n update(\n id: string,\n patch: Partial<\n Pick<\n ScheduledTask,\n | 'enabled'\n | 'lastFiredAt'\n | 'nextFireAt'\n | 'maxDurationMs'\n | 'maxRetries'\n | 'lastFireFailed'\n | 'lastFireError'\n | 'lastFireCancelled'\n >\n >,\n ): boolean {\n const task = this.tasks.get(id)\n if (!task) return false\n\n if (patch.enabled !== undefined) task.enabled = patch.enabled\n if (patch.lastFiredAt !== undefined) task.lastFiredAt = patch.lastFiredAt\n if (patch.nextFireAt !== undefined) task.nextFireAt = patch.nextFireAt\n if (patch.maxDurationMs !== undefined) task.maxDurationMs = patch.maxDurationMs\n if (patch.maxRetries !== undefined) task.maxRetries = patch.maxRetries\n if (patch.lastFireFailed !== undefined) task.lastFireFailed = patch.lastFireFailed\n if (patch.lastFireError !== undefined) task.lastFireError = patch.lastFireError\n if (patch.lastFireCancelled !== undefined) task.lastFireCancelled = patch.lastFireCancelled\n\n return true\n }\n\n get(id: string): ScheduledTask | undefined {\n return this.tasks.get(id)\n }\n\n list(): ScheduledTask[] {\n return [...this.tasks.values()].sort((a, b) => a.nextFireAt - b.nextFireAt)\n }\n\n /**\n * Find tasks that are due for execution.\n * Returns all enabled tasks (recurring or one-shot) whose nextFireAt <= now.\n * The 'firing' guard is a scheduler concern (managed via a separate Set).\n *\n * RFC-423:eventTrigger 任务永不由时间\"到点\"——它们由进程内事件驱动(InProcessEventTrigger\n * arm 监听),不进 cron 轮询。此处排除,使 cron 轮询(tick)与事件监听(arm)\n * 两条路径互斥(D5)。\n */\n findDue(now: number): ScheduledTask[] {\n const due: ScheduledTask[] = []\n for (const task of this.tasks.values()) {\n if (task.eventTrigger) continue\n if (task.enabled && task.nextFireAt <= now) {\n due.push(task)\n }\n }\n due.sort((a, b) => a.nextFireAt - b.nextFireAt)\n return due\n }\n\n count(): number {\n return this.tasks.size\n }\n\n has(id: string): boolean {\n return this.tasks.has(id)\n }\n}\n","/**\n * fire-target.ts —— RFC-406 D1/D2:FireTarget 贡献点接口 + 注册表。\n *\n * 投递目标(job / session / subscriber / 自定义)从 scheduler.ts fire() 的硬编码 if/else\n * 分支拆出为独立 handler。插件可注册新 handler(webhook、email 等),不修改 scheduler.ts。\n *\n * 设计参照 deepseek-harness capability-seams 模式:\n * - FireTarget 接口 = Service Definition(契约)\n * - JobFireTarget / SessionFireTarget / SubscriberFireTarget = 内置 Service Provider\n * - ScheduleCapability = Consumer(model 工具消费方)\n * - 插件注册的新 handler = 外部 Service Provider\n */\nimport type { CancelResult, ScheduledTask } from './types'\n\n// ---------------------------------------------------------------------------\n// FireTarget 接口\n// ---------------------------------------------------------------------------\n\nexport interface FireTarget {\n /** 唯一标识('job' | 'session' | 'plugin-service') */\n readonly type: string\n\n /** 判断此 handler 是否能处理该 task。注册顺序决定优先级——先注册的先匹配。 */\n canHandle(task: ScheduledTask): boolean\n\n /**\n * 执行投递。返回 FireResult——scheduler 不关心 handler 内部如何实现,\n * 只根据 outcome 决定后续行为(one-shot 清理 / recurring 记账)。\n */\n fire(task: ScheduledTask, ctx: FireContext): Promise<FireResult>\n\n /**\n * fire() 退出保障钩子(M1 修复①):无论 fire 正常返回、抛错还是早退,executor 的\n * finally 块都会调用。handler 必须在此清理本次 fire 的运行态记账\n * (runningJobs / cancelRequested 等)——漏清会导致 cancel 意图残留、\n * recurring 任务被永久跳过、运行态 Map 无界泄漏。\n */\n cleanup?(taskId: string): void\n\n /**\n * 可选能力:支持取消正在运行的投递(job 路径实现)。\n * SchedulerService.cancel() 路由到当前 fire 该任务的 handler。\n */\n requestCancel?(taskId: string): CancelResult\n\n /** 可选能力:当前有并发运行态的 handler(job 路径)暴露运行中的 task id 集合。 */\n getRunningTaskIds?(): Set<string>\n\n /**\n * 可选能力:session 投递 handler 暴露\"创建任务时绑定的当前 sessionId\"端口,\n * scheduler.add() 经此填充 boundSessionId(RFC-406 D6)。\n */\n getSessionId?(): string\n}\n\n// ---------------------------------------------------------------------------\n// 上下文与结果\n// ---------------------------------------------------------------------------\n\nexport interface FireContext {\n /** 本次计划触发时刻(ticker 路径传入,runNow 用实际时刻)。用于 occurrenceId 派生。 */\n scheduledFor?: number\n now: () => number\n logger?: { error: (msg: string | Error, ...args: unknown[]) => void }\n}\n\nexport interface FireResult {\n outcome: 'success' | 'failed'\n /** cancel 信号——JobFireTarget 在 cancel() 路径设置 */\n cancelled?: boolean\n error?: string\n /** job-specific 信号(JobFireTarget 附加,用于 runningJobsByTaskId 记账) */\n jobId?: string\n}\n\n// ---------------------------------------------------------------------------\n// FireTargetRegistry\n// ---------------------------------------------------------------------------\n\n/**\n * FireTarget handler 注册表。注册顺序决定匹配优先级——先注册的先匹配。\n *\n * 内置 handler 注册顺序:subscriber > session > job\n * (保持当前 fire() 的分支优先级:subscriber 优先于 target:session,job 是默认路径)。\n */\nexport class FireTargetRegistry {\n private handlers: FireTarget[] = []\n\n register(handler: FireTarget): void {\n this.handlers.push(handler)\n }\n\n find(task: ScheduledTask): FireTarget | undefined {\n return this.handlers.find((h) => h.canHandle(task))\n }\n}\n","/**\n * fire-executor.ts —— RFC-406 D4:Fire 执行器。\n *\n * 从 scheduler.ts fire() 入口拆出。持有 FireTargetRegistry,路由到匹配的 handler。\n * 处理 fire 前后记账:firing Set(防重复 fire)、registry 更新(lastFiredAt/lastFireFailed)、\n * one-shot 清理(成功删除 / 失败保留 / cancel 保留)、持久化(saveOne)。\n *\n * 原代码位置:scheduler.ts fire() 方法的外层逻辑(firing Set / finally 块 / one-shot 处理)。\n */\nimport type { ScheduleRegistry } from './registry'\nimport type { SchedulePersistence } from './schedule-persistence'\nimport type { FireTarget, FireContext } from './fire-target'\nimport { FireTargetRegistry } from './fire-target'\nimport type { FireLogger } from './fire-logger'\nimport type { TerminationReason } from './scheduler'\nimport type { ScheduledTask } from './types'\n\nexport interface FireExecutorDeps {\n registry: ScheduleRegistry\n persistence: SchedulePersistence\n fireLogger: FireLogger\n fireTargets: FireTarget[]\n now: () => number\n logger?: { error: (msg: string | Error, ...args: unknown[]) => void }\n /**\n * RFC-433 D1:任务终结咽喉点(`SchedulerService.terminate`)。one-shot 成功后\n * 必须经此而非自行 `registry.remove`——后者不退订事件监听,会导致任务已删除\n * 却仍被事件触发(RFC-433 §1.2 M2)。\n */\n onTerminate: (taskId: string, reason: TerminationReason) => void\n /**\n * RFC-433 D2:fire ownership 准入。cron 路径由 tick 的 renew/isOwner 把关,\n * 但**事件路径不经过 tick**——监听独立于 ticker 存活,`TickLoop.stop()` 停不掉它。\n * 唯一的 fire 入口现场问一次,比在 ownership 状态机每个转换点维护 arm/disarm\n * 对称性更可靠(无状态判定,不会漏)。\n */\n isFireOwner: () => boolean\n}\n\nexport class FireExecutor {\n private readonly registry: FireTargetRegistry = new FireTargetRegistry()\n private readonly firing: Set<string> = new Set()\n private readonly deps: FireExecutorDeps\n\n constructor(deps: FireExecutorDeps) {\n this.deps = deps\n // 注册 handler——顺序决定优先级:subscriber > session > job\n for (const handler of deps.fireTargets) {\n this.registry.register(handler)\n }\n }\n\n /** 当前正在 fire 的 task id 集合(防重复 fire)。 */\n has(taskId: string): boolean {\n return this.firing.has(taskId)\n }\n\n /** 标记 task 正在 fire(同步,在 await 之前调用)。 */\n addFiring(taskId: string): void {\n this.firing.add(taskId)\n }\n\n /**\n * 执行一次 fire:路由到匹配的 FireTarget handler,处理结果记账。\n *\n * 调用方(tick / runNow)必须在调用前将 taskId 加入 firing Set(addFiring)。\n * finally 块负责移除,并调用 handler.cleanup() 清理 handler 侧运行态\n * (M1 修复①——拆分前该清理在旧 fire() 的 finally 里,拆分时被遗漏)。\n */\n async fire(task: ScheduledTask, scheduledFor?: number): Promise<void> {\n // ── 准入闸门(RFC-433 D2)──\n // fire() 是所有触发路径的唯一汇合点(tick / runNow / 事件 arm 回调),\n // 两道准入放这里才能覆盖全部路径:\n // · enabled —— 此前只在 registry.findDue 判,事件路径整条链零检查(M1)\n // · isFireOwner —— 此前只在 tick 判,事件监听独立于 ticker 存活(M7)\n // 上游的 findDue/tick 检查保留:它们负责\"要不要提出候选\"(筛选与触发 recovery),\n // 这里负责\"候选能不能真的执行\"(准入)。职责不同,不是重复。\n if (!task.enabled || !this.deps.isFireOwner()) {\n this.firing.delete(task.id)\n return\n }\n\n const ctx: FireContext = {\n scheduledFor,\n now: this.deps.now,\n logger: this.deps.logger,\n }\n\n let handler: FireTarget | undefined\n try {\n handler = this.registry.find(task)\n if (!handler) {\n this.deps.fireLogger.write(task.id, `no fire target handler for task (subscriber=${!!task.subscriber}, target=${task.target ?? 'default'})`)\n // RFC-435 D3:no-handler 的 **one-shot** 任务必须走终态——tick 在 fire 之前已\n // advance nextFireAt(tick-loop.ts),不终结会无限复燃:每天到点记一条日志、\n // 永不消失。标 lastFireFailed 保留(经咽喉点,用户可见可删),recurring 照旧\n // 每日日志(那是设计内:任务保留、不投递、不崩溃)。\n this.handleOneShotCompletion(task, { outcome: 'failed', error: 'no fire target handler (headless mode)' })\n return\n }\n\n const result = await handler.fire(task, ctx)\n\n // 成功记账(三态统一:session 成功不落盘,其余 saveOne)\n if (result.outcome === 'success' && !result.cancelled) {\n this.deps.registry.update(task.id, {\n lastFiredAt: this.deps.now(),\n lastFireFailed: false,\n lastFireError: undefined,\n })\n if (task.target !== 'session') {\n this.deps.persistence.saveOne(task)\n }\n }\n\n // one-shot 终态处理(原 scheduler.ts fire() finally 块语义,M1 修复②恢复统一保障):\n // success → 删除;cancelled → 标记 lastFireCancelled 保留;failed → 标记 lastFireFailed 保留。\n // session 分支不再早退——一次性 session 任务同样按此处理,否则成功后不删、\n // 失败不标,tick 已 advance 的 nextFireAt 会让它按 cron 点无限复燃。\n this.handleOneShotCompletion(task, result)\n } catch (err) {\n this.deps.logger?.error(`[schedules] fire failed for task ${task.id}:`, err)\n this.deps.fireLogger.write(task.id, `error: ${err instanceof Error ? err.message : String(err)}`)\n // handler 抛错时 one-shot 按失败终态处理(对齐 HEAD 版 catch→finally 行为)\n this.handleOneShotCompletion(task, { outcome: 'failed', error: err instanceof Error ? err.message : String(err) })\n } finally {\n handler?.cleanup?.(task.id)\n this.firing.delete(task.id)\n }\n }\n\n /**\n * one-shot 任务 fire 完成后的终态记账(fire() 内部调用,外部无调用方)。\n *\n * 成功 → 从 registry 和 store 删除。\n * cancel → 标记 lastFireCancelled,保留(不删除)。\n * 失败 → 标记 lastFireFailed + lastFireError,保留(review S6)。\n */\n private handleOneShotCompletion(task: ScheduledTask, result: { outcome: 'success' | 'failed'; cancelled?: boolean; error?: string }): void {\n if (task.recurring) return\n\n if (result.outcome === 'success') {\n // RFC-433 D1:经咽喉点终结——直接 registry.remove 不退订事件监听,\n // 会让已完成的 one-shot 事件任务在此后每次同类事件都重新投递(M2)。\n this.deps.onTerminate(task.id, 'one-shot-complete')\n } else if (result.cancelled) {\n this.deps.registry.update(task.id, {\n lastFireCancelled: true,\n lastFireError: undefined,\n })\n this.deps.persistence.saveOne(task)\n } else {\n this.deps.registry.update(task.id, {\n lastFireFailed: true,\n lastFireError: result.error,\n })\n this.deps.persistence.saveOne(task)\n }\n }\n}\n","/**\n * fire-logger.ts —— RFC-406 D4:Fire 日志写入/清理/读取。\n *\n * 从 scheduler.ts 拆出。每个 fire 结果写到 ${OTTO_HOME}/schedule-logs/<taskId>-<epoch>.log,\n * 每任务保留最新 maxFireLogsPerTask 条。best-effort——错误静默吞(日志非关键)。\n */\nimport { writeFileSync, readFileSync, mkdirSync, readdirSync, unlinkSync } from 'node:fs'\nimport { resolve } from 'node:path'\n\nimport { OTTO_HOME, isShadowModeEnabled } from '@x-otto/env'\n\nimport type { ScheduleConfig } from './schedule-config'\n\nexport class FireLogger {\n constructor(private config: ScheduleConfig) {}\n\n /**\n * 写一行 fire log 到 ${OTTO_HOME}/schedule-logs/<taskId>-<epoch>.log,然后清理超出\n * maxFireLogsPerTask 的旧日志。best-effort——错误静默吞。\n *\n * RFC-345:影子态零磁盘写入——fire log 是纯本机持久化产物,影子进程跳过。\n */\n write(taskId: string, status: string): void {\n if (isShadowModeEnabled()) return\n const logDir = resolve(OTTO_HOME, 'schedule-logs')\n const logFile = resolve(logDir, `${taskId}-${Date.now()}.log`)\n try {\n mkdirSync(logDir, { recursive: true })\n writeFileSync(logFile, `${new Date().toISOString()} ${status}\\n`, 'utf-8')\n this.prune(logDir, taskId)\n } catch {\n // best-effort logging\n }\n }\n\n /** 保留最新 maxFireLogsPerTask 条日志文件。 */\n private prune(logDir: string, taskId: string): void {\n try {\n const prefix = `${taskId}-`\n const files = readdirSync(logDir)\n .filter((f) => f.startsWith(prefix) && f.endsWith('.log'))\n .sort() // filenames embed epoch ms — lexical sort == chronological\n const excess = files.length - this.config.maxFireLogsPerTask\n if (excess <= 0) return\n for (const f of files.slice(0, excess)) {\n try {\n unlinkSync(resolve(logDir, f))\n } catch {\n // best-effort — a single stale/locked file must not block the rest\n }\n }\n } catch {\n // best-effort — directory listing failure is non-critical\n }\n }\n\n /**\n * 读取最近的 fire log 条目。返回最新 maxEntries 条,每条 { timestamp, status }。\n */\n read(taskId: string, maxEntries: number = 20): Array<{ timestamp: string; status: string }> {\n const logDir = resolve(OTTO_HOME, 'schedule-logs')\n const prefix = `${taskId}-`\n try {\n const files = readdirSync(logDir)\n .filter((f) => f.startsWith(prefix) && f.endsWith('.log'))\n .sort()\n .reverse() // newest first\n const entries: Array<{ timestamp: string; status: string }> = []\n for (let i = 0; i < files.length && entries.length < maxEntries; i++) {\n try {\n const content = readFileSync(resolve(logDir, files[i]!), 'utf-8').trim()\n const spaceIdx = content.indexOf(' ')\n const timestamp = spaceIdx > 0 ? content.slice(0, spaceIdx) : content\n const status = spaceIdx > 0 ? content.slice(spaceIdx + 1) : ''\n entries.push({ timestamp, status })\n } catch {\n // skip unreadable file\n }\n }\n return entries\n } catch {\n return []\n }\n }\n}\n","/**\n * schedule-config.ts —— RFC-406 D4:常量配置化。\n *\n * 之前散落在 scheduler.ts 顶部的硬编码常量,提取为可注入的配置接口。\n * 不同部署场景(单用户 vs 团队共享、高频 vs 低频)可调整参数,不需改源码。\n *\n * **不纳入本接口的常量**(刻意游离,非遗漏):\n * - `fire-ownership.ts` 的 `STALE_MS`(5 分钟)——本地锁过期阈值,有不变量约束\n * (必须超过 tick renew 间隔,否则锁在 renew 周期间被误判过期),调错会致\n * 多进程重复 fire。它是 fire-ownership 的领域常量,不是可调运维参数。\n * - `remote-fire-ownership.ts` 的 `HEARTBEAT_MS`(30s)/ `STALE_MS`(180s)——\n * 远程租约心跳与过期,有 1:6 比例约束(过期 > 6 倍心跳,防网络抖动误夺锁)。\n * 调错同样致多终端重复 fire 或锁饥饿。两者已通过模块 export 暴露\n * (`REMOTE_FIRE_OWNERSHIP_HEARTBEAT_MS` / `REMOTE_FIRE_OWNERSHIP_STALE_MS`),\n * 不进 ScheduleConfig 以免暗示可随意调。\n */\n\nexport interface ScheduleConfig {\n /** 最大任务数 */\n maxTasks: number\n /** recurring 任务过期阈值(超过此时间未触发则清除) */\n recurringEvictionMs: number\n /** recurring 任务 jitter 窗口 */\n recurringJitterWindowMs: number\n /** one-shot 任务 jitter 窗口 */\n oneShotJitterWindowMs: number\n /** 默认单次执行超时 */\n defaultMaxDurationMs: number\n /** 默认重试次数 */\n defaultMaxRetries: number\n /** ticker 间隔 */\n tickIntervalMs: number\n /** needs_input 硬上限倍数 */\n needsInputMaxExtensionFactor: number\n /** 每任务最大 fire log 条数 */\n maxFireLogsPerTask: number\n /** owner 从 store 重新同步的间隔(tick 数) */\n reloadIntervalTicks: number\n /** recovery 模式重试间隔 */\n recoveryRetryIntervalMs: number\n}\n\nexport const DEFAULT_SCHEDULE_CONFIG: ScheduleConfig = {\n maxTasks: 50,\n recurringEvictionMs: 7 * 24 * 60 * 60 * 1000, // 7 days\n recurringJitterWindowMs: 30 * 60 * 1000, // 30 minutes\n oneShotJitterWindowMs: 90 * 1000, // 90 seconds\n defaultMaxDurationMs: 300_000, // 5 minutes\n defaultMaxRetries: 0,\n tickIntervalMs: 1000,\n needsInputMaxExtensionFactor: 3,\n maxFireLogsPerTask: 20,\n reloadIntervalTicks: 30,\n recoveryRetryIntervalMs: 30_000,\n}\n\n// ---------------------------------------------------------------------------\n// Jitter — RFC-406 终局自检:收敛 scheduler.ts / tick-loop.ts 两处重复的 djb2 hash。\n// ---------------------------------------------------------------------------\n\n/**\n * 确定性 per-task jitter(djb2 hash of taskId),跨重启可复现。\n * recurring 任务用 30 分钟窗口(分散周期负载);one-shot 用 90 秒窗口(RFC-087 §D3)。\n */\nexport function applyJitter(config: ScheduleConfig, taskId: string, baseTime: Date, recurring: boolean): number {\n let hash = 0\n for (let i = 0; i < taskId.length; i++) {\n hash = ((hash << 5) - hash) + taskId.charCodeAt(i)\n hash |= 0\n }\n const window = recurring ? config.recurringJitterWindowMs : config.oneShotJitterWindowMs\n const jitter = Math.abs(hash) % window\n return baseTime.getTime() + jitter\n}\n","/**\n * tick-loop.ts —— RFC-406 D4:Tick 循环。\n *\n * 从 scheduler.ts tick() 拆出。1 秒间隔心跳:renew ownership → 检查 owner →\n * reloadFromStore(每 N tick)→ 过期清理 → fire due tasks。\n *\n * 原代码位置:scheduler.ts tick() + reloadFromStore() + eviction 逻辑。\n */\nimport { parseCron } from './cron-parser'\nimport { applyJitter } from './schedule-config'\nimport type { ScheduleRegistry } from './registry'\nimport type { ScheduleStore } from './schedule-store'\nimport type { SchedulePersistence } from './schedule-persistence'\nimport type { FireOwnership } from './fire-ownership'\nimport type { FireExecutor } from './fire-executor'\nimport type { ScheduleConfig } from './schedule-config'\nimport type { TerminationReason } from './scheduler'\nimport type { ScheduledTask } from './types'\n\nexport interface TickLoopDeps {\n registry: ScheduleRegistry\n store: ScheduleStore\n persistence: SchedulePersistence\n ownership: FireOwnership\n fireExecutor: FireExecutor\n config: ScheduleConfig\n now: () => number\n logger?: { error: (msg: string | Error, ...args: unknown[]) => void }\n /**\n * ownership 丢失时的回调(SchedulerService 进入 recovery mode)。\n */\n onOwnershipLost: () => void\n /**\n * RFC-433 D1:任务终结咽喉点(滞留驱逐用)。不可自行 `registry.remove`——\n * 那样不退订事件监听。\n */\n onTerminate: (taskId: string, reason: TerminationReason) => void\n /**\n * RFC-433 D1:只从内存移除、保留 store 快照(差量同步用——store 里已经没有它了,\n * 是别的进程删的,再发一次 delete 是多余 IO)。同样必须退订。\n */\n onEvictFromMemory: (taskId: string) => void\n}\n\nexport class TickLoop {\n private readonly deps: TickLoopDeps\n private ticker: ReturnType<typeof setInterval> | null = null\n private tickInProgress = false\n private tickCount = 0\n\n constructor(deps: TickLoopDeps) {\n this.deps = deps\n }\n\n /** 启动 tick 循环。 */\n start(): void {\n if (this.ticker) return\n this.ticker = setInterval(() => {\n void this.tick()\n }, this.deps.config.tickIntervalMs)\n this.ticker.unref()\n }\n\n /** 停止 tick 循环。 */\n stop(): void {\n if (this.ticker) {\n clearInterval(this.ticker)\n this.ticker = null\n }\n }\n\n /**\n * 手动触发一次 tick(测试用)。\n *\n * **同时供测试与调度器复用**:`lifecycle-regression.test.ts` 用它精确控制\"8 天后\n * 跑一次 tick\"的场景(M3 系列回归),而 `SchedulerService` 也保留同名方法用于\n * 相同目的——两者都走同一个 `tick()` 内部方法,避免平行实现漂移。\n */\n async tickOnce(): Promise<void> {\n await this.tick()\n }\n\n // ── Internal ──\n\n private async tick(): Promise<void> {\n if (this.tickInProgress) return\n this.tickInProgress = true\n\n try {\n // A3: heartbeat the ownership lock\n await this.deps.ownership.renew()\n\n // S4: if renew() detected a takeover, stop firing + enter recovery\n if (!this.deps.ownership.isOwner()) {\n this.stop()\n this.deps.onOwnershipLost()\n return\n }\n\n // S5: periodically re-sync from store\n this.tickCount++\n if (this.tickCount % this.deps.config.reloadIntervalTicks === 0) {\n await this.reloadFromStore()\n }\n\n const now = this.deps.now()\n\n // Evict recurring tasks whose next fire is >7 days stale.\n //\n // RFC-433 D3:滞留判据是 `nextFireAt`,**只对 cron 任务有语义**。事件驱动任务的\n // nextFireAt 是 add() 时的 cron 占位符产物且永不更新(findDue 排除它们,只有\n // fire 路径才 advance),所以每个 recurring 事件任务的 nextFireAt 原地不动,\n // 第 7 天 100% 越过阈值被误删——任务从 UI 消失,用户既看不见也删不掉。\n //\n // TODO(RFC-434): 事件任务因此失去了这道\"最终安全阀\"——永远等不到事件的任务会\n // 永久占用 maxTasks 配额。正确的清理判据是 createdAt/lastFiredAt 而非 nextFireAt,\n // 属于新规则设计,随 trigger/delivery 可辨识联合一起做。\n for (const task of this.deps.registry.list()) {\n if (task.eventTrigger) continue\n if (task.recurring && now - task.nextFireAt > this.deps.config.recurringEvictionMs) {\n this.deps.onTerminate(task.id, 'stale-evicted')\n }\n }\n\n // Fire due tasks\n const due = this.deps.registry.findDue(now)\n for (const task of due) {\n if (this.deps.fireExecutor.has(task.id)) continue\n\n // S6: failed/canceled one-shot guard — no auto-refire\n if (!task.recurring && (task.lastFireFailed || task.lastFireCancelled)) continue\n\n // Advance nextFireAt before dispatching (MF-1); persist\n const scheduledFor = task.nextFireAt\n const nextBase = parseCron(task.cronExpression, new Date(now + 60_000))\n const jittered = applyJitter(this.deps.config, task.id, nextBase, task.recurring)\n this.deps.registry.update(task.id, { nextFireAt: jittered })\n this.deps.persistence.saveOne(task)\n\n this.deps.fireExecutor.addFiring(task.id)\n void this.deps.fireExecutor.fire(task, scheduledFor)\n }\n } finally {\n this.tickInProgress = false\n }\n }\n\n private async reloadFromStore(): Promise<void> {\n let tasks: ScheduledTask[]\n try {\n tasks = await this.deps.store.loadAllTasks()\n } catch (err) {\n this.deps.logger?.error('[schedules] Failed to reload schedules from store:', err)\n return\n }\n\n const storeIds = new Set(tasks.map((t) => t.id))\n\n for (const task of tasks) {\n if (this.deps.fireExecutor.has(task.id)) continue\n if (!task.recurring && task.lastFiredAt != null && !this.deps.registry.get(task.id)) {\n void this.deps.persistence.deleteOne(task.id).catch((err) => {\n this.deps.logger?.error(`[schedules] Failed to delete zombie one-shot ${task.id}:`, err)\n })\n continue\n }\n const existing = this.deps.registry.get(task.id)\n if (!existing) {\n try { this.deps.registry.add(task) } catch { /* raced */ }\n } else {\n existing.enabled = task.enabled\n existing.name = task.name\n existing.prompt = task.prompt\n existing.cronExpression = task.cronExpression\n existing.maxDurationMs = task.maxDurationMs\n existing.maxRetries = task.maxRetries\n }\n }\n\n for (const task of this.deps.registry.list()) {\n if (task.target === 'session') continue\n if (!storeIds.has(task.id) && !this.deps.fireExecutor.has(task.id)) {\n // RFC-433 D1:只从内存移除(store 里已经没有它了),但仍须退订。\n this.deps.onEvictFromMemory(task.id)\n }\n }\n }\n}\n","/**\n * schedule-persistence.ts —— RFC-406 终局自检:持久化咽喉点收敛。\n *\n * 重构后 saveOne/deleteOne/flushAll 的 session-local 跳过 + 错误吞逻辑在 scheduler.ts /\n * fire-executor.ts / tick-loop.ts 三处重复。收敛为单一真源——所有写盘调用共享此对象,\n * session-local 任务永不落盘(RFC-357 D2)这条防线只有一个判断点。\n */\nimport type { ScheduleRegistry } from './registry'\nimport type { ScheduleStore } from './schedule-store'\nimport type { ScheduledTask } from './types'\n\nexport interface SchedulePersistenceDeps {\n registry: ScheduleRegistry\n store: ScheduleStore\n logger?: { error: (msg: string | Error, ...args: unknown[]) => void }\n}\n\nexport class SchedulePersistence {\n constructor(private deps: SchedulePersistenceDeps) {}\n\n /**\n * 持久化单个任务(fire-and-forget)。RFC-357 D2:session-local 任务永不落盘——\n * 这是唯一判断点,所有调用方共享此拦截。\n */\n saveOne(task: ScheduledTask): void {\n if (task.target === 'session') return\n void this.deps.store.saveTask(task).catch((err) => {\n this.deps.logger?.error(`[schedules] Failed to save schedule ${task.id}:`, err)\n })\n }\n\n /**\n * 删除单个任务快照(awaited——必须确认删除以防 store reload 时复活为僵尸)。\n * session-local 任务从不落盘,删除时直接视为成功(不发起多余 IO)。\n */\n async deleteOne(id: string): Promise<boolean> {\n const task = this.deps.registry.get(id)\n if (task?.target === 'session') return true\n try {\n return await this.deps.store.deleteTask(id)\n } catch (err) {\n this.deps.logger?.error(`[schedules] Failed to delete schedule ${id}:`, err)\n return false\n }\n }\n\n /**\n * 把所有内存任务写盘(stop 时调用;bounded,无 diff-delete)。\n * RFC-357 D2:跳过 session-local 任务——否则进程正常退出时会把它们写进 store,\n * 下次 start() 从 store 加载后在新进程的新 sessionRef 上\"复活\",击穿\"不落盘\"防线。\n */\n async flushAll(tasks: ScheduledTask[]): Promise<void> {\n try {\n for (const task of tasks) {\n if (task.target === 'session') continue\n await this.deps.store.saveTask(task)\n }\n } catch (err) {\n this.deps.logger?.error('[schedules] Failed to flush schedules:', err)\n }\n }\n}\n","// schedule/src/scheduler.ts — RFC-406: Schedule lifecycle orchestrator.\n// Lifecycle: start→tick→fire→stop. Delegates to TickLoop + FireExecutor + FireLogger.\n// Fire logs → ${OTTO_HOME}/schedule-logs/. Clock: Date.now() by default, injectable for testing.\n\nimport { parseCron } from './cron-parser'\nimport { ScheduleRegistry } from './registry'\nimport type { ScheduleStore } from './schedule-store'\nimport type { FireOwnership } from './fire-ownership'\nimport { FireExecutor } from './fire-executor'\nimport { FireLogger } from './fire-logger'\nimport { TickLoop } from './tick-loop'\nimport { SchedulePersistence } from './schedule-persistence'\nimport { DEFAULT_SCHEDULE_CONFIG, applyJitter, type ScheduleConfig } from './schedule-config'\nimport type { FireTarget } from './fire-target'\nimport { type EventDrivenTrigger } from './trigger'\nimport type { CancelResult, ScheduledTask, ScheduledTaskInput } from './types'\n\n// ---------------------------------------------------------------------------\n// Task termination\n// ---------------------------------------------------------------------------\n\n/**\n * RFC-433 D1:任务终结的来源。仅用于诊断/日志语义——终结动作本身对三者一致\n * (退订 → 删快照 → 删 registry),区分来源是为了让调用点自我说明。\n */\nexport type TerminationReason =\n /** 用户经 `/schedule remove`、面板或 `schedule_delete` 工具删除。 */\n | 'user-removed'\n /** one-shot 任务成功执行完毕(FireExecutor 终态处理)。 */\n | 'one-shot-complete'\n /** recurring cron 任务 7 天未触发,被 TickLoop 判为滞留清除。 */\n | 'stale-evicted'\n\n// ---------------------------------------------------------------------------\n// Dependencies\n// ---------------------------------------------------------------------------\n\nexport interface SchedulerDeps {\n registry: ScheduleRegistry\n store: ScheduleStore\n ownership: FireOwnership\n /**\n * FireTarget handler 列表。内置 handler(job/session/subscriber)由消费方构造并传入,\n * SchedulerService 不自动注册——消费方完全控制 handler 集合和优先级。\n *\n * 如果传入空数组,fire() 会找不到 handler 并记日志跳过——不会崩溃。\n */\n fireTargets: FireTarget[]\n /**\n * RFC-423:事件驱动 Trigger 列表(如 InProcessEventTrigger)。缺省空数组——只有 cron\n * 触发(cron 任务由 TickLoop 轮询驱动,无需消费方传入)。消费方传入事件 trigger\n * 即启用事件触发能力。\n */\n triggers?: EventDrivenTrigger[]\n /** 可选配置覆盖(常量配置化,RFC-406 D4) */\n config?: Partial<ScheduleConfig>\n now?: () => number\n /**\n * `debug` 可选——生产装配(`interactive-schedule.ts`)注入的 logger 只有 `error`;\n * 有 `debug` 的实现能拿到任务终结的来源(user-removed / one-shot-complete /\n * stale-evicted),排查\"任务怎么没了\"时不必猜。\n */\n logger?: {\n error: (msg: string | Error, ...args: unknown[]) => void\n debug?: (msg: string, ...args: unknown[]) => void\n }\n}\n\n// ---------------------------------------------------------------------------\n// SchedulerService — lifecycle orchestrator\n// ---------------------------------------------------------------------------\n\nexport class SchedulerService {\n private readonly deps: SchedulerDeps\n private readonly registry: ScheduleRegistry\n private readonly persistence: SchedulePersistence\n private readonly fireExecutor: FireExecutor\n private readonly fireLogger: FireLogger\n private readonly tickLoop: TickLoop\n private readonly config: ScheduleConfig\n private recoveryTimer: ReturnType<typeof setInterval> | null = null\n /** RFC-435 D4:从未当过 owner 的非 owner 进程的 acquire 重试循环(TUI 释放锁后本进程可接管)。 */\n private ensureOwnershipTimer: ReturnType<typeof setInterval> | null = null\n private running = false\n /** RFC-423:事件驱动 trigger 列表(消费方传入)。 */\n private readonly triggers: EventDrivenTrigger[]\n /** RFC-423:已 arm 的事件任务 → 退订函数(remove/stop 时调用,防监听泄漏)。 */\n private readonly armedTriggers = new Map<string, () => void>()\n\n constructor(deps: SchedulerDeps) {\n this.deps = deps\n this.registry = deps.registry\n this.config = { ...DEFAULT_SCHEDULE_CONFIG, ...deps.config }\n this.fireLogger = new FireLogger(this.config)\n // RFC-406 终局自检:持久化咽喉点单一真源,三个消费者(scheduler/executor/tick)共享。\n this.persistence = new SchedulePersistence({\n registry: deps.registry,\n store: deps.store,\n logger: deps.logger,\n })\n this.fireExecutor = new FireExecutor({\n registry: deps.registry,\n persistence: this.persistence,\n fireLogger: this.fireLogger,\n fireTargets: deps.fireTargets,\n now: deps.now ?? Date.now,\n logger: deps.logger,\n // RFC-433 D1:one-shot 终结经咽喉点,而非自行 registry.remove(会漏退订)。\n onTerminate: (taskId, reason) => { this.terminate(taskId, reason) },\n // RFC-433 D2:fire 准入的 ownership 一侧。事件路径不经过 tick,\n // tick 里的 ownership 检查管不到它,必须在唯一的 fire 入口独立把关。\n isFireOwner: () => this.deps.ownership.isOwner(),\n })\n this.tickLoop = new TickLoop({\n registry: deps.registry,\n store: deps.store,\n persistence: this.persistence,\n ownership: deps.ownership,\n fireExecutor: this.fireExecutor,\n config: this.config,\n now: deps.now ?? Date.now,\n logger: deps.logger,\n onOwnershipLost: () => this.startRecovery(),\n // RFC-433 D1:eviction 与差量同步两条删除路径同样经咽喉点。\n onTerminate: (taskId, reason) => { this.terminate(taskId, reason) },\n onEvictFromMemory: (taskId) => { this.evictFromMemory(taskId) },\n })\n this.triggers = deps.triggers ?? []\n }\n\n // ── Public: ownership status ──\n\n isFireOwner(): boolean {\n return this.deps.ownership.isOwner()\n }\n\n fireOwnerFailureReason(): 'occupied' | 'network-error' | undefined {\n return this.deps.ownership.lastFailureReason?.()\n }\n\n // ── Lifecycle ──\n\n /** Load persisted tasks and start the ticker (if fire owner). */\n async start(): Promise<void> {\n if (this.running) return\n\n const isOwner = await this.deps.ownership.tryAcquire()\n if (!isOwner) {\n try {\n const tasks = await this.deps.store.loadAllTasks()\n for (const task of tasks) {\n try { this.registry.add(task) } catch { /* skip duplicate */ }\n }\n } catch (err) {\n this.deps.logger?.error('[schedules] Failed to load schedules (read-only mode):', err)\n }\n // RFC-435 D4:非 owner 也必须周期尝试 acquire——否则另一进程(TUI)释放锁后\n // 本进程(daemon)永远接管不了,凌晨任务静默不跑,直到进程重启。\n // (此前 start() 非 owner 分支加载一次就休眠;startRecovery 只在\"曾是 owner 后\n // 丢失\"时触发,对\"从未 owner\"的 daemon 无效。)\n this.startEnsureOwnership()\n return\n }\n\n try {\n const now = this.deps.now?.() ?? Date.now()\n const tasks = await this.deps.store.loadAllTasks()\n for (const task of tasks) {\n if (task.recurring && task.nextFireAt < now) {\n try {\n const nextBase = parseCron(task.cronExpression, new Date(now + 60_000))\n task.nextFireAt = applyJitter(this.config, task.id, nextBase, true)\n } catch {\n /* keep stored nextFireAt if cron no longer parses */\n }\n }\n try { this.registry.add(task) } catch { /* skip duplicate */ }\n }\n } catch (err) {\n this.deps.logger?.error('[schedules] Failed to load schedules:', err)\n }\n\n // RFC-423:为加载的事件驱动任务建立监听(cron 任务由 tickLoop 轮询,无需 arm)。\n for (const task of this.registry.list()) {\n this.armIfEventDriven(task)\n }\n\n this.running = true\n this.tickLoop.start()\n }\n\n // ── Task termination (RFC-433 D1) ──\n\n /**\n * 任务终结的**唯一入口**(RFC-433 D1 / R2)。三条删除路径——用户 `remove()`、\n * one-shot 完成、7 天滞留驱逐——全部经此,否则事件监听会泄漏:任务从 registry\n * 消失后,`armedTriggers` 里的退订函数仍在,而 arm 回调闭包捕获的是 **task 对象\n * 引用本身**(见 `armIfEventDriven`),registry 里有没有它根本不影响 fire。\n *\n * **三步顺序不可调换(R2b 不变量)**:`persistence.deleteOne()` 内部要\n * `registry.get(id)` 判 session-local(`schedule-persistence.ts:37`)——registry\n * 先删会让它读不到 task、判不出 session-local,对本就不落盘的任务白发一次 store IO。\n */\n private terminate(taskId: string, reason: TerminationReason): boolean {\n // 未知 id 不产生任何副作用(保持 remove() 的既有语义:不存在则 false,不发 store IO)。\n // disarm 仍先做一次——它对未 arm 的 id 是 no-op,但能兜住\"已泄漏\"的历史脏状态。\n this.disarmIfArmed(taskId) // ① 退订事件监听\n if (!this.registry.has(taskId)) return false\n void this.persistence.deleteOne(taskId) // ② 删快照(须先于 ③,见上)\n const removed = this.registry.remove(taskId) // ③ 从内存 registry 移除\n this.deps.logger?.debug?.(`[schedules] terminated ${taskId} (${reason})`)\n return removed\n }\n\n /**\n * 只从内存移除、**保留** store 快照——`reloadFromStore` 的差量同步用(store 里\n * 本来就已经没有它了,是别的进程删的;再发一次 delete 是多余 IO)。\n * 同样必须退订,理由同 `terminate()`。\n */\n private evictFromMemory(taskId: string): boolean {\n this.disarmIfArmed(taskId)\n return this.registry.remove(taskId)\n }\n\n /** Clear ticker, release ownership, and persist current state. */\n async stop(): Promise<void> {\n // RFC-435 D4:先清 acquire 重试循环——非 owner + timer 在跑时若被首行守卫短路,\n // timer 会泄漏(进程常驻则每 30s 空转一次 acquire)。\n this.stopEnsureOwnership()\n if (!this.running && !this.deps.ownership.isOwner()) return\n\n this.stopRecovery()\n this.tickLoop.stop()\n this.disarmAll()\n\n this.running = false\n await this.deps.ownership.release()\n await this.persistence.flushAll(this.registry.list())\n }\n\n // ── Recovery ──\n\n private startRecovery(): void {\n if (this.recoveryTimer) return\n this.deps.logger?.error('[schedules] Lost fire ownership — entering recovery mode')\n this.recoveryTimer = setInterval(() => {\n void this.recoveryAttempt()\n }, this.config.recoveryRetryIntervalMs)\n this.recoveryTimer.unref()\n }\n\n private async recoveryAttempt(): Promise<void> {\n try {\n const acquired = await this.deps.ownership.tryAcquire()\n if (acquired && this.deps.ownership.isOwner()) {\n this.stopRecovery()\n this.running = true\n // RFC-433 D3b:重获 ownership 后补 arm——与 start() 的 owner 分支对齐。\n // 不补的话事件任务在一次锁抖动后**永久假死**:cron 任务随 ticker 自动恢复,\n // 事件任务的监听却可能从未建立(非 owner 期间 start() 只加载不 arm)。\n // armIfEventDriven 内部幂等(先 disarm 再 arm),重复调用安全。\n for (const task of this.registry.list()) this.armIfEventDriven(task)\n this.tickLoop.start()\n this.deps.logger?.error('[schedules] Re-acquired fire ownership — recovery complete')\n }\n } catch {\n // tryAcquire failures during recovery are expected — suppress.\n }\n }\n\n private stopRecovery(): void {\n if (this.recoveryTimer) {\n clearInterval(this.recoveryTimer)\n this.recoveryTimer = null\n }\n }\n\n // ── RFC-435 D4: never-owner acquire retry ──\n\n /**\n * 非 owner 进程的低频 acquire 重试。TUI 先开持锁 → daemon 后起(acquire 失败,\n * 本循环待机)→ TUI 关闭释放锁 → 本循环在下一次 tick 接管。锁的原子性\n * (O_EXCL / 服务端 CAS)保证任一时刻只有一个 owner——重试不会造成双主。\n */\n private startEnsureOwnership(): void {\n if (this.ensureOwnershipTimer) return\n this.ensureOwnershipTimer = setInterval(() => {\n void this.ensureOwnershipAttempt()\n }, this.config.recoveryRetryIntervalMs)\n this.ensureOwnershipTimer.unref()\n }\n\n private async ensureOwnershipAttempt(): Promise<void> {\n try {\n const acquired = await this.deps.ownership.tryAcquire()\n if (acquired && this.deps.ownership.isOwner()) {\n this.stopEnsureOwnership()\n // 与 start() owner 分支对齐:重新 reload(待机期间可能有新任务写入 store)+\n // arm 事件任务 + 启动 ticker。\n try {\n const tasks = await this.deps.store.loadAllTasks()\n for (const task of tasks) {\n try { this.registry.add(task) } catch { /* skip duplicate */ }\n }\n } catch (err) {\n this.deps.logger?.error('[schedules] Failed to reload schedules after acquiring ownership:', err)\n }\n for (const task of this.registry.list()) this.armIfEventDriven(task)\n this.running = true\n this.tickLoop.start()\n this.deps.logger?.error('[schedules] Acquired fire ownership (was waiting for release)')\n }\n } catch {\n // tryAcquire failures during wait are expected — suppress.\n }\n }\n\n private stopEnsureOwnership(): void {\n if (this.ensureOwnershipTimer) {\n clearInterval(this.ensureOwnershipTimer)\n this.ensureOwnershipTimer = null\n }\n }\n\n // ── Public CRUD ──\n\n add(input: ScheduledTaskInput): ScheduledTask {\n if (this.registry.count() >= this.config.maxTasks) {\n throw new Error(`Schedule limit reached (max ${this.config.maxTasks}). Remove some tasks before adding more.`)\n }\n\n const wantsSession = input.target === 'session' && !input.subscriber\n\n if (wantsSession && !this.deps.ownership.isOwner()) {\n throw new Error(this.describeSessionOwnershipRefusal())\n }\n\n // RFC-423 D4 闸门2:事件驱动任务默认拒绝 job 投递(会产生新 job → 高死循环风险)。\n // 事件触发只放行 subscriber/session 目标(不产生新 job)。这也匹配\"注入对话继续\"诉求。\n if (input.eventTrigger && !input.subscriber && input.target !== 'session') {\n throw new Error(\n 'Event-triggered tasks must deliver to a session (target:\"session\") or a plugin subscriber, '\n + 'not an isolated job — job delivery from an event trigger risks a self-firing loop (RFC-423 D4).',\n )\n }\n\n const id = crypto.randomUUID().slice(0, 8)\n const now = this.deps.now?.() ?? Date.now()\n const nextBase = parseCron(input.cronExpression, new Date(now + 60_000))\n const nextFireAt = applyJitter(this.config, id, nextBase, input.recurring)\n\n const task: ScheduledTask = {\n id,\n name: input.name,\n prompt: input.prompt,\n cronExpression: input.cronExpression,\n enabled: true,\n recurring: input.recurring,\n origin: input.origin,\n createdAt: now,\n nextFireAt,\n maxDurationMs: input.maxDurationMs ?? this.config.defaultMaxDurationMs,\n maxRetries: input.maxRetries ?? this.config.defaultMaxRetries,\n ...(input.subscriber ? { subscriber: input.subscriber } : {}),\n ...(wantsSession ? { target: 'session' as const, boundSessionId: this.getSessionIdForTask() } : {}),\n ...(input.eventTrigger ? { eventTrigger: input.eventTrigger } : {}),\n ...(input.modelId ? { modelId: input.modelId } : {}),\n ...(input.thinkingLevel ? { thinkingLevel: input.thinkingLevel } : {}),\n }\n\n this.registry.add(task)\n this.persistence.saveOne(task)\n this.armIfEventDriven(task)\n return task\n }\n\n remove(id: string): boolean {\n return this.terminate(id, 'user-removed')\n }\n\n update(\n id: string,\n patch: Partial<Pick<ScheduledTask, 'name' | 'prompt' | 'cronExpression' | 'enabled'>>,\n ): boolean {\n const task = this.registry.get(id)\n if (!task) return false\n\n if (patch.name !== undefined) task.name = patch.name\n if (patch.prompt !== undefined) task.prompt = patch.prompt\n if (patch.enabled !== undefined) task.enabled = patch.enabled\n if (patch.cronExpression !== undefined) {\n task.cronExpression = patch.cronExpression\n const now = this.deps.now?.() ?? Date.now()\n const nextBase = parseCron(task.cronExpression, new Date(now + 60_000))\n task.nextFireAt = applyJitter(this.config, task.id, nextBase, task.recurring)\n }\n\n this.persistence.saveOne(task)\n return true\n }\n\n list(): ScheduledTask[] {\n return this.registry.list()\n }\n\n getRunningTaskIds(): Set<string> {\n // 委托给实现了 getRunningTaskIds() 的 handler(job 路径)\n for (const handler of this.deps.fireTargets) {\n if (handler.getRunningTaskIds) {\n return handler.getRunningTaskIds()\n }\n }\n return new Set()\n }\n\n readFireLogs(taskId: string, maxEntries: number = 20): Array<{ timestamp: string; status: string }> {\n return this.fireLogger.read(taskId, maxEntries)\n }\n\n runNow(id: string): void {\n const task = this.registry.get(id)\n if (!task) {\n throw new Error(`Schedule task \"${id}\" not found`)\n }\n if (!task.enabled) {\n throw new Error(`Schedule task \"${id}\" is disabled`)\n }\n // RFC-435 D1:非 owner 进程(serve/daemon 与交互会话并存时的只读加载方)不能 fire——\n // 否则 fireExecutor 准入静默吞掉,用户以为执行了。显式抛错(面板/toast 可提示\"另一\n // 进程持锁\")。D4 的非 owner acquire 重试只负责\"锁释放后接管\",不代表 runNow 可执行。\n if (!this.deps.ownership.isOwner()) {\n throw new Error(`Schedule task \"${id}\" cannot be run now: another process holds the fire ownership`)\n }\n if (this.fireExecutor.has(task.id)) {\n throw new Error(`Schedule task \"${id}\" is already firing`)\n }\n this.fireExecutor.addFiring(task.id)\n void this.fireExecutor.fire(task)\n }\n\n cancel(taskId: string): CancelResult {\n const task = this.registry.get(taskId)\n if (!task) return { ok: false, reason: 'task_not_found' }\n\n // 委托给实现了 requestCancel() 的 handler(job 路径)\n for (const handler of this.deps.fireTargets) {\n if (handler.requestCancel) {\n return handler.requestCancel(taskId)\n }\n }\n return { ok: false, reason: 'not_running' }\n }\n\n // ── RFC-423: event-trigger arm/disarm ──\n\n /**\n * 若任务是事件驱动(eventTrigger 存在),用匹配的 EventDrivenTrigger arm 监听。\n * fire 回调复用既有 fireExecutor 路径——Trigger 只决定\"何时\",FireTarget 决定\"投哪\"。\n *\n * 死循环闸门2(D4):事件驱动任务默认拒绝 JobFireTarget 投递——只有 subscriber/session\n * 目标(不产生新 job)才放行。配 job 会产生新 job → 高死循环风险,在此拒绝而非静默。\n */\n private armIfEventDriven(task: ScheduledTask): void {\n if (!task.eventTrigger) return\n const trigger = this.triggers.find((t) => t.canHandle(task))\n if (!trigger) {\n this.deps.logger?.error(\n `[schedules] event-trigger task ${task.id} has no matching trigger (source=${task.eventTrigger.source}) — will never fire`,\n )\n return\n }\n // 已 arm 的先退订(幂等,防重复 arm 泄漏)。\n this.disarmIfArmed(task.id)\n const disarm = trigger.arm(task, (t) => {\n if (this.fireExecutor.has(t.id)) return\n this.fireExecutor.addFiring(t.id)\n void this.fireExecutor.fire(t)\n })\n this.armedTriggers.set(task.id, disarm)\n }\n\n private disarmIfArmed(taskId: string): void {\n const disarm = this.armedTriggers.get(taskId)\n if (disarm) {\n disarm()\n this.armedTriggers.delete(taskId)\n }\n }\n\n private disarmAll(): void {\n for (const disarm of this.armedTriggers.values()) disarm()\n this.armedTriggers.clear()\n }\n\n /**\n * 非 owner 进程拒绝创建 session-local 任务时的诊断文案(RFC-357 D2 R3 的执行面)。\n *\n * **拒绝本身不放宽**——它防的是\"任务出现在列表里却永远不触发\"的静默失效:非 owner\n * 进程不跑 ticker,任务又 durable:false 不落盘,owner 进程 loadAllTasks 也看不见它。\n * 本方法只解决\"用户知道被拒了,但不知道该怎么办\":补上**谁持有**(本地实现可从锁\n * 文件解出 pid/host)与**多久能好**(RFC-435 D4 的非 owner acquire 重试会在锁释放后\n * 自动接管,无需重启进程)。\n *\n * 三态退化:远程实现无本地 pid 概念 → 无主体文案;`network-error` 时不能说\"另一终端\n * 持有\"(可能压根没连上服务端,让用户去找一个不存在的第二终端是更糟的误导)——这条\n * 三态区分沿用 `handlers/schedule.ts:105-109` 既有 readOnlyHint 范式,不另造一套判据。\n */\n private describeSessionOwnershipRefusal(): string {\n const base = 'Cannot create a session-local scheduled task: this terminal does not hold fire ownership.'\n\n if (this.deps.ownership.lastFailureReason?.() === 'network-error') {\n return `${base} Could not reach the remote schedule service — check network/persistenced connectivity, then retry.`\n }\n\n const owner = this.deps.ownership.describeOwner?.()\n const holder = owner?.pid !== undefined\n ? ` It is currently held by another otto process (pid ${owner.pid}${owner.host !== undefined ? ` on ${owner.host}` : ''}).`\n : ' It is currently held by another otto process for this workspace.'\n\n return `${base}${holder}`\n + ' Create the task in that terminal instead, or close it — this process retries acquiring'\n + ` ownership every ${Math.round(this.config.recoveryRetryIntervalMs / 1000)}s and will take over automatically once the lease is released (no restart needed).`\n }\n\n // ── Internal: session id for task creation ──\n\n /**\n * session-local 任务创建时绑定的 sessionId。\n * RFC-406 D6:从 SessionFireTarget handler 的 getSessionId() 端口读取(旧 SchedulerDeps.getSessionId\n * 语义迁入此 handler)。add() 用它来设置 boundSessionId。\n *\n * 找不到 SessionFireTarget(消费方未装配 session 投递)时回退空字符串——此时 add() 的\n * wantsSession 分支不会命中(session 任务无 handler 可 fire),boundSessionId 也无意义。\n */\n private getSessionIdForTask(): string {\n for (const handler of this.deps.fireTargets) {\n if (handler.getSessionId) {\n return handler.getSessionId()\n }\n }\n return ''\n }\n}\n","// schedule/src/schedule-store.ts — Snapshot-per-id storage via Persistence<ScheduledTask>.\n// RFC-169 D1: partitioned by workspaceKey. Fail-open: read errors → [].\nimport { createPersistence, type Persistence, type Snapshot } from '@x-otto/persistence'\nimport { OTTO_HOME, isShadowModeEnabled } from '@x-otto/env'\nimport { resolve } from 'node:path'\n\nimport type { ScheduledTask } from './types'\n\nconst SNAPSHOT_VERSION = 1\n\n/** Per-workspace schedules directory: `${OTTO_HOME}/schedules/<workspaceKey>/`. */\nfunction schedulesDir(workspaceKey: string): string {\n return resolve(OTTO_HOME, 'schedules', workspaceKey)\n}\n\nexport interface ScheduleStore {\n saveTask(task: ScheduledTask): Promise<void>\n loadAllTasks(): Promise<ScheduledTask[]>\n deleteTask(id: string): Promise<boolean>\n}\n\nexport interface ScheduleStoreOptions {\n persistence?: Persistence<ScheduledTask>\n /** Logger (injected, replaces bare console.error). */\n logger?: { error: (msg: string | Error, ...args: unknown[]) => void }\n}\n\n/**\n * Create a ScheduleStore backed by FilePersistence in ${OTTO_HOME}/schedules/<workspaceKey>/.\n *\n * `workspaceKey` is required (RFC-169 D1) — every call site must explicitly state which\n * workspace's schedules it wants, so a caller can never silently fall back to a shared\n * global path. Remote mode (RemoteScheduleStore, RFC-169 D3) is a separate implementation\n * selected by the caller based on `--session-url`, not by this factory.\n */\nexport function createScheduleStore(workspaceKey: string, options: ScheduleStoreOptions = {}): ScheduleStore {\n // RFC-345:影子模式(OTTO_SHADOW)下默认持久化切到 memory 后端——`/schedule add`\n // 等交互路径只写内存、零磁盘落盘,符合影子态「写静默吞」(§D3)语义。调用方显式\n // 注入 options.persistence 时不受影子态控制。\n //\n // 已知折衷:影子态下 `/schedule list` 只含本进程内存任务,既有磁盘任务不可见。\n // 读穿透叠加 merge 的复杂度不值得为一个短暂影子会话引入,故不做。\n const store =\n options.persistence ??\n (isShadowModeEnabled()\n ? createPersistence<ScheduledTask>({ type: 'memory' })\n : createPersistence<ScheduledTask>({ type: 'file', baseDir: schedulesDir(workspaceKey) }))\n const logger = options.logger\n\n return {\n async saveTask(task: ScheduledTask): Promise<void> {\n const snapshot: Snapshot<ScheduledTask> = {\n version: SNAPSHOT_VERSION,\n id: task.id,\n data: task,\n metadata: {\n createdAt: task.createdAt,\n updatedAt: Date.now(),\n tags: ['schedule'],\n },\n }\n await store.save(snapshot)\n },\n\n async loadAllTasks(): Promise<ScheduledTask[]> {\n let ids: string[]\n try {\n ids = await store.list()\n } catch (err) {\n logger?.error('[schedules] Failed to list schedule snapshots:', err)\n return []\n }\n\n const tasks: ScheduledTask[] = []\n for (const id of ids) {\n try {\n const snapshot = await store.load(id)\n if (snapshot) {\n tasks.push(snapshot.data)\n }\n } catch (err) {\n logger?.error(`[schedules] Failed to load schedule \"${id}\":`, err)\n // skip corrupted record, continue loading rest\n }\n }\n\n tasks.sort((a, b) => a.nextFireAt - b.nextFireAt)\n return tasks\n },\n\n async deleteTask(id: string): Promise<boolean> {\n try {\n return await store.delete(id)\n } catch (err) {\n logger?.error(`[schedules] Failed to delete schedule \"${id}\":`, err)\n return false\n }\n },\n }\n}\n","/**\n * remote-schedule-store.ts —— RFC-169 D3: 远程模式的 ScheduleStore 实现。\n *\n * `extends RemotePersistence<ScheduledTask>`(@x-otto/persistence 既有抽象基类,已有\n * RemoteSessionPersistence/PanelState 两个先例)——复用 wsKey 路径分区、鉴权 header 注入、\n * 超时/连接失败错误文案,不重新实现 HTTP 客户端。`ScheduleStore` 接口形状(saveTask/\n * loadAllTasks/deleteTask)与本地 `createScheduleStore` 一致,`setupScheduleService` 可\n * 按 remoteSessionConfigured 无痛切换两种实现(RFC-169 §4 重要事项规则 6)。\n */\nimport { RemotePersistence } from '@x-otto/persistence'\nimport type { RemotePersistenceOptions } from '@x-otto/persistence'\n\nimport type { ScheduleStore } from './schedule-store'\nimport type { ScheduledTask } from './types'\n\nconst SNAPSHOT_VERSION = 1\n\ninterface ScheduleSnapshot {\n version: number\n id: string\n data: ScheduledTask\n}\n\nclass RemoteScheduleStoreImpl extends RemotePersistence<ScheduleSnapshot> implements ScheduleStore {\n protected override get serviceLabel(): string {\n return 'schedule 远程服务'\n }\n protected override get endpointFlag(): string {\n return '--session-url'\n }\n\n protected extractId(snapshot: ScheduleSnapshot): string {\n return snapshot.id\n }\n\n async saveTask(task: ScheduledTask): Promise<void> {\n await this.save({ version: SNAPSHOT_VERSION, id: task.id, data: task })\n }\n\n /**\n * M3 里程碑完成审核记录的已知架构成本:list()+load() 是 1+N 次 HTTP 请求(先拿 id 列表,\n * 再逐个拉快照),非单次批量端点。可接受——schedule 任务集合天然小(MAX_TASKS=50,\n * scheduler.ts),且 loadAllTasks 只在 SchedulerService.start()/定期 reloadFromStore()\n * 调用,不在 tick 热路径。真要优化需要 persistenced 加批量读端点,超出本 RFC 范围。\n */\n async loadAllTasks(): Promise<ScheduledTask[]> {\n const ids = await this.list()\n const tasks: ScheduledTask[] = []\n for (const id of ids) {\n const snapshot = await this.load(id)\n if (snapshot) tasks.push(snapshot.data)\n }\n tasks.sort((a, b) => a.nextFireAt - b.nextFireAt)\n return tasks\n }\n\n async deleteTask(id: string): Promise<boolean> {\n return this.delete(id)\n }\n}\n\nexport interface RemoteScheduleStoreOptions {\n /**\n * `--session-url` 的原始值(如 `http://host:3001/api/storage/sessions`)——**不是**裸\n * origin,该值本身是完整资源路径(对齐 `otto serve` 后端约定)。内部用\n * `new URL(sessionUrl).origin` 提取协议+主机+端口,再拼接 `/api/storage/schedules`——\n * 与 `remote-fire-ownership.ts` 的同一提取逻辑保持一致,不能直接拿 sessionUrl 当前缀用。\n */\n sessionUrl: string\n getAuth: () => Promise<{ token: string }>\n fetch?: typeof globalThis.fetch\n timeoutMs?: number\n wsKey?: string | (() => string | undefined)\n}\n\n/**\n * Create a ScheduleStore backed by the persistenced `/api/storage/schedules` namespace's\n * generic storage route (`storage.ts`, `namespace='schedules'`) — distinct from the\n * lease sub-route (`schedule-lease.ts`, `/api/schedules/...`) which handles fire-ownership\n * arbitration only.\n */\nexport function createRemoteScheduleStore(options: RemoteScheduleStoreOptions): ScheduleStore {\n const origin = new URL(options.sessionUrl).origin\n const remoteOptions: RemotePersistenceOptions<ScheduleSnapshot> = {\n baseUrl: `${origin}/api/storage/schedules`,\n getAuth: options.getAuth,\n fetch: options.fetch ?? globalThis.fetch,\n timeoutMs: options.timeoutMs ?? 10_000,\n wsKey: options.wsKey,\n }\n return new RemoteScheduleStoreImpl(remoteOptions)\n}\n","/**\n * schedule-lease-migrations.ts —— RFC-169 D3a: schedule_lease 表的独立 migration 段.\n *\n * `schedule_lease` is a schedule-domain table (server-arbitrated fire-ownership lease,\n * RFC-169 D3), not a generic blob-snapshot table — it must not join `BLOB_MIGRATIONS`\n * (@x-otto/persistence's generic storage kernel schema), per the \"generic storage kernel\n * does not hardcode domain schema\" principle already established by RFC-074 M5-02b\n * (session tables were carved out of the shared migration set for the same reason).\n *\n * Consumed by `persistenced-app.ts` via `acquireDb(leaseDbPath, wal, SCHEDULE_LEASE_MIGRATIONS)`\n * — deliberately a SEPARATE db file from the blob-snapshot `storage.db` (acquireDb's\n * fail-fast guard rejects injecting two different migration sets into the same db path,\n * `sqlite-db-cache.ts:134-140`).\n */\nimport type { Migration } from '@x-otto/persistence'\n\nexport const SCHEDULE_LEASE_MIGRATIONS: Migration[] = [\n {\n version: 1,\n up: (db) => {\n db.exec(`\n CREATE TABLE IF NOT EXISTS schedule_lease (\n ws_key TEXT PRIMARY KEY,\n token TEXT NOT NULL,\n renewed_at INTEGER NOT NULL\n );\n `)\n },\n },\n]\n","// schedule/src/fire-ownership.ts — Per-fire lease acquisition (async tryAcquire).\n// RFC-169 D1: partitioned by workspaceKey.\n// RFC-171 D2/D5: lease format token:pid:hostname + liveness probe.\nimport {\n closeSync,\n mkdirSync,\n openSync,\n writeSync,\n readFileSync,\n statSync,\n renameSync,\n rmSync,\n unlinkSync,\n utimesSync,\n} from 'node:fs'\nimport { resolve } from 'node:path'\nimport { randomUUID } from 'node:crypto'\n\nimport {\n OTTO_HOME,\n currentHostname,\n decodeLeaseToken,\n encodeLeaseToken,\n isProcessAlive,\n isShadowModeEnabled,\n} from '@x-otto/env'\n\n/** Per-workspace lock file: `${OTTO_HOME}/schedule-owner-<workspaceKey>.lock`. */\nfunction lockFilePath(workspaceKey: string): string {\n return resolve(OTTO_HOME, `schedule-owner-${workspaceKey}.lock`)\n}\n\nconst STALE_MS = 5 * 60_000 // 5-minute stale threshold (must exceed renew interval)\n\nexport interface FireOwnership {\n tryAcquire(): Promise<boolean>\n\n renew(): Promise<void>\n\n release(): Promise<void>\n\n lastFailureReason?(): 'occupied' | 'network-error' | undefined\n\n /**\n * 当前锁持有者的可读描述(诊断用,非语义判定)。返回 `undefined` 表示无法确定\n * (旧格式锁文件、锁已消失、或远程实现——服务端仲裁没有本地 pid 概念)。\n *\n * 为什么是可选端口:`FireOwnership` 是本地 O_EXCL 与远程 HTTP 双实现的公共接口\n * (RFC-169 D3/D3b),\"谁持有\"只在本地实现里有确切答案。消费方必须按可选处理,\n * 拿不到就退回不带主体的提示文案,不得据此做任何准入判定。\n */\n describeOwner?(): { pid?: number; host?: string } | undefined\n\n /** Whether this process currently holds the lock (verified against the token). */\n isOwner(): boolean\n}\n\n/**\n * Create a local token-based O_EXCL FireOwnership, scoped to `workspaceKey` (RFC-169 D1).\n * Only the lock holder runs the ticker and fires tasks.\n */\nexport function createLocalFireOwnership(workspaceKey: string): FireOwnership {\n const token = randomUUID()\n const LOCK_FILE = lockFilePath(workspaceKey)\n const myHostname = currentHostname()\n let owned = false\n\n /** Create the lockfile with our token (encoded as `token:pid:hostname`, RFC-171 D2). Returns false if it already exists. */\n const writeLock = (): boolean => {\n try {\n // OTTO_HOME may not exist yet on a fresh install — otto doesn't self-create it\n // ahead of first write. mkdirSync recursive is a cheap no-op once it does.\n mkdirSync(OTTO_HOME, { recursive: true })\n const fd = openSync(LOCK_FILE, 'wx') // O_EXCL\n try {\n writeSync(fd, encodeLeaseToken(token, process.pid, myHostname))\n } finally {\n closeSync(fd)\n }\n owned = true\n return true\n } catch (err) {\n if ((err as NodeJS.ErrnoException).code !== 'EEXIST') throw err\n return false\n }\n }\n\n /** Read + decode the lockfile currently written, or null if unreadable. */\n const readDecoded = (): { token: string; pid?: number; host?: string } | null => {\n try {\n const raw = readFileSync(LOCK_FILE, 'utf-8').trim()\n return decodeLeaseToken(raw)\n } catch {\n return null\n }\n }\n\n /** Read only the token field (fencing comparisons). */\n const readToken = (): string | null => readDecoded()?.token ?? null\n\n /** RFC-171 D2: confirmed-dead check — same hostname + pid not alive. Cross-host/malformed → false (safe fallback to stale-timeout). */\n const isConfirmedDead = (decoded: { pid?: number; host?: string }): boolean => {\n if (decoded.pid === undefined || decoded.host === undefined) return false\n if (decoded.host !== myHostname) return false\n return !isProcessAlive(decoded.pid)\n }\n\n /** RFC-171 D2: confirmed-alive check — same hostname + pid alive. Used to reject takeover even past STALE_MS. */\n const isConfirmedAlive = (decoded: { pid?: number; host?: string }): boolean => {\n if (decoded.pid === undefined || decoded.host === undefined) return false\n if (decoded.host !== myHostname) return false\n return isProcessAlive(decoded.pid)\n }\n\n // RFC-169 D3/D3b: FireOwnership methods are Promise-returning so this local O_EXCL\n // implementation and the remote HTTP one (remote-fire-ownership.ts) satisfy the same\n // interface. All work below is genuinely synchronous fs I/O — wrapped in an\n // immediately-resolved Promise, not an actual async boundary.\n const tryAcquireSync = (): boolean => {\n if (owned) return true\n // RFC-345:影子态不参与 fire ownership 竞选(§D9)——不建 schedule-owner-*.lock、\n // 不报失败原因。影子会话加载任务列表只读展示,fire 由真实 owner(或影子会话内部的\n // 其它路径)承担。这样影子进程既零写盘,也不会因竞选失败而降级走 STALE_MS 等\n // 既有的健康检查路径。\n if (isShadowModeEnabled()) return false\n if (writeLock()) return true\n\n // Lock exists — try liveness probe first (RFC-171 D2), fall back to mtime stale-timeout.\n const decoded = readDecoded()\n let mtime: number\n try {\n mtime = statSync(LOCK_FILE).mtimeMs\n } catch {\n // Vanished between calls — retry create.\n return writeLock()\n }\n\n if (decoded !== null && isConfirmedAlive(decoded)) {\n // Confirmed same-host owner is alive — reject even past STALE_MS (hung-process\n // trade-off; see module header + session-write-lease.ts D2 for the rationale).\n return false\n }\n\n const confirmedDead = decoded !== null && isConfirmedDead(decoded)\n if (!confirmedDead && Date.now() - mtime <= STALE_MS) return false\n\n // Stale or confirmed dead: atomically claim it away, then recreate with our token.\n const claimPath = `${LOCK_FILE}.stale-${randomUUID()}`\n try {\n renameSync(LOCK_FILE, claimPath)\n rmSync(claimPath, { force: true })\n } catch {\n return false // raced another taker — let the winner keep it\n }\n return writeLock()\n }\n\n return {\n async tryAcquire(): Promise<boolean> {\n return tryAcquireSync()\n },\n\n async renew(): Promise<void> {\n if (!owned) return\n // Only touch the lock if it is still ours (guards against a takeover).\n if (readToken() !== token) {\n owned = false\n return\n }\n try {\n const t = new Date()\n utimesSync(LOCK_FILE, t, t)\n } catch {\n owned = false // lock vanished under us\n }\n },\n\n async release(): Promise<void> {\n if (!owned) return\n // Only unlink if the lock is still ours (never delete a successor's lock).\n if (readToken() === token) {\n try {\n unlinkSync(LOCK_FILE)\n } catch {\n // best-effort cleanup\n }\n }\n owned = false\n },\n\n isOwner(): boolean {\n return owned && readToken() === token\n },\n\n /**\n * 读当前锁文件、解出持有者 pid/host(纯诊断)。锁不存在/旧格式裸 token/字段非法\n * 时返回 undefined——`decodeLeaseToken` 已对 pid 做合法性校验并安全降级。\n */\n describeOwner(): { pid?: number; host?: string } | undefined {\n const decoded = readDecoded()\n if (decoded === null) return undefined\n if (decoded.pid === undefined && decoded.host === undefined) return undefined\n return { ...(decoded.pid !== undefined ? { pid: decoded.pid } : {}), ...(decoded.host !== undefined ? { host: decoded.host } : {}) }\n },\n }\n}\n","// schedule/src/remote-fire-ownership.ts — Remote fire ownership via HTTP storage host.\n// RFC-169 D3c: 30s heartbeat, 180s stale.\n// D3d: fallback to tryAcquire on renew failure.\n// F1: in-flight dedup via inFlightAcquire.\nimport { randomUUID } from 'node:crypto'\n\nimport type { FireOwnership } from './fire-ownership'\n\nconst HEARTBEAT_MS = 30_000\nconst STALE_MS = 180_000\n\nexport interface RemoteFireOwnershipOptions {\n /**\n * `--session-url` 的原始值(如 `http://host:3001/api/storage/sessions`)——**不是**裸\n * origin。该值本身是完整资源路径(对齐 `otto serve` 后端约定,见 RFC-146 §1.3 两条远程\n * 产品线对照表),本构造函数内部用 `new URL(sessionUrl).origin` 提取协议+主机+端口,\n * 再拼接 `/api/schedules/...` lease 路径——不能直接拿 `sessionUrl` 当 baseUrl 使用。\n */\n sessionUrl: string\n wsKey: string\n getAuth: () => Promise<{ token: string }>\n fetch?: typeof globalThis.fetch\n timeoutMs?: number\n now?: () => number\n}\n\ntype LeaseAction = 'acquire' | 'renew' | 'release'\n\n/**\n * F2 (RFC-169 终局 review 2026-07-15): `callLease` 此前把\"服务端明确拒绝\"(HTTP 200 但\n * `acquired:false`/`renewed:false`)和\"请求本身没打到/没打通\"(网络错误/超时/非 2xx HTTP\n * 状态)统一折叠成同一个 `null` 返回值——消费方因此无法区分\"另一个终端真的持有租约\"和\n * \"我这边压根没连上服务端、连判定结果都没拿到\",导致 TUI 只读提示在网络故障时显示一句\n * 具体但错误的诊断(\"另一终端持有触发权\")。现在用一个可辨识返回类型显式区分三态:\n * `{ ok:true, data }`(服务端已应答,无论 acquired/renewed 是 true 还是 false,这本身\n * 就是一次成功的判定往返)、`{ ok:false, reason:'network-error' }`(请求本身失败)。\n * `response.ok===false`(如 403/500)也归为 network-error——那不是\"lease 被占用\"这个\n * 业务语义上的拒绝,是请求层面出了问题(鉴权/服务端错误),同样不该说成\"被占用\"。\n *\n * 已知精度局限(本次 review 复核发现,暂不展开为独立修复):`network-error` 这个名字\n * 把\"真连不上网络\"和\"鉴权失败(403 token 不对)\"两种不同性质的请求层失败合并成一类\n * ——两者的正确排查方向不同(前者查网络连通性,后者查 token 配置),但当前 TUI 提示\n * 文案统一说\"连接远程服务失败\",对鉴权失败场景不够精确。之所以不现在拆出独立的\n * `'auth-error'` 三态:① token 由 `getAuth()` 单一配置源注入,生产场景下 401/403 出现\n * 概率极低(配置错误通常在首次连接就会暴露,不会隐蔽到运行时才触发);② 拆分收益不足以\n * 覆盖新增一个枚举值分支的代码/文档维护成本,属于本次 review 判断\"可保留观察\"的一项。\n */\ntype LeaseCallResult =\n | { ok: true; data: Record<string, unknown> }\n | { ok: false; reason: 'network-error' }\n\n/** Create a server-arbitrated FireOwnership backed by persistenced's lease endpoints. */\nexport function createRemoteFireOwnership(options: RemoteFireOwnershipOptions): FireOwnership {\n const token = randomUUID()\n const fetchImpl = options.fetch ?? globalThis.fetch\n const timeoutMs = options.timeoutMs ?? 10_000\n const baseUrl = new URL(options.sessionUrl).origin\n const now = options.now ?? Date.now\n let owned = false\n /** Last time an actual HTTP renew/acquire heartbeat was sent (throttling — see header). */\n let lastHeartbeatAt = 0\n /** F1: dedupe concurrent tryAcquire() calls onto one in-flight request/state-write. */\n let inFlightAcquire: Promise<boolean> | undefined\n /** F2: reason for the most recent non-owner outcome, for TUI diagnostics. */\n let lastFailureReason: 'occupied' | 'network-error' | undefined\n\n const callLease = async (action: LeaseAction): Promise<LeaseCallResult> => {\n const auth = await options.getAuth()\n const controller = new AbortController()\n const timeout = setTimeout(() => controller.abort(), timeoutMs)\n try {\n const response = await fetchImpl(\n `${baseUrl}/api/schedules/${encodeURIComponent(options.wsKey)}/lease/${action}`,\n {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n Authorization: `Bearer ${auth.token}`,\n },\n body: JSON.stringify({ token }),\n signal: controller.signal,\n },\n )\n if (!response.ok) return { ok: false, reason: 'network-error' }\n return { ok: true, data: (await response.json()) as Record<string, unknown> }\n } catch {\n // Network error / timeout / abort — treated as \"did not succeed\", never thrown.\n // The scheduler's tick loop tolerates transient failures and retries next heartbeat.\n return { ok: false, reason: 'network-error' }\n } finally {\n clearTimeout(timeout)\n }\n }\n\n const tryAcquire = (): Promise<boolean> => {\n // F1: if a tryAcquire() is already in flight, share its result instead of firing a\n // second concurrent HTTP request whose (possibly stale-ordered) response could\n // clobber `owned` after the first call already set it. Both callers get the same\n // outcome, which is the only sane semantics for \"try to acquire\" called concurrently.\n if (inFlightAcquire) return inFlightAcquire\n\n const promise = (async (): Promise<boolean> => {\n lastHeartbeatAt = now()\n const result = await callLease('acquire')\n if (!result.ok) {\n owned = false\n lastFailureReason = 'network-error'\n return false\n }\n const acquired = result.data['acquired'] === true\n owned = acquired\n lastFailureReason = acquired ? undefined : 'occupied'\n return acquired\n })()\n\n inFlightAcquire = promise\n promise.finally(() => {\n if (inFlightAcquire === promise) inFlightAcquire = undefined\n })\n return promise\n }\n\n return {\n tryAcquire,\n\n async renew(): Promise<void> {\n // Throttle: SchedulerService.tick() calls renew() every second, but a real HTTP\n // heartbeat only needs to fire every HEARTBEAT_MS (30s, D3c) — everything in between\n // is a free no-op. The very first call after construction always fires (lastHeartbeatAt\n // starts at 0), matching the local implementation's \"renew is safe to call immediately\".\n if (now() - lastHeartbeatAt < HEARTBEAT_MS) return\n\n if (!owned) {\n // D3d: not currently owner (lost it, or never acquired) — every heartbeat is an\n // acquire attempt so a healed network partition or an expired competitor's lease\n // can be reclaimed without waiting for a full process restart.\n await tryAcquire()\n return\n }\n lastHeartbeatAt = now()\n const result = await callLease('renew')\n if (!result.ok) {\n // F2: renew failed at the transport level (not a server-side \"superseded\"\n // rejection) — still downgrade to non-owner (D3d semantics unchanged), but record\n // the accurate reason so the TUI doesn't claim \"another terminal holds it\".\n owned = false\n lastFailureReason = 'network-error'\n return\n }\n const renewed = result.data['renewed'] === true\n if (!renewed) {\n // D3d: lost the lease (superseded, or this renew call itself failed/timed out).\n // Don't distinguish \"genuinely superseded\" from \"network blip\" here — both cases\n // downgrade to non-owner and let the next heartbeat's tryAcquire() sort it out.\n owned = false\n lastFailureReason = 'occupied'\n } else {\n lastFailureReason = undefined\n }\n },\n\n async release(): Promise<void> {\n if (!owned) return\n await callLease('release')\n owned = false\n },\n\n isOwner(): boolean {\n return owned\n },\n\n lastFailureReason(): 'occupied' | 'network-error' | undefined {\n return owned ? undefined : lastFailureReason\n },\n }\n}\n\nexport { HEARTBEAT_MS as REMOTE_FIRE_OWNERSHIP_HEARTBEAT_MS, STALE_MS as REMOTE_FIRE_OWNERSHIP_STALE_MS }\n","/**\n * job-fire-target.ts —— RFC-406 D3:JobFireTarget handler。\n *\n * 从 scheduler.ts fire() 的 job 分支拆出。到点派发 AgentJob(隔离 worktree 执行),\n * 监控完成状态,失败重试(maxRetries 次),支持 cancel。\n *\n * 原代码位置:scheduler.ts fire() 的 else 分支 + waitForCompletion() + cancel() 的\n * runningJobsByTaskId/cancelRequested 记账。\n *\n * **cancel 交互**:SchedulerService.cancel() 调用此 handler 的 requestCancel(),\n * 设置 cancelRequested 标志。fire() 循环在每次 attempt 前检查此标志。\n */\nimport type { AgentJobRegistry } from '@x-otto/runtime'\nimport type { ThinkingLevel } from '@x-otto/interchange'\n\nimport type { FireTarget, FireContext, FireResult } from '../fire-target'\nimport type { FireLogger } from '../fire-logger'\nimport type { CancelResult, ScheduledTask } from '../types'\n\nexport interface JobFireTargetDeps {\n jobRegistry: AgentJobRegistry\n /**\n * Dispatch an agent job. Mirrors AgentJobService.start() shape:\n * synchronously returns a record with at least { id }.\n */\n startJob: (input: {\n title: string\n prompt: string\n sessionId: string\n origin: 'user' | 'main'\n /** RFC-438:per-job 指定模型与思考档位(task.modelId/thinkingLevel 透传)。 */\n model?: { id: string; thinkingLevel?: ThinkingLevel }\n }) => { id: string }\n /**\n * RFC-435 D1:绑定会话 id 端口。serve/daemon 无人值守装配可缺省——\n * job-only 模式无会话,fire 时 sessionId 用空串(无会话绑定,notify 不投递)。\n */\n getSessionId?: () => string\n /** RFC-230:重试之间的固定延迟(ms)。默认 0。 */\n retryBackoffMs?: number\n /** 测试可注入假 sleep 避免真实等待。 */\n sleep?: (ms: number) => Promise<void>\n /** FireLogger 实例(写 fire 日志) */\n fireLogger: FireLogger\n /** needs_input 硬上限倍数(对齐 ScheduleConfig.needsInputMaxExtensionFactor,默认 3)。 */\n needsInputMaxExtensionFactor?: number\n}\n\ninterface RunningJobRecord {\n jobId: string\n fireToken: number\n attempt: number\n}\n\nexport class JobFireTarget implements FireTarget {\n readonly type = 'job'\n\n private readonly runningJobsByTaskId: Map<string, RunningJobRecord> = new Map()\n private readonly cancelRequested: Set<string> = new Set()\n private _nextFireToken = 0\n\n constructor(private deps: JobFireTargetDeps) {}\n\n canHandle(task: ScheduledTask): boolean {\n // job 是默认路径——当 task 既无 subscriber 也非 target:session 时命中\n return !task.subscriber && task.target !== 'session'\n }\n\n async fire(task: ScheduledTask, ctx: FireContext): Promise<FireResult> {\n let outcome: 'success' | 'failed' = 'failed'\n let outcomeCancelled = false\n let lastError: string | undefined\n const fireToken = ++this._nextFireToken\n\n const origin: 'user' | 'main' = task.origin === 'model' ? 'main' : 'user'\n // RFC-435 D1:无人值守(无 getSessionId 端口)时用空串——job 无会话绑定,\n // 下游 notify 无匹配消费者即安全旁路(同 fail-open 纪律)。\n const sessionId = this.deps.getSessionId?.() ?? ''\n\n for (let attempt = 0; attempt <= task.maxRetries; attempt++) {\n if (this.cancelRequested.has(task.id)) {\n this.deps.fireLogger.write(task.id, `canceled by user (attempt ${attempt + 1})`)\n outcomeCancelled = true\n break\n }\n\n // RFC-438 评审阻断②:startJob 同步抛错(如 per-job 模型在 fire 时才失效)必须\n // 折叠为一次失败 attempt——否则跳出循环,maxRetries 被跳过、recurring 不标\n // lastFireFailed(= 每晚静默失败仅一行 fire log)。\n let job: { id: string }\n try {\n job = this.deps.startJob({\n title: task.name,\n prompt: task.prompt,\n sessionId,\n origin,\n ...(task.modelId ? { model: { id: task.modelId, thinkingLevel: task.thinkingLevel } } : {}),\n })\n } catch (err) {\n lastError = err instanceof Error ? err.message : String(err)\n this.deps.fireLogger.write(task.id, `startJob failed (attempt ${attempt + 1}): ${lastError}`)\n if (attempt < task.maxRetries) {\n const backoffMs = this.deps.retryBackoffMs ?? 0\n const sleep = this.deps.sleep ?? ((ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms)))\n await sleep(backoffMs)\n }\n continue\n }\n\n this.runningJobsByTaskId.set(task.id, { jobId: job.id, fireToken, attempt })\n\n const result = await this.waitForCompletion(job.id, task.maxDurationMs, ctx)\n\n if (result === 'success') {\n // review I3: when the job produced a diff (status 'ready'), surface the pending review.\n const finishedJob = this.deps.jobRegistry.get(job.id)\n const pendingApply = finishedJob?.status === 'ready' && !!finishedJob.diff?.trim()\n this.deps.fireLogger.write(\n task.id,\n pendingApply\n ? `completed (attempt ${attempt + 1}) — diff ready, /job apply ${job.id}`\n : `completed (attempt ${attempt + 1})`,\n )\n outcome = 'success'\n return { outcome, jobId: job.id }\n }\n\n if (result === 'canceled') {\n this.deps.fireLogger.write(task.id, `canceled by user (attempt ${attempt + 1})`)\n outcomeCancelled = true\n break\n }\n\n lastError = result === 'timeout' ? 'timed out' : 'job failed'\n\n if (attempt < task.maxRetries) {\n const backoffMs = this.deps.retryBackoffMs ?? 0\n const sleep = this.deps.sleep ?? ((ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms)))\n await sleep(backoffMs)\n }\n }\n\n if (!outcomeCancelled) {\n this.deps.fireLogger.write(task.id, `failed after ${task.maxRetries + 1} attempt(s)`)\n }\n\n return {\n outcome,\n cancelled: outcomeCancelled,\n error: lastError,\n }\n }\n\n // ── Cancel support ──\n\n /**\n * 设置 cancel 意图——fire() 循环在下次 attempt 前检查并停止。\n * 同时调用 jobRegistry.cancel() 终止当前正在运行的 job。\n * 返回 CancelResult(与 SchedulerService.cancel() 的返回类型对齐)。\n */\n requestCancel(taskId: string): CancelResult {\n let jobId: string | undefined\n const record = this.runningJobsByTaskId.get(taskId)\n if (record) {\n jobId = record.jobId\n } else {\n // Fallback: scan jobRegistry for a still-running job (best-effort after process restart)\n const runningJobs = this.deps.jobRegistry.list().filter(\n (j) => j.status === 'running' || j.status === 'needs_input',\n )\n if (runningJobs.length === 1) {\n jobId = runningJobs[0]!.id\n }\n }\n if (!jobId) return { ok: false, reason: 'not_running' }\n\n const job = this.deps.jobRegistry.get(jobId)\n if (!job) return { ok: false, reason: 'job_not_found' }\n if (job.status === 'ready') return { ok: false, reason: 'job_is_ready' }\n if (job.status === 'applied') return { ok: false, reason: 'job_already_applied' }\n if (job.status === 'failed') return { ok: false, reason: 'job_already_failed' }\n if (job.status === 'canceled') return { ok: false, reason: 'job_already_canceled' }\n\n this.cancelRequested.add(taskId)\n this.deps.jobRegistry.cancel(jobId)\n return { ok: true }\n }\n\n /** 清理 fire 完成后的记账状态。由 FireExecutor 在 fire().finally 中调用。 */\n cleanup(taskId: string): void {\n this.runningJobsByTaskId.delete(taskId)\n this.cancelRequested.delete(taskId)\n }\n\n /** 当前有正在运行的 job 的 task id 集合。 */\n getRunningTaskIds(): Set<string> {\n return new Set(this.runningJobsByTaskId.keys())\n }\n\n // ── Internal: event-based completion monitor ──\n\n private waitForCompletion(\n jobId: string,\n maxDurationMs: number,\n ctx: FireContext,\n ): Promise<'success' | 'failed' | 'timeout' | 'canceled'> {\n const needsInputMaxExtensionFactor = this.deps.needsInputMaxExtensionFactor ?? 3 // review S8: hard cap on needs_input total wait\n\n return new Promise((resolve) => {\n let settled = false\n let timeout: ReturnType<typeof setTimeout> | undefined\n let unsubExit: (() => void) | undefined\n let unsubUpdate: (() => void) | undefined\n\n const startedAt = ctx.now()\n const hardDeadline = startedAt + maxDurationMs * needsInputMaxExtensionFactor\n\n const cleanup = () => {\n if (timeout) clearTimeout(timeout)\n unsubExit?.()\n unsubUpdate?.()\n }\n\n const finish = (result: 'success' | 'failed' | 'timeout' | 'canceled') => {\n if (settled) return\n settled = true\n cleanup()\n resolve(result)\n }\n\n const armTimeout = (ms: number) => {\n if (timeout) clearTimeout(timeout)\n timeout = setTimeout(() => {\n this.deps.jobRegistry.cancel(jobId)\n finish('failed')\n }, ms)\n }\n\n unsubUpdate = this.deps.jobRegistry.onUpdate((job) => {\n if (job.id !== jobId) return\n\n if (job.status === 'ready') {\n finish('success')\n } else if (job.status === 'needs_input') {\n const now = ctx.now()\n const remaining = Math.min(maxDurationMs, hardDeadline - now)\n if (remaining <= 0) {\n this.deps.jobRegistry.cancel(jobId)\n finish('failed')\n return\n }\n armTimeout(remaining)\n }\n })\n\n unsubExit = this.deps.jobRegistry.onExit((job) => {\n if (job.id !== jobId) return\n\n if (job.status === 'failed') {\n finish('failed')\n } else if (job.status === 'canceled') {\n finish('canceled')\n } else if (job.status === 'applied') {\n finish('success')\n }\n })\n\n armTimeout(maxDurationMs)\n })\n }\n}\n","/**\n * session-fire-target.ts —— RFC-406 D3:SessionFireTarget handler。\n *\n * 从 scheduler.ts fireSession() 拆出。把 target==='session' 任务的 prompt 注入\n * boundSessionId 对应的当前会话。不建 worktree、不派 AgentJob、不进重试循环。\n *\n * 原代码位置:scheduler.ts fireSession() 方法。\n */\nimport type { FireTarget, FireContext, FireResult } from '../fire-target'\nimport type { FireLogger } from '../fire-logger'\nimport type { ScheduledTask } from '../types'\n\nexport interface SessionFireTargetDeps {\n /**\n * 把 prompt 注入 boundSessionId 对应的当前会话。\n * 返回 true=已成功注入;false=失效(会话身份不匹配/已销毁/archived/paused 等)。\n */\n followUp: (task: {\n prompt: string\n boundSessionId: string\n name: string\n origin: 'user' | 'model'\n }) => boolean\n /**\n * RFC-406 D6:session-local 任务创建时绑定的当前 sessionId 端口。\n * scheduler.add() 经 SchedulerService.getSessionIdForTask() 读取此值填充 boundSessionId。\n * (旧 SchedulerDeps.getSessionId 的语义迁入此 handler。)\n */\n getSessionId: () => string\n /** FireLogger 实例(写 fire 日志) */\n fireLogger: FireLogger\n}\n\nexport class SessionFireTarget implements FireTarget {\n readonly type = 'session'\n\n constructor(private deps: SessionFireTargetDeps) {}\n\n canHandle(task: ScheduledTask): boolean {\n return task.target === 'session'\n }\n\n /** RFC-406 D6:暴露当前 sessionId 供 scheduler.add() 绑定 boundSessionId。 */\n getSessionId(): string {\n return this.deps.getSessionId()\n }\n\n async fire(task: ScheduledTask, _ctx: FireContext): Promise<FireResult> {\n if (!task.boundSessionId) {\n this.deps.fireLogger.write(task.id, 'session tick skipped: task has no boundSessionId (invalid state)')\n return { outcome: 'failed', error: 'no boundSessionId' }\n }\n\n // RFC-357 G5:每次 session-local 投递都带可见来源标记。\n // D3 新增约束1:模型创建的 session-local 任务额外在首次 fire 加一段更详细的提示。\n const isFirstModelFire = task.origin === 'model' && task.lastFiredAt == null\n const marker = `⏰ scheduled: ${task.name}`\n const prompt = isFirstModelFire\n ? `${marker} [created by the model — first fire, use /schedule cancel ${task.id} to stop it]\\n\\n${task.prompt}`\n : `${marker}\\n\\n${task.prompt}`\n\n let delivered = false\n try {\n delivered = this.deps.followUp({\n prompt,\n boundSessionId: task.boundSessionId,\n name: task.name,\n origin: task.origin,\n })\n } catch (err) {\n this.deps.fireLogger.write(\n task.id,\n `session tick error: ${err instanceof Error ? err.message : String(err)}`,\n )\n return { outcome: 'failed', error: err instanceof Error ? err.message : String(err) }\n }\n\n this.deps.fireLogger.write(\n task.id,\n delivered\n ? `session tick delivered (session ${task.boundSessionId})`\n : `session tick not delivered (session ${task.boundSessionId} unavailable — evicted/disposed/archived/paused/mismatched)`,\n )\n return { outcome: delivered ? 'success' : 'failed' }\n }\n}\n","/**\n * subscriber-fire-target.ts —— RFC-406 D3:SubscriberFireTarget handler。\n *\n * 从 scheduler.ts fireSubscriber() 拆出。到点只投递一条通知给已订阅的插件常驻服务,\n * 不派 AgentJob——服务若要产生工作须自己走 capability-gated startJob。\n *\n * 原代码位置:scheduler.ts fireSubscriber() 方法。\n */\nimport type { FireTarget, FireContext, FireResult } from '../fire-target'\nimport type { FireLogger } from '../fire-logger'\nimport type { ScheduleSubscriber, ScheduledTask } from '../types'\n\nexport interface SubscriberFireTargetDeps {\n /**\n * 向订阅者投递到点通知的窄端口。返回是否真的送达——false 表示服务未运行/已停机。\n * 端口刻意只接受纯数据标识:handler 不知道插件服务的存在形式,也不持有其句柄。\n */\n notify: (\n subscriber: ScheduleSubscriber,\n tick: { scheduleId: string; occurrenceId: string; occurredAt: number },\n ) => boolean\n /** FireLogger 实例(写 fire 日志) */\n fireLogger: FireLogger\n}\n\nexport class SubscriberFireTarget implements FireTarget {\n readonly type = 'plugin-service'\n\n constructor(private deps: SubscriberFireTargetDeps) {}\n\n canHandle(task: ScheduledTask): boolean {\n return !!task.subscriber\n }\n\n async fire(task: ScheduledTask, ctx: FireContext): Promise<FireResult> {\n const subscriber = task.subscriber\n if (!subscriber) return { outcome: 'failed', error: 'no subscriber on task' }\n\n const occurredAt = ctx.now()\n // occurrenceId 由 {taskId, 本次计划触发时刻} 派生——同一次计划触发的重复投递\n // 得到同一个 id(幂等键)。ticker 路径由调用方传入 scheduledFor;runNow 用实际触发时刻。\n const occurrenceId = `${task.id}@${ctx.scheduledFor ?? occurredAt}`\n\n let delivered = false\n try {\n delivered = this.deps.notify(subscriber, { scheduleId: task.id, occurrenceId, occurredAt })\n } catch (err) {\n this.deps.fireLogger.write(\n task.id,\n `subscriber tick error: ${err instanceof Error ? err.message : String(err)}`,\n )\n return { outcome: 'failed', error: err instanceof Error ? err.message : String(err) }\n }\n\n this.deps.fireLogger.write(\n task.id,\n delivered\n ? `subscriber tick delivered (${subscriber.pluginId}:${subscriber.serviceId}, occurrence ${occurrenceId})`\n : `subscriber tick not delivered (${subscriber.pluginId}:${subscriber.serviceId} not running)`,\n )\n return { outcome: delivered ? 'success' : 'failed' }\n }\n}\n","/**\n * in-process-event-trigger.ts —— 进程内事件触发器(RFC-423 D3/D4,经 RFC-433 D4 收敛)。\n *\n * 监听 AgentJobRegistry 终态事件(onExit:applied/failed/canceled),事件满足\n * EventTriggerSpec 时回调 fire(task)。MVP 唯一的进程内事件源。\n *\n * ## 两重死循环闸门(RFC-357 §7 点名的核心风险)\n *\n * 自触发死循环场景:event(job.applied) × JobFireTarget → 触发的 job apply 后又满足\n * job.applied → 再触发 → 无限循环。两重闸门:\n *\n * 1. **拒绝 job 投递**(闸门A,`SchedulerService.add()`):event × JobFireTarget 组合被\n * **无条件拒绝**——事件任务只能投 session 或 subscriber,两者都不产生新 job,\n * 自触发链在源头就不成立。这也正好匹配\"注入对话继续任务\"的诉求。\n * 2. **频率 debounce**(闸门B,本文件):单任务在窗口 debounceWindowMs 内最多\n * maxFiresPerWindow 次,超限记日志跳过。防高频事件(如连续多个 job 快速 apply)\n * 打爆当前对话。\n *\n * **RFC-433 D4 删除的第三重闸门**:原设计还有\"自产 job 排除\"(`markSelfDerived` +\n * `selfDerivedJobIds`),用于在 event × job 组合下不对自己产出的 job 触发。它从未接线\n * (生产代码零调用方)——因为闸门A 已让该组合根本不可能存在,闸门内的场景不可达。\n * **若将来放开闸门A,必须同时恢复自产 job 排除**(→ RFC-433 §4 R4)。\n */\nimport type { AgentJobRegistry, AgentSessionJob } from '@x-otto/runtime'\n\nimport type { EventDrivenTrigger } from '../trigger'\nimport type { ScheduledTask, EventTriggerSpec } from '../types'\n\nexport interface InProcessEventTriggerDeps {\n jobRegistry: AgentJobRegistry\n now?: () => number\n logger?: { error: (msg: string | Error, ...args: unknown[]) => void }\n /** 闸门B:debounce 窗口(ms)。缺省 60s。 */\n debounceWindowMs?: number\n /** 闸门B:窗口内最大触发次数。缺省 5。 */\n maxFiresPerWindow?: number\n}\n\nexport class InProcessEventTrigger implements EventDrivenTrigger {\n readonly type = 'in-process-event'\n\n /** 闸门B:每任务的触发时刻队列(滑动窗口)。 */\n private readonly fireTimestamps = new Map<string, number[]>()\n\n private readonly now: () => number\n private readonly debounceWindowMs: number\n private readonly maxFiresPerWindow: number\n\n constructor(private deps: InProcessEventTriggerDeps) {\n this.now = deps.now ?? Date.now\n this.debounceWindowMs = deps.debounceWindowMs ?? 60_000\n this.maxFiresPerWindow = deps.maxFiresPerWindow ?? 5\n }\n\n canHandle(task: ScheduledTask): boolean {\n // 只接管进程内 job 事件源的任务(MVP 唯一支持的 source)。\n return task.eventTrigger?.source === 'job'\n }\n\n arm(task: ScheduledTask, fire: (task: ScheduledTask) => void): () => void {\n const spec = task.eventTrigger\n if (!spec || spec.source !== 'job') return () => {}\n\n // 监听 AgentJobRegistry 终态(applied/failed/canceled)。\n return this.deps.jobRegistry.onExit((job) => {\n if (!this.matches(job, spec)) return\n if (!this.passesDebounce(task.id)) {\n this.deps.logger?.error(\n `[schedules] event-trigger ${task.id} debounced (>${this.maxFiresPerWindow} fires / ${this.debounceWindowMs}ms)`,\n )\n return\n }\n fire(task)\n })\n }\n\n // ── 匹配 + 闸门 ──\n\n private matches(job: AgentSessionJob, spec: EventTriggerSpec): boolean {\n // 事件名匹配 job 终态\n if (spec.event !== job.status) return false\n // 可选 sessionId 过滤\n if (spec.filter?.sessionId && spec.filter.sessionId !== job.sessionId) return false\n return true\n }\n\n /** 闸门B:滑动窗口频率检查。返回 true=放行,false=超限拦截。 */\n private passesDebounce(taskId: string): boolean {\n const now = this.now()\n const cutoff = now - this.debounceWindowMs\n const stamps = (this.fireTimestamps.get(taskId) ?? []).filter((t) => t > cutoff)\n if (stamps.length >= this.maxFiresPerWindow) {\n this.fireTimestamps.set(taskId, stamps) // 保留裁剪后的窗口\n return false\n }\n stamps.push(now)\n this.fireTimestamps.set(taskId, stamps)\n return true\n }\n}\n","/**\n * schedule-capability.ts —— Binds a SchedulerService to the shape the schedule_* model\n * tools consume (ToolCallbacksDeps.schedule).\n *\n * `origin` is pinned to 'model' HERE (the model-vs-user distinction lives in\n * wiring, not tool execute — ToolCallContext has no origin field). The scheduler\n * is resolved lazily so this capability object can exist at tool-registration\n * time (the `when: Boolean(c.schedule)` gate) even before the interactive scheduler\n * is constructed; it is populated via App.setScheduleService during interactive\n * setup (RFC-087 A1).\n */\nimport type { SchedulerService } from './scheduler'\n\nexport interface ScheduleCapability {\n create: (input: {\n cron: string\n prompt: string\n recurring: boolean\n name?: string\n /** review I2: optional override for the default 5-min single-fire timeout. */\n maxDurationMs?: number\n /**\n * RFC-357 D3 (M2): where the prompt fires. Default 'job' (existing behavior, zero\n * change). 'session' injects into the CURRENT conversation via followUp instead of\n * dispatching an isolated worktree AgentJob — see `create.ts`'s tool description for\n * the model-facing guidance on when to pick which.\n */\n target?: 'job' | 'session'\n /**\n * RFC-423:进程内事件触发。存在时任务由事件驱动(如 job diff applied)而非 cron。\n * 必须配 target:'session'(或 subscriber)——事件触发不允许 job 投递(死循环闸门)。\n */\n eventTrigger?: { source: string; event: string; filter?: { sessionId?: string } }\n /** RFC-438:到点执行时使用的模型 id(如 \"deepseek/deepseek-v4-flash\")。缺省 = 当前会话模型。 */\n modelId?: string\n /** RFC-438:思考档位(配 modelId 使用)。 */\n thinkingLevel?: 'low' | 'medium' | 'high' | 'xhigh' | 'max'\n }) => {\n taskId: string\n nextFireAt: number\n name: string\n /** RFC-357 D3: echoes the resolved target so callers (e.g. first-fire notice, M2) can react. */\n target?: 'job' | 'session'\n }\n list: () => Array<{\n id: string\n name: string\n cronExpression: string\n enabled: boolean\n recurring: boolean\n nextFireAt: number\n lastFiredAt?: number\n lastFireFailed?: boolean\n lastFireError?: string\n lastFireCancelled?: boolean\n }>\n delete: (taskId: string) => boolean\n run: (taskId: string) => void\n cancel: (taskId: string) => { ok: boolean; reason?: string }\n}\n\nexport interface ScheduleCapabilityDeps {\n /**\n * RFC-438 D3b:create 时校验模型 id。返回错误信息或 undefined(合法)。\n * 缺省 = 不校验(运行时 fail-closed 兜底——fire 时 resolveJobModel 抛错)。\n */\n validateModelId?: (id: string) => string | undefined\n}\n\nexport function createScheduleCapability(\n getScheduler: () => SchedulerService | undefined,\n deps: ScheduleCapabilityDeps = {},\n): ScheduleCapability {\n return {\n create: (input) => {\n const scheduler = getScheduler()\n if (!scheduler) {\n throw new Error('Schedule scheduler is not available in this session.')\n }\n // RFC-438 D3b:create 时提前校验模型 id——模型乱填 id 应在创建时明确拒绝\n // (否则任务建了、到点 fire 才炸,recurring 每晚静默失败)。\n if (input.modelId) {\n const err = deps.validateModelId?.(input.modelId)\n if (err) throw new Error(err)\n }\n const task = scheduler.add({\n name: input.name ?? 'schedule',\n prompt: input.prompt,\n cronExpression: input.cron,\n recurring: input.recurring,\n origin: 'model', // pinned by wiring — schedule_create is always model-originated\n maxDurationMs: input.maxDurationMs,\n // RFC-357 D3: model may request target:'session'. Non-owner rejection and\n // boundSessionId binding happen inside scheduler.add() (RFC-357 M1) — this\n // capability layer does not duplicate that logic.\n target: input.target,\n // RFC-423:事件触发透传。scheduler.add() 内建闸门2(event × job 拒绝)。\n eventTrigger: input.eventTrigger,\n // RFC-438:per-job 模型指定透传(D3b 已校验 modelId 合法)。\n modelId: input.modelId,\n thinkingLevel: input.thinkingLevel,\n })\n return { taskId: task.id, nextFireAt: task.nextFireAt, name: task.name, target: task.target }\n },\n list: () =>\n (getScheduler()?.list() ?? []).map((t) => ({\n id: t.id,\n name: t.name,\n cronExpression: t.cronExpression,\n enabled: t.enabled,\n recurring: t.recurring,\n nextFireAt: t.nextFireAt,\n lastFiredAt: t.lastFiredAt,\n lastFireFailed: t.lastFireFailed,\n lastFireError: t.lastFireError,\n lastFireCancelled: t.lastFireCancelled,\n })),\n delete: (taskId) => getScheduler()?.remove(taskId) ?? false,\n run: (taskId) => {\n getScheduler()?.runNow(taskId)\n },\n cancel: (taskId) => {\n return getScheduler()?.cancel(taskId) ?? { ok: false, reason: 'Schedule scheduler is not available.' }\n },\n }\n}\n"],"mappings":"kgBAyBA,SAAgB,EAAU,EAAc,EAAmB,CACzD,GAAM,CAAE,cAAa,YAAW,WAAU,aAAY,WAAU,iBAAgB,kBAC9E,EAAgB,EAAK,CAEjB,EAAY,EAAO,IAAI,KAAK,EAAK,SAAS,CAAC,CAAG,IAAI,KACxD,EAAU,WAAW,EAAG,EAAE,CAE1B,IAAM,EAAU,IAAI,KAAK,EAAU,SAAS,CAAG,IAAM,GAAK,GAAK,GAAK,IAAK,CACnE,EAAU,IAAI,KAAK,EAAU,SAAS,CAAC,CAE7C,KAAO,EAAQ,SAAS,EAAI,EAAQ,SAAS,EAAE,CAE7C,IAAM,EAAS,EAAQ,YAAY,CAC7B,EAAU,EAAU,EAAa,EAAO,CAC9C,GAAI,IAAY,KAAM,CACpB,EAAQ,WAAW,EAAW,EAAY,CAAC,CAC3C,EAAQ,SAAS,EAAQ,UAAU,CAAG,EAAE,CACxC,EAAQ,WAAW,EAAG,EAAE,CACxB,SAEF,GAAI,IAAY,EAAQ,CACtB,EAAQ,WAAW,EAAQ,CAC3B,EAAQ,WAAW,EAAG,EAAE,CACxB,SAIF,IAAM,EAAO,EAAQ,UAAU,CACzB,EAAS,EAAU,EAAW,EAAK,CACzC,GAAI,IAAW,KAAM,CACnB,EAAQ,SAAS,EAAW,EAAU,CAAC,CACvC,EAAQ,WAAW,EAAW,EAAY,CAAC,CAC3C,EAAQ,QAAQ,EAAQ,SAAS,CAAG,EAAE,CACtC,EAAQ,WAAW,EAAG,EAAE,CACxB,SAEF,GAAI,IAAW,EAAM,CACnB,EAAQ,SAAS,EAAO,CACxB,EAAQ,WAAW,EAAW,EAAY,CAAC,CAC3C,EAAQ,WAAW,EAAG,EAAE,CACxB,SAIF,IAAM,EAAM,EAAQ,SAAS,CACvB,EAAM,EAAQ,QAAQ,CACtB,EAAW,EAAS,IAAI,EAAI,CAC5B,EAAW,EAAS,IAAI,EAAI,CAE9B,EAWJ,GAVA,AAOE,EAPE,GAAkB,EACT,GAAY,EACd,EACE,EACF,EACE,EAEA,GAGT,CAAC,EAAU,CACb,EAAQ,QAAQ,EAAQ,SAAS,CAAG,EAAE,CACtC,EAAQ,SAAS,EAAW,EAAU,CAAC,CACvC,EAAQ,WAAW,EAAW,EAAY,CAAC,CAC3C,EAAQ,WAAW,EAAG,EAAE,CACxB,SAIF,IAAM,EAAQ,EAAQ,UAAU,CAAG,EAC7B,EAAS,EAAU,EAAY,EAAM,CAC3C,GAAI,IAAW,KAAM,CAEnB,EAAQ,SAAS,EAAW,EAAW,CAAG,EAAE,CAC5C,EAAQ,YAAY,EAAQ,aAAa,CAAG,EAAE,CAC9C,EAAQ,QAAQ,EAAE,CAClB,EAAQ,SAAS,EAAW,EAAU,CAAC,CACvC,EAAQ,WAAW,EAAW,EAAY,CAAC,CAC3C,EAAQ,WAAW,EAAG,EAAE,CACxB,SAEF,GAAI,IAAW,EAAO,CACpB,EAAQ,SAAS,EAAS,EAAE,CAC5B,EAAQ,QAAQ,EAAE,CAClB,EAAQ,SAAS,EAAW,EAAU,CAAC,CACvC,EAAQ,WAAW,EAAW,EAAY,CAAC,CAC3C,EAAQ,WAAW,EAAG,EAAE,CACxB,SAIF,OAAO,EAGT,MAAU,MAAM,oCAAoC,EAAK,uBAAuB,EAAU,aAAa,GAAG,CAiB5G,SAAS,EAAgB,EAAgC,CACvD,GAAI,CAAC,GAAQ,CAAC,EAAK,MAAM,CACvB,MAAU,MAAM,wCAAwC,CAG1D,IAAM,EAAS,EAAK,MAAM,CAAC,MAAM,MAAM,CACvC,GAAI,EAAO,SAAW,EACpB,MAAU,MAAM,mDAAmD,EAAO,OAAO,IAAI,EAAK,GAAG,CAG/F,GAAM,CAAC,EAAU,EAAW,EAAU,EAAY,GAAY,EAExD,EAAc,EAAW,EAAU,EAAG,GAAG,CACzC,EAAY,EAAW,EAAW,EAAG,GAAG,CACxC,EAAW,EAAW,EAAU,EAAG,GAAG,CACtC,EAAa,EAAW,EAAY,EAAG,GAAG,CAC1C,EAAW,EAAW,EAAU,EAAG,EAAE,CAGvC,EAAS,IAAI,EAAE,EACjB,EAAS,IAAI,EAAE,CAQjB,IAAM,EAAwB,GAA2B,IAAU,KAAO,IAAU,MAIpF,MAAO,CAAE,cAAa,YAAW,WAAU,aAAY,WAAU,eAH1C,CAAC,EAAqB,EAAS,CAG2B,eAF1D,CAAC,EAAqB,EAAS,CAE2C,CAgBnG,SAAS,EAAW,EAAe,EAAa,EAAuB,CACrE,EAAoB,EAAM,CAE1B,IAAM,EAAS,IAAI,IACb,EAAQ,EAAM,MAAM,IAAI,CAE9B,IAAK,IAAM,KAAQ,EAAO,CACxB,GAAM,CAAE,QAAO,QAAS,EAAU,EAAK,CAGvC,GAFA,EAAa,EAAM,EAAK,CAEpB,IAAU,IACZ,IAAK,IAAI,EAAI,EAAK,GAAK,EAAK,GAAK,EAC/B,EAAO,IAAI,EAAE,SAEN,EAAM,SAAS,IAAI,CAAE,CAC9B,GAAM,CAAC,EAAU,GAAU,EAAM,MAAM,IAAI,CACrC,EAAQ,EAAc,EAAU,EAAK,CACrC,EAAM,EAAc,EAAQ,EAAK,CACvC,GAAI,EAAQ,GAAO,EAAM,GAAO,EAAQ,EACtC,MAAU,MAAM,uBAAuB,EAAM,YAAY,EAAM,GAAG,EAAI,mBAAmB,EAAI,GAAG,EAAI,GAAG,CAEzG,IAAK,IAAI,EAAI,EAAO,GAAK,EAAK,GAAK,EACjC,EAAO,IAAI,EAAE,KAEV,CACL,IAAM,EAAM,EAAc,EAAO,EAAK,CACtC,GAAI,EAAM,GAAO,EAAM,EACrB,MAAU,MACR,uBAAuB,EAAM,WAAW,EAAI,iBAAiB,EAAI,GAAG,EAAI,GACzE,CAEH,GAAI,EAAO,EAET,IAAK,IAAI,EAAI,EAAK,GAAK,EAAK,GAAK,EAC/B,EAAO,IAAI,EAAE,MAGf,EAAO,IAAI,EAAI,EAKrB,OAAO,EAGT,SAAS,EAAoB,EAAqB,CAGhD,GADgB,EAAM,QAAQ,aAAc,GAAG,CACnC,OAAS,EACnB,MAAU,MAAM,uBAAuB,EAAM,uBAAuB,CAIxE,SAAS,EAAU,EAA+C,CAChE,IAAM,EAAW,EAAK,QAAQ,IAAI,CAIlC,OAHI,IAAa,GACR,CAAE,MAAO,EAAM,KAAM,EAAG,CAE1B,CACL,MAAO,EAAK,UAAU,EAAG,EAAS,CAClC,KAAM,SAAS,EAAK,UAAU,EAAW,EAAE,CAAE,GAAG,CACjD,CAGH,SAAS,EAAa,EAAc,EAAoB,CACtD,GAAI,MAAM,EAAK,EAAI,EAAO,EACxB,MAAU,MAAM,wCAAwC,EAAK,GAAG,CAIpE,SAAS,EAAc,EAAW,EAA6B,CAC7D,IAAM,EAAI,SAAS,EAAG,GAAG,CACzB,GAAI,MAAM,EAAE,CACV,MAAU,MAAM,6CAA6C,EAAE,QAAQ,EAAY,GAAG,CAExF,OAAO,EAGT,SAAS,EAAU,EAAe,EAAgC,CAChE,IAAI,EAAsB,KAC1B,IAAK,IAAM,KAAK,EACV,GAAK,IAAY,IAAS,MAAQ,EAAI,KACxC,EAAO,GAGX,OAAO,EAGT,SAAS,EAAW,EAAuB,CACzC,IAAI,EAAO,IACX,IAAK,IAAM,KAAK,EACV,EAAI,IAAM,EAAO,GAEvB,OAAO,EClRT,IAAa,EAAb,KAA8B,CAC5B,MAEA,aAAc,CACZ,KAAK,MAAQ,IAAI,IAGnB,IAAI,EAA2B,CAC7B,GAAI,KAAK,MAAM,IAAI,EAAK,GAAG,CACzB,MAAU,MAAM,wCAAwC,EAAK,GAAG,GAAG,CAErE,KAAK,MAAM,IAAI,EAAK,GAAI,EAAK,CAG/B,OAAO,EAAqB,CAC1B,OAAO,KAAK,MAAM,OAAO,EAAG,CAG9B,OACE,EACA,EAaS,CACT,IAAM,EAAO,KAAK,MAAM,IAAI,EAAG,CAY/B,OAXK,GAED,EAAM,UAAY,IAAA,KAAW,EAAK,QAAU,EAAM,SAClD,EAAM,cAAgB,IAAA,KAAW,EAAK,YAAc,EAAM,aAC1D,EAAM,aAAe,IAAA,KAAW,EAAK,WAAa,EAAM,YACxD,EAAM,gBAAkB,IAAA,KAAW,EAAK,cAAgB,EAAM,eAC9D,EAAM,aAAe,IAAA,KAAW,EAAK,WAAa,EAAM,YACxD,EAAM,iBAAmB,IAAA,KAAW,EAAK,eAAiB,EAAM,gBAChE,EAAM,gBAAkB,IAAA,KAAW,EAAK,cAAgB,EAAM,eAC9D,EAAM,oBAAsB,IAAA,KAAW,EAAK,kBAAoB,EAAM,mBAEnE,IAXW,GAcpB,IAAI,EAAuC,CACzC,OAAO,KAAK,MAAM,IAAI,EAAG,CAG3B,MAAwB,CACtB,MAAO,CAAC,GAAG,KAAK,MAAM,QAAQ,CAAC,CAAC,MAAM,EAAG,IAAM,EAAE,WAAa,EAAE,WAAW,CAY7E,QAAQ,EAA8B,CACpC,IAAM,EAAuB,EAAE,CAC/B,IAAK,IAAM,KAAQ,KAAK,MAAM,QAAQ,CAChC,EAAK,cACL,EAAK,SAAW,EAAK,YAAc,GACrC,EAAI,KAAK,EAAK,CAIlB,OADA,EAAI,MAAM,EAAG,IAAM,EAAE,WAAa,EAAE,WAAW,CACxC,EAGT,OAAgB,CACd,OAAO,KAAK,MAAM,KAGpB,IAAI,EAAqB,CACvB,OAAO,KAAK,MAAM,IAAI,EAAG,GCDhB,EAAb,KAAgC,CAC9B,SAAiC,EAAE,CAEnC,SAAS,EAA2B,CAClC,KAAK,SAAS,KAAK,EAAQ,CAG7B,KAAK,EAA6C,CAChD,OAAO,KAAK,SAAS,KAAM,GAAM,EAAE,UAAU,EAAK,CAAC,GCtD1C,EAAb,KAA0B,CACxB,SAAgD,IAAI,EACpD,OAAuC,IAAI,IAC3C,KAEA,YAAY,EAAwB,CAClC,KAAK,KAAO,EAEZ,IAAK,IAAM,KAAW,EAAK,YACzB,KAAK,SAAS,SAAS,EAAQ,CAKnC,IAAI,EAAyB,CAC3B,OAAO,KAAK,OAAO,IAAI,EAAO,CAIhC,UAAU,EAAsB,CAC9B,KAAK,OAAO,IAAI,EAAO,CAUzB,MAAM,KAAK,EAAqB,EAAsC,CAQpE,GAAI,CAAC,EAAK,SAAW,CAAC,KAAK,KAAK,aAAa,CAAE,CAC7C,KAAK,OAAO,OAAO,EAAK,GAAG,CAC3B,OAGF,IAAM,EAAmB,CACvB,eACA,IAAK,KAAK,KAAK,IACf,OAAQ,KAAK,KAAK,OACnB,CAEG,EACJ,GAAI,CAEF,GADA,EAAU,KAAK,SAAS,KAAK,EAAK,CAC9B,CAAC,EAAS,CACZ,KAAK,KAAK,WAAW,MAAM,EAAK,GAAI,+CAA+C,CAAC,CAAC,EAAK,WAAW,WAAW,EAAK,QAAU,UAAU,GAAG,CAK5I,KAAK,wBAAwB,EAAM,CAAE,QAAS,SAAU,MAAO,yCAA0C,CAAC,CAC1G,OAGF,IAAM,EAAS,MAAM,EAAQ,KAAK,EAAM,EAAI,CAGxC,EAAO,UAAY,WAAa,CAAC,EAAO,YAC1C,KAAK,KAAK,SAAS,OAAO,EAAK,GAAI,CACjC,YAAa,KAAK,KAAK,KAAK,CAC5B,eAAgB,GAChB,cAAe,IAAA,GAChB,CAAC,CACE,EAAK,SAAW,WAClB,KAAK,KAAK,YAAY,QAAQ,EAAK,EAQvC,KAAK,wBAAwB,EAAM,EAAO,OACnC,EAAK,CACZ,KAAK,KAAK,QAAQ,MAAM,oCAAoC,EAAK,GAAG,GAAI,EAAI,CAC5E,KAAK,KAAK,WAAW,MAAM,EAAK,GAAI,UAAU,aAAe,MAAQ,EAAI,QAAU,OAAO,EAAI,GAAG,CAEjG,KAAK,wBAAwB,EAAM,CAAE,QAAS,SAAU,MAAO,aAAe,MAAQ,EAAI,QAAU,OAAO,EAAI,CAAE,CAAC,QAC1G,CACR,GAAS,UAAU,EAAK,GAAG,CAC3B,KAAK,OAAO,OAAO,EAAK,GAAG,EAW/B,wBAAgC,EAAqB,EAAsF,CACrI,EAAK,YAEL,EAAO,UAAY,UAGrB,KAAK,KAAK,YAAY,EAAK,GAAI,oBAAoB,CAC1C,EAAO,WAChB,KAAK,KAAK,SAAS,OAAO,EAAK,GAAI,CACjC,kBAAmB,GACnB,cAAe,IAAA,GAChB,CAAC,CACF,KAAK,KAAK,YAAY,QAAQ,EAAK,GAEnC,KAAK,KAAK,SAAS,OAAO,EAAK,GAAI,CACjC,eAAgB,GAChB,cAAe,EAAO,MACvB,CAAC,CACF,KAAK,KAAK,YAAY,QAAQ,EAAK,KC/I5B,EAAb,KAAwB,CACtB,YAAY,EAAgC,CAAxB,KAAA,OAAA,EAQpB,MAAM,EAAgB,EAAsB,CAC1C,GAAI,GAAqB,CAAE,OAC3B,IAAM,EAAS,EAAQ,EAAW,gBAAgB,CAC5C,EAAU,EAAQ,EAAQ,GAAG,EAAO,GAAG,KAAK,KAAK,CAAC,MAAM,CAC9D,GAAI,CACF,EAAU,EAAQ,CAAE,UAAW,GAAM,CAAC,CACtC,EAAc,EAAS,GAAG,IAAI,MAAM,CAAC,aAAa,CAAC,GAAG,EAAO,IAAK,QAAQ,CAC1E,KAAK,MAAM,EAAQ,EAAO,MACpB,GAMV,MAAc,EAAgB,EAAsB,CAClD,GAAI,CACF,IAAM,EAAS,GAAG,EAAO,GACnB,EAAQ,EAAY,EAAO,CAC9B,OAAQ,GAAM,EAAE,WAAW,EAAO,EAAI,EAAE,SAAS,OAAO,CAAC,CACzD,MAAM,CACH,EAAS,EAAM,OAAS,KAAK,OAAO,mBAC1C,GAAI,GAAU,EAAG,OACjB,IAAK,IAAM,KAAK,EAAM,MAAM,EAAG,EAAO,CACpC,GAAI,CACF,EAAW,EAAQ,EAAQ,EAAE,CAAC,MACxB,QAIJ,GAQV,KAAK,EAAgB,EAAqB,GAAkD,CAC1F,IAAM,EAAS,EAAQ,EAAW,gBAAgB,CAC5C,EAAS,GAAG,EAAO,GACzB,GAAI,CACF,IAAM,EAAQ,EAAY,EAAO,CAC9B,OAAQ,GAAM,EAAE,WAAW,EAAO,EAAI,EAAE,SAAS,OAAO,CAAC,CACzD,MAAM,CACN,SAAS,CACN,EAAwD,EAAE,CAChE,IAAK,IAAI,EAAI,EAAG,EAAI,EAAM,QAAU,EAAQ,OAAS,EAAY,IAC/D,GAAI,CACF,IAAM,EAAU,EAAa,EAAQ,EAAQ,EAAM,GAAI,CAAE,QAAQ,CAAC,MAAM,CAClE,EAAW,EAAQ,QAAQ,IAAI,CAC/B,EAAY,EAAW,EAAI,EAAQ,MAAM,EAAG,EAAS,CAAG,EACxD,EAAS,EAAW,EAAI,EAAQ,MAAM,EAAW,EAAE,CAAG,GAC5D,EAAQ,KAAK,CAAE,YAAW,SAAQ,CAAC,MAC7B,EAIV,OAAO,OACD,CACN,MAAO,EAAE,ICvCf,MAAa,EAA0C,CACrD,SAAU,GACV,oBAAqB,MAAc,GAAK,IACxC,wBAAyB,KAAU,IACnC,sBAAuB,GAAK,IAC5B,qBAAsB,IACtB,kBAAmB,EACnB,eAAgB,IAChB,6BAA8B,EAC9B,mBAAoB,GACpB,oBAAqB,GACrB,wBAAyB,IAC1B,CAUD,SAAgB,EAAY,EAAwB,EAAgB,EAAgB,EAA4B,CAC9G,IAAI,EAAO,EACX,IAAK,IAAI,EAAI,EAAG,EAAI,EAAO,OAAQ,IACjC,GAAS,GAAQ,GAAK,EAAQ,EAAO,WAAW,EAAE,CAClD,GAAQ,EAEV,IAAM,EAAS,EAAY,EAAO,wBAA0B,EAAO,sBAC7D,EAAS,KAAK,IAAI,EAAK,CAAG,EAChC,OAAO,EAAS,SAAS,CAAG,EC5B9B,IAAa,EAAb,KAAsB,CACpB,KACA,OAAwD,KACxD,eAAyB,GACzB,UAAoB,EAEpB,YAAY,EAAoB,CAC9B,KAAK,KAAO,EAId,OAAc,CACR,KAAK,SACT,KAAK,OAAS,gBAAkB,CACzB,KAAK,MAAM,EACf,KAAK,KAAK,OAAO,eAAe,CACnC,KAAK,OAAO,OAAO,EAIrB,MAAa,CACX,AAEE,KAAK,UADL,cAAc,KAAK,OAAO,CACZ,MAWlB,MAAM,UAA0B,CAC9B,MAAM,KAAK,MAAM,CAKnB,MAAc,MAAsB,CAC9B,SAAK,eACT,MAAK,eAAiB,GAEtB,GAAI,CAKF,GAHA,MAAM,KAAK,KAAK,UAAU,OAAO,CAG7B,CAAC,KAAK,KAAK,UAAU,SAAS,CAAE,CAClC,KAAK,MAAM,CACX,KAAK,KAAK,iBAAiB,CAC3B,OAIF,KAAK,YACD,KAAK,UAAY,KAAK,KAAK,OAAO,sBAAwB,GAC5D,MAAM,KAAK,iBAAiB,CAG9B,IAAM,EAAM,KAAK,KAAK,KAAK,CAY3B,IAAK,IAAM,KAAQ,KAAK,KAAK,SAAS,MAAM,CACtC,EAAK,cACL,EAAK,WAAa,EAAM,EAAK,WAAa,KAAK,KAAK,OAAO,qBAC7D,KAAK,KAAK,YAAY,EAAK,GAAI,gBAAgB,CAKnD,IAAM,EAAM,KAAK,KAAK,SAAS,QAAQ,EAAI,CAC3C,IAAK,IAAM,KAAQ,EAAK,CAItB,GAHI,KAAK,KAAK,aAAa,IAAI,EAAK,GAAG,EAGnC,CAAC,EAAK,YAAc,EAAK,gBAAkB,EAAK,mBAAoB,SAGxE,IAAM,EAAe,EAAK,WACpB,EAAW,EAAU,EAAK,eAAgB,IAAI,KAAK,EAAM,IAAO,CAAC,CACjE,EAAW,EAAY,KAAK,KAAK,OAAQ,EAAK,GAAI,EAAU,EAAK,UAAU,CACjF,KAAK,KAAK,SAAS,OAAO,EAAK,GAAI,CAAE,WAAY,EAAU,CAAC,CAC5D,KAAK,KAAK,YAAY,QAAQ,EAAK,CAEnC,KAAK,KAAK,aAAa,UAAU,EAAK,GAAG,CACpC,KAAK,KAAK,aAAa,KAAK,EAAM,EAAa,SAE9C,CACR,KAAK,eAAiB,KAI1B,MAAc,iBAAiC,CAC7C,IAAI,EACJ,GAAI,CACF,EAAQ,MAAM,KAAK,KAAK,MAAM,cAAc,OACrC,EAAK,CACZ,KAAK,KAAK,QAAQ,MAAM,qDAAsD,EAAI,CAClF,OAGF,IAAM,EAAW,IAAI,IAAI,EAAM,IAAK,GAAM,EAAE,GAAG,CAAC,CAEhD,IAAK,IAAM,KAAQ,EAAO,CACxB,GAAI,KAAK,KAAK,aAAa,IAAI,EAAK,GAAG,CAAE,SACzC,GAAI,CAAC,EAAK,WAAa,EAAK,aAAe,MAAQ,CAAC,KAAK,KAAK,SAAS,IAAI,EAAK,GAAG,CAAE,CAC9E,KAAK,KAAK,YAAY,UAAU,EAAK,GAAG,CAAC,MAAO,GAAQ,CAC3D,KAAK,KAAK,QAAQ,MAAM,gDAAgD,EAAK,GAAG,GAAI,EAAI,EACxF,CACF,SAEF,IAAM,EAAW,KAAK,KAAK,SAAS,IAAI,EAAK,GAAG,CAChD,GAAK,EAGH,EAAS,QAAU,EAAK,QACxB,EAAS,KAAO,EAAK,KACrB,EAAS,OAAS,EAAK,OACvB,EAAS,eAAiB,EAAK,eAC/B,EAAS,cAAgB,EAAK,cAC9B,EAAS,WAAa,EAAK,gBAP3B,GAAI,CAAE,KAAK,KAAK,SAAS,IAAI,EAAK,MAAS,GAW/C,IAAK,IAAM,KAAQ,KAAK,KAAK,SAAS,MAAM,CACtC,EAAK,SAAW,WAChB,CAAC,EAAS,IAAI,EAAK,GAAG,EAAI,CAAC,KAAK,KAAK,aAAa,IAAI,EAAK,GAAG,EAEhE,KAAK,KAAK,kBAAkB,EAAK,GAAG,GCtK/B,EAAb,KAAiC,CAC/B,YAAY,EAAuC,CAA/B,KAAA,KAAA,EAMpB,QAAQ,EAA2B,CAC7B,EAAK,SAAW,WACf,KAAK,KAAK,MAAM,SAAS,EAAK,CAAC,MAAO,GAAQ,CACjD,KAAK,KAAK,QAAQ,MAAM,uCAAuC,EAAK,GAAG,GAAI,EAAI,EAC/E,CAOJ,MAAM,UAAU,EAA8B,CAE5C,GADa,KAAK,KAAK,SAAS,IAAI,EAAG,EAC7B,SAAW,UAAW,MAAO,GACvC,GAAI,CACF,OAAO,MAAM,KAAK,KAAK,MAAM,WAAW,EAAG,OACpC,EAAK,CAEZ,OADA,KAAK,KAAK,QAAQ,MAAM,yCAAyC,EAAG,GAAI,EAAI,CACrE,IASX,MAAM,SAAS,EAAuC,CACpD,GAAI,CACF,IAAK,IAAM,KAAQ,EACb,EAAK,SAAW,WACpB,MAAM,KAAK,KAAK,MAAM,SAAS,EAAK,OAE/B,EAAK,CACZ,KAAK,KAAK,QAAQ,MAAM,yCAA0C,EAAI,ICc/D,EAAb,KAA8B,CAC5B,KACA,SACA,YACA,aACA,WACA,SACA,OACA,cAA+D,KAE/D,qBAAsE,KACtE,QAAkB,GAElB,SAEA,cAAiC,IAAI,IAErC,YAAY,EAAqB,CAC/B,KAAK,KAAO,EACZ,KAAK,SAAW,EAAK,SACrB,KAAK,OAAS,CAAE,GAAG,EAAyB,GAAG,EAAK,OAAQ,CAC5D,KAAK,WAAa,IAAI,EAAW,KAAK,OAAO,CAE7C,KAAK,YAAc,IAAI,EAAoB,CACzC,SAAU,EAAK,SACf,MAAO,EAAK,MACZ,OAAQ,EAAK,OACd,CAAC,CACF,KAAK,aAAe,IAAI,EAAa,CACnC,SAAU,EAAK,SACf,YAAa,KAAK,YAClB,WAAY,KAAK,WACjB,YAAa,EAAK,YAClB,IAAK,EAAK,KAAO,KAAK,IACtB,OAAQ,EAAK,OAEb,aAAc,EAAQ,IAAW,CAAE,KAAK,UAAU,EAAQ,EAAO,EAGjE,gBAAmB,KAAK,KAAK,UAAU,SAAS,CACjD,CAAC,CACF,KAAK,SAAW,IAAI,EAAS,CAC3B,SAAU,EAAK,SACf,MAAO,EAAK,MACZ,YAAa,KAAK,YAClB,UAAW,EAAK,UAChB,aAAc,KAAK,aACnB,OAAQ,KAAK,OACb,IAAK,EAAK,KAAO,KAAK,IACtB,OAAQ,EAAK,OACb,oBAAuB,KAAK,eAAe,CAE3C,aAAc,EAAQ,IAAW,CAAE,KAAK,UAAU,EAAQ,EAAO,EACjE,kBAAoB,GAAW,CAAE,KAAK,gBAAgB,EAAO,EAC9D,CAAC,CACF,KAAK,SAAW,EAAK,UAAY,EAAE,CAKrC,aAAuB,CACrB,OAAO,KAAK,KAAK,UAAU,SAAS,CAGtC,wBAAmE,CACjE,OAAO,KAAK,KAAK,UAAU,qBAAqB,CAMlD,MAAM,OAAuB,CACvB,SAAK,QAGT,IAAI,CADY,MAAM,KAAK,KAAK,UAAU,YAAY,CACxC,CACZ,GAAI,CACF,IAAM,EAAQ,MAAM,KAAK,KAAK,MAAM,cAAc,CAClD,IAAK,IAAM,KAAQ,EACjB,GAAI,CAAE,KAAK,SAAS,IAAI,EAAK,MAAS,SAEjC,EAAK,CACZ,KAAK,KAAK,QAAQ,MAAM,yDAA0D,EAAI,CAMxF,KAAK,sBAAsB,CAC3B,OAGF,GAAI,CACF,IAAM,EAAM,KAAK,KAAK,OAAO,EAAI,KAAK,KAAK,CACrC,EAAQ,MAAM,KAAK,KAAK,MAAM,cAAc,CAClD,IAAK,IAAM,KAAQ,EAAO,CACxB,GAAI,EAAK,WAAa,EAAK,WAAa,EACtC,GAAI,CACF,IAAM,EAAW,EAAU,EAAK,eAAgB,IAAI,KAAK,EAAM,IAAO,CAAC,CACvE,EAAK,WAAa,EAAY,KAAK,OAAQ,EAAK,GAAI,EAAU,GAAK,MAC7D,EAIV,GAAI,CAAE,KAAK,SAAS,IAAI,EAAK,MAAS,UAEjC,EAAK,CACZ,KAAK,KAAK,QAAQ,MAAM,wCAAyC,EAAI,CAIvE,IAAK,IAAM,KAAQ,KAAK,SAAS,MAAM,CACrC,KAAK,iBAAiB,EAAK,CAG7B,KAAK,QAAU,GACf,KAAK,SAAS,OAAO,EAevB,UAAkB,EAAgB,EAAoC,CAIpE,GADA,KAAK,cAAc,EAAO,CACtB,CAAC,KAAK,SAAS,IAAI,EAAO,CAAE,MAAO,GAClC,KAAK,YAAY,UAAU,EAAO,CACvC,IAAM,EAAU,KAAK,SAAS,OAAO,EAAO,CAE5C,OADA,KAAK,KAAK,QAAQ,QAAQ,0BAA0B,EAAO,IAAI,EAAO,GAAG,CAClE,EAQT,gBAAwB,EAAyB,CAE/C,OADA,KAAK,cAAc,EAAO,CACnB,KAAK,SAAS,OAAO,EAAO,CAIrC,MAAM,MAAsB,CAG1B,KAAK,qBAAqB,CACtB,GAAC,KAAK,SAAW,CAAC,KAAK,KAAK,UAAU,SAAS,IAEnD,KAAK,cAAc,CACnB,KAAK,SAAS,MAAM,CACpB,KAAK,WAAW,CAEhB,KAAK,QAAU,GACf,MAAM,KAAK,KAAK,UAAU,SAAS,CACnC,MAAM,KAAK,YAAY,SAAS,KAAK,SAAS,MAAM,CAAC,EAKvD,eAA8B,CACxB,KAAK,gBACT,KAAK,KAAK,QAAQ,MAAM,2DAA2D,CACnF,KAAK,cAAgB,gBAAkB,CAChC,KAAK,iBAAiB,EAC1B,KAAK,OAAO,wBAAwB,CACvC,KAAK,cAAc,OAAO,EAG5B,MAAc,iBAAiC,CAC7C,GAAI,CAEF,GADiB,MAAM,KAAK,KAAK,UAAU,YAAY,EACvC,KAAK,KAAK,UAAU,SAAS,CAAE,CAC7C,KAAK,cAAc,CACnB,KAAK,QAAU,GAKf,IAAK,IAAM,KAAQ,KAAK,SAAS,MAAM,CAAE,KAAK,iBAAiB,EAAK,CACpE,KAAK,SAAS,OAAO,CACrB,KAAK,KAAK,QAAQ,MAAM,6DAA6D,OAEjF,GAKV,cAA6B,CAC3B,AAEE,KAAK,iBADL,cAAc,KAAK,cAAc,CACZ,MAWzB,sBAAqC,CAC/B,KAAK,uBACT,KAAK,qBAAuB,gBAAkB,CACvC,KAAK,wBAAwB,EACjC,KAAK,OAAO,wBAAwB,CACvC,KAAK,qBAAqB,OAAO,EAGnC,MAAc,wBAAwC,CACpD,GAAI,CAEF,GADiB,MAAM,KAAK,KAAK,UAAU,YAAY,EACvC,KAAK,KAAK,UAAU,SAAS,CAAE,CAC7C,KAAK,qBAAqB,CAG1B,GAAI,CACF,IAAM,EAAQ,MAAM,KAAK,KAAK,MAAM,cAAc,CAClD,IAAK,IAAM,KAAQ,EACjB,GAAI,CAAE,KAAK,SAAS,IAAI,EAAK,MAAS,SAEjC,EAAK,CACZ,KAAK,KAAK,QAAQ,MAAM,oEAAqE,EAAI,CAEnG,IAAK,IAAM,KAAQ,KAAK,SAAS,MAAM,CAAE,KAAK,iBAAiB,EAAK,CACpE,KAAK,QAAU,GACf,KAAK,SAAS,OAAO,CACrB,KAAK,KAAK,QAAQ,MAAM,gEAAgE,OAEpF,GAKV,qBAAoC,CAClC,AAEE,KAAK,wBADL,cAAc,KAAK,qBAAqB,CACZ,MAMhC,IAAI,EAA0C,CAC5C,GAAI,KAAK,SAAS,OAAO,EAAI,KAAK,OAAO,SACvC,MAAU,MAAM,+BAA+B,KAAK,OAAO,SAAS,0CAA0C,CAGhH,IAAM,EAAe,EAAM,SAAW,WAAa,CAAC,EAAM,WAE1D,GAAI,GAAgB,CAAC,KAAK,KAAK,UAAU,SAAS,CAChD,MAAU,MAAM,KAAK,iCAAiC,CAAC,CAKzD,GAAI,EAAM,cAAgB,CAAC,EAAM,YAAc,EAAM,SAAW,UAC9D,MAAU,MACR,6LAED,CAGH,IAAM,EAAK,OAAO,YAAY,CAAC,MAAM,EAAG,EAAE,CACpC,EAAM,KAAK,KAAK,OAAO,EAAI,KAAK,KAAK,CACrC,EAAW,EAAU,EAAM,eAAgB,IAAI,KAAK,EAAM,IAAO,CAAC,CAClE,EAAa,EAAY,KAAK,OAAQ,EAAI,EAAU,EAAM,UAAU,CAEpE,EAAsB,CAC1B,KACA,KAAM,EAAM,KACZ,OAAQ,EAAM,OACd,eAAgB,EAAM,eACtB,QAAS,GACT,UAAW,EAAM,UACjB,OAAQ,EAAM,OACd,UAAW,EACX,aACA,cAAe,EAAM,eAAiB,KAAK,OAAO,qBAClD,WAAY,EAAM,YAAc,KAAK,OAAO,kBAC5C,GAAI,EAAM,WAAa,CAAE,WAAY,EAAM,WAAY,CAAG,EAAE,CAC5D,GAAI,EAAe,CAAE,OAAQ,UAAoB,eAAgB,KAAK,qBAAqB,CAAE,CAAG,EAAE,CAClG,GAAI,EAAM,aAAe,CAAE,aAAc,EAAM,aAAc,CAAG,EAAE,CAClE,GAAI,EAAM,QAAU,CAAE,QAAS,EAAM,QAAS,CAAG,EAAE,CACnD,GAAI,EAAM,cAAgB,CAAE,cAAe,EAAM,cAAe,CAAG,EAAE,CACtE,CAKD,OAHA,KAAK,SAAS,IAAI,EAAK,CACvB,KAAK,YAAY,QAAQ,EAAK,CAC9B,KAAK,iBAAiB,EAAK,CACpB,EAGT,OAAO,EAAqB,CAC1B,OAAO,KAAK,UAAU,EAAI,eAAe,CAG3C,OACE,EACA,EACS,CACT,IAAM,EAAO,KAAK,SAAS,IAAI,EAAG,CAClC,GAAI,CAAC,EAAM,MAAO,GAKlB,GAHI,EAAM,OAAS,IAAA,KAAW,EAAK,KAAO,EAAM,MAC5C,EAAM,SAAW,IAAA,KAAW,EAAK,OAAS,EAAM,QAChD,EAAM,UAAY,IAAA,KAAW,EAAK,QAAU,EAAM,SAClD,EAAM,iBAAmB,IAAA,GAAW,CACtC,EAAK,eAAiB,EAAM,eAC5B,IAAM,EAAM,KAAK,KAAK,OAAO,EAAI,KAAK,KAAK,CACrC,EAAW,EAAU,EAAK,eAAgB,IAAI,KAAK,EAAM,IAAO,CAAC,CACvE,EAAK,WAAa,EAAY,KAAK,OAAQ,EAAK,GAAI,EAAU,EAAK,UAAU,CAI/E,OADA,KAAK,YAAY,QAAQ,EAAK,CACvB,GAGT,MAAwB,CACtB,OAAO,KAAK,SAAS,MAAM,CAG7B,mBAAiC,CAE/B,IAAK,IAAM,KAAW,KAAK,KAAK,YAC9B,GAAI,EAAQ,kBACV,OAAO,EAAQ,mBAAmB,CAGtC,OAAO,IAAI,IAGb,aAAa,EAAgB,EAAqB,GAAkD,CAClG,OAAO,KAAK,WAAW,KAAK,EAAQ,EAAW,CAGjD,OAAO,EAAkB,CACvB,IAAM,EAAO,KAAK,SAAS,IAAI,EAAG,CAClC,GAAI,CAAC,EACH,MAAU,MAAM,kBAAkB,EAAG,aAAa,CAEpD,GAAI,CAAC,EAAK,QACR,MAAU,MAAM,kBAAkB,EAAG,eAAe,CAKtD,GAAI,CAAC,KAAK,KAAK,UAAU,SAAS,CAChC,MAAU,MAAM,kBAAkB,EAAG,+DAA+D,CAEtG,GAAI,KAAK,aAAa,IAAI,EAAK,GAAG,CAChC,MAAU,MAAM,kBAAkB,EAAG,qBAAqB,CAE5D,KAAK,aAAa,UAAU,EAAK,GAAG,CAC/B,KAAK,aAAa,KAAK,EAAK,CAGnC,OAAO,EAA8B,CAEnC,GAAI,CADS,KAAK,SAAS,IAAI,EAAO,CAC3B,MAAO,CAAE,GAAI,GAAO,OAAQ,iBAAkB,CAGzD,IAAK,IAAM,KAAW,KAAK,KAAK,YAC9B,GAAI,EAAQ,cACV,OAAO,EAAQ,cAAc,EAAO,CAGxC,MAAO,CAAE,GAAI,GAAO,OAAQ,cAAe,CAY7C,iBAAyB,EAA2B,CAClD,GAAI,CAAC,EAAK,aAAc,OACxB,IAAM,EAAU,KAAK,SAAS,KAAM,GAAM,EAAE,UAAU,EAAK,CAAC,CAC5D,GAAI,CAAC,EAAS,CACZ,KAAK,KAAK,QAAQ,MAChB,kCAAkC,EAAK,GAAG,mCAAmC,EAAK,aAAa,OAAO,qBACvG,CACD,OAGF,KAAK,cAAc,EAAK,GAAG,CAC3B,IAAM,EAAS,EAAQ,IAAI,EAAO,GAAM,CAClC,KAAK,aAAa,IAAI,EAAE,GAAG,GAC/B,KAAK,aAAa,UAAU,EAAE,GAAG,CAC5B,KAAK,aAAa,KAAK,EAAE,GAC9B,CACF,KAAK,cAAc,IAAI,EAAK,GAAI,EAAO,CAGzC,cAAsB,EAAsB,CAC1C,IAAM,EAAS,KAAK,cAAc,IAAI,EAAO,CACzC,IACF,GAAQ,CACR,KAAK,cAAc,OAAO,EAAO,EAIrC,WAA0B,CACxB,IAAK,IAAM,KAAU,KAAK,cAAc,QAAQ,CAAE,GAAQ,CAC1D,KAAK,cAAc,OAAO,CAgB5B,iCAAkD,CAChD,IAAM,EAAO,4FAEb,GAAI,KAAK,KAAK,UAAU,qBAAqB,GAAK,gBAChD,MAAO,GAAG,EAAK,qGAGjB,IAAM,EAAQ,KAAK,KAAK,UAAU,iBAAiB,CAKnD,MAAO,GAAG,IAJK,GAAO,MAAQ,IAAA,GAE1B,oEADA,sDAAsD,EAAM,MAAM,EAAM,OAAS,IAAA,GAAkC,GAAtB,OAAO,EAAM,OAAY,IACtH,0GAIoB,KAAK,MAAM,KAAK,OAAO,wBAA0B,IAAK,CAAC,oFAajF,qBAAsC,CACpC,IAAK,IAAM,KAAW,KAAK,KAAK,YAC9B,GAAI,EAAQ,aACV,OAAO,EAAQ,cAAc,CAGjC,MAAO,KCjhBX,SAAS,EAAa,EAA8B,CAClD,OAAO,EAAQ,EAAW,YAAa,EAAa,CAuBtD,SAAgB,EAAoB,EAAsB,EAAgC,EAAE,CAAiB,CAO3G,IAAM,EACJ,EAAQ,cACP,GAAqB,CAClB,EAAiC,CAAE,KAAM,SAAU,CAAC,CACpD,EAAiC,CAAE,KAAM,OAAQ,QAAS,EAAa,EAAa,CAAE,CAAC,EACvF,EAAS,EAAQ,OAEvB,MAAO,CACL,MAAM,SAAS,EAAoC,CACjD,IAAM,EAAoC,CACxC,QAASA,EACT,GAAI,EAAK,GACT,KAAM,EACN,SAAU,CACR,UAAW,EAAK,UAChB,UAAW,KAAK,KAAK,CACrB,KAAM,CAAC,WAAW,CACnB,CACF,CACD,MAAM,EAAM,KAAK,EAAS,EAG5B,MAAM,cAAyC,CAC7C,IAAI,EACJ,GAAI,CACF,EAAM,MAAM,EAAM,MAAM,OACjB,EAAK,CAEZ,OADA,GAAQ,MAAM,iDAAkD,EAAI,CAC7D,EAAE,CAGX,IAAM,EAAyB,EAAE,CACjC,IAAK,IAAM,KAAM,EACf,GAAI,CACF,IAAM,EAAW,MAAM,EAAM,KAAK,EAAG,CACjC,GACF,EAAM,KAAK,EAAS,KAAK,OAEpB,EAAK,CACZ,GAAQ,MAAM,wCAAwC,EAAG,IAAK,EAAI,CAMtE,OADA,EAAM,MAAM,EAAG,IAAM,EAAE,WAAa,EAAE,WAAW,CAC1C,GAGT,MAAM,WAAW,EAA8B,CAC7C,GAAI,CACF,OAAO,MAAM,EAAM,OAAO,EAAG,OACtB,EAAK,CAEZ,OADA,GAAQ,MAAM,0CAA0C,EAAG,IAAK,EAAI,CAC7D,KAGZ,CC3EH,IAAM,EAAN,cAAsC,CAA6D,CACjG,IAAuB,cAAuB,CAC5C,MAAO,gBAET,IAAuB,cAAuB,CAC5C,MAAO,gBAGT,UAAoB,EAAoC,CACtD,OAAO,EAAS,GAGlB,MAAM,SAAS,EAAoC,CACjD,MAAM,KAAK,KAAK,CAAE,QAAS,EAAkB,GAAI,EAAK,GAAI,KAAM,EAAM,CAAC,CASzE,MAAM,cAAyC,CAC7C,IAAM,EAAM,MAAM,KAAK,MAAM,CACvB,EAAyB,EAAE,CACjC,IAAK,IAAM,KAAM,EAAK,CACpB,IAAM,EAAW,MAAM,KAAK,KAAK,EAAG,CAChC,GAAU,EAAM,KAAK,EAAS,KAAK,CAGzC,OADA,EAAM,MAAM,EAAG,IAAM,EAAE,WAAa,EAAE,WAAW,CAC1C,EAGT,MAAM,WAAW,EAA8B,CAC7C,OAAO,KAAK,OAAO,EAAG,GAwB1B,SAAgB,EAA0B,EAAoD,CAS5F,OAAO,IAAI,EAPuD,CAChE,QAAS,GAFI,IAAI,IAAI,EAAQ,WAAW,CAAC,OAEtB,wBACnB,QAAS,EAAQ,QACjB,MAAO,EAAQ,OAAS,WAAW,MACnC,UAAW,EAAQ,WAAa,IAChC,MAAO,EAAQ,MAChB,CACgD,CC1EnD,MAAa,EAAyC,CACpD,CACE,QAAS,EACT,GAAK,GAAO,CACV,EAAG,KAAK;;;;;;QAMN,EAEL,CACF,CCDD,SAAS,EAAa,EAA8B,CAClD,OAAO,EAAQ,EAAW,kBAAkB,EAAa,OAAO,CAgClE,SAAgB,EAAyB,EAAqC,CAC5E,IAAM,EAAQ,GAAY,CACpB,EAAY,EAAa,EAAa,CACtC,EAAa,GAAiB,CAChC,EAAQ,GAGN,MAA2B,CAC/B,GAAI,CAGF,EAAU,EAAW,CAAE,UAAW,GAAM,CAAC,CACzC,IAAM,EAAK,EAAS,EAAW,KAAK,CACpC,GAAI,CACF,EAAU,EAAI,EAAiB,EAAO,QAAQ,IAAK,EAAW,CAAC,QACvD,CACR,EAAU,EAAG,CAGf,MADA,GAAQ,GACD,SACA,EAAK,CACZ,GAAK,EAA8B,OAAS,SAAU,MAAM,EAC5D,MAAO,KAKL,MAA2E,CAC/E,GAAI,CAEF,OAAO,EADK,EAAa,EAAW,QAAQ,CAAC,MAAM,CACvB,MACtB,CACN,OAAO,OAKL,MAAiC,GAAa,EAAE,OAAS,KAGzD,EAAmB,GACnB,EAAQ,MAAQ,IAAA,IAAa,EAAQ,OAAS,IAAA,IAC9C,EAAQ,OAAS,EAAmB,GACjC,CAAC,EAAe,EAAQ,IAAI,CAI/B,EAAoB,GACpB,EAAQ,MAAQ,IAAA,IAAa,EAAQ,OAAS,IAAA,IAC9C,EAAQ,OAAS,EAAmB,GACjC,EAAe,EAAQ,IAAI,CAO9B,MAAgC,CACpC,GAAI,EAAO,MAAO,GAKlB,GAAI,GAAqB,CAAE,MAAO,GAClC,GAAI,GAAW,CAAE,MAAO,GAGxB,IAAM,EAAU,GAAa,CACzB,EACJ,GAAI,CACF,EAAQ,EAAS,EAAU,CAAC,aACtB,CAEN,OAAO,GAAW,CAUpB,GAPI,IAAY,MAAQ,EAAiB,EAAQ,EAO7C,EADkB,IAAY,MAAQ,EAAgB,EAAQ,GAC5C,KAAK,KAAK,CAAG,GAAS,IAAU,MAAO,GAG7D,IAAM,EAAY,GAAG,EAAU,SAAS,GAAY,GACpD,GAAI,CACF,EAAW,EAAW,EAAU,CAChC,EAAO,EAAW,CAAE,MAAO,GAAM,CAAC,MAC5B,CACN,MAAO,GAET,OAAO,GAAW,EAGpB,MAAO,CACL,MAAM,YAA+B,CACnC,OAAO,GAAgB,EAGzB,MAAM,OAAuB,CACtB,KAEL,IAAI,GAAW,GAAK,EAAO,CACzB,EAAQ,GACR,OAEF,GAAI,CACF,IAAM,EAAI,IAAI,KACd,EAAW,EAAW,EAAG,EAAE,MACrB,CACN,EAAQ,MAIZ,MAAM,SAAyB,CACxB,KAEL,IAAI,GAAW,GAAK,EAClB,GAAI,CACF,EAAW,EAAU,MACf,EAIV,EAAQ,KAGV,SAAmB,CACjB,OAAO,GAAS,GAAW,GAAK,GAOlC,eAA6D,CAC3D,IAAM,EAAU,GAAa,CACzB,OAAY,MACZ,IAAQ,MAAQ,IAAA,IAAa,EAAQ,OAAS,IAAA,IAClD,MAAO,CAAE,GAAI,EAAQ,MAAQ,IAAA,GAAmC,EAAE,CAAzB,CAAE,IAAK,EAAQ,IAAK,CAAQ,GAAI,EAAQ,OAAS,IAAA,GAAqC,EAAE,CAA3B,CAAE,KAAM,EAAQ,KAAM,CAAQ,EAEvI,CCxJH,SAAgB,EAA0B,EAAoD,CAC5F,IAAM,EAAQ,GAAY,CACpB,EAAY,EAAQ,OAAS,WAAW,MACxC,EAAY,EAAQ,WAAa,IACjC,EAAU,IAAI,IAAI,EAAQ,WAAW,CAAC,OACtC,EAAM,EAAQ,KAAO,KAAK,IAC5B,EAAQ,GAER,EAAkB,EAElB,EAEA,EAEE,EAAY,KAAO,IAAkD,CACzE,IAAM,EAAO,MAAM,EAAQ,SAAS,CAC9B,EAAa,IAAI,gBACjB,EAAU,eAAiB,EAAW,OAAO,CAAE,EAAU,CAC/D,GAAI,CACF,IAAM,EAAW,MAAM,EACrB,GAAG,EAAQ,iBAAiB,mBAAmB,EAAQ,MAAM,CAAC,SAAS,IACvE,CACE,OAAQ,OACR,QAAS,CACP,eAAgB,mBAChB,cAAe,UAAU,EAAK,QAC/B,CACD,KAAM,KAAK,UAAU,CAAE,QAAO,CAAC,CAC/B,OAAQ,EAAW,OACpB,CACF,CAED,OADK,EAAS,GACP,CAAE,GAAI,GAAM,KAAO,MAAM,EAAS,MAAM,CAA8B,CADpD,CAAE,GAAI,GAAO,OAAQ,gBAAiB,MAEzD,CAGN,MAAO,CAAE,GAAI,GAAO,OAAQ,gBAAiB,QACrC,CACR,aAAa,EAAQ,GAInB,MAAqC,CAKzC,GAAI,EAAiB,OAAO,EAE5B,IAAM,GAAW,SAA8B,CAC7C,EAAkB,GAAK,CACvB,IAAM,EAAS,MAAM,EAAU,UAAU,CACzC,GAAI,CAAC,EAAO,GAGV,MAFA,GAAQ,GACR,EAAoB,gBACb,GAET,IAAM,EAAW,EAAO,KAAK,WAAgB,GAG7C,MAFA,GAAQ,EACR,EAAoB,EAAW,IAAA,GAAY,WACpC,KACL,CAMJ,MAJA,GAAkB,EAClB,EAAQ,YAAc,CAChB,IAAoB,IAAS,EAAkB,IAAA,KACnD,CACK,GAGT,MAAO,CACL,aAEA,MAAM,OAAuB,CAK3B,GAAI,GAAK,CAAG,EAAA,IAAgC,OAE5C,GAAI,CAAC,EAAO,CAIV,MAAM,GAAY,CAClB,OAEF,EAAkB,GAAK,CACvB,IAAM,EAAS,MAAM,EAAU,QAAQ,CACvC,GAAI,CAAC,EAAO,GAAI,CAId,EAAQ,GACR,EAAoB,gBACpB,OAEc,EAAO,KAAK,UAAe,GAQzC,EAAoB,IAAA,IAHpB,EAAQ,GACR,EAAoB,aAMxB,MAAM,SAAyB,CACxB,AAEL,KADA,MAAM,EAAU,UAAU,CAClB,KAGV,SAAmB,CACjB,OAAO,GAGT,mBAA8D,CAC5D,OAAO,EAAQ,IAAA,GAAY,GAE9B,CCxHH,IAAa,EAAb,KAAiD,CAC/C,KAAgB,MAEhB,oBAAsE,IAAI,IAC1E,gBAAgD,IAAI,IACpD,eAAyB,EAEzB,YAAY,EAAiC,CAAzB,KAAA,KAAA,EAEpB,UAAU,EAA8B,CAEtC,MAAO,CAAC,EAAK,YAAc,EAAK,SAAW,UAG7C,MAAM,KAAK,EAAqB,EAAuC,CACrE,IAAI,EAAgC,SAChC,EAAmB,GACnB,EACE,EAAY,EAAE,KAAK,eAEnB,EAA0B,EAAK,SAAW,QAAU,OAAS,OAG7D,EAAY,KAAK,KAAK,gBAAgB,EAAI,GAEhD,IAAK,IAAI,EAAU,EAAG,GAAW,EAAK,WAAY,IAAW,CAC3D,GAAI,KAAK,gBAAgB,IAAI,EAAK,GAAG,CAAE,CACrC,KAAK,KAAK,WAAW,MAAM,EAAK,GAAI,6BAA6B,EAAU,EAAE,GAAG,CAChF,EAAmB,GACnB,MAMF,IAAI,EACJ,GAAI,CACF,EAAM,KAAK,KAAK,SAAS,CACvB,MAAO,EAAK,KACZ,OAAQ,EAAK,OACb,YACA,SACA,GAAI,EAAK,QAAU,CAAE,MAAO,CAAE,GAAI,EAAK,QAAS,cAAe,EAAK,cAAe,CAAE,CAAG,EAAE,CAC3F,CAAC,OACK,EAAK,CAGZ,GAFA,EAAY,aAAe,MAAQ,EAAI,QAAU,OAAO,EAAI,CAC5D,KAAK,KAAK,WAAW,MAAM,EAAK,GAAI,4BAA4B,EAAU,EAAE,KAAK,IAAY,CACzF,EAAU,EAAK,WAAY,CAC7B,IAAM,EAAY,KAAK,KAAK,gBAAkB,EAE9C,MADc,KAAK,KAAK,QAAW,GAAe,IAAI,QAAe,GAAY,WAAW,EAAS,EAAG,CAAC,GAC7F,EAAU,CAExB,SAGF,KAAK,oBAAoB,IAAI,EAAK,GAAI,CAAE,MAAO,EAAI,GAAI,YAAW,UAAS,CAAC,CAE5E,IAAM,EAAS,MAAM,KAAK,kBAAkB,EAAI,GAAI,EAAK,cAAe,EAAI,CAE5E,GAAI,IAAW,UAAW,CAExB,IAAM,EAAc,KAAK,KAAK,YAAY,IAAI,EAAI,GAAG,CAC/C,EAAe,GAAa,SAAW,SAAW,CAAC,CAAC,EAAY,MAAM,MAAM,CAQlF,OAPA,KAAK,KAAK,WAAW,MACnB,EAAK,GACL,EACI,sBAAsB,EAAU,EAAE,6BAA6B,EAAI,KACnE,sBAAsB,EAAU,EAAE,GACvC,CACD,EAAU,UACH,CAAE,UAAS,MAAO,EAAI,GAAI,CAGnC,GAAI,IAAW,WAAY,CACzB,KAAK,KAAK,WAAW,MAAM,EAAK,GAAI,6BAA6B,EAAU,EAAE,GAAG,CAChF,EAAmB,GACnB,MAKF,GAFA,EAAY,IAAW,UAAY,YAAc,aAE7C,EAAU,EAAK,WAAY,CAC7B,IAAM,EAAY,KAAK,KAAK,gBAAkB,EAE9C,MADc,KAAK,KAAK,QAAW,GAAe,IAAI,QAAe,GAAY,WAAW,EAAS,EAAG,CAAC,GAC7F,EAAU,EAQ1B,OAJK,GACH,KAAK,KAAK,WAAW,MAAM,EAAK,GAAI,gBAAgB,EAAK,WAAa,EAAE,aAAa,CAGhF,CACL,UACA,UAAW,EACX,MAAO,EACR,CAUH,cAAc,EAA8B,CAC1C,IAAI,EACE,EAAS,KAAK,oBAAoB,IAAI,EAAO,CACnD,GAAI,EACF,EAAQ,EAAO,UACV,CAEL,IAAM,EAAc,KAAK,KAAK,YAAY,MAAM,CAAC,OAC9C,GAAM,EAAE,SAAW,WAAa,EAAE,SAAW,cAC/C,CACG,EAAY,SAAW,IACzB,EAAQ,EAAY,GAAI,IAG5B,GAAI,CAAC,EAAO,MAAO,CAAE,GAAI,GAAO,OAAQ,cAAe,CAEvD,IAAM,EAAM,KAAK,KAAK,YAAY,IAAI,EAAM,CAS5C,OARK,EACD,EAAI,SAAW,QAAgB,CAAE,GAAI,GAAO,OAAQ,eAAgB,CACpE,EAAI,SAAW,UAAkB,CAAE,GAAI,GAAO,OAAQ,sBAAuB,CAC7E,EAAI,SAAW,SAAiB,CAAE,GAAI,GAAO,OAAQ,qBAAsB,CAC3E,EAAI,SAAW,WAAmB,CAAE,GAAI,GAAO,OAAQ,uBAAwB,EAEnF,KAAK,gBAAgB,IAAI,EAAO,CAChC,KAAK,KAAK,YAAY,OAAO,EAAM,CAC5B,CAAE,GAAI,GAAM,EARF,CAAE,GAAI,GAAO,OAAQ,gBAAiB,CAYzD,QAAQ,EAAsB,CAC5B,KAAK,oBAAoB,OAAO,EAAO,CACvC,KAAK,gBAAgB,OAAO,EAAO,CAIrC,mBAAiC,CAC/B,OAAO,IAAI,IAAI,KAAK,oBAAoB,MAAM,CAAC,CAKjD,kBACE,EACA,EACA,EACwD,CACxD,IAAM,EAA+B,KAAK,KAAK,8BAAgC,EAE/E,OAAO,IAAI,QAAS,GAAY,CAC9B,IAAI,EAAU,GACV,EACA,EACA,EAGE,EADY,EAAI,KAAK,CACM,EAAgB,EAE3C,MAAgB,CAChB,GAAS,aAAa,EAAQ,CAClC,KAAa,CACb,KAAe,EAGX,EAAU,GAA0D,CACpE,IACJ,EAAU,GACV,GAAS,CACT,EAAQ,EAAO,GAGX,EAAc,GAAe,CAC7B,GAAS,aAAa,EAAQ,CAClC,EAAU,eAAiB,CACzB,KAAK,KAAK,YAAY,OAAO,EAAM,CACnC,EAAO,SAAS,EACf,EAAG,EAGR,EAAc,KAAK,KAAK,YAAY,SAAU,GAAQ,CAChD,KAAI,KAAO,EAEf,IAAI,EAAI,SAAW,QACjB,EAAO,UAAU,SACR,EAAI,SAAW,cAAe,CACvC,IAAM,EAAM,EAAI,KAAK,CACf,EAAY,KAAK,IAAI,EAAe,EAAe,EAAI,CAC7D,GAAI,GAAa,EAAG,CAClB,KAAK,KAAK,YAAY,OAAO,EAAM,CACnC,EAAO,SAAS,CAChB,OAEF,EAAW,EAAU,IAEvB,CAEF,EAAY,KAAK,KAAK,YAAY,OAAQ,GAAQ,CAC5C,EAAI,KAAO,IAEX,EAAI,SAAW,SACjB,EAAO,SAAS,CACP,EAAI,SAAW,WACxB,EAAO,WAAW,CACT,EAAI,SAAW,WACxB,EAAO,UAAU,GAEnB,CAEF,EAAW,EAAc,EACzB,GC3OO,EAAb,KAAqD,CACnD,KAAgB,UAEhB,YAAY,EAAqC,CAA7B,KAAA,KAAA,EAEpB,UAAU,EAA8B,CACtC,OAAO,EAAK,SAAW,UAIzB,cAAuB,CACrB,OAAO,KAAK,KAAK,cAAc,CAGjC,MAAM,KAAK,EAAqB,EAAwC,CACtE,GAAI,CAAC,EAAK,eAER,OADA,KAAK,KAAK,WAAW,MAAM,EAAK,GAAI,mEAAmE,CAChG,CAAE,QAAS,SAAU,MAAO,oBAAqB,CAK1D,IAAM,EAAmB,EAAK,SAAW,SAAW,EAAK,aAAe,KAClE,EAAS,gBAAgB,EAAK,OAC9B,EAAS,EACX,GAAG,EAAO,4DAA4D,EAAK,GAAG,kBAAkB,EAAK,SACrG,GAAG,EAAO,MAAM,EAAK,SAErB,EAAY,GAChB,GAAI,CACF,EAAY,KAAK,KAAK,SAAS,CAC7B,SACA,eAAgB,EAAK,eACrB,KAAM,EAAK,KACX,OAAQ,EAAK,OACd,CAAC,OACK,EAAK,CAKZ,OAJA,KAAK,KAAK,WAAW,MACnB,EAAK,GACL,uBAAuB,aAAe,MAAQ,EAAI,QAAU,OAAO,EAAI,GACxE,CACM,CAAE,QAAS,SAAU,MAAO,aAAe,MAAQ,EAAI,QAAU,OAAO,EAAI,CAAE,CASvF,OANA,KAAK,KAAK,WAAW,MACnB,EAAK,GACL,EACI,mCAAmC,EAAK,eAAe,GACvD,uCAAuC,EAAK,eAAe,6DAChE,CACM,CAAE,QAAS,EAAY,UAAY,SAAU,GC1D3C,EAAb,KAAwD,CACtD,KAAgB,iBAEhB,YAAY,EAAwC,CAAhC,KAAA,KAAA,EAEpB,UAAU,EAA8B,CACtC,MAAO,CAAC,CAAC,EAAK,WAGhB,MAAM,KAAK,EAAqB,EAAuC,CACrE,IAAM,EAAa,EAAK,WACxB,GAAI,CAAC,EAAY,MAAO,CAAE,QAAS,SAAU,MAAO,wBAAyB,CAE7E,IAAM,EAAa,EAAI,KAAK,CAGtB,EAAe,GAAG,EAAK,GAAG,GAAG,EAAI,cAAgB,IAEnD,EAAY,GAChB,GAAI,CACF,EAAY,KAAK,KAAK,OAAO,EAAY,CAAE,WAAY,EAAK,GAAI,eAAc,aAAY,CAAC,OACpF,EAAK,CAKZ,OAJA,KAAK,KAAK,WAAW,MACnB,EAAK,GACL,0BAA0B,aAAe,MAAQ,EAAI,QAAU,OAAO,EAAI,GAC3E,CACM,CAAE,QAAS,SAAU,MAAO,aAAe,MAAQ,EAAI,QAAU,OAAO,EAAI,CAAE,CASvF,OANA,KAAK,KAAK,WAAW,MACnB,EAAK,GACL,EACI,8BAA8B,EAAW,SAAS,GAAG,EAAW,UAAU,eAAe,EAAa,GACtG,kCAAkC,EAAW,SAAS,GAAG,EAAW,UAAU,eACnF,CACM,CAAE,QAAS,EAAY,UAAY,SAAU,GCtB3C,EAAb,KAAiE,CAC/D,KAAgB,mBAGhB,eAAkC,IAAI,IAEtC,IACA,iBACA,kBAEA,YAAY,EAAyC,CAAjC,KAAA,KAAA,EAClB,KAAK,IAAM,EAAK,KAAO,KAAK,IAC5B,KAAK,iBAAmB,EAAK,kBAAoB,IACjD,KAAK,kBAAoB,EAAK,mBAAqB,EAGrD,UAAU,EAA8B,CAEtC,OAAO,EAAK,cAAc,SAAW,MAGvC,IAAI,EAAqB,EAAiD,CACxE,IAAM,EAAO,EAAK,aAIlB,MAHI,CAAC,GAAQ,EAAK,SAAW,UAAoB,GAG1C,KAAK,KAAK,YAAY,OAAQ,GAAQ,CACtC,QAAK,QAAQ,EAAK,EAAK,CAC5B,IAAI,CAAC,KAAK,eAAe,EAAK,GAAG,CAAE,CACjC,KAAK,KAAK,QAAQ,MAChB,6BAA6B,EAAK,GAAG,eAAe,KAAK,kBAAkB,WAAW,KAAK,iBAAiB,KAC7G,CACD,OAEF,EAAK,EAAK,GACV,CAKJ,QAAgB,EAAsB,EAAiC,CAKrE,MADA,EAFI,EAAK,QAAU,EAAI,QAEnB,EAAK,QAAQ,WAAa,EAAK,OAAO,YAAc,EAAI,WAK9D,eAAuB,EAAyB,CAC9C,IAAM,EAAM,KAAK,KAAK,CAChB,EAAS,EAAM,KAAK,iBACpB,GAAU,KAAK,eAAe,IAAI,EAAO,EAAI,EAAE,EAAE,OAAQ,GAAM,EAAI,EAAO,CAOhF,OANI,EAAO,QAAU,KAAK,mBACxB,KAAK,eAAe,IAAI,EAAQ,EAAO,CAChC,KAET,EAAO,KAAK,EAAI,CAChB,KAAK,eAAe,IAAI,EAAQ,EAAO,CAChC,MC5BX,SAAgB,EACd,EACA,EAA+B,EAAE,CACb,CACpB,MAAO,CACL,OAAS,GAAU,CACjB,IAAM,EAAY,GAAc,CAChC,GAAI,CAAC,EACH,MAAU,MAAM,uDAAuD,CAIzE,GAAI,EAAM,QAAS,CACjB,IAAM,EAAM,EAAK,kBAAkB,EAAM,QAAQ,CACjD,GAAI,EAAK,MAAU,MAAM,EAAI,CAE/B,IAAM,EAAO,EAAU,IAAI,CACzB,KAAM,EAAM,MAAQ,WACpB,OAAQ,EAAM,OACd,eAAgB,EAAM,KACtB,UAAW,EAAM,UACjB,OAAQ,QACR,cAAe,EAAM,cAIrB,OAAQ,EAAM,OAEd,aAAc,EAAM,aAEpB,QAAS,EAAM,QACf,cAAe,EAAM,cACtB,CAAC,CACF,MAAO,CAAE,OAAQ,EAAK,GAAI,WAAY,EAAK,WAAY,KAAM,EAAK,KAAM,OAAQ,EAAK,OAAQ,EAE/F,UACG,GAAc,EAAE,MAAM,EAAI,EAAE,EAAE,IAAK,IAAO,CACzC,GAAI,EAAE,GACN,KAAM,EAAE,KACR,eAAgB,EAAE,eAClB,QAAS,EAAE,QACX,UAAW,EAAE,UACb,WAAY,EAAE,WACd,YAAa,EAAE,YACf,eAAgB,EAAE,eAClB,cAAe,EAAE,cACjB,kBAAmB,EAAE,kBACtB,EAAE,CACL,OAAS,GAAW,GAAc,EAAE,OAAO,EAAO,EAAI,GACtD,IAAM,GAAW,CACf,GAAc,EAAE,OAAO,EAAO,EAEhC,OAAS,GACA,GAAc,EAAE,OAAO,EAAO,EAAI,CAAE,GAAI,GAAO,OAAQ,uCAAwC,CAEzG"}