illusion-code 0.1.0__py3-none-any.whl

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.
Files changed (214) hide show
  1. illusion/__init__.py +24 -0
  2. illusion/__main__.py +15 -0
  3. illusion/_frontend/dist/index.mjs +39208 -0
  4. illusion/_frontend/package.json +27 -0
  5. illusion/_frontend/src/App.tsx +624 -0
  6. illusion/_frontend/src/components/CommandPicker.tsx +98 -0
  7. illusion/_frontend/src/components/Composer.tsx +55 -0
  8. illusion/_frontend/src/components/ComposerController.tsx +128 -0
  9. illusion/_frontend/src/components/ConversationView.tsx +750 -0
  10. illusion/_frontend/src/components/Footer.tsx +25 -0
  11. illusion/_frontend/src/components/MarkdownContent.tsx +537 -0
  12. illusion/_frontend/src/components/MarkdownTable.tsx +245 -0
  13. illusion/_frontend/src/components/ModalHost.tsx +425 -0
  14. illusion/_frontend/src/components/MultilineTextInput.tsx +250 -0
  15. illusion/_frontend/src/components/PromptInput.tsx +64 -0
  16. illusion/_frontend/src/components/SelectModal.tsx +78 -0
  17. illusion/_frontend/src/components/SidePanel.tsx +175 -0
  18. illusion/_frontend/src/components/Spinner.tsx +77 -0
  19. illusion/_frontend/src/components/StatusBar.tsx +142 -0
  20. illusion/_frontend/src/components/SwarmPanel.tsx +141 -0
  21. illusion/_frontend/src/components/TodoPanel.tsx +126 -0
  22. illusion/_frontend/src/components/ToolCallDisplay.tsx +202 -0
  23. illusion/_frontend/src/components/TranscriptPane.tsx +79 -0
  24. illusion/_frontend/src/components/WelcomeBanner.tsx +37 -0
  25. illusion/_frontend/src/hooks/useBackendSession.ts +468 -0
  26. illusion/_frontend/src/hooks/useTerminalSize.ts +9 -0
  27. illusion/_frontend/src/i18n.ts +78 -0
  28. illusion/_frontend/src/index.tsx +42 -0
  29. illusion/_frontend/src/theme/ThemeContext.tsx +19 -0
  30. illusion/_frontend/src/theme/builtinThemes.ts +89 -0
  31. illusion/_frontend/src/types.ts +110 -0
  32. illusion/_frontend/src/utils/markdown.ts +33 -0
  33. illusion/_frontend/src/utils/thinking.ts +191 -0
  34. illusion/_frontend/tsconfig.json +13 -0
  35. illusion/_web_dist/assets/index-BseIw-ik.css +10 -0
  36. illusion/_web_dist/assets/index-C_0ZWMuW.js +82 -0
  37. illusion/_web_dist/index.html +16 -0
  38. illusion/api/__init__.py +36 -0
  39. illusion/api/client.py +568 -0
  40. illusion/api/codex_client.py +563 -0
  41. illusion/api/compat.py +138 -0
  42. illusion/api/effort.py +128 -0
  43. illusion/api/errors.py +57 -0
  44. illusion/api/openai_client.py +819 -0
  45. illusion/api/provider.py +148 -0
  46. illusion/api/registry.py +479 -0
  47. illusion/api/usage.py +45 -0
  48. illusion/auth/__init__.py +50 -0
  49. illusion/auth/copilot.py +419 -0
  50. illusion/auth/external.py +612 -0
  51. illusion/auth/flows.py +58 -0
  52. illusion/auth/manager.py +214 -0
  53. illusion/auth/storage.py +372 -0
  54. illusion/bridge/__init__.py +38 -0
  55. illusion/bridge/manager.py +190 -0
  56. illusion/bridge/session_runner.py +84 -0
  57. illusion/bridge/types.py +113 -0
  58. illusion/bridge/work_secret.py +131 -0
  59. illusion/cli.py +1228 -0
  60. illusion/commands/__init__.py +32 -0
  61. illusion/commands/registry.py +1934 -0
  62. illusion/config/__init__.py +39 -0
  63. illusion/config/i18n.py +522 -0
  64. illusion/config/paths.py +259 -0
  65. illusion/config/settings.py +564 -0
  66. illusion/coordinator/__init__.py +41 -0
  67. illusion/coordinator/agent_definitions.py +1093 -0
  68. illusion/coordinator/coordinator_mode.py +127 -0
  69. illusion/engine/__init__.py +95 -0
  70. illusion/engine/cost_tracker.py +55 -0
  71. illusion/engine/messages.py +369 -0
  72. illusion/engine/query.py +632 -0
  73. illusion/engine/query_engine.py +343 -0
  74. illusion/engine/stream_events.py +169 -0
  75. illusion/hooks/__init__.py +67 -0
  76. illusion/hooks/events.py +43 -0
  77. illusion/hooks/executor.py +397 -0
  78. illusion/hooks/hot_reload.py +74 -0
  79. illusion/hooks/loader.py +133 -0
  80. illusion/hooks/schemas.py +121 -0
  81. illusion/hooks/types.py +86 -0
  82. illusion/mcp/__init__.py +104 -0
  83. illusion/mcp/client.py +377 -0
  84. illusion/mcp/config.py +140 -0
  85. illusion/mcp/types.py +175 -0
  86. illusion/memory/__init__.py +36 -0
  87. illusion/memory/manager.py +94 -0
  88. illusion/memory/memdir.py +58 -0
  89. illusion/memory/paths.py +57 -0
  90. illusion/memory/scan.py +120 -0
  91. illusion/memory/search.py +83 -0
  92. illusion/memory/types.py +43 -0
  93. illusion/output_styles/__init__.py +15 -0
  94. illusion/output_styles/loader.py +64 -0
  95. illusion/permissions/__init__.py +39 -0
  96. illusion/permissions/checker.py +174 -0
  97. illusion/permissions/modes.py +38 -0
  98. illusion/platforms.py +148 -0
  99. illusion/plugins/__init__.py +71 -0
  100. illusion/plugins/bundled/__init__.py +0 -0
  101. illusion/plugins/installer.py +59 -0
  102. illusion/plugins/loader.py +301 -0
  103. illusion/plugins/schemas.py +51 -0
  104. illusion/plugins/types.py +56 -0
  105. illusion/prompts/__init__.py +29 -0
  106. illusion/prompts/claudemd.py +74 -0
  107. illusion/prompts/context.py +187 -0
  108. illusion/prompts/environment.py +189 -0
  109. illusion/prompts/system_prompt.py +155 -0
  110. illusion/py.typed +0 -0
  111. illusion/sandbox/__init__.py +29 -0
  112. illusion/sandbox/adapter.py +174 -0
  113. illusion/services/__init__.py +59 -0
  114. illusion/services/compact/__init__.py +1015 -0
  115. illusion/services/cron.py +338 -0
  116. illusion/services/cron_scheduler.py +715 -0
  117. illusion/services/file_history.py +258 -0
  118. illusion/services/lsp/__init__.py +455 -0
  119. illusion/services/session_storage.py +237 -0
  120. illusion/services/token_estimation.py +72 -0
  121. illusion/skills/__init__.py +60 -0
  122. illusion/skills/bundled/__init__.py +110 -0
  123. illusion/skills/bundled/content/batch.md +86 -0
  124. illusion/skills/bundled/content/coding-guidelines.md +70 -0
  125. illusion/skills/bundled/content/debug.md +38 -0
  126. illusion/skills/bundled/content/loop.md +82 -0
  127. illusion/skills/bundled/content/remember.md +105 -0
  128. illusion/skills/bundled/content/simplify.md +53 -0
  129. illusion/skills/bundled/content/skillify.md +113 -0
  130. illusion/skills/bundled/content/stuck.md +54 -0
  131. illusion/skills/bundled/content/update-config.md +329 -0
  132. illusion/skills/bundled/content/verify.md +74 -0
  133. illusion/skills/loader.py +219 -0
  134. illusion/skills/registry.py +40 -0
  135. illusion/skills/types.py +24 -0
  136. illusion/state/__init__.py +18 -0
  137. illusion/state/app_state.py +67 -0
  138. illusion/state/store.py +93 -0
  139. illusion/swarm/__init__.py +71 -0
  140. illusion/swarm/agent_executor.py +857 -0
  141. illusion/swarm/in_process.py +259 -0
  142. illusion/swarm/subprocess_backend.py +136 -0
  143. illusion/swarm/team_helpers.py +123 -0
  144. illusion/swarm/types.py +159 -0
  145. illusion/swarm/worktree.py +347 -0
  146. illusion/tasks/__init__.py +33 -0
  147. illusion/tasks/local_agent_task.py +42 -0
  148. illusion/tasks/local_shell_task.py +27 -0
  149. illusion/tasks/manager.py +377 -0
  150. illusion/tasks/stop_task.py +21 -0
  151. illusion/tasks/types.py +88 -0
  152. illusion/tools/__init__.py +126 -0
  153. illusion/tools/agent_tool.py +388 -0
  154. illusion/tools/ask_user_question_tool.py +186 -0
  155. illusion/tools/base.py +149 -0
  156. illusion/tools/bash_tool.py +413 -0
  157. illusion/tools/config_tool.py +90 -0
  158. illusion/tools/cron_tool.py +473 -0
  159. illusion/tools/enter_plan_mode_tool.py +147 -0
  160. illusion/tools/enter_worktree_tool.py +188 -0
  161. illusion/tools/exit_plan_mode_tool.py +69 -0
  162. illusion/tools/exit_worktree_tool.py +225 -0
  163. illusion/tools/file_edit_tool.py +283 -0
  164. illusion/tools/file_read_tool.py +294 -0
  165. illusion/tools/file_write_tool.py +184 -0
  166. illusion/tools/glob_tool.py +165 -0
  167. illusion/tools/grep_tool.py +190 -0
  168. illusion/tools/list_mcp_resources_tool.py +80 -0
  169. illusion/tools/lsp_tool.py +333 -0
  170. illusion/tools/mcp_auth_tool.py +100 -0
  171. illusion/tools/mcp_tool.py +75 -0
  172. illusion/tools/notebook_edit_tool.py +242 -0
  173. illusion/tools/powershell_tool.py +334 -0
  174. illusion/tools/read_mcp_resource_tool.py +63 -0
  175. illusion/tools/repl_tool.py +100 -0
  176. illusion/tools/send_message_tool.py +112 -0
  177. illusion/tools/shell_common.py +187 -0
  178. illusion/tools/skill_tool.py +86 -0
  179. illusion/tools/sleep_tool.py +62 -0
  180. illusion/tools/structured_output_tool.py +58 -0
  181. illusion/tools/task_create_tool.py +98 -0
  182. illusion/tools/task_get_tool.py +94 -0
  183. illusion/tools/task_list_tool.py +94 -0
  184. illusion/tools/task_output_tool.py +55 -0
  185. illusion/tools/task_stop_tool.py +52 -0
  186. illusion/tools/task_update_tool.py +224 -0
  187. illusion/tools/team_create_tool.py +236 -0
  188. illusion/tools/team_delete_tool.py +104 -0
  189. illusion/tools/todo_write_tool.py +198 -0
  190. illusion/tools/tool_search_tool.py +156 -0
  191. illusion/tools/web_fetch_tool.py +264 -0
  192. illusion/tools/web_search_tool.py +186 -0
  193. illusion/ui/__init__.py +23 -0
  194. illusion/ui/app.py +258 -0
  195. illusion/ui/backend_host.py +1180 -0
  196. illusion/ui/input.py +86 -0
  197. illusion/ui/output.py +363 -0
  198. illusion/ui/permission_dialog.py +47 -0
  199. illusion/ui/permission_store.py +99 -0
  200. illusion/ui/protocol.py +384 -0
  201. illusion/ui/react_launcher.py +280 -0
  202. illusion/ui/runtime.py +787 -0
  203. illusion/ui/textual_app.py +603 -0
  204. illusion/ui/web/__init__.py +10 -0
  205. illusion/ui/web/server.py +87 -0
  206. illusion/ui/web/ws_host.py +1197 -0
  207. illusion/utils/__init__.py +0 -0
  208. illusion/utils/ripgrep.py +299 -0
  209. illusion/utils/shell.py +248 -0
  210. illusion_code-0.1.0.dist-info/METADATA +1159 -0
  211. illusion_code-0.1.0.dist-info/RECORD +214 -0
  212. illusion_code-0.1.0.dist-info/WHEEL +4 -0
  213. illusion_code-0.1.0.dist-info/entry_points.txt +2 -0
  214. illusion_code-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,715 @@
1
+ """
2
+ Cron 调度器模块
3
+ ===============
4
+
5
+ 对齐 openclaw 的定时任务调度模式:非阻塞、独立会话执行。
6
+
7
+ 核心设计:
8
+ - 调度器作为后台 asyncio 任务运行,不阻塞当前会话
9
+ - 每个到期任务在独立子进程中执行(通过 illusion -p 启动)
10
+ - 使用本地时间进行调度判断
11
+ - 支持错误退避和连续错误跟踪
12
+ - 跨平台兼容(Windows / Linux / macOS)
13
+
14
+ 主要组件:
15
+ - CronScheduler: 调度器类,管理后台调度循环
16
+ - append_history / load_history: 执行历史记录
17
+ - scheduler_status: 调度器状态查询
18
+ - ensure_started: 确保调度器正在运行(创建任务时自动调用)
19
+
20
+ 使用示例:
21
+ >>> from illusion.services.cron_scheduler import get_scheduler, ensure_started
22
+ >>> await ensure_started() # 确保调度器运行
23
+ >>> scheduler = get_scheduler()
24
+ >>> scheduler.is_running # 检查状态
25
+ >>> await scheduler.stop() # 停止调度
26
+ """
27
+
28
+ from __future__ import annotations
29
+
30
+ import asyncio
31
+ import json
32
+ import logging
33
+ import os
34
+ import shutil
35
+ import subprocess
36
+ import sys
37
+ from datetime import datetime
38
+ from pathlib import Path
39
+ from typing import Any
40
+
41
+ from illusion.config.paths import get_cron_dir, get_logs_dir
42
+ from illusion.services.cron import (
43
+ load_cron_jobs,
44
+ mark_job_run,
45
+ remove_expired_jobs,
46
+ validate_cron_expression,
47
+ )
48
+
49
+ # 模块级日志记录器
50
+ logger = logging.getLogger(__name__)
51
+
52
+ # 调度周期间隔(秒)- 每 30 秒检查一次到期任务
53
+ TICK_INTERVAL_SECONDS = 30
54
+ """调度器检查到期任务的频率(秒)。"""
55
+
56
+ # 错误退避策略(秒):连续错误后逐渐增加等待时间
57
+ # 对齐 openclaw DEFAULT_ERROR_BACKOFF_SCHEDULE_MS
58
+ _ERROR_BACKOFF_SECONDS = [30, 60, 300, 900, 3600]
59
+ """错误退避时间序列(秒),按连续错误次数索引。"""
60
+
61
+ # 任务执行超时(秒)
62
+ _JOB_TIMEOUT_SECONDS = 300
63
+ """单个任务的执行超时时间(秒),默认 5 分钟。"""
64
+
65
+ # 最大并发任务数
66
+ _MAX_CONCURRENT_JOBS = 1
67
+ """同时执行的最大任务数,对齐 openclaw maxConcurrentRuns。"""
68
+
69
+
70
+ # ---------------------------------------------------------------------------
71
+ # 历史记录
72
+ # ---------------------------------------------------------------------------
73
+
74
+ def get_history_path() -> Path:
75
+ """返回 Cron 执行历史记录文件路径。"""
76
+ return get_cron_dir() / "history.jsonl"
77
+
78
+
79
+ def append_history(entry: dict[str, Any]) -> None:
80
+ """向历史日志追加一条执行记录。
81
+
82
+ 每条记录包含:name, prompt, started_at, ended_at, returncode, status, stdout, stderr。
83
+ """
84
+ path = get_history_path()
85
+ path.parent.mkdir(parents=True, exist_ok=True)
86
+ with path.open("a", encoding="utf-8") as fh:
87
+ fh.write(json.dumps(entry, ensure_ascii=False) + "\n")
88
+
89
+
90
+ def load_history(
91
+ *,
92
+ limit: int = 50,
93
+ job_name: str | None = None,
94
+ job_id: str | None = None,
95
+ ) -> list[dict[str, Any]]:
96
+ """加载最近的执行历史记录。
97
+
98
+ Args:
99
+ limit: 最大返回条数
100
+ job_name: 按任务名称过滤
101
+ job_id: 按任务 ID 过滤
102
+
103
+ Returns:
104
+ 历史记录列表,按时间正序排列
105
+ """
106
+ path = get_history_path()
107
+ if not path.exists():
108
+ return []
109
+ entries: list[dict[str, Any]] = []
110
+ for line in path.read_text(encoding="utf-8").splitlines():
111
+ line = line.strip()
112
+ if not line:
113
+ continue
114
+ try:
115
+ entry = json.loads(line)
116
+ except json.JSONDecodeError:
117
+ continue
118
+ if job_name and entry.get("name") != job_name:
119
+ continue
120
+ if job_id and entry.get("id") != job_id:
121
+ continue
122
+ entries.append(entry)
123
+ return entries[-limit:]
124
+
125
+
126
+ # ---------------------------------------------------------------------------
127
+ # PID 文件管理
128
+ # ---------------------------------------------------------------------------
129
+
130
+ def get_pid_path() -> Path:
131
+ """返回调度器 PID 文件路径。"""
132
+ return get_cron_dir() / "scheduler.pid"
133
+
134
+
135
+ def _is_process_alive(pid: int) -> bool:
136
+ """检查给定 PID 的进程是否存活(跨平台安全)。
137
+
138
+ 注意:Windows 上 os.kill(pid, 0) 会发送 CTRL_C_EVENT(signal 0 == CTRL_C_EVENT),
139
+ 而非 POSIX 上的无操作检测,因此必须使用 OpenProcess 替代。
140
+ """
141
+ if sys.platform == "win32":
142
+ import ctypes
143
+
144
+ kernel32 = ctypes.windll.kernel32
145
+ # PROCESS_QUERY_LIMITED_INFORMATION = 0x1000 (Vista+)
146
+ handle = kernel32.OpenProcess(0x1000, False, pid)
147
+ if handle:
148
+ kernel32.CloseHandle(handle)
149
+ return True
150
+ return False
151
+ else:
152
+ try:
153
+ os.kill(pid, 0)
154
+ return True
155
+ except OSError:
156
+ return False
157
+
158
+
159
+ def read_pid() -> int | None:
160
+ """读取运行中的调度器 PID,如果不存在或进程已退出则返回 None。"""
161
+ path = get_pid_path()
162
+ if not path.exists():
163
+ return None
164
+ try:
165
+ pid = int(path.read_text(encoding="utf-8").strip())
166
+ except (ValueError, OSError):
167
+ return None
168
+ if not _is_process_alive(pid):
169
+ logger.debug("Removed stale scheduler PID file (pid=%d)", pid)
170
+ path.unlink(missing_ok=True)
171
+ return None
172
+ return pid
173
+
174
+
175
+ def write_pid(pid: int) -> None:
176
+ """写入指定的进程 PID。"""
177
+ path = get_pid_path()
178
+ path.parent.mkdir(parents=True, exist_ok=True)
179
+ path.write_text(str(pid) + "\n", encoding="utf-8")
180
+
181
+
182
+ def remove_pid() -> None:
183
+ """删除 PID 文件。"""
184
+ get_pid_path().unlink(missing_ok=True)
185
+
186
+
187
+ def is_scheduler_running() -> bool:
188
+ """返回是否存在运行的调度器进程。"""
189
+ return read_pid() is not None
190
+
191
+
192
+ # ---------------------------------------------------------------------------
193
+ # 错误退避计算
194
+ # ---------------------------------------------------------------------------
195
+
196
+ def _get_backoff_seconds(consecutive_errors: int) -> int:
197
+ """根据连续错误次数返回退避等待时间(秒)。
198
+
199
+ 使用预定义的退避序列,超出序列范围则使用最大值。
200
+ """
201
+ if consecutive_errors <= 0:
202
+ return 0
203
+ index = min(consecutive_errors - 1, len(_ERROR_BACKOFF_SECONDS) - 1)
204
+ return _ERROR_BACKOFF_SECONDS[index]
205
+
206
+
207
+ # ---------------------------------------------------------------------------
208
+ # 可执行文件查找
209
+ # ---------------------------------------------------------------------------
210
+
211
+ def _find_illusion_command() -> list[str]:
212
+ """查找 illusion 命令,返回可作为 subprocess 参数的命令列表。
213
+
214
+ 优先级:
215
+ 1. PATH 中的 illusion 可执行文件(pip install 后可用)
216
+ 2. python -m illusion(开发模式 / 模块运行)
217
+ """
218
+ # 方式 1:查找 PATH 中的 illusion 命令
219
+ which = shutil.which("illusion")
220
+ if which:
221
+ return [which]
222
+
223
+ # 方式 2:使用当前 Python 解释器运行模块
224
+ return [sys.executable, "-m", "illusion"]
225
+
226
+
227
+ # ---------------------------------------------------------------------------
228
+ # 任务执行
229
+ # ---------------------------------------------------------------------------
230
+
231
+
232
+ def _filter_mcp_log_noise(stderr_text: str) -> str:
233
+ """过滤 stderr 中 MCP 日志文件相关的噪声行。
234
+
235
+ MCP 服务器的 stderr 输出(如 log_file 路径引用)不应出现在 cron 历史中。
236
+
237
+ Args:
238
+ stderr_text: 原始 stderr 文本
239
+
240
+ Returns:
241
+ 过滤后的 stderr 文本
242
+ """
243
+ import re
244
+ lines = stderr_text.splitlines(keepends=True)
245
+ filtered = []
246
+ for line in lines:
247
+ # 跳过包含 mcp.log 或 .mcp. 日志文件路径的行
248
+ if re.search(r'[\w/\\.-]*mcp[\w/\\-]*\.log', line, re.IGNORECASE):
249
+ continue
250
+ filtered.append(line)
251
+ return "".join(filtered)
252
+
253
+
254
+ async def _execute_prompt_in_subprocess(
255
+ prompt: str,
256
+ cwd: Path,
257
+ timeout: int = _JOB_TIMEOUT_SECONDS,
258
+ ) -> dict[str, Any]:
259
+ """在独立子进程中执行提示词。
260
+
261
+ 通过 `illusion -p "<prompt>"` 启动独立会话,
262
+ 确保不阻塞当前会话,且任务在隔离环境中运行。
263
+
264
+ Args:
265
+ prompt: 要执行的提示词
266
+ cwd: 工作目录
267
+ timeout: 超时秒数
268
+
269
+ Returns:
270
+ 包含 returncode, stdout, stderr, status 的结果字典
271
+ """
272
+ cmd = _find_illusion_command() + ["-p", prompt]
273
+
274
+ logger.info("Executing cron subprocess: %s", " ".join(cmd[:3]) + " -p <prompt>")
275
+ logger.debug("Full command: %s, cwd: %s", cmd, cwd)
276
+
277
+ try:
278
+ # Windows: 使用 CREATE_NO_WINDOW 防止弹出黑色控制台窗口
279
+ # 非 Windows 平台该标志不存在,设为 0 不影响行为
280
+ creationflags = getattr(subprocess, "CREATE_NO_WINDOW", 0)
281
+
282
+ process = await asyncio.create_subprocess_exec(
283
+ *cmd,
284
+ cwd=str(cwd),
285
+ stdout=asyncio.subprocess.PIPE,
286
+ stderr=asyncio.subprocess.PIPE,
287
+ # Windows: 防止句柄继承死锁
288
+ stdin=asyncio.subprocess.DEVNULL,
289
+ # Windows: 不弹出控制台窗口
290
+ creationflags=creationflags,
291
+ )
292
+ stdout, stderr = await asyncio.wait_for(
293
+ process.communicate(),
294
+ timeout=timeout,
295
+ )
296
+ except asyncio.TimeoutError:
297
+ # 超时处理:终止子进程
298
+ try:
299
+ process.kill()
300
+ await process.wait()
301
+ except Exception:
302
+ pass
303
+ logger.warning("Cron subprocess timed out after %ds", timeout)
304
+ return {
305
+ "returncode": -1,
306
+ "status": "timeout",
307
+ "stdout": "",
308
+ "stderr": f"Job timed out after {timeout}s",
309
+ }
310
+ except FileNotFoundError as exc:
311
+ # illusion 命令未找到
312
+ logger.error("Failed to start cron subprocess: %s", exc)
313
+ return {
314
+ "returncode": -1,
315
+ "status": "error",
316
+ "stdout": "",
317
+ "stderr": f"illusion command not found: {exc}",
318
+ }
319
+ except Exception as exc:
320
+ logger.error("Failed to start cron subprocess: %s", exc)
321
+ return {
322
+ "returncode": -1,
323
+ "status": "error",
324
+ "stdout": "",
325
+ "stderr": str(exc),
326
+ }
327
+
328
+ success = process.returncode == 0
329
+ stderr_text = stderr.decode("utf-8", errors="replace") if stderr else ""
330
+
331
+ # 过滤 MCP 日志文件相关的输出(如 mcp.log 文件路径引用)
332
+ if stderr_text:
333
+ stderr_text = _filter_mcp_log_noise(stderr_text)
334
+
335
+ # 记录子进程 stderr 到日志(便于调试)
336
+ if stderr_text.strip():
337
+ logger.debug("Cron subprocess stderr: %s", stderr_text[:500])
338
+
339
+ if not success:
340
+ logger.warning(
341
+ "Cron subprocess exited with rc=%d, stderr: %s",
342
+ process.returncode,
343
+ stderr_text[:200],
344
+ )
345
+
346
+ return {
347
+ "returncode": process.returncode,
348
+ "status": "success" if success else "failed",
349
+ "stdout": (stdout.decode("utf-8", errors="replace")[-2000:] if stdout else ""),
350
+ "stderr": stderr_text[-2000:] if stderr_text else "",
351
+ }
352
+
353
+
354
+ async def execute_job(
355
+ job: dict[str, Any],
356
+ timeout: int = _JOB_TIMEOUT_SECONDS,
357
+ ) -> dict[str, Any]:
358
+ """执行单个 Cron 任务并返回历史记录条目。
359
+
360
+ 任务通过 `illusion -p` 在独立子进程中执行,不阻塞当前会话。
361
+
362
+ Args:
363
+ job: 任务字典,包含 name, prompt, cwd 等字段
364
+ timeout: 子进程执行超时秒数,默认 300
365
+
366
+ Returns:
367
+ 历史记录条目字典
368
+ """
369
+ name = job.get("name", job.get("id", "unknown"))
370
+ prompt = job.get("prompt", "")
371
+ cwd = Path(job.get("cwd") or ".").expanduser()
372
+ started_at = _now_local()
373
+
374
+ if not prompt:
375
+ entry = {
376
+ "id": job.get("id", ""),
377
+ "name": name,
378
+ "prompt": prompt,
379
+ "started_at": started_at.isoformat(),
380
+ "ended_at": _now_local().isoformat(),
381
+ "returncode": -1,
382
+ "status": "error",
383
+ "stdout": "",
384
+ "stderr": "Job has no prompt field",
385
+ }
386
+ logger.error("Cron job %r has no prompt, skipping", name)
387
+ mark_job_run(job.get("id", name), success=False, status="error")
388
+ append_history(entry)
389
+ return entry
390
+
391
+ logger.info("Executing cron job %r: %.80s", name, prompt)
392
+
393
+ # 在独立子进程中执行提示词
394
+ result = await _execute_prompt_in_subprocess(prompt, cwd, timeout=timeout)
395
+
396
+ ended_at = _now_local()
397
+ success = result["status"] == "success"
398
+
399
+ entry = {
400
+ "id": job.get("id", ""),
401
+ "name": name,
402
+ "prompt": prompt,
403
+ "started_at": started_at.isoformat(),
404
+ "ended_at": ended_at.isoformat(),
405
+ "returncode": result["returncode"],
406
+ "status": result["status"],
407
+ "stdout": result["stdout"],
408
+ "stderr": result["stderr"],
409
+ }
410
+
411
+ # 更新任务执行状态
412
+ mark_job_run(job.get("id", name), success=success, status=result["status"])
413
+
414
+ # 记录历史
415
+ append_history(entry)
416
+ logger.info(
417
+ "Cron job %r finished: status=%s rc=%s",
418
+ name,
419
+ result["status"],
420
+ result["returncode"],
421
+ )
422
+
423
+ return entry
424
+
425
+
426
+ def _now_local() -> datetime:
427
+ """返回本地时间。"""
428
+ return datetime.now().replace(microsecond=0)
429
+
430
+
431
+ # ---------------------------------------------------------------------------
432
+ # 调度器核心类
433
+ # ---------------------------------------------------------------------------
434
+
435
+ def _jobs_due(jobs: list[dict[str, Any]], now: datetime) -> list[dict[str, Any]]:
436
+ """返回当前时间到期的任务列表。
437
+
438
+ 检查条件:
439
+ 1. 任务已启用
440
+ 2. cron 表达式有效
441
+ 3. next_run 时间已到
442
+ 4. 不在错误退避期内
443
+
444
+ Args:
445
+ jobs: 任务列表
446
+ now: 当前本地时间
447
+
448
+ Returns:
449
+ 到期任务列表
450
+ """
451
+ due: list[dict[str, Any]] = []
452
+ for job in jobs:
453
+ # 跳过禁用的任务
454
+ if not job.get("enabled", True):
455
+ continue
456
+
457
+ # 验证 cron 表达式
458
+ schedule = job.get("schedule", "")
459
+ if not validate_cron_expression(schedule):
460
+ continue
461
+
462
+ # 检查 next_run
463
+ next_run_str = job.get("next_run")
464
+ if not next_run_str:
465
+ continue
466
+ try:
467
+ next_run = datetime.fromisoformat(next_run_str)
468
+ # 兼容旧格式:移除时区信息进行比较
469
+ if next_run.tzinfo is not None:
470
+ next_run = next_run.replace(tzinfo=None)
471
+ except (ValueError, TypeError):
472
+ continue
473
+
474
+ # 检查是否到期
475
+ if next_run > now:
476
+ continue
477
+
478
+ # 检查错误退避
479
+ consecutive_errors = job.get("consecutive_errors", 0)
480
+ if consecutive_errors > 0:
481
+ last_run_str = job.get("last_run")
482
+ if last_run_str:
483
+ try:
484
+ last_run = datetime.fromisoformat(last_run_str)
485
+ if last_run.tzinfo is not None:
486
+ last_run = last_run.replace(tzinfo=None)
487
+ backoff = _get_backoff_seconds(consecutive_errors)
488
+ elapsed = (now - last_run).total_seconds()
489
+ if elapsed < backoff:
490
+ continue
491
+ except (ValueError, TypeError):
492
+ pass
493
+
494
+ due.append(job)
495
+
496
+ return due
497
+
498
+
499
+ class CronScheduler:
500
+ """Cron 调度器。
501
+
502
+ 作为后台 asyncio 任务运行,不阻塞当前会话。
503
+ 每个 tick 检查到期任务,在独立子进程中执行。
504
+
505
+ 使用方式:
506
+ scheduler = get_scheduler()
507
+ await scheduler.start() # 启动
508
+ await scheduler.stop() # 停止
509
+ """
510
+
511
+ def __init__(self) -> None:
512
+ self._task: asyncio.Task[None] | None = None
513
+ self._shutdown = asyncio.Event()
514
+ self._running = False
515
+
516
+ @property
517
+ def is_running(self) -> bool:
518
+ """调度器是否正在运行。"""
519
+ return self._running and self._task is not None and not self._task.done()
520
+
521
+ async def start(self) -> None:
522
+ """启动调度器后台任务。
523
+
524
+ 调度器作为 asyncio.Task 运行,不阻塞当前会话。
525
+ 如果已在运行则忽略。
526
+ """
527
+ if self.is_running:
528
+ logger.debug("Scheduler already running, ignoring duplicate start")
529
+ return
530
+
531
+ self._shutdown.clear()
532
+ self._running = True
533
+ write_pid(os.getpid())
534
+
535
+ # 创建后台任务
536
+ self._task = asyncio.create_task(
537
+ self._run_loop(),
538
+ name="cron-scheduler",
539
+ )
540
+ logger.info("Cron scheduler started (tick=%ds)", TICK_INTERVAL_SECONDS)
541
+
542
+ async def stop(self) -> None:
543
+ """停止调度器后台任务。"""
544
+ if not self.is_running:
545
+ return
546
+
547
+ self._shutdown.set()
548
+ self._running = False
549
+
550
+ if self._task and not self._task.done():
551
+ self._task.cancel()
552
+ try:
553
+ await self._task
554
+ except asyncio.CancelledError:
555
+ pass
556
+
557
+ self._task = None
558
+ remove_pid()
559
+ logger.info("Cron scheduler stopped")
560
+
561
+ async def _run_loop(self) -> None:
562
+ """调度器主循环。"""
563
+ write_pid(os.getpid())
564
+
565
+ try:
566
+ while not self._shutdown.is_set():
567
+ await self._tick()
568
+
569
+ # 清理已完成的一次性任务
570
+ removed = remove_expired_jobs()
571
+ if removed:
572
+ logger.info("Cleaned up %d expired cron job(s)", len(removed))
573
+
574
+ # 等待下一个 tick 或关闭信号
575
+ try:
576
+ await asyncio.wait_for(
577
+ self._shutdown.wait(),
578
+ timeout=TICK_INTERVAL_SECONDS,
579
+ )
580
+ break
581
+ except asyncio.TimeoutError:
582
+ pass
583
+ except asyncio.CancelledError:
584
+ logger.debug("Scheduler loop cancelled")
585
+ except Exception:
586
+ logger.exception("Scheduler loop crashed")
587
+ finally:
588
+ self._running = False
589
+ remove_pid()
590
+
591
+ async def _tick(self) -> None:
592
+ """单次调度周期:检查到期任务并执行。"""
593
+ now = _now_local()
594
+ jobs = load_cron_jobs()
595
+ due = _jobs_due(jobs, now)
596
+
597
+ if not due:
598
+ return
599
+
600
+ logger.info("Tick: %d job(s) due", len(due))
601
+
602
+ # 受并发限制执行任务
603
+ semaphore = asyncio.Semaphore(_MAX_CONCURRENT_JOBS)
604
+
605
+ async def _run_with_limit(job: dict[str, Any]) -> dict[str, Any]:
606
+ async with semaphore:
607
+ return await execute_job(job)
608
+
609
+ # 并发执行到期任务
610
+ results = await asyncio.gather(
611
+ *(_run_with_limit(job) for job in due),
612
+ return_exceptions=True,
613
+ )
614
+
615
+ # 记录异常
616
+ for result in results:
617
+ if isinstance(result, BaseException):
618
+ logger.error("Unexpected error executing cron job: %s", result)
619
+
620
+ def status(self) -> dict[str, Any]:
621
+ """返回调度器状态信息。"""
622
+ jobs = load_cron_jobs()
623
+ enabled = [j for j in jobs if j.get("enabled", True)]
624
+ log_path = get_logs_dir() / "cron_scheduler.log"
625
+ return {
626
+ "running": self.is_running,
627
+ "pid": os.getpid() if self.is_running else None,
628
+ "total_jobs": len(jobs),
629
+ "enabled_jobs": len(enabled),
630
+ "log_file": str(log_path),
631
+ "history_file": str(get_history_path()),
632
+ }
633
+
634
+
635
+ # ---------------------------------------------------------------------------
636
+ # 全局单例
637
+ # ---------------------------------------------------------------------------
638
+
639
+ _scheduler: CronScheduler | None = None
640
+
641
+
642
+ def get_scheduler() -> CronScheduler:
643
+ """获取全局调度器单例。"""
644
+ global _scheduler
645
+ if _scheduler is None:
646
+ _scheduler = CronScheduler()
647
+ return _scheduler
648
+
649
+
650
+ async def ensure_started() -> None:
651
+ """确保调度器正在运行。如果未运行则自动启动。
652
+
653
+ 在创建 cron 任务时调用,确保调度器自动启动。
654
+ """
655
+ scheduler = get_scheduler()
656
+ if not scheduler.is_running:
657
+ await scheduler.start()
658
+
659
+
660
+ # ---------------------------------------------------------------------------
661
+ # 向后兼容的函数接口
662
+ # ---------------------------------------------------------------------------
663
+
664
+ def scheduler_status() -> dict[str, Any]:
665
+ """返回调度器状态信息字典(向后兼容函数接口)。"""
666
+ return get_scheduler().status()
667
+
668
+
669
+ def start_daemon() -> int:
670
+ """启动调度器守护进程。
671
+
672
+ 注意:此函数为向后兼容保留。推荐使用 ensure_started()。
673
+
674
+ Returns:
675
+ 当前进程 PID
676
+ """
677
+ write_pid(os.getpid())
678
+ return os.getpid()
679
+
680
+
681
+ def stop_scheduler() -> bool:
682
+ """停止调度器。
683
+
684
+ 注意:此函数为向后兼容保留。推荐使用 get_scheduler().stop()。
685
+
686
+ Returns:
687
+ 是否成功停止
688
+ """
689
+ global _scheduler
690
+ if _scheduler and _scheduler.is_running:
691
+ _scheduler._shutdown.set()
692
+ _scheduler._running = False
693
+ remove_pid()
694
+ return True
695
+ pid = read_pid()
696
+ if pid is None:
697
+ return False
698
+ remove_pid()
699
+ return True
700
+
701
+
702
+ # 导出的公共接口
703
+ __all__ = [
704
+ "CronScheduler",
705
+ "get_scheduler",
706
+ "ensure_started",
707
+ "scheduler_status",
708
+ "start_daemon",
709
+ "stop_scheduler",
710
+ "is_scheduler_running",
711
+ "execute_job",
712
+ "append_history",
713
+ "load_history",
714
+ "TICK_INTERVAL_SECONDS",
715
+ ]