vibego 0.2.11__py3-none-any.whl → 0.2.13__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.

Potentially problematic release.


This version of vibego might be problematic. Click here for more details.

bot.py CHANGED
@@ -219,7 +219,7 @@ SEND_RETRY_BASE_DELAY = float(os.environ.get("SEND_RETRY_BASE_DELAY", "0.5"))
219
219
  SEND_FAILURE_NOTICE_COOLDOWN = float(os.environ.get("SEND_FAILURE_NOTICE_COOLDOWN", "30"))
220
220
  SESSION_INITIAL_BACKTRACK_BYTES = int(os.environ.get("SESSION_INITIAL_BACKTRACK_BYTES", "16384"))
221
221
  ENABLE_PLAN_PROGRESS = (os.environ.get("ENABLE_PLAN_PROGRESS", "1").strip().lower() not in {"0", "false", "no", "off"})
222
- AUTO_COMPACT_THRESHOLD = max(_env_int("AUTO_COMPACT_THRESHOLD", 10), 0)
222
+ AUTO_COMPACT_THRESHOLD = max(_env_int("AUTO_COMPACT_THRESHOLD", 0), 0)
223
223
 
224
224
  PLAN_STATUS_LABELS = {
225
225
  "completed": "✅",
@@ -967,6 +967,9 @@ async def _dispatch_prompt_to_model(
967
967
  return True, session_path
968
968
  await asyncio.sleep(0.3)
969
969
 
970
+ # 中断旧的延迟轮询(如果存在)
971
+ await _interrupt_long_poll(chat_id)
972
+
970
973
  watcher_task = asyncio.create_task(
971
974
  _watch_and_notify(
972
975
  chat_id,
@@ -1200,6 +1203,118 @@ def _resolve_worker_target_chat_ids() -> List[int]:
1200
1203
  return sorted(targets)
1201
1204
 
1202
1205
 
1206
+ def _auto_record_chat_id(chat_id: int) -> None:
1207
+ """首次收到消息时自动将 chat_id 记录到 state 文件。
1208
+
1209
+ 仅在以下条件同时满足时写入:
1210
+ 1. STATE_FILE 环境变量已配置
1211
+ 2. state 文件存在
1212
+ 3. 当前项目在 state 中的 chat_id 为空
1213
+ """
1214
+ state_file_env = os.environ.get("STATE_FILE")
1215
+ if not state_file_env:
1216
+ return
1217
+
1218
+ state_path = Path(state_file_env).expanduser()
1219
+ if not state_path.exists():
1220
+ worker_log.debug(
1221
+ "STATE_FILE 不存在,跳过自动记录 chat_id",
1222
+ extra={**_session_extra(), "path": str(state_path)},
1223
+ )
1224
+ return
1225
+
1226
+ # 使用文件锁保证并发安全
1227
+ lock_path = state_path.with_suffix(state_path.suffix + ".lock")
1228
+ import fcntl
1229
+
1230
+ try:
1231
+ with open(lock_path, "w", encoding="utf-8") as lock_file:
1232
+ fcntl.flock(lock_file.fileno(), fcntl.LOCK_EX)
1233
+
1234
+ try:
1235
+ # 读取当前 state
1236
+ raw_state = json.loads(state_path.read_text(encoding="utf-8"))
1237
+ if not isinstance(raw_state, dict):
1238
+ worker_log.warning(
1239
+ "STATE_FILE 格式异常,跳过自动记录",
1240
+ extra=_session_extra(),
1241
+ )
1242
+ return
1243
+
1244
+ # 检查当前项目的 chat_id
1245
+ project_key = PROJECT_SLUG or PROJECT_NAME
1246
+ if not project_key:
1247
+ worker_log.warning(
1248
+ "PROJECT_SLUG 和 PROJECT_NAME 均未设置,跳过自动记录",
1249
+ extra=_session_extra(),
1250
+ )
1251
+ return
1252
+
1253
+ project_state = raw_state.get(project_key)
1254
+ if not isinstance(project_state, dict):
1255
+ # 项目不存在,创建新条目
1256
+ raw_state[project_key] = {
1257
+ "chat_id": chat_id,
1258
+ "model": ACTIVE_MODEL or "codex",
1259
+ "status": "running",
1260
+ }
1261
+ need_write = True
1262
+ elif project_state.get("chat_id") is None:
1263
+ # chat_id 为空,更新
1264
+ project_state["chat_id"] = chat_id
1265
+ need_write = True
1266
+ else:
1267
+ # chat_id 已存在,无需更新
1268
+ need_write = False
1269
+
1270
+ if need_write:
1271
+ # 写入更新后的 state
1272
+ tmp_path = state_path.with_suffix(state_path.suffix + ".tmp")
1273
+ tmp_path.write_text(
1274
+ json.dumps(raw_state, ensure_ascii=False, indent=4),
1275
+ encoding="utf-8",
1276
+ )
1277
+ tmp_path.replace(state_path)
1278
+ worker_log.info(
1279
+ "已自动记录 chat_id=%s 到 state 文件",
1280
+ chat_id,
1281
+ extra={**_session_extra(), "project": project_key},
1282
+ )
1283
+ else:
1284
+ worker_log.debug(
1285
+ "chat_id 已存在,跳过自动记录",
1286
+ extra={**_session_extra(), "existing_chat_id": project_state.get("chat_id")},
1287
+ )
1288
+
1289
+ except json.JSONDecodeError as exc:
1290
+ worker_log.error(
1291
+ "STATE_FILE 解析失败,跳过自动记录:%s",
1292
+ exc,
1293
+ extra=_session_extra(),
1294
+ )
1295
+ except Exception as exc:
1296
+ worker_log.error(
1297
+ "自动记录 chat_id 失败:%s",
1298
+ exc,
1299
+ extra={**_session_extra(), "chat": chat_id},
1300
+ )
1301
+ finally:
1302
+ fcntl.flock(lock_file.fileno(), fcntl.LOCK_UN)
1303
+ except Exception as exc:
1304
+ worker_log.error(
1305
+ "获取文件锁失败:%s",
1306
+ exc,
1307
+ extra=_session_extra(),
1308
+ )
1309
+ finally:
1310
+ # 清理锁文件
1311
+ try:
1312
+ if lock_path.exists():
1313
+ lock_path.unlink()
1314
+ except Exception:
1315
+ pass
1316
+
1317
+
1203
1318
  async def _broadcast_worker_keyboard(bot: Bot) -> None:
1204
1319
  """启动时主动推送菜单,确保 Telegram 键盘同步。"""
1205
1320
  targets = _resolve_worker_target_chat_ids()
@@ -2654,7 +2769,8 @@ def _build_summary_prompt(
2654
2769
  ) -> str:
2655
2770
  """构造模型摘要提示词,要求携带请求标识。"""
2656
2771
 
2657
- task_code = _format_task_command(task.id)
2772
+ # 摘要提示词是发送给模型的,使用纯文本格式,不需要 Markdown 转义
2773
+ task_code = f"/{task.id}" if task.id else "-"
2658
2774
  title = task.title or "-"
2659
2775
  status_label = STATUS_LABELS.get(task.status, task.status)
2660
2776
  note_lines: list[str] = []
@@ -2817,8 +2933,9 @@ def _build_task_history_view(
2817
2933
  if total_items == 0:
2818
2934
  raise ValueError("暂无事件记录")
2819
2935
 
2936
+ # 历史记录会被包裹在代码块中显示,使用纯文本格式,不需要 Markdown 转义
2820
2937
  title_text = normalize_newlines(task.title or "").strip() or "-"
2821
- title_display = _escape_markdown_text(title_text) if _IS_MARKDOWN_V2 else title_text
2938
+ title_display = title_text
2822
2939
 
2823
2940
  digit_width = len(str(max(total_items, 1)))
2824
2941
  placeholder_page = "9" * digit_width
@@ -3050,6 +3167,9 @@ CHAT_DELIVERED_HASHES: Dict[int, Dict[str, set[str]]] = {}
3050
3167
  CHAT_DELIVERED_OFFSETS: Dict[int, Dict[str, set[int]]] = {}
3051
3168
  CHAT_REPLY_COUNT: Dict[int, Dict[str, int]] = {}
3052
3169
  CHAT_COMPACT_STATE: Dict[int, Dict[str, Dict[str, Any]]] = {}
3170
+ # 长轮询状态:用于延迟轮询机制
3171
+ CHAT_LONG_POLL_STATE: Dict[int, Dict[str, Any]] = {}
3172
+ CHAT_LONG_POLL_LOCK: Optional[asyncio.Lock] = None # 在事件循环启动后初始化
3053
3173
  SUMMARY_REQUEST_TIMEOUT_SECONDS = 300.0
3054
3174
 
3055
3175
 
@@ -3644,7 +3764,19 @@ def _get_delivered_offsets(chat_id: int, session_key: str) -> set[int]:
3644
3764
  return CHAT_DELIVERED_OFFSETS.setdefault(chat_id, {}).setdefault(session_key, set())
3645
3765
 
3646
3766
 
3647
- async def _deliver_pending_messages(chat_id: int, session_path: Path) -> bool:
3767
+ async def _deliver_pending_messages(
3768
+ chat_id: int,
3769
+ session_path: Path,
3770
+ *,
3771
+ add_completion_header: bool = True
3772
+ ) -> bool:
3773
+ """发送待处理的模型消息。
3774
+
3775
+ Args:
3776
+ chat_id: Telegram 聊天 ID
3777
+ session_path: 会话文件路径
3778
+ add_completion_header: 是否添加"✅模型执行完成"前缀(快速轮询阶段为 True,延迟轮询为 False)
3779
+ """
3648
3780
  session_key = str(session_path)
3649
3781
  previous_offset = SESSION_OFFSETS.get(session_key, 0)
3650
3782
  new_offset, events = _read_session_events(session_path)
@@ -3719,7 +3851,8 @@ async def _deliver_pending_messages(chat_id: int, session_path: Path) -> bool:
3719
3851
  last_committed_offset = event_offset
3720
3852
  SESSION_OFFSETS[session_key] = event_offset
3721
3853
  continue
3722
- formatted_text = _prepend_completion_header(text_to_send)
3854
+ # 根据轮询阶段决定是否添加完成前缀
3855
+ formatted_text = _prepend_completion_header(text_to_send) if add_completion_header else text_to_send
3723
3856
  payload_for_hash = _prepare_model_payload(formatted_text)
3724
3857
  initial_hash = hashlib.sha256(payload_for_hash.encode("utf-8", errors="ignore")).hexdigest()
3725
3858
  if initial_hash in delivered_hashes:
@@ -3830,22 +3963,24 @@ async def _deliver_pending_messages(chat_id: int, session_path: Path) -> bool:
3830
3963
  SESSION_OFFSETS[session_key] = max(last_committed_offset, new_offset)
3831
3964
 
3832
3965
  if delivered_response:
3966
+ # 实际发送了消息,返回 True 表示本次调用成功发送
3967
+ # 这样可以确保延迟轮询机制被正确触发
3833
3968
  if ENABLE_PLAN_PROGRESS and plan_active:
3834
3969
  worker_log.info(
3835
- "模型输出已发送,但计划仍在更新,继续监听",
3970
+ "模型输出已发送,但计划仍在更新",
3971
+ extra={
3972
+ **_session_extra(path=session_path),
3973
+ "chat": chat_id,
3974
+ },
3975
+ )
3976
+ else:
3977
+ worker_log.info(
3978
+ "模型输出已发送且计划完成",
3836
3979
  extra={
3837
3980
  **_session_extra(path=session_path),
3838
3981
  "chat": chat_id,
3839
3982
  },
3840
3983
  )
3841
- return False
3842
- worker_log.info(
3843
- "模型输出已发送且计划完成",
3844
- extra={
3845
- **_session_extra(path=session_path),
3846
- "chat": chat_id,
3847
- },
3848
- )
3849
3984
  return True
3850
3985
 
3851
3986
  if ENABLE_PLAN_PROGRESS and not plan_active and final_response_sent:
@@ -3992,6 +4127,9 @@ async def _ensure_session_watcher(chat_id: int) -> Optional[Path]:
3992
4127
  if watcher is not None and watcher.done():
3993
4128
  CHAT_WATCHERS.pop(chat_id, None)
3994
4129
 
4130
+ # 中断旧的延迟轮询(如果存在)
4131
+ await _interrupt_long_poll(chat_id)
4132
+
3995
4133
  CHAT_WATCHERS[chat_id] = asyncio.create_task(
3996
4134
  _watch_and_notify(
3997
4135
  chat_id,
@@ -4151,23 +4289,181 @@ async def _finalize_plan_progress(chat_id: int) -> None:
4151
4289
 
4152
4290
 
4153
4291
 
4292
+ async def _interrupt_long_poll(chat_id: int) -> None:
4293
+ """
4294
+ 中断指定 chat 的延迟轮询。
4295
+
4296
+ 当用户发送新消息时调用,确保旧的延迟轮询被终止,
4297
+ 为新的监听任务让路。
4298
+
4299
+ 线程安全:使用 asyncio.Lock 保护状态访问。
4300
+ """
4301
+ if CHAT_LONG_POLL_LOCK is None:
4302
+ # 锁未初始化(测试环境或启动早期)
4303
+ return
4304
+
4305
+ async with CHAT_LONG_POLL_LOCK:
4306
+ state = CHAT_LONG_POLL_STATE.get(chat_id)
4307
+ if state is not None:
4308
+ state["interrupted"] = True
4309
+ worker_log.info(
4310
+ "标记延迟轮询为待中断",
4311
+ extra={"chat": chat_id},
4312
+ )
4313
+
4314
+
4154
4315
  async def _watch_and_notify(chat_id: int, session_path: Path,
4155
4316
  max_wait: float, interval: float):
4317
+ """
4318
+ 监听会话文件并发送消息。
4319
+
4320
+ 两阶段轮询机制:
4321
+ - 阶段1(快速轮询):interval 间隔(通常 0.3 秒),直到首次发送成功
4322
+ - 阶段2(延迟轮询):3 秒间隔,最多 600 次(持续 30 分钟),捕获长时间任务的后续输出
4323
+
4324
+ 异常安全:使用 try...finally 确保状态清理。
4325
+ 中断机制:收到新 Telegram 消息时会设置 interrupted 标志,轮询自动停止。
4326
+ """
4156
4327
  start = time.monotonic()
4157
- while True:
4158
- await asyncio.sleep(interval)
4159
- if max_wait > 0 and time.monotonic() - start > max_wait:
4160
- worker_log.warning(
4161
- "[session-map] chat=%s 长时间未获取到 Codex 输出,停止轮询",
4162
- chat_id,
4163
- extra=_session_extra(path=session_path),
4164
- )
4165
- return
4166
- if not session_path.exists():
4167
- continue
4168
- delivered = await _deliver_pending_messages(chat_id, session_path)
4169
- if delivered:
4170
- return
4328
+ first_delivery_done = False
4329
+ current_interval = interval # 初始为快速轮询间隔(0.3 秒)
4330
+ long_poll_rounds = 0
4331
+ long_poll_max_rounds = 600 # 30 分钟 / 3 秒 = 600 次
4332
+ long_poll_interval = 3.0 # 3
4333
+
4334
+ try:
4335
+ while True:
4336
+ # 检查是否被新消息中断(使用锁保护)
4337
+ if CHAT_LONG_POLL_LOCK is not None:
4338
+ async with CHAT_LONG_POLL_LOCK:
4339
+ state = CHAT_LONG_POLL_STATE.get(chat_id)
4340
+ if state is not None and state.get("interrupted", False):
4341
+ worker_log.info(
4342
+ "延迟轮询被新消息中断",
4343
+ extra={
4344
+ **_session_extra(path=session_path),
4345
+ "chat": chat_id,
4346
+ "round": long_poll_rounds,
4347
+ },
4348
+ )
4349
+ return
4350
+
4351
+ await asyncio.sleep(current_interval)
4352
+
4353
+ # 检查超时(仅在快速轮询阶段)
4354
+ if not first_delivery_done and max_wait > 0 and time.monotonic() - start > max_wait:
4355
+ worker_log.warning(
4356
+ "[session-map] chat=%s 长时间未获取到 Codex 输出,停止轮询",
4357
+ chat_id,
4358
+ extra=_session_extra(path=session_path),
4359
+ )
4360
+ return
4361
+
4362
+ if not session_path.exists():
4363
+ continue
4364
+
4365
+ try:
4366
+ # 快速轮询阶段添加前缀,延迟轮询阶段不添加
4367
+ delivered = await _deliver_pending_messages(
4368
+ chat_id,
4369
+ session_path,
4370
+ add_completion_header=not first_delivery_done
4371
+ )
4372
+ except Exception as exc:
4373
+ worker_log.error(
4374
+ "消息发送时发生未预期异常",
4375
+ exc_info=exc,
4376
+ extra={
4377
+ **_session_extra(path=session_path),
4378
+ "chat": chat_id,
4379
+ },
4380
+ )
4381
+ delivered = False
4382
+
4383
+ # 首次发送成功,切换到延迟轮询模式
4384
+ if delivered and not first_delivery_done:
4385
+ first_delivery_done = True
4386
+ current_interval = long_poll_interval
4387
+ if CHAT_LONG_POLL_LOCK is not None:
4388
+ async with CHAT_LONG_POLL_LOCK:
4389
+ CHAT_LONG_POLL_STATE[chat_id] = {
4390
+ "active": True,
4391
+ "round": 0,
4392
+ "max_rounds": long_poll_max_rounds,
4393
+ "interrupted": False,
4394
+ }
4395
+ worker_log.info(
4396
+ "首次发送成功,启动延迟轮询模式",
4397
+ extra={
4398
+ **_session_extra(path=session_path),
4399
+ "chat": chat_id,
4400
+ "interval": long_poll_interval,
4401
+ "max_rounds": long_poll_max_rounds,
4402
+ },
4403
+ )
4404
+ continue
4405
+
4406
+ # 延迟轮询阶段
4407
+ if first_delivery_done:
4408
+ if delivered:
4409
+ # 又收到新消息,重置轮询计数
4410
+ long_poll_rounds = 0
4411
+ if CHAT_LONG_POLL_LOCK is not None:
4412
+ async with CHAT_LONG_POLL_LOCK:
4413
+ state = CHAT_LONG_POLL_STATE.get(chat_id)
4414
+ if state is not None:
4415
+ state["round"] = 0
4416
+ worker_log.info(
4417
+ "延迟轮询中收到新消息,重置计数",
4418
+ extra={
4419
+ **_session_extra(path=session_path),
4420
+ "chat": chat_id,
4421
+ },
4422
+ )
4423
+ else:
4424
+ # 无新消息,增加轮询计数
4425
+ long_poll_rounds += 1
4426
+ if CHAT_LONG_POLL_LOCK is not None:
4427
+ async with CHAT_LONG_POLL_LOCK:
4428
+ state = CHAT_LONG_POLL_STATE.get(chat_id)
4429
+ if state is not None:
4430
+ state["round"] = long_poll_rounds
4431
+
4432
+ if long_poll_rounds >= long_poll_max_rounds:
4433
+ worker_log.info(
4434
+ "延迟轮询达到最大次数,停止监听",
4435
+ extra={
4436
+ **_session_extra(path=session_path),
4437
+ "chat": chat_id,
4438
+ "total_rounds": long_poll_rounds,
4439
+ },
4440
+ )
4441
+ return
4442
+
4443
+ worker_log.debug(
4444
+ "延迟轮询中无新消息",
4445
+ extra={
4446
+ **_session_extra(path=session_path),
4447
+ "chat": chat_id,
4448
+ "round": f"{long_poll_rounds}/{long_poll_max_rounds}",
4449
+ },
4450
+ )
4451
+ continue
4452
+
4453
+ # 快速轮询阶段:如果已发送消息,退出
4454
+ if delivered:
4455
+ return
4456
+
4457
+ finally:
4458
+ # 确保无论如何都清理延迟轮询状态
4459
+ if CHAT_LONG_POLL_LOCK is not None:
4460
+ async with CHAT_LONG_POLL_LOCK:
4461
+ if chat_id in CHAT_LONG_POLL_STATE:
4462
+ CHAT_LONG_POLL_STATE.pop(chat_id, None)
4463
+ worker_log.debug(
4464
+ "监听任务退出,已清理延迟轮询状态",
4465
+ extra={"chat": chat_id},
4466
+ )
4171
4467
 
4172
4468
 
4173
4469
  def _read_pointer_path(pointer: Path) -> Optional[Path]:
@@ -5422,7 +5718,7 @@ async def on_task_desc_input(message: Message, state: FSMContext) -> None:
5422
5718
 
5423
5719
  @router.message(TaskDescriptionStates.waiting_confirm)
5424
5720
  async def on_task_desc_confirm_stage_text(message: Message, state: FSMContext) -> None:
5425
- """处理任务描述确认阶段的菜单指令。"""
5721
+ """处理任务描述确认阶段的菜单指令。支持按钮点击、数字编号和直接文本输入。"""
5426
5722
 
5427
5723
  data = await state.get_data()
5428
5724
  task_id = data.get("task_id")
@@ -5431,13 +5727,19 @@ async def on_task_desc_confirm_stage_text(message: Message, state: FSMContext) -
5431
5727
  await message.answer("会话已失效,请重新操作。", reply_markup=_build_worker_main_keyboard())
5432
5728
  return
5433
5729
 
5434
- token = _normalize_choice_token(message.text or "")
5435
- if _is_cancel_message(token) or token == _normalize_choice_token(TASK_DESC_CANCEL_TEXT):
5730
+ # 使用 _resolve_reply_choice() 智能解析用户输入,支持数字编号、按钮文本和直接文本
5731
+ options = [TASK_DESC_CONFIRM_TEXT, TASK_DESC_RETRY_TEXT, TASK_DESC_CANCEL_TEXT]
5732
+ resolved = _resolve_reply_choice(message.text, options=options)
5733
+ stripped = _strip_number_prefix((message.text or "").strip()).lower()
5734
+
5735
+ # 处理取消操作
5736
+ if resolved == options[2] or _is_cancel_message(resolved) or stripped in {"取消"}:
5436
5737
  await state.clear()
5437
5738
  await message.answer("已取消编辑任务描述。", reply_markup=_build_worker_main_keyboard())
5438
5739
  return
5439
5740
 
5440
- if token == _normalize_choice_token(TASK_DESC_RETRY_TEXT):
5741
+ # 处理重新输入操作
5742
+ if resolved == options[1] or stripped in {"重新输入"}:
5441
5743
  task = await TASK_SERVICE.get_task(task_id)
5442
5744
  if task is None:
5443
5745
  await state.clear()
@@ -5455,7 +5757,8 @@ async def on_task_desc_confirm_stage_text(message: Message, state: FSMContext) -
5455
5757
  )
5456
5758
  return
5457
5759
 
5458
- if token == _normalize_choice_token(TASK_DESC_CONFIRM_TEXT):
5760
+ # 处理确认更新操作
5761
+ if resolved == options[0] or stripped in {"确认", "确认更新"}:
5459
5762
  new_description = data.get("new_description")
5460
5763
  if new_description is None:
5461
5764
  await state.set_state(TaskDescriptionStates.waiting_content)
@@ -5486,8 +5789,9 @@ async def on_task_desc_confirm_stage_text(message: Message, state: FSMContext) -
5486
5789
  )
5487
5790
  return
5488
5791
 
5792
+ # 无效输入,提示用户
5489
5793
  await message.answer(
5490
- "当前处于确认阶段,请使用菜单中的按钮确认或重新输入。",
5794
+ "当前处于确认阶段,请选择确认、重新输入或取消,可直接输入编号或点击键盘按钮:",
5491
5795
  reply_markup=_build_task_desc_confirm_keyboard(),
5492
5796
  )
5493
5797
 
@@ -6749,6 +7053,9 @@ async def on_edit_new_value(message: Message, state: FSMContext) -> None:
6749
7053
 
6750
7054
  @router.message(CommandStart())
6751
7055
  async def on_start(m: Message):
7056
+ # 首次收到消息时自动记录 chat_id 到 state 文件
7057
+ _auto_record_chat_id(m.chat.id)
7058
+
6752
7059
  await m.answer(
6753
7060
  (
6754
7061
  f"Hello, {m.from_user.full_name}!\n"
@@ -6764,6 +7071,9 @@ async def on_start(m: Message):
6764
7071
 
6765
7072
  @router.message(F.text)
6766
7073
  async def on_text(m: Message):
7074
+ # 首次收到消息时自动记录 chat_id 到 state 文件
7075
+ _auto_record_chat_id(m.chat.id)
7076
+
6767
7077
  prompt = (m.text or "").strip()
6768
7078
  if not prompt:
6769
7079
  return await m.answer("请输入非空提示词")
@@ -6869,7 +7179,9 @@ async def _ensure_worker_menu_button(bot: Bot) -> None:
6869
7179
  )
6870
7180
 
6871
7181
  async def main():
6872
- global _bot
7182
+ global _bot, CHAT_LONG_POLL_LOCK
7183
+ # 初始化长轮询锁
7184
+ CHAT_LONG_POLL_LOCK = asyncio.Lock()
6873
7185
  _bot = build_bot()
6874
7186
  try:
6875
7187
  await ensure_telegram_connectivity(_bot)
@@ -0,0 +1,152 @@
1
+ #!/usr/bin/env bash
2
+ # 版本管理便捷脚本
3
+ # 使用方式:
4
+ # ./scripts/bump_version.sh patch
5
+ # ./scripts/bump_version.sh minor
6
+ # ./scripts/bump_version.sh major
7
+ # ./scripts/bump_version.sh show
8
+ # ./scripts/bump_version.sh --help
9
+
10
+ set -e
11
+
12
+ # 项目根目录
13
+ PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
14
+ cd "$PROJECT_ROOT"
15
+
16
+ # bump-my-version 路径
17
+ BUMP_CMD="/Users/david/.config/vibego/runtime/.venv/bin/bump-my-version"
18
+
19
+ # 检查 bump-my-version 是否存在
20
+ if [ ! -f "$BUMP_CMD" ]; then
21
+ echo "错误:找不到 bump-my-version"
22
+ echo "请先安装:pip install bump-my-version"
23
+ exit 1
24
+ fi
25
+
26
+ # 如果没有参数,显示帮助
27
+ if [ $# -eq 0 ]; then
28
+ echo "用法:"
29
+ echo " $0 patch 递增补丁版本 (0.2.11 → 0.2.12)"
30
+ echo " 自动提交:fix: bugfixes"
31
+ echo " $0 minor 递增次版本 (0.2.11 → 0.3.0)"
32
+ echo " 自动提交:feat: 添加新功能"
33
+ echo " $0 major 递增主版本 (0.2.11 → 1.0.0)"
34
+ echo " 自动提交:feat!: 重大变更"
35
+ echo " $0 show 显示当前版本"
36
+ echo " $0 --dry-run 预览变更(添加在 patch/minor/major 后)"
37
+ echo ""
38
+ echo "说明:"
39
+ echo " 脚本会自动提交当前未提交的修改,然后递增版本号。"
40
+ echo " 如果不想自动提交,请在参数中添加 --no-auto-commit"
41
+ echo ""
42
+ echo "示例:"
43
+ echo " $0 patch # 自动提交修改并递增补丁版本"
44
+ echo " $0 patch --dry-run # 预览补丁版本递增(不会提交)"
45
+ echo " $0 minor --no-auto-commit # 仅递增版本,不自动提交当前修改"
46
+ exit 0
47
+ fi
48
+
49
+ # 处理 show 命令
50
+ if [ "$1" = "show" ]; then
51
+ "$BUMP_CMD" show current_version
52
+ exit 0
53
+ fi
54
+
55
+ # 处理 --help
56
+ if [ "$1" = "--help" ] || [ "$1" = "-h" ]; then
57
+ "$BUMP_CMD" --help
58
+ exit 0
59
+ fi
60
+
61
+ # 检查是否禁用自动提交
62
+ AUTO_COMMIT=true
63
+ if [[ "$*" =~ "--no-auto-commit" ]]; then
64
+ AUTO_COMMIT=false
65
+ fi
66
+
67
+ # 检查是否是 dry-run
68
+ DRY_RUN=false
69
+ if [[ "$*" =~ "--dry-run" ]]; then
70
+ DRY_RUN=true
71
+ fi
72
+
73
+ # 获取版本类型
74
+ VERSION_TYPE="$1"
75
+
76
+ # 获取对应版本类型的 commit 消息
77
+ get_commit_message() {
78
+ case "$1" in
79
+ patch)
80
+ echo "fix: bugfixes"
81
+ ;;
82
+ minor)
83
+ echo "feat: 添加新功能"
84
+ ;;
85
+ major)
86
+ echo "feat!: 重大变更"
87
+ ;;
88
+ *)
89
+ echo ""
90
+ ;;
91
+ esac
92
+ }
93
+
94
+ # 检查版本类型是否有效
95
+ COMMIT_MSG=$(get_commit_message "$VERSION_TYPE")
96
+ if [ -z "$COMMIT_MSG" ]; then
97
+ # 如果不是有效的版本类型,直接传递给 bump-my-version
98
+ "$BUMP_CMD" bump "$@"
99
+ exit 0
100
+ fi
101
+
102
+ # 显示当前版本
103
+ echo "📦 当前版本:$("$BUMP_CMD" show current_version)"
104
+ echo ""
105
+
106
+ # 检查是否有未提交的修改
107
+ if [ "$AUTO_COMMIT" = true ] && [ "$DRY_RUN" = false ]; then
108
+ if ! git diff-index --quiet HEAD -- 2>/dev/null; then
109
+ echo "📝 检测到未提交的修改,准备创建 commit..."
110
+ echo ""
111
+
112
+ echo "Commit 消息:$COMMIT_MSG"
113
+ echo ""
114
+
115
+ # 显示将要提交的文件
116
+ echo "将要提交的文件:"
117
+ git status --short
118
+ echo ""
119
+
120
+ # 提交所有修改
121
+ git add .
122
+ git commit -m "$COMMIT_MSG"
123
+
124
+ echo "✅ 代码修改已提交"
125
+ echo ""
126
+ else
127
+ echo "ℹ️ 没有未提交的修改,跳过自动 commit"
128
+ echo ""
129
+ fi
130
+ fi
131
+
132
+ # 执行版本递增
133
+ echo "🚀 开始递增版本..."
134
+ echo ""
135
+
136
+ "$BUMP_CMD" bump "$@"
137
+
138
+ echo ""
139
+ echo "✅ 版本管理完成!"
140
+ echo ""
141
+ echo "📋 操作摘要:"
142
+ if [ "$AUTO_COMMIT" = true ] && [ "$DRY_RUN" = false ]; then
143
+ echo " 1. 已提交代码修改(如有)"
144
+ echo " 2. 已递增版本号"
145
+ echo " 3. 已创建版本 commit 和 tag"
146
+ else
147
+ echo " 1. 已递增版本号"
148
+ echo " 2. 已创建版本 commit 和 tag"
149
+ fi
150
+ echo ""
151
+ echo "💡 提示:如需推送到远程,请执行:"
152
+ echo " git push && git push --tags"
@@ -20,6 +20,10 @@ claude_project_key_from_workdir() {
20
20
  model_configure() {
21
21
  MODEL_NAME="ClaudeCode"
22
22
  MODEL_WORKDIR="${CLAUDE_WORKDIR:-${MODEL_WORKDIR:-$ROOT_DIR}}"
23
+ # 默认关闭文件快照,避免孤儿 CLI 持续写入 jsonl
24
+ CLAUDE_DISABLE_FILE_CHECKPOINTING="${CLAUDE_DISABLE_FILE_CHECKPOINTING:-1}"
25
+ CLAUDE_CODE_DISABLE_FILE_CHECKPOINTING="${CLAUDE_CODE_DISABLE_FILE_CHECKPOINTING:-$CLAUDE_DISABLE_FILE_CHECKPOINTING}"
26
+ export CLAUDE_CODE_DISABLE_FILE_CHECKPOINTING
23
27
  local project_key
24
28
  if [[ -n "${CLAUDE_PROJECT_KEY:-}" ]]; then
25
29
  project_key="$CLAUDE_PROJECT_KEY"
scripts/run_bot.sh CHANGED
@@ -151,6 +151,9 @@ export TMUX_LOG="$MODEL_LOG"
151
151
  export PROJECT_NAME="$PROJECT_NAME"
152
152
  export LOG_DIR="$LOG_DIR"
153
153
  export ROOT_DIR="$SOURCE_ROOT"
154
+ if [[ -n "${CLAUDE_CODE_DISABLE_FILE_CHECKPOINTING:-}" ]]; then
155
+ export CLAUDE_CODE_DISABLE_FILE_CHECKPOINTING
156
+ fi
154
157
 
155
158
  "$SOURCE_ROOT/scripts/start_tmux_codex.sh" --kill >/dev/null
156
159
 
@@ -117,13 +117,20 @@ run_tmux pipe-pane -o -t "$SESSION_NAME" "$PIPE_CMD"
117
117
 
118
118
  # 同步环境变量到 tmux 服务端,避免复用旧会话时丢失设置
119
119
  run_tmux set-environment -t "$SESSION_NAME" DISABLE_UPDATE_PROMPT "${DISABLE_UPDATE_PROMPT:-true}"
120
+ if [[ -n "${CLAUDE_CODE_DISABLE_FILE_CHECKPOINTING:-}" ]]; then
121
+ run_tmux set-environment -t "$SESSION_NAME" CLAUDE_CODE_DISABLE_FILE_CHECKPOINTING "${CLAUDE_CODE_DISABLE_FILE_CHECKPOINTING}"
122
+ fi
120
123
 
121
124
  if (( RESTART )); then
122
125
  run_tmux send-keys -t "$SESSION_NAME" C-c
123
126
  sleep 1
124
127
  fi
125
128
 
126
- printf -v FINAL_CMD 'env DISABLE_UPDATE_PROMPT=%q %s' "${DISABLE_UPDATE_PROMPT:-true}" "$MODEL_CMD"
129
+ env_prefix="env $(printf '%q' "DISABLE_UPDATE_PROMPT=${DISABLE_UPDATE_PROMPT:-true}")"
130
+ if [[ -n "${CLAUDE_CODE_DISABLE_FILE_CHECKPOINTING:-}" ]]; then
131
+ env_prefix+=" $(printf '%q' "CLAUDE_CODE_DISABLE_FILE_CHECKPOINTING=${CLAUDE_CODE_DISABLE_FILE_CHECKPOINTING}")"
132
+ fi
133
+ printf -v FINAL_CMD '%s %s' "$env_prefix" "$MODEL_CMD"
127
134
 
128
135
  if (( SESSION_CREATED )) || (( FORCE_START )); then
129
136
  run_tmux send-keys -t "$SESSION_NAME" "$FINAL_CMD" C-m
scripts/stop_bot.sh CHANGED
@@ -47,6 +47,62 @@ fi
47
47
 
48
48
  POINTER_BASENAME="${MODEL_POINTER_BASENAME:-current_session.txt}"
49
49
 
50
+ graceful_shutdown_claudecode() {
51
+ local session="$1"
52
+ local timeout="${2:-10}"
53
+ if ! command -v tmux >/dev/null 2>&1; then
54
+ return 0
55
+ fi
56
+ if ! tmux -u has-session -t "$session" >/dev/null 2>&1; then
57
+ return 0
58
+ fi
59
+ local pane_ids=()
60
+ while IFS= read -r pane; do
61
+ [[ -z "$pane" ]] && continue
62
+ pane_ids+=("$pane")
63
+ done < <(tmux -u list-panes -t "$session" -F "#{pane_id}" 2>/dev/null || true)
64
+ (( ${#pane_ids[@]} )) || return 0
65
+
66
+ local current_cmd has_claude=0
67
+ for pane in "${pane_ids[@]}"; do
68
+ current_cmd=$(tmux -u display-message -p -t "$pane" '#{pane_current_command}' 2>/dev/null || echo "")
69
+ if [[ "$current_cmd" == claude* ]]; then
70
+ has_claude=1
71
+ break
72
+ fi
73
+ done
74
+ (( has_claude )) || return 0
75
+
76
+ printf '[stop-bot] 检测到 ClaudeCode 会话,尝试发送 /exit (session=%s)\n' "$session"
77
+ for pane in "${pane_ids[@]}"; do
78
+ tmux -u send-keys -t "$pane" Escape
79
+ tmux -u send-keys -t "$pane" C-u
80
+ tmux -u send-keys -t "$pane" "/exit" C-m
81
+ done
82
+
83
+ local end_time=$(( $(date +%s) + timeout ))
84
+ while tmux -u has-session -t "$session" >/dev/null 2>&1; do
85
+ local still_running=0
86
+ for pane in "${pane_ids[@]}"; do
87
+ current_cmd=$(tmux -u display-message -p -t "$pane" '#{pane_current_command}' 2>/dev/null || echo "")
88
+ if [[ "$current_cmd" == claude* ]]; then
89
+ still_running=1
90
+ break
91
+ fi
92
+ done
93
+ if (( still_running == 0 )); then
94
+ printf '[stop-bot] ClaudeCode 会话已响应 /exit (session=%s)\n' "$session"
95
+ break
96
+ fi
97
+ if (( $(date +%s) >= end_time )); then
98
+ printf '[stop-bot] ClaudeCode /exit 超时,将继续执行强制关闭 (session=%s)\n' "$session" >&2
99
+ break
100
+ fi
101
+ sleep 0.5
102
+ done
103
+ return 0
104
+ }
105
+
50
106
  kill_tty_sessions() {
51
107
  local session="$1"
52
108
  if command -v tmux >/dev/null 2>&1 && tmux -u has-session -t "$session" >/dev/null 2>&1; then
@@ -64,8 +120,10 @@ kill_pid_file() {
64
120
  if [[ ! -f "$pid_file" ]]; then
65
121
  local fallback_dir
66
122
  fallback_dir="$(dirname "$pid_file")"
67
- [[ -d "$fallback_dir" ]] && clear_session_files "$fallback_dir"
68
- return
123
+ if [[ -d "$fallback_dir" ]]; then
124
+ clear_session_files "$fallback_dir"
125
+ fi
126
+ return 0
69
127
  fi
70
128
  local bot_pid
71
129
  bot_pid=$(cat "$pid_file")
@@ -79,7 +137,10 @@ kill_pid_file() {
79
137
  rm -f "$pid_file"
80
138
  local pid_dir
81
139
  pid_dir="$(dirname "$pid_file")"
82
- [[ -d "$pid_dir" ]] && clear_session_files "$pid_dir"
140
+ if [[ -d "$pid_dir" ]]; then
141
+ clear_session_files "$pid_dir"
142
+ fi
143
+ return 0
83
144
  }
84
145
 
85
146
  stop_single_worker() {
@@ -88,9 +149,11 @@ stop_single_worker() {
88
149
  log_dir="$(log_dir_for "$model_name" "$project_name")"
89
150
  pid_file="$log_dir/bot.pid"
90
151
  tmux_session="$(tmux_session_for "$project_name")"
152
+ graceful_shutdown_claudecode "$tmux_session" 15 || true
91
153
  kill_tty_sessions "$tmux_session"
92
154
  kill_pid_file "$pid_file"
93
155
  clear_session_files "$log_dir"
156
+ return 0
94
157
  }
95
158
 
96
159
  stop_all_workers() {
@@ -109,6 +172,7 @@ stop_all_workers() {
109
172
  if [[ -n "$sessions" ]]; then
110
173
  while IFS= read -r sess; do
111
174
  [[ -z "$sess" ]] && continue
175
+ graceful_shutdown_claudecode "$sess" 15 || true
112
176
  tmux -u kill-session -t "$sess" >/dev/null 2>&1 || true
113
177
  stopped=1
114
178
  done <<<"$sessions"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: vibego
3
- Version: 0.2.11
3
+ Version: 0.2.13
4
4
  Summary: vibego CLI:用于初始化与管理 Telegram Master Bot 的工具
5
5
  Author: Hypha
6
6
  License-Expression: LicenseRef-Proprietary
@@ -1,16 +1,17 @@
1
- bot.py,sha256=q_aRgSBen4XThKdExfa-6NeX9R_Mgs0UjXYx32rsGzo,249827
1
+ bot.py,sha256=YsMdqha8MaJrnDJQwZ0GgQFrnoYKctV5es-RmbL-VeU,262371
2
2
  logging_setup.py,sha256=gvxHi8mUwK3IhXJrsGNTDo-DR6ngkyav1X-tvlBF_IE,4613
3
3
  master.py,sha256=Qz2NTapUexVvpQz8Y_pVhKd-uXkqp3M6oclzfAzIuGs,106497
4
4
  project_repository.py,sha256=UcthtSGOJK0cTE5bQCneo3xkomRG-kyc1N1QVqxeHIs,17577
5
5
  scripts/__init__.py,sha256=LVrXUkvWKoc6Sb47X5G0gbIxu5aJ2ARW-qJ14vwi5vM,65
6
+ scripts/bump_version.sh,sha256=a4uB8V8Y5LPsoqTCdzQKsEE8HhwpBmqRaQInG52LDig,4089
6
7
  scripts/log_writer.py,sha256=8euoMlRo7cbtHApbcEoJnwzLABxti-ovJWFLRN1oDQw,3843
7
8
  scripts/master_healthcheck.py,sha256=-X0VVsZ0AXaOb7izxTO_oyu23g_1jsirNdGIcP8nrSI,8321
8
9
  scripts/requirements.txt,sha256=QSt30DSSSHtfucTFPpc7twk9kLS5rVLNTcvDiagxrZg,62
9
- scripts/run_bot.sh,sha256=rM2Op_l4mP9asT3VoTwGQuHocwaxiGyQp2l1PLegsY4,4481
10
+ scripts/run_bot.sh,sha256=rN4K1nz041XBaUJmnBBKHS2cHmQf11vPNX8wf1hbVR4,4596
10
11
  scripts/start.sh,sha256=IsNjFWXX3qmFxx_7iiVEidTj9VHBUTkNNbsCPoNSL0E,6782
11
- scripts/start_tmux_codex.sh,sha256=4ko72SK7EDJmzXVuLBOz1_S1lLEmgIpLAVuD7mZhpdk,4018
12
+ scripts/start_tmux_codex.sh,sha256=xyLv29p924q-ysxvZYAP3T6VrqLPBPMBWo9QP7cuL50,4438
12
13
  scripts/stop_all.sh,sha256=FOz07gi2CI9sMHxBb8XkqHtxRYs3jt1RYgGrEi-htVg,4086
13
- scripts/stop_bot.sh,sha256=b9vvrke5jeyO18SEBFZxQD09Gf65BgGzeKtpM8sa60Y,3677
14
+ scripts/stop_bot.sh,sha256=ot6Sm0IYoXuRNslUVEflQmJKu5Agm-5xIUXXudJWyTM,5588
14
15
  scripts/test_deps_check.sh,sha256=AeSTucbNuNdOPSGvqVp0m31TVFDb0DmU8gSKg-Y5z2I,1696
15
16
  scripts/.venv/lib/python3.14/site-packages/pip/__init__.py,sha256=_lgs5Mfp0t7AGtI7sTVwxKWquz_vahWWH9kKO1cJusA,353
16
17
  scripts/.venv/lib/python3.14/site-packages/pip/__main__.py,sha256=WzbhHXTbSE6gBY19mNN9m4s5o_365LOvTYSgqgbdBhE,854
@@ -414,7 +415,7 @@ scripts/.venv/lib/python3.14/site-packages/pip/_vendor/urllib3/util/ssltransport
414
415
  scripts/.venv/lib/python3.14/site-packages/pip/_vendor/urllib3/util/timeout.py,sha256=cwq4dMk87mJHSBktK1miYJ-85G-3T3RmT20v7SFCpno,10168
415
416
  scripts/.venv/lib/python3.14/site-packages/pip/_vendor/urllib3/util/url.py,sha256=lCAE7M5myA8EDdW0sJuyyZhVB9K_j38ljWhHAnFaWoE,14296
416
417
  scripts/.venv/lib/python3.14/site-packages/pip/_vendor/urllib3/util/wait.py,sha256=fOX0_faozG2P7iVojQoE1mbydweNyTcm-hXEfFrTtLI,5403
417
- scripts/models/claudecode.sh,sha256=tCyvGVPUtewVPimC7q_QoDNkXypvZoSPTiRk8mf7DAw,1212
418
+ scripts/models/claudecode.sh,sha256=k8153NH6O925VvhXh2rywVRje9CyUyIurn42EetZHEI,1526
418
419
  scripts/models/codex.sh,sha256=_vp-v4L4eNz20JC2JIw1npE0Qz-HBUpdzyxLAtqridU,457
419
420
  scripts/models/common.sh,sha256=Xx6BaVqWSxpZUUHf6s6u_rkfbNts0UePeA9J9uoC_HA,2168
420
421
  scripts/models/gemini.sh,sha256=0fychnJL4uQJ1_wuc8O2sAQ4K0q3v7_Ku_9EOgUdocI,449
@@ -424,14 +425,14 @@ tasks/constants.py,sha256=tS1kZxBIUm3JJUMHm25XI-KHNUZl5NhbbuzjzL_rF-c,299
424
425
  tasks/fsm.py,sha256=rKXXLEieQQU4r2z_CZUvn1_70FXiZXBBugF40gpe_tQ,1476
425
426
  tasks/models.py,sha256=N_qqRBo9xMSV0vbn4k6bLBXT8C_dp_oTFUxvdx16ZQM,2459
426
427
  tasks/service.py,sha256=w_S_aWiVqRXzXEpimLDsuCCCX2lB5uDkff9aKThBw9c,41916
427
- vibego_cli/__init__.py,sha256=9Jk4Uc0BhWjiJHoQ1kI0WHFB-tawfl43z4PnmF8S1Gg,310
428
+ vibego_cli/__init__.py,sha256=HDuRE3BFyJQ0KL0Ozp34-WoYBjtGdy6lzYEt1AS7vyY,311
428
429
  vibego_cli/__main__.py,sha256=qqTrYmRRLe4361fMzbI3-CqpZ7AhTofIHmfp4ykrrBY,158
429
430
  vibego_cli/config.py,sha256=33WSORCfUIxrDtgASPEbVqVLBVNHh-RSFLpNy7tfc0s,2992
430
431
  vibego_cli/deps.py,sha256=1nRXI7Dd-S1hYE8DligzK5fIluQWETRUj4_OKL0DikQ,1419
431
432
  vibego_cli/main.py,sha256=e2W5Pb9U9rfmF-jNX9uIA3222lhM0GgcvSdFTDBZd2s,12086
432
433
  vibego_cli/data/worker_requirements.txt,sha256=QSt30DSSSHtfucTFPpc7twk9kLS5rVLNTcvDiagxrZg,62
433
- vibego-0.2.11.dist-info/METADATA,sha256=qdYmMPXO440tHnQCLv6SojrTvBsPqj2Pbi1sUrKp9KI,10475
434
- vibego-0.2.11.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
435
- vibego-0.2.11.dist-info/entry_points.txt,sha256=Lsy_zm-dlyxt8-9DL9blBReIwU2k22c8-kifr46ND1M,48
436
- vibego-0.2.11.dist-info/top_level.txt,sha256=R56CT3nW5H5v3ce0l3QDN4-C4qxTrNWzRTwrxnkDX4U,69
437
- vibego-0.2.11.dist-info/RECORD,,
434
+ vibego-0.2.13.dist-info/METADATA,sha256=eaSYWnC-3iRHcqmq3ewae3njV2s53eWMwS1Rggkqrhg,10475
435
+ vibego-0.2.13.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
436
+ vibego-0.2.13.dist-info/entry_points.txt,sha256=Lsy_zm-dlyxt8-9DL9blBReIwU2k22c8-kifr46ND1M,48
437
+ vibego-0.2.13.dist-info/top_level.txt,sha256=R56CT3nW5H5v3ce0l3QDN4-C4qxTrNWzRTwrxnkDX4U,69
438
+ vibego-0.2.13.dist-info/RECORD,,
vibego_cli/__init__.py CHANGED
@@ -7,6 +7,6 @@ from __future__ import annotations
7
7
 
8
8
  __all__ = ["main", "__version__"]
9
9
 
10
- __version__ = "0.1.0"
10
+ __version__ = "0.2.13"
11
11
 
12
12
  from .main import main # noqa: E402