yee88 0.8.0__py3-none-any.whl → 0.9.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.
- yee88/cron/manager.py +12 -20
- yee88/cron/scheduler.py +105 -12
- yee88/cron/watch.py +54 -0
- yee88/telegram/loop.py +61 -8
- {yee88-0.8.0.dist-info → yee88-0.9.0.dist-info}/METADATA +1 -1
- {yee88-0.8.0.dist-info → yee88-0.9.0.dist-info}/RECORD +9 -8
- {yee88-0.8.0.dist-info → yee88-0.9.0.dist-info}/WHEEL +0 -0
- {yee88-0.8.0.dist-info → yee88-0.9.0.dist-info}/entry_points.txt +0 -0
- {yee88-0.8.0.dist-info → yee88-0.9.0.dist-info}/licenses/LICENSE +0 -0
yee88/cron/manager.py
CHANGED
|
@@ -19,37 +19,32 @@ class CronManager:
|
|
|
19
19
|
def _validate_project(self, project: str) -> None:
|
|
20
20
|
if not project:
|
|
21
21
|
return
|
|
22
|
-
|
|
22
|
+
|
|
23
23
|
path = Path(project).expanduser().resolve()
|
|
24
24
|
if path.exists() and path.is_dir():
|
|
25
25
|
return
|
|
26
|
-
|
|
26
|
+
|
|
27
27
|
from ..settings import load_settings_if_exists
|
|
28
28
|
from ..engines import list_backend_ids
|
|
29
|
-
|
|
29
|
+
|
|
30
30
|
result = load_settings_if_exists()
|
|
31
31
|
if result is None:
|
|
32
32
|
return
|
|
33
|
-
|
|
33
|
+
|
|
34
34
|
settings, config_path = result
|
|
35
35
|
engine_ids = list_backend_ids()
|
|
36
36
|
projects_config = settings.to_projects_config(
|
|
37
|
-
config_path=config_path,
|
|
38
|
-
engine_ids=engine_ids
|
|
37
|
+
config_path=config_path, engine_ids=engine_ids
|
|
39
38
|
)
|
|
40
|
-
|
|
39
|
+
|
|
41
40
|
if project.lower() in projects_config.projects:
|
|
42
41
|
return
|
|
43
|
-
|
|
42
|
+
|
|
44
43
|
available = list(projects_config.projects.keys())
|
|
45
44
|
if available:
|
|
46
|
-
raise ValueError(
|
|
47
|
-
f"未知项目: {project}。可用项目: {', '.join(available)}"
|
|
48
|
-
)
|
|
45
|
+
raise ValueError(f"未知项目: {project}。可用项目: {', '.join(available)}")
|
|
49
46
|
else:
|
|
50
|
-
raise ValueError(
|
|
51
|
-
f"未知项目: {project}。请先使用 'yee88 init' 注册项目"
|
|
52
|
-
)
|
|
47
|
+
raise ValueError(f"未知项目: {project}。请先使用 'yee88 init' 注册项目")
|
|
53
48
|
|
|
54
49
|
def load(self):
|
|
55
50
|
if not self.file.exists():
|
|
@@ -59,10 +54,7 @@ class CronManager:
|
|
|
59
54
|
with open(self.file, "rb") as f:
|
|
60
55
|
data = tomllib.load(f)
|
|
61
56
|
|
|
62
|
-
self.jobs = [
|
|
63
|
-
CronJob(**job)
|
|
64
|
-
for job in data.get("jobs", [])
|
|
65
|
-
]
|
|
57
|
+
self.jobs = [CronJob(**job) for job in data.get("jobs", [])]
|
|
66
58
|
|
|
67
59
|
def save(self):
|
|
68
60
|
data = {
|
|
@@ -76,8 +68,8 @@ class CronManager:
|
|
|
76
68
|
"last_run": job.last_run,
|
|
77
69
|
"next_run": job.next_run,
|
|
78
70
|
"one_time": job.one_time,
|
|
79
|
-
"engine": job.engine,
|
|
80
|
-
"model": job.model,
|
|
71
|
+
"engine": job.engine or "",
|
|
72
|
+
"model": job.model or "",
|
|
81
73
|
}
|
|
82
74
|
for job in self.jobs
|
|
83
75
|
]
|
yee88/cron/scheduler.py
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import asyncio
|
|
2
|
-
from
|
|
2
|
+
from datetime import datetime
|
|
3
|
+
from typing import Callable, Awaitable, Set, Dict
|
|
3
4
|
from anyio.abc import TaskGroup
|
|
5
|
+
from anyio import Lock, move_on_after
|
|
4
6
|
from .manager import CronManager
|
|
5
7
|
from .models import CronJob
|
|
6
8
|
from ..logging import get_logger
|
|
@@ -19,6 +21,52 @@ class CronScheduler:
|
|
|
19
21
|
self.callback = callback
|
|
20
22
|
self.task_group = task_group
|
|
21
23
|
self.running = False
|
|
24
|
+
self._running_jobs: Set[str] = set()
|
|
25
|
+
self._job_locks: Dict[str, Lock] = {}
|
|
26
|
+
|
|
27
|
+
def _calculate_next_check(self) -> float:
|
|
28
|
+
now = datetime.now(self.manager.timezone)
|
|
29
|
+
min_sleep = 1.0
|
|
30
|
+
max_sleep = 60.0
|
|
31
|
+
|
|
32
|
+
earliest_next_run = None
|
|
33
|
+
|
|
34
|
+
for job in self.manager.jobs:
|
|
35
|
+
if not job.enabled:
|
|
36
|
+
continue
|
|
37
|
+
|
|
38
|
+
try:
|
|
39
|
+
if job.one_time:
|
|
40
|
+
exec_time = datetime.fromisoformat(job.schedule)
|
|
41
|
+
if exec_time.tzinfo is None:
|
|
42
|
+
exec_time = exec_time.replace(tzinfo=self.manager.timezone)
|
|
43
|
+
if exec_time > now:
|
|
44
|
+
if earliest_next_run is None or exec_time < earliest_next_run:
|
|
45
|
+
earliest_next_run = exec_time
|
|
46
|
+
else:
|
|
47
|
+
if job.next_run:
|
|
48
|
+
next_run = datetime.fromisoformat(job.next_run)
|
|
49
|
+
if next_run.tzinfo is None:
|
|
50
|
+
next_run = next_run.replace(tzinfo=self.manager.timezone)
|
|
51
|
+
else:
|
|
52
|
+
from croniter import croniter
|
|
53
|
+
|
|
54
|
+
itr = croniter(job.schedule, now)
|
|
55
|
+
next_run = itr.get_next(datetime)
|
|
56
|
+
if next_run.tzinfo is None:
|
|
57
|
+
next_run = next_run.replace(tzinfo=self.manager.timezone)
|
|
58
|
+
|
|
59
|
+
if next_run > now:
|
|
60
|
+
if earliest_next_run is None or next_run < earliest_next_run:
|
|
61
|
+
earliest_next_run = next_run
|
|
62
|
+
except Exception:
|
|
63
|
+
continue
|
|
64
|
+
|
|
65
|
+
if earliest_next_run is None:
|
|
66
|
+
return max_sleep
|
|
67
|
+
|
|
68
|
+
seconds_until = (earliest_next_run - now).total_seconds()
|
|
69
|
+
return max(min_sleep, min(seconds_until, max_sleep))
|
|
22
70
|
|
|
23
71
|
async def start(self):
|
|
24
72
|
self.running = True
|
|
@@ -28,29 +76,74 @@ class CronScheduler:
|
|
|
28
76
|
cycle = 0
|
|
29
77
|
while self.running:
|
|
30
78
|
cycle += 1
|
|
31
|
-
self.
|
|
32
|
-
|
|
79
|
+
sleep_seconds = self._calculate_next_check()
|
|
80
|
+
|
|
81
|
+
if sleep_seconds > 5:
|
|
82
|
+
logger.info(
|
|
83
|
+
"cron.scheduler.check_cycle",
|
|
84
|
+
cycle=cycle,
|
|
85
|
+
job_count=len(self.manager.jobs),
|
|
86
|
+
next_check_in=sleep_seconds,
|
|
87
|
+
)
|
|
88
|
+
|
|
33
89
|
due_jobs = self.manager.get_due_jobs()
|
|
34
90
|
|
|
35
91
|
if due_jobs:
|
|
36
|
-
logger.info(
|
|
37
|
-
|
|
38
|
-
|
|
92
|
+
logger.info(
|
|
93
|
+
"cron.scheduler.due_jobs_found",
|
|
94
|
+
cycle=cycle,
|
|
95
|
+
count=len(due_jobs),
|
|
96
|
+
job_ids=[j.id for j in due_jobs],
|
|
97
|
+
)
|
|
98
|
+
elif sleep_seconds <= 5:
|
|
99
|
+
logger.debug("cron.scheduler.no_due_jobs", cycle=cycle)
|
|
39
100
|
|
|
40
101
|
for job in due_jobs:
|
|
41
|
-
logger.info(
|
|
102
|
+
logger.info(
|
|
103
|
+
"cron.scheduler.dispatching_job",
|
|
104
|
+
job_id=job.id,
|
|
105
|
+
message=job.message[:50],
|
|
106
|
+
)
|
|
42
107
|
self.task_group.start_soon(self._run_job_safe, job)
|
|
43
108
|
|
|
44
|
-
logger.info("cron.scheduler.sleeping", cycle=cycle, seconds=
|
|
45
|
-
await asyncio.sleep(
|
|
109
|
+
logger.info("cron.scheduler.sleeping", cycle=cycle, seconds=sleep_seconds)
|
|
110
|
+
await asyncio.sleep(sleep_seconds)
|
|
111
|
+
|
|
112
|
+
def _acquire_job_lock(self, job_id: str) -> bool:
|
|
113
|
+
if job_id in self._running_jobs:
|
|
114
|
+
return False
|
|
115
|
+
|
|
116
|
+
if job_id not in self._job_locks:
|
|
117
|
+
self._job_locks[job_id] = Lock()
|
|
118
|
+
|
|
119
|
+
job_lock = self._job_locks[job_id]
|
|
120
|
+
|
|
121
|
+
if job_lock.locked():
|
|
122
|
+
return False
|
|
123
|
+
|
|
124
|
+
self._running_jobs.add(job_id)
|
|
125
|
+
return True
|
|
126
|
+
|
|
127
|
+
def _release_job_lock(self, job_id: str) -> None:
|
|
128
|
+
self._running_jobs.discard(job_id)
|
|
46
129
|
|
|
47
130
|
async def _run_job_safe(self, job: CronJob) -> None:
|
|
48
|
-
|
|
131
|
+
if not self._acquire_job_lock(job.id):
|
|
132
|
+
logger.warning("cron.job.already_running", job_id=job.id)
|
|
133
|
+
return
|
|
134
|
+
|
|
49
135
|
try:
|
|
50
|
-
|
|
51
|
-
|
|
136
|
+
logger.info("cron.job.executing", job_id=job.id)
|
|
137
|
+
with move_on_after(180):
|
|
138
|
+
await self.callback(job)
|
|
139
|
+
logger.info("cron.job.completed", job_id=job.id)
|
|
140
|
+
return
|
|
141
|
+
|
|
142
|
+
logger.warning("cron.job.timeout", job_id=job.id, timeout_s=180)
|
|
52
143
|
except Exception as exc:
|
|
53
144
|
logger.error("cron.job.failed", job_id=job.id, error=str(exc))
|
|
145
|
+
finally:
|
|
146
|
+
self._release_job_lock(job.id)
|
|
54
147
|
|
|
55
148
|
def stop(self):
|
|
56
149
|
self.running = False
|
yee88/cron/watch.py
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
from typing import Callable, Awaitable, List
|
|
3
|
+
|
|
4
|
+
import anyio
|
|
5
|
+
from watchfiles import watch
|
|
6
|
+
|
|
7
|
+
from ..logging import get_logger
|
|
8
|
+
from .manager import CronManager
|
|
9
|
+
|
|
10
|
+
logger = get_logger()
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
async def watch_cron_config(
|
|
14
|
+
cron_file: Path,
|
|
15
|
+
manager: CronManager,
|
|
16
|
+
on_reload: Callable[[List[str]], Awaitable[None]] | None = None,
|
|
17
|
+
) -> None:
|
|
18
|
+
if not cron_file.exists():
|
|
19
|
+
logger.warning("cron.watch.file_not_found", path=str(cron_file))
|
|
20
|
+
return
|
|
21
|
+
|
|
22
|
+
logger.info("cron.watch.started", path=str(cron_file))
|
|
23
|
+
|
|
24
|
+
try:
|
|
25
|
+
for changes in watch(str(cron_file)):
|
|
26
|
+
for change_type, path in changes:
|
|
27
|
+
if Path(path).name == "cron.toml":
|
|
28
|
+
logger.info(
|
|
29
|
+
"cron.watch.file_changed",
|
|
30
|
+
path=path,
|
|
31
|
+
change_type=change_type.name,
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
changed_jobs = manager.reload_jobs()
|
|
35
|
+
|
|
36
|
+
if changed_jobs:
|
|
37
|
+
logger.info(
|
|
38
|
+
"cron.watch.reloaded",
|
|
39
|
+
changed_count=len(changed_jobs),
|
|
40
|
+
changed_jobs=changed_jobs,
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
if on_reload:
|
|
44
|
+
await on_reload(changed_jobs)
|
|
45
|
+
else:
|
|
46
|
+
logger.debug("cron.watch.no_changes_detected")
|
|
47
|
+
|
|
48
|
+
break
|
|
49
|
+
except anyio.get_cancelled_exc_class():
|
|
50
|
+
logger.info("cron.watch.cancelled")
|
|
51
|
+
raise
|
|
52
|
+
except Exception as exc:
|
|
53
|
+
logger.error("cron.watch.error", error=str(exc))
|
|
54
|
+
raise
|
yee88/telegram/loop.py
CHANGED
|
@@ -1093,15 +1093,52 @@ async def run_main_loop(
|
|
|
1093
1093
|
async def _execute_cron_job(job: CronJob) -> None:
|
|
1094
1094
|
try:
|
|
1095
1095
|
from ..model import EngineId
|
|
1096
|
-
|
|
1097
|
-
|
|
1096
|
+
from ..markdown import MarkdownParts
|
|
1097
|
+
from ..transport import RenderedMessage, SendOptions
|
|
1098
|
+
from .render import prepare_telegram
|
|
1099
|
+
|
|
1100
|
+
context = (
|
|
1101
|
+
RunContext(project=job.project) if job.project else None
|
|
1102
|
+
)
|
|
1103
|
+
engine_override: EngineId | None = (
|
|
1104
|
+
job.engine if job.engine else None
|
|
1105
|
+
)
|
|
1106
|
+
|
|
1107
|
+
header_text = f"⏰ 定时任务开始: {job.id}"
|
|
1108
|
+
if job.project:
|
|
1109
|
+
header_text += f"\n📁 项目: {job.project}"
|
|
1110
|
+
|
|
1111
|
+
rendered_text, entities = prepare_telegram(
|
|
1112
|
+
MarkdownParts(header=header_text)
|
|
1113
|
+
)
|
|
1114
|
+
|
|
1115
|
+
initial_ref = await cfg.exec_cfg.transport.send(
|
|
1116
|
+
channel_id=cfg.chat_id,
|
|
1117
|
+
message=RenderedMessage(
|
|
1118
|
+
text=rendered_text, extra={"entities": entities}
|
|
1119
|
+
),
|
|
1120
|
+
options=SendOptions(
|
|
1121
|
+
notify=True,
|
|
1122
|
+
),
|
|
1123
|
+
)
|
|
1124
|
+
|
|
1125
|
+
if initial_ref is None:
|
|
1126
|
+
logger.error(
|
|
1127
|
+
"cron.initial_message_failed",
|
|
1128
|
+
job_id=job.id,
|
|
1129
|
+
error="Failed to send initial message to Telegram",
|
|
1130
|
+
)
|
|
1131
|
+
return
|
|
1132
|
+
|
|
1098
1133
|
await run_job(
|
|
1099
1134
|
chat_id=cfg.chat_id,
|
|
1100
|
-
user_msg_id=
|
|
1135
|
+
user_msg_id=int(initial_ref.message_id),
|
|
1101
1136
|
text=job.message,
|
|
1102
1137
|
resume_token=None,
|
|
1103
1138
|
context=context,
|
|
1104
|
-
thread_id=
|
|
1139
|
+
thread_id=int(initial_ref.thread_id)
|
|
1140
|
+
if initial_ref.thread_id
|
|
1141
|
+
else None,
|
|
1105
1142
|
force_hide_resume_line=True,
|
|
1106
1143
|
force_new_session=True,
|
|
1107
1144
|
run_options_model=job.model,
|
|
@@ -1121,6 +1158,18 @@ async def run_main_loop(
|
|
|
1121
1158
|
|
|
1122
1159
|
tg.start_soon(run_cron_scheduler)
|
|
1123
1160
|
|
|
1161
|
+
from ..cron.watch import watch_cron_config
|
|
1162
|
+
|
|
1163
|
+
cron_file = config_path.parent / "cron.toml"
|
|
1164
|
+
|
|
1165
|
+
async def run_cron_watch() -> None:
|
|
1166
|
+
await watch_cron_config(
|
|
1167
|
+
cron_file=cron_file,
|
|
1168
|
+
manager=cron_manager,
|
|
1169
|
+
)
|
|
1170
|
+
|
|
1171
|
+
tg.start_soon(run_cron_watch)
|
|
1172
|
+
|
|
1124
1173
|
async def run_signal_watcher() -> None:
|
|
1125
1174
|
if not hasattr(signal, "SIGHUP"):
|
|
1126
1175
|
return
|
|
@@ -1189,10 +1238,14 @@ async def run_main_loop(
|
|
|
1189
1238
|
topic_key = None
|
|
1190
1239
|
chat_session_key = None
|
|
1191
1240
|
stateful_mode = topic_key is not None or chat_session_key is not None
|
|
1192
|
-
show_resume_line =
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1241
|
+
show_resume_line = (
|
|
1242
|
+
False
|
|
1243
|
+
if force_hide_resume_line
|
|
1244
|
+
else should_show_resume_line(
|
|
1245
|
+
show_resume_line=cfg.show_resume_line,
|
|
1246
|
+
stateful_mode=stateful_mode,
|
|
1247
|
+
context=context,
|
|
1248
|
+
)
|
|
1196
1249
|
)
|
|
1197
1250
|
engine_for_overrides = (
|
|
1198
1251
|
resume_token.engine
|
|
@@ -40,9 +40,10 @@ yee88/cli/reload.py,sha256=ays9R2kJ0IQEPGfxb3BY7R2mxbJBmp325dKrqqv1nw8,3552
|
|
|
40
40
|
yee88/cli/run.py,sha256=qKMJCEwoiNN4Qqt59Gmm1bDQHTboGf8JO73gMeACAck,14197
|
|
41
41
|
yee88/cli/topic.py,sha256=6j_o0wpHMfkyeyj9wcjU5T5PVXw_cOM0qM5fsMDRiIw,11019
|
|
42
42
|
yee88/cron/__init__.py,sha256=WZN2RcqJD4i6ZPZjI6b_sKI1VxhNGzhDu2Pju5u9J0o,152
|
|
43
|
-
yee88/cron/manager.py,sha256=
|
|
43
|
+
yee88/cron/manager.py,sha256=VRnhue8AzIWoRyqSs_xe4xiMT01Dp24v_yOVeFXk-T0,5440
|
|
44
44
|
yee88/cron/models.py,sha256=Ue7Q9ZhA34t0y4HttZrJX9aU_v8G0dT4fsotz1iGpXg,317
|
|
45
|
-
yee88/cron/scheduler.py,sha256=
|
|
45
|
+
yee88/cron/scheduler.py,sha256=bpjLRy0aVUG6lMzkR8Ty_1S5I6PybmOxhBDbk_pTIzI,5071
|
|
46
|
+
yee88/cron/watch.py,sha256=pMIYiQuXcm9gW0G4ns7GRakkBtB_yGM5wwi8cvxJmDM,1637
|
|
46
47
|
yee88/runners/__init__.py,sha256=McKaMqLXT9dJlgiEwKf6biD0Ns66Fk7SrxwtcP0ZgzI,30
|
|
47
48
|
yee88/runners/claude.py,sha256=mj-L_V-cuCUWL9BW90I645EgDsjRHNdmpLQxN7skUL0,15518
|
|
48
49
|
yee88/runners/codex.py,sha256=fUipu9NsRhhHCIt8s5WkZcT03si1CIEbGrXGmcSlFUo,21427
|
|
@@ -70,7 +71,7 @@ yee88/telegram/context.py,sha256=Hb8-k-YbAjO0EmZ35hA8yyMvl1kozgYHUL1L-lbCglA,468
|
|
|
70
71
|
yee88/telegram/engine_defaults.py,sha256=n6ROkTmP_s-H5AhPz_OdT62oZf0QtZJyFEDjp5gfub4,2594
|
|
71
72
|
yee88/telegram/engine_overrides.py,sha256=kv2j102VP-Bqzbutd5ApBkjW3LmVwvCYixsFewVXVeY,3122
|
|
72
73
|
yee88/telegram/files.py,sha256=Cvmw6r_ocSb3jLzJLGVbzr46m8cRU159majJ1-A5lvg,5053
|
|
73
|
-
yee88/telegram/loop.py,sha256=
|
|
74
|
+
yee88/telegram/loop.py,sha256=emRad9j60oQ_T0V8Ku2FsHr6hunT23uzCJ69va4dSAI,72880
|
|
74
75
|
yee88/telegram/onboarding.py,sha256=QWYaJT_s2bujDxzKjsZuLytyxs_XFDRuiBrsZGRjoOw,35633
|
|
75
76
|
yee88/telegram/outbox.py,sha256=OcoRyQ7zmQCXR8ZXEMK2f_7-UMRVRAbBgmJGS1u_lcU,5939
|
|
76
77
|
yee88/telegram/parsing.py,sha256=5PvIPns1NnKryt3XNxPCp4BpWX1gI8kjKi4VxcQ0W-Q,7429
|
|
@@ -104,8 +105,8 @@ yee88/utils/json_state.py,sha256=cnSvGbB9zj90GdYSyigId1M0nEx54T3A3CqqhkAm9kQ,524
|
|
|
104
105
|
yee88/utils/paths.py,sha256=_Tp-LyFLeyGD0P0agRudLuT1NR_XTIpryxk3OYDJAGQ,1318
|
|
105
106
|
yee88/utils/streams.py,sha256=TQezA-A5VCNksLOtwsJplfr8vm1xPTXoGxvik8G2NPI,1121
|
|
106
107
|
yee88/utils/subprocess.py,sha256=2if6IxTZVSB1kDa8SXw3igj3E-zhKB8P4z5MVe-odzY,2169
|
|
107
|
-
yee88-0.
|
|
108
|
-
yee88-0.
|
|
109
|
-
yee88-0.
|
|
110
|
-
yee88-0.
|
|
111
|
-
yee88-0.
|
|
108
|
+
yee88-0.9.0.dist-info/METADATA,sha256=jglWFP-VyXSgi3a0tuGE_A0SPfIpkms5rg3NjY9QQB8,4340
|
|
109
|
+
yee88-0.9.0.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
|
|
110
|
+
yee88-0.9.0.dist-info/entry_points.txt,sha256=P4MVZ_sZfrHaARVMImNJjoGamP8VDukARWMKfDh20V8,282
|
|
111
|
+
yee88-0.9.0.dist-info/licenses/LICENSE,sha256=poyQ59wnbmL3Ox3TiiephfHvUpLvJl0DwLFFgqBDdHY,1063
|
|
112
|
+
yee88-0.9.0.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|