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.
- illusion/__init__.py +24 -0
- illusion/__main__.py +15 -0
- illusion/_frontend/dist/index.mjs +39208 -0
- illusion/_frontend/package.json +27 -0
- illusion/_frontend/src/App.tsx +624 -0
- illusion/_frontend/src/components/CommandPicker.tsx +98 -0
- illusion/_frontend/src/components/Composer.tsx +55 -0
- illusion/_frontend/src/components/ComposerController.tsx +128 -0
- illusion/_frontend/src/components/ConversationView.tsx +750 -0
- illusion/_frontend/src/components/Footer.tsx +25 -0
- illusion/_frontend/src/components/MarkdownContent.tsx +537 -0
- illusion/_frontend/src/components/MarkdownTable.tsx +245 -0
- illusion/_frontend/src/components/ModalHost.tsx +425 -0
- illusion/_frontend/src/components/MultilineTextInput.tsx +250 -0
- illusion/_frontend/src/components/PromptInput.tsx +64 -0
- illusion/_frontend/src/components/SelectModal.tsx +78 -0
- illusion/_frontend/src/components/SidePanel.tsx +175 -0
- illusion/_frontend/src/components/Spinner.tsx +77 -0
- illusion/_frontend/src/components/StatusBar.tsx +142 -0
- illusion/_frontend/src/components/SwarmPanel.tsx +141 -0
- illusion/_frontend/src/components/TodoPanel.tsx +126 -0
- illusion/_frontend/src/components/ToolCallDisplay.tsx +202 -0
- illusion/_frontend/src/components/TranscriptPane.tsx +79 -0
- illusion/_frontend/src/components/WelcomeBanner.tsx +37 -0
- illusion/_frontend/src/hooks/useBackendSession.ts +468 -0
- illusion/_frontend/src/hooks/useTerminalSize.ts +9 -0
- illusion/_frontend/src/i18n.ts +78 -0
- illusion/_frontend/src/index.tsx +42 -0
- illusion/_frontend/src/theme/ThemeContext.tsx +19 -0
- illusion/_frontend/src/theme/builtinThemes.ts +89 -0
- illusion/_frontend/src/types.ts +110 -0
- illusion/_frontend/src/utils/markdown.ts +33 -0
- illusion/_frontend/src/utils/thinking.ts +191 -0
- illusion/_frontend/tsconfig.json +13 -0
- illusion/_web_dist/assets/index-BseIw-ik.css +10 -0
- illusion/_web_dist/assets/index-C_0ZWMuW.js +82 -0
- illusion/_web_dist/index.html +16 -0
- illusion/api/__init__.py +36 -0
- illusion/api/client.py +568 -0
- illusion/api/codex_client.py +563 -0
- illusion/api/compat.py +138 -0
- illusion/api/effort.py +128 -0
- illusion/api/errors.py +57 -0
- illusion/api/openai_client.py +819 -0
- illusion/api/provider.py +148 -0
- illusion/api/registry.py +479 -0
- illusion/api/usage.py +45 -0
- illusion/auth/__init__.py +50 -0
- illusion/auth/copilot.py +419 -0
- illusion/auth/external.py +612 -0
- illusion/auth/flows.py +58 -0
- illusion/auth/manager.py +214 -0
- illusion/auth/storage.py +372 -0
- illusion/bridge/__init__.py +38 -0
- illusion/bridge/manager.py +190 -0
- illusion/bridge/session_runner.py +84 -0
- illusion/bridge/types.py +113 -0
- illusion/bridge/work_secret.py +131 -0
- illusion/cli.py +1228 -0
- illusion/commands/__init__.py +32 -0
- illusion/commands/registry.py +1934 -0
- illusion/config/__init__.py +39 -0
- illusion/config/i18n.py +522 -0
- illusion/config/paths.py +259 -0
- illusion/config/settings.py +564 -0
- illusion/coordinator/__init__.py +41 -0
- illusion/coordinator/agent_definitions.py +1093 -0
- illusion/coordinator/coordinator_mode.py +127 -0
- illusion/engine/__init__.py +95 -0
- illusion/engine/cost_tracker.py +55 -0
- illusion/engine/messages.py +369 -0
- illusion/engine/query.py +632 -0
- illusion/engine/query_engine.py +343 -0
- illusion/engine/stream_events.py +169 -0
- illusion/hooks/__init__.py +67 -0
- illusion/hooks/events.py +43 -0
- illusion/hooks/executor.py +397 -0
- illusion/hooks/hot_reload.py +74 -0
- illusion/hooks/loader.py +133 -0
- illusion/hooks/schemas.py +121 -0
- illusion/hooks/types.py +86 -0
- illusion/mcp/__init__.py +104 -0
- illusion/mcp/client.py +377 -0
- illusion/mcp/config.py +140 -0
- illusion/mcp/types.py +175 -0
- illusion/memory/__init__.py +36 -0
- illusion/memory/manager.py +94 -0
- illusion/memory/memdir.py +58 -0
- illusion/memory/paths.py +57 -0
- illusion/memory/scan.py +120 -0
- illusion/memory/search.py +83 -0
- illusion/memory/types.py +43 -0
- illusion/output_styles/__init__.py +15 -0
- illusion/output_styles/loader.py +64 -0
- illusion/permissions/__init__.py +39 -0
- illusion/permissions/checker.py +174 -0
- illusion/permissions/modes.py +38 -0
- illusion/platforms.py +148 -0
- illusion/plugins/__init__.py +71 -0
- illusion/plugins/bundled/__init__.py +0 -0
- illusion/plugins/installer.py +59 -0
- illusion/plugins/loader.py +301 -0
- illusion/plugins/schemas.py +51 -0
- illusion/plugins/types.py +56 -0
- illusion/prompts/__init__.py +29 -0
- illusion/prompts/claudemd.py +74 -0
- illusion/prompts/context.py +187 -0
- illusion/prompts/environment.py +189 -0
- illusion/prompts/system_prompt.py +155 -0
- illusion/py.typed +0 -0
- illusion/sandbox/__init__.py +29 -0
- illusion/sandbox/adapter.py +174 -0
- illusion/services/__init__.py +59 -0
- illusion/services/compact/__init__.py +1015 -0
- illusion/services/cron.py +338 -0
- illusion/services/cron_scheduler.py +715 -0
- illusion/services/file_history.py +258 -0
- illusion/services/lsp/__init__.py +455 -0
- illusion/services/session_storage.py +237 -0
- illusion/services/token_estimation.py +72 -0
- illusion/skills/__init__.py +60 -0
- illusion/skills/bundled/__init__.py +110 -0
- illusion/skills/bundled/content/batch.md +86 -0
- illusion/skills/bundled/content/coding-guidelines.md +70 -0
- illusion/skills/bundled/content/debug.md +38 -0
- illusion/skills/bundled/content/loop.md +82 -0
- illusion/skills/bundled/content/remember.md +105 -0
- illusion/skills/bundled/content/simplify.md +53 -0
- illusion/skills/bundled/content/skillify.md +113 -0
- illusion/skills/bundled/content/stuck.md +54 -0
- illusion/skills/bundled/content/update-config.md +329 -0
- illusion/skills/bundled/content/verify.md +74 -0
- illusion/skills/loader.py +219 -0
- illusion/skills/registry.py +40 -0
- illusion/skills/types.py +24 -0
- illusion/state/__init__.py +18 -0
- illusion/state/app_state.py +67 -0
- illusion/state/store.py +93 -0
- illusion/swarm/__init__.py +71 -0
- illusion/swarm/agent_executor.py +857 -0
- illusion/swarm/in_process.py +259 -0
- illusion/swarm/subprocess_backend.py +136 -0
- illusion/swarm/team_helpers.py +123 -0
- illusion/swarm/types.py +159 -0
- illusion/swarm/worktree.py +347 -0
- illusion/tasks/__init__.py +33 -0
- illusion/tasks/local_agent_task.py +42 -0
- illusion/tasks/local_shell_task.py +27 -0
- illusion/tasks/manager.py +377 -0
- illusion/tasks/stop_task.py +21 -0
- illusion/tasks/types.py +88 -0
- illusion/tools/__init__.py +126 -0
- illusion/tools/agent_tool.py +388 -0
- illusion/tools/ask_user_question_tool.py +186 -0
- illusion/tools/base.py +149 -0
- illusion/tools/bash_tool.py +413 -0
- illusion/tools/config_tool.py +90 -0
- illusion/tools/cron_tool.py +473 -0
- illusion/tools/enter_plan_mode_tool.py +147 -0
- illusion/tools/enter_worktree_tool.py +188 -0
- illusion/tools/exit_plan_mode_tool.py +69 -0
- illusion/tools/exit_worktree_tool.py +225 -0
- illusion/tools/file_edit_tool.py +283 -0
- illusion/tools/file_read_tool.py +294 -0
- illusion/tools/file_write_tool.py +184 -0
- illusion/tools/glob_tool.py +165 -0
- illusion/tools/grep_tool.py +190 -0
- illusion/tools/list_mcp_resources_tool.py +80 -0
- illusion/tools/lsp_tool.py +333 -0
- illusion/tools/mcp_auth_tool.py +100 -0
- illusion/tools/mcp_tool.py +75 -0
- illusion/tools/notebook_edit_tool.py +242 -0
- illusion/tools/powershell_tool.py +334 -0
- illusion/tools/read_mcp_resource_tool.py +63 -0
- illusion/tools/repl_tool.py +100 -0
- illusion/tools/send_message_tool.py +112 -0
- illusion/tools/shell_common.py +187 -0
- illusion/tools/skill_tool.py +86 -0
- illusion/tools/sleep_tool.py +62 -0
- illusion/tools/structured_output_tool.py +58 -0
- illusion/tools/task_create_tool.py +98 -0
- illusion/tools/task_get_tool.py +94 -0
- illusion/tools/task_list_tool.py +94 -0
- illusion/tools/task_output_tool.py +55 -0
- illusion/tools/task_stop_tool.py +52 -0
- illusion/tools/task_update_tool.py +224 -0
- illusion/tools/team_create_tool.py +236 -0
- illusion/tools/team_delete_tool.py +104 -0
- illusion/tools/todo_write_tool.py +198 -0
- illusion/tools/tool_search_tool.py +156 -0
- illusion/tools/web_fetch_tool.py +264 -0
- illusion/tools/web_search_tool.py +186 -0
- illusion/ui/__init__.py +23 -0
- illusion/ui/app.py +258 -0
- illusion/ui/backend_host.py +1180 -0
- illusion/ui/input.py +86 -0
- illusion/ui/output.py +363 -0
- illusion/ui/permission_dialog.py +47 -0
- illusion/ui/permission_store.py +99 -0
- illusion/ui/protocol.py +384 -0
- illusion/ui/react_launcher.py +280 -0
- illusion/ui/runtime.py +787 -0
- illusion/ui/textual_app.py +603 -0
- illusion/ui/web/__init__.py +10 -0
- illusion/ui/web/server.py +87 -0
- illusion/ui/web/ws_host.py +1197 -0
- illusion/utils/__init__.py +0 -0
- illusion/utils/ripgrep.py +299 -0
- illusion/utils/shell.py +248 -0
- illusion_code-0.1.0.dist-info/METADATA +1159 -0
- illusion_code-0.1.0.dist-info/RECORD +214 -0
- illusion_code-0.1.0.dist-info/WHEEL +4 -0
- illusion_code-0.1.0.dist-info/entry_points.txt +2 -0
- 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
|
+
]
|