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 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 typing import Callable, Awaitable
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.manager.load()
32
- logger.info("cron.scheduler.check_cycle", cycle=cycle, job_count=len(self.manager.jobs))
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("cron.scheduler.due_jobs_found", cycle=cycle, count=len(due_jobs), job_ids=[j.id for j in due_jobs])
37
- else:
38
- logger.info("cron.scheduler.no_due_jobs", cycle=cycle)
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("cron.scheduler.dispatching_job", job_id=job.id, message=job.message[:50])
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=60)
45
- await asyncio.sleep(60)
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
- logger.info("cron.job.executing", job_id=job.id)
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
- await self.callback(job)
51
- logger.info("cron.job.completed", job_id=job.id)
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
- context = RunContext(project=job.project) if job.project else None
1097
- engine_override: EngineId | None = job.engine if job.engine else None
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=0,
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=None,
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 = False if force_hide_resume_line else should_show_resume_line(
1193
- show_resume_line=cfg.show_resume_line,
1194
- stateful_mode=stateful_mode,
1195
- context=context,
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
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: yee88
3
- Version: 0.8.0
3
+ Version: 0.9.0
4
4
  Summary: Telegram bridge for Codex, Claude Code, and other agent CLIs.
5
5
  Project-URL: Homepage, https://github.com/banteg/yee88
6
6
  Project-URL: Documentation, https://yee88.dev/
@@ -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=_gqPXgnflb-i8-Mb8GS8LgcSGDbRa_X6K64xGPQXkt4,5582
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=ITNqpF4MXUieuuFBvVHgzOVxxR5bnhGAFYvWTJWy-IU,1922
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=r_z2gKRf1Z9dLF8qb8Df3Id__ycfHIEFmGpQ4VHLI00,70813
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.8.0.dist-info/METADATA,sha256=_oAnZ-DG_kvqqQcf16urGMb5rq4586IOyDqruoXa8IM,4340
108
- yee88-0.8.0.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
109
- yee88-0.8.0.dist-info/entry_points.txt,sha256=P4MVZ_sZfrHaARVMImNJjoGamP8VDukARWMKfDh20V8,282
110
- yee88-0.8.0.dist-info/licenses/LICENSE,sha256=poyQ59wnbmL3Ox3TiiephfHvUpLvJl0DwLFFgqBDdHY,1063
111
- yee88-0.8.0.dist-info/RECORD,,
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