vibego 1.0.2__py3-none-any.whl → 1.0.11__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.
bot.py CHANGED
@@ -248,6 +248,7 @@ CODEX_SESSION_FILE_PATH = os.environ.get("CODEX_SESSION_FILE_PATH", "").strip()
248
248
  CODEX_SESSIONS_ROOT = os.environ.get("CODEX_SESSIONS_ROOT", "").strip()
249
249
  MODEL_SESSION_ROOT = os.environ.get("MODEL_SESSION_ROOT", "").strip()
250
250
  MODEL_SESSION_GLOB = os.environ.get("MODEL_SESSION_GLOB", "rollout-*.jsonl").strip() or "rollout-*.jsonl"
251
+ SESSION_LOCK_FILE_PATH = os.environ.get("SESSION_LOCK_FILE_PATH", "").strip()
251
252
  SESSION_POLL_TIMEOUT = float(os.environ.get("SESSION_POLL_TIMEOUT", "2"))
252
253
  WATCH_MAX_WAIT = float(os.environ.get("WATCH_MAX_WAIT", "0"))
253
254
  WATCH_INTERVAL = float(os.environ.get("WATCH_INTERVAL", "2"))
@@ -257,6 +258,7 @@ SEND_FAILURE_NOTICE_COOLDOWN = float(os.environ.get("SEND_FAILURE_NOTICE_COOLDOW
257
258
  SESSION_INITIAL_BACKTRACK_BYTES = int(os.environ.get("SESSION_INITIAL_BACKTRACK_BYTES", "16384"))
258
259
  ENABLE_PLAN_PROGRESS = (os.environ.get("ENABLE_PLAN_PROGRESS", "1").strip().lower() not in {"0", "false", "no", "off"})
259
260
  AUTO_COMPACT_THRESHOLD = max(_env_int("AUTO_COMPACT_THRESHOLD", 0), 0)
261
+ SESSION_LOCK_REQUIRED = (os.environ.get("SESSION_LOCK_REQUIRED", "1").strip().lower() not in {"0", "false", "no", "off"})
260
262
 
261
263
  PLAN_STATUS_LABELS = {
262
264
  "completed": "✅",
@@ -956,7 +958,7 @@ async def _send_session_ack(
956
958
  model_label = (ACTIVE_MODEL or "Model").strip() or "Model"
957
959
  session_id = session_path.stem if session_path else "unknown"
958
960
  prompt_message = (
959
- f"💭 {model_label} is processing. Listening for model output...\n"
961
+ f"💭 {model_label} is Thinking... Listening for model output...\n"
960
962
  f"sessionId : {session_id}"
961
963
  )
962
964
  ack_message = await _reply_to_chat(
@@ -1036,52 +1038,84 @@ async def _dispatch_prompt_to_model(
1036
1038
  if CODEX_SESSION_FILE_PATH:
1037
1039
  pointer_path = resolve_path(CODEX_SESSION_FILE_PATH)
1038
1040
 
1039
- if pointer_path is not None and session_path is None:
1040
- session_path = _read_pointer_path(pointer_path)
1041
+ lock_session = _read_session_lock_path()
1042
+ lock_required = _is_session_lock_enforced()
1043
+ if lock_session is not None:
1044
+ if session_path is None:
1045
+ worker_log.info(
1046
+ "[session-map] chat=%s use lock session %s",
1047
+ chat_id,
1048
+ lock_session,
1049
+ extra=_session_extra(path=lock_session),
1050
+ )
1051
+ elif session_path != lock_session:
1052
+ worker_log.info(
1053
+ "[session-map] chat=%s override session with lock %s",
1054
+ chat_id,
1055
+ lock_session,
1056
+ extra=_session_extra(path=lock_session),
1057
+ )
1058
+ _sync_pointer_with_lock(pointer_path, lock_session)
1059
+ session_path = lock_session
1060
+ else:
1041
1061
  if session_path is not None:
1042
1062
  worker_log.info(
1043
- "[session-map] chat=%s pointer -> %s",
1063
+ "[session-map] chat=%s reuse session %s",
1044
1064
  chat_id,
1045
1065
  session_path,
1046
1066
  extra=_session_extra(path=session_path),
1047
1067
  )
1048
- elif session_path is not None:
1049
- worker_log.info(
1050
- "[session-map] chat=%s reuse session %s",
1051
- chat_id,
1052
- session_path,
1053
- extra=_session_extra(path=session_path),
1054
- )
1068
+ elif lock_required:
1069
+ await _reply_to_chat(
1070
+ chat_id,
1071
+ _session_lock_missing_message(),
1072
+ reply_to=reply_to,
1073
+ )
1074
+ worker_log.error(
1075
+ "[session-lock] Session lock required but missing",
1076
+ extra={"chat": chat_id, "lock_file": SESSION_LOCK_FILE_PATH or "-"},
1077
+ )
1078
+ return False, None
1079
+ else:
1080
+ if pointer_path is not None and session_path is None:
1081
+ session_path = _read_pointer_path(pointer_path)
1082
+ if session_path is not None:
1083
+ worker_log.info(
1084
+ "[session-map] chat=%s pointer -> %s",
1085
+ chat_id,
1086
+ session_path,
1087
+ extra=_session_extra(path=session_path),
1088
+ )
1055
1089
 
1056
- target_cwd = CODEX_WORKDIR if CODEX_WORKDIR else None
1057
- if pointer_path is not None:
1058
- current_cwd = _read_session_meta_cwd(session_path) if session_path else None
1059
- if session_path is None or (target_cwd and current_cwd != target_cwd):
1060
- latest = _find_latest_rollout_for_cwd(pointer_path, target_cwd)
1061
- if latest is not None:
1062
- try:
1063
- SESSION_OFFSETS[str(latest)] = latest.stat().st_size
1064
- except FileNotFoundError:
1065
- SESSION_OFFSETS[str(latest)] = 0
1066
- _update_pointer(pointer_path, latest)
1067
- session_path = latest
1068
- worker_log.info(
1069
- "[session-map] chat=%s switch to cwd-matched %s",
1070
- chat_id,
1071
- session_path,
1072
- extra=_session_extra(path=session_path),
1073
- )
1074
- if _is_claudecode_model():
1075
- fallback = _find_latest_claudecode_rollout(pointer_path)
1076
- if fallback is not None and fallback != session_path:
1077
- _update_pointer(pointer_path, fallback)
1078
- session_path = fallback
1079
- worker_log.info(
1080
- "[session-map] chat=%s fallback to ClaudeCode session %s",
1081
- chat_id,
1082
- session_path,
1083
- extra=_session_extra(path=session_path),
1084
- )
1090
+ target_cwd = CODEX_WORKDIR if CODEX_WORKDIR else None
1091
+ if pointer_path is not None:
1092
+ current_cwd = _read_session_meta_cwd(session_path) if session_path else None
1093
+ if session_path is None or (target_cwd and current_cwd != target_cwd):
1094
+ latest = _find_latest_rollout_for_cwd(pointer_path, target_cwd)
1095
+ if latest is not None:
1096
+ try:
1097
+ SESSION_OFFSETS[str(latest)] = latest.stat().st_size
1098
+ except FileNotFoundError:
1099
+ SESSION_OFFSETS[str(latest)] = 0
1100
+ _update_pointer(pointer_path, latest)
1101
+ session_path = latest
1102
+ worker_log.info(
1103
+ "[session-map] chat=%s (lock disabled) switch to cwd-matched %s",
1104
+ chat_id,
1105
+ session_path,
1106
+ extra=_session_extra(path=session_path),
1107
+ )
1108
+ if _is_claudecode_model():
1109
+ fallback = _find_latest_claudecode_rollout(pointer_path)
1110
+ if fallback is not None and fallback != session_path:
1111
+ _update_pointer(pointer_path, fallback)
1112
+ session_path = fallback
1113
+ worker_log.info(
1114
+ "[session-map] chat=%s (lock disabled) fallback to ClaudeCode session %s",
1115
+ chat_id,
1116
+ session_path,
1117
+ extra=_session_extra(path=session_path),
1118
+ )
1085
1119
 
1086
1120
  needs_session_wait = session_path is None
1087
1121
  if needs_session_wait and pointer_path is None:
@@ -1820,8 +1854,33 @@ BOT_COMMANDS: list[tuple[str, str]] = [
1820
1854
  COMMAND_KEYWORDS: set[str] = {command for command, _ in BOT_COMMANDS}
1821
1855
  COMMAND_KEYWORDS.update({"task_child", "task_children", "task_delete"})
1822
1856
 
1823
- WORKER_MENU_BUTTON_TEXT = "📋 Task List"
1824
- WORKER_CREATE_TASK_BUTTON_TEXT = "➕ Create Task"
1857
+ def _button_text_variants(env_key: str, fallback: Sequence[str]) -> tuple[str, ...]:
1858
+ """Return button label candidates sourced from env or fallback list."""
1859
+
1860
+ raw = (os.environ.get(env_key) or "").strip()
1861
+ if not raw:
1862
+ return tuple(fallback)
1863
+ variants = []
1864
+ for segment in raw.split("|"):
1865
+ cleaned = segment.strip()
1866
+ if cleaned and cleaned not in variants:
1867
+ variants.append(cleaned)
1868
+ return tuple(variants or fallback)
1869
+
1870
+
1871
+ WORKER_MENU_BUTTON_TEXT_VARIANTS = _button_text_variants(
1872
+ "WORKER_MENU_BUTTON_TEXTS",
1873
+ ("📋 Task List", "📋 任务列表"),
1874
+ )
1875
+ WORKER_CREATE_TASK_BUTTON_TEXT_VARIANTS = _button_text_variants(
1876
+ "WORKER_CREATE_TASK_BUTTON_TEXTS",
1877
+ ("➕ Create Task", "➕ 创建任务"),
1878
+ )
1879
+
1880
+ WORKER_MENU_BUTTON_TEXT = WORKER_MENU_BUTTON_TEXT_VARIANTS[0]
1881
+ WORKER_CREATE_TASK_BUTTON_TEXT = WORKER_CREATE_TASK_BUTTON_TEXT_VARIANTS[0]
1882
+ WORKER_MENU_BUTTON_TEXT_SET = set(WORKER_MENU_BUTTON_TEXT_VARIANTS)
1883
+ WORKER_CREATE_TASK_BUTTON_TEXT_SET = set(WORKER_CREATE_TASK_BUTTON_TEXT_VARIANTS)
1825
1884
 
1826
1885
  TASK_ID_VALID_PATTERN = re.compile(r"^TASK_[A-Z0-9_]+$")
1827
1886
  TASK_ID_USAGE_TIP = "Invalid task ID format. Use patterns like TASK_0001."
@@ -2335,6 +2394,9 @@ MODEL_PUSH_SUPPLEMENT_STATUSES: set[str] = {
2335
2394
  "research",
2336
2395
  "test",
2337
2396
  }
2397
+ PUSH_MODEL_SUPPLEMENT_IN_PROGRESS_TEXT = (
2398
+ "A supplementary description prompt is already active. Please respond or tap Skip/Cancel."
2399
+ )
2338
2400
 
2339
2401
  SUMMARY_COMMAND_PREFIX = "/task_summary_request_"
2340
2402
  SUMMARY_COMMAND_ALIASES: tuple[str, ...] = (
@@ -3010,6 +3072,9 @@ async def _build_history_context_for_model(task_id: str) -> tuple[str, int]:
3010
3072
  return "\n".join(trimmed_lines), len(trimmed_lines)
3011
3073
 
3012
3074
 
3075
+ SKIPPED_TASK_HISTORY_ACTIONS: set[str] = {"push_model", "summary_request"}
3076
+
3077
+
3013
3078
  async def _log_task_action(
3014
3079
  task_id: str,
3015
3080
  *,
@@ -3023,11 +3088,20 @@ async def _log_task_action(
3023
3088
  ) -> None:
3024
3089
  """Encapsulate task event writing and record logs when exceptions occur to avoid interrupting the main process."""
3025
3090
 
3091
+ action_token = (action or "").strip()
3092
+ if action_token in SKIPPED_TASK_HISTORY_ACTIONS:
3093
+ worker_log.debug(
3094
+ "Skipped logging task action in history: task_id=%s action=%s",
3095
+ task_id,
3096
+ action_token,
3097
+ extra=_session_extra(),
3098
+ )
3099
+ return
3026
3100
  data_payload: Optional[Dict[str, Any]]
3027
3101
  if payload is None:
3028
- data_payload = {"action": action}
3102
+ data_payload = {"action": action_token}
3029
3103
  else:
3030
- data_payload = {"action": action, **payload}
3104
+ data_payload = {"action": action_token, **payload}
3031
3105
  try:
3032
3106
  await TASK_SERVICE.log_task_event(
3033
3107
  task_id,
@@ -4298,29 +4372,12 @@ async def _log_model_reply_event(
4298
4372
  session_path: Path,
4299
4373
  event_offset: int,
4300
4374
  ) -> None:
4301
- """Write model responses to Task history."""
4375
+ """Model replies are no longer persisted to history."""
4302
4376
 
4303
- trimmed = _trim_history_value(content, limit=HISTORY_DISPLAY_VALUE_LIMIT)
4304
- payload = {
4305
- "model": ACTIVE_MODEL or "",
4306
- "session": str(session_path),
4307
- "offset": event_offset,
4308
- }
4309
- if content:
4310
- payload["content"] = content[:MODEL_REPLY_PAYLOAD_LIMIT]
4311
- try:
4312
- await TASK_SERVICE.log_task_event(
4313
- task_id,
4314
- event_type=HISTORY_EVENT_MODEL_REPLY,
4315
- actor=f"model/{ACTIVE_MODEL or 'codex'}",
4316
- new_value=trimmed,
4317
- payload=payload,
4318
- )
4319
- except ValueError:
4320
- worker_log.warning(
4321
- "The model reply writes fail: Task does not exist",
4322
- extra={"task_id": task_id, **_session_extra(path=session_path)},
4323
- )
4377
+ worker_log.debug(
4378
+ "Skipping history write for model reply",
4379
+ extra={"task_id": task_id, "session": str(session_path)},
4380
+ )
4324
4381
 
4325
4382
 
4326
4383
  async def _maybe_finalize_summary(
@@ -4792,60 +4849,79 @@ async def _ensure_session_watcher(chat_id: int) -> Optional[Path]:
4792
4849
 
4793
4850
  target_cwd = CODEX_WORKDIR or None
4794
4851
 
4795
- if session_path is None and pointer_path is not None:
4796
- session_path = _read_pointer_path(pointer_path)
4797
- if session_path is not None:
4852
+ lock_session = _read_session_lock_path()
4853
+ lock_required = _is_session_lock_enforced()
4854
+ if lock_session is not None:
4855
+ if session_path is None or session_path != lock_session:
4798
4856
  worker_log.info(
4799
- "[session-map] chat=%s pointer -> %s",
4857
+ "[session-map] chat=%s use lock session %s",
4800
4858
  chat_id,
4801
- session_path,
4802
- extra=_session_extra(path=session_path),
4859
+ lock_session,
4860
+ extra=_session_extra(path=lock_session),
4803
4861
  )
4804
- if session_path is None and pointer_path is not None:
4805
- latest = _find_latest_rollout_for_cwd(pointer_path, target_cwd)
4806
- if latest is not None:
4807
- session_path = latest
4808
- _update_pointer(pointer_path, latest)
4809
- worker_log.info(
4810
- "[session-map] chat=%s locate latest rollout %s",
4811
- chat_id,
4812
- session_path,
4813
- extra=_session_extra(path=session_path),
4862
+ _sync_pointer_with_lock(pointer_path, lock_session)
4863
+ session_path = lock_session
4864
+ else:
4865
+ if lock_required:
4866
+ worker_log.error(
4867
+ "[session-lock] Session lock required but missing during watcher ensure",
4868
+ extra={"chat": chat_id, "lock_file": SESSION_LOCK_FILE_PATH or "-"},
4814
4869
  )
4870
+ return None
4871
+ if session_path is None and pointer_path is not None:
4872
+ session_path = _read_pointer_path(pointer_path)
4873
+ if session_path is not None:
4874
+ worker_log.info(
4875
+ "[session-map] chat=%s pointer -> %s",
4876
+ chat_id,
4877
+ session_path,
4878
+ extra=_session_extra(path=session_path),
4879
+ )
4880
+ if session_path is None and pointer_path is not None:
4881
+ latest = _find_latest_rollout_for_cwd(pointer_path, target_cwd)
4882
+ if latest is not None:
4883
+ session_path = latest
4884
+ _update_pointer(pointer_path, latest)
4885
+ worker_log.info(
4886
+ "[session-map] chat=%s locate latest rollout %s",
4887
+ chat_id,
4888
+ session_path,
4889
+ extra=_session_extra(path=session_path),
4890
+ )
4815
4891
 
4816
- if pointer_path is not None and _is_claudecode_model():
4817
- fallback = _find_latest_claudecode_rollout(pointer_path)
4818
- if fallback is not None and fallback != session_path:
4819
- session_path = fallback
4820
- _update_pointer(pointer_path, session_path)
4821
- worker_log.info(
4822
- "[session-map] chat=%s resume ClaudeCode session %s",
4823
- chat_id,
4824
- session_path,
4825
- extra=_session_extra(path=session_path),
4826
- )
4892
+ if pointer_path is not None and _is_claudecode_model():
4893
+ fallback = _find_latest_claudecode_rollout(pointer_path)
4894
+ if fallback is not None and fallback != session_path:
4895
+ session_path = fallback
4896
+ _update_pointer(pointer_path, session_path)
4897
+ worker_log.info(
4898
+ "[session-map] chat=%s resume ClaudeCode session %s",
4899
+ chat_id,
4900
+ session_path,
4901
+ extra=_session_extra(path=session_path),
4902
+ )
4827
4903
 
4828
- if session_path is None and pointer_path is not None:
4829
- session_path = await _await_session_path(pointer_path, target_cwd)
4830
- if session_path is not None:
4831
- _update_pointer(pointer_path, session_path)
4832
- worker_log.info(
4833
- "[session-map] chat=%s bind fresh session %s",
4834
- chat_id,
4835
- session_path,
4836
- extra=_session_extra(path=session_path),
4837
- )
4838
- if session_path is None and pointer_path is not None and _is_claudecode_model():
4839
- fallback = _find_latest_claudecode_rollout(pointer_path)
4840
- if fallback is not None:
4841
- session_path = fallback
4842
- _update_pointer(pointer_path, session_path)
4843
- worker_log.info(
4844
- "[session-map] chat=%s fallback bind ClaudeCode session %s",
4845
- chat_id,
4846
- session_path,
4847
- extra=_session_extra(path=session_path),
4848
- )
4904
+ if session_path is None and pointer_path is not None:
4905
+ session_path = await _await_session_path(pointer_path, target_cwd)
4906
+ if session_path is not None:
4907
+ _update_pointer(pointer_path, session_path)
4908
+ worker_log.info(
4909
+ "[session-map] chat=%s bind fresh session %s",
4910
+ chat_id,
4911
+ session_path,
4912
+ extra=_session_extra(path=session_path),
4913
+ )
4914
+ if session_path is None and pointer_path is not None and _is_claudecode_model():
4915
+ fallback = _find_latest_claudecode_rollout(pointer_path)
4916
+ if fallback is not None:
4917
+ session_path = fallback
4918
+ _update_pointer(pointer_path, session_path)
4919
+ worker_log.info(
4920
+ "[session-map] chat=%s fallback bind ClaudeCode session %s",
4921
+ chat_id,
4922
+ session_path,
4923
+ extra=_session_extra(path=session_path),
4924
+ )
4849
4925
 
4850
4926
  if session_path is None:
4851
4927
  worker_log.warning(
@@ -5268,6 +5344,115 @@ async def _watch_and_notify(chat_id: int, session_path: Path,
5268
5344
  )
5269
5345
 
5270
5346
 
5347
+ _SESSION_LOCK_CACHE_MTIME: Optional[float] = None
5348
+ _SESSION_LOCK_CACHE_VALUE: Optional[Path] = None
5349
+
5350
+
5351
+ def _session_lock_file() -> Optional[Path]:
5352
+ if not SESSION_LOCK_FILE_PATH:
5353
+ return None
5354
+ return resolve_path(SESSION_LOCK_FILE_PATH)
5355
+
5356
+
5357
+ def _is_session_lock_enforced() -> bool:
5358
+ """Return True when the worker must rely on the captured session lock."""
5359
+
5360
+ return SESSION_LOCK_REQUIRED and bool(SESSION_LOCK_FILE_PATH)
5361
+
5362
+
5363
+ def _session_lock_missing_message() -> str:
5364
+ """Build a human-readable error when the session lock is missing."""
5365
+
5366
+ target = SESSION_LOCK_FILE_PATH or "session_lock.json"
5367
+ return (
5368
+ "当前 worker 未检测到会话锁,无法定位本项目独占的模型会话。\n"
5369
+ f"缺失的锁文件:{target}\n"
5370
+ "请在对应项目目录重新执行 scripts/run_bot.sh(或等效启动脚本)以捕获新的 tmux 会话。"
5371
+ )
5372
+
5373
+
5374
+ def _read_session_lock_path() -> Optional[Path]:
5375
+ """Read the persisted session lock and return the rollout path when valid."""
5376
+
5377
+ lock_file = _session_lock_file()
5378
+ if lock_file is None:
5379
+ return None
5380
+ global _SESSION_LOCK_CACHE_MTIME, _SESSION_LOCK_CACHE_VALUE
5381
+
5382
+ try:
5383
+ stat = lock_file.stat()
5384
+ except FileNotFoundError:
5385
+ _SESSION_LOCK_CACHE_MTIME = None
5386
+ _SESSION_LOCK_CACHE_VALUE = None
5387
+ return None
5388
+
5389
+ mtime = stat.st_mtime
5390
+ if _SESSION_LOCK_CACHE_MTIME == mtime and _SESSION_LOCK_CACHE_VALUE is not None:
5391
+ return _SESSION_LOCK_CACHE_VALUE
5392
+
5393
+ try:
5394
+ raw = lock_file.read_text(encoding="utf-8")
5395
+ except OSError:
5396
+ _SESSION_LOCK_CACHE_MTIME = mtime
5397
+ _SESSION_LOCK_CACHE_VALUE = None
5398
+ return None
5399
+ try:
5400
+ payload = json.loads(raw)
5401
+ except json.JSONDecodeError:
5402
+ worker_log.warning(
5403
+ "[session-lock] Invalid JSON payload",
5404
+ extra={"lock": str(lock_file)},
5405
+ )
5406
+ _SESSION_LOCK_CACHE_MTIME = mtime
5407
+ _SESSION_LOCK_CACHE_VALUE = None
5408
+ return None
5409
+
5410
+ session_raw = payload.get("session_path")
5411
+ if not isinstance(session_raw, str) or not session_raw.strip():
5412
+ _SESSION_LOCK_CACHE_MTIME = mtime
5413
+ _SESSION_LOCK_CACHE_VALUE = None
5414
+ return None
5415
+
5416
+ rollout = resolve_path(session_raw.strip())
5417
+ if not rollout.exists():
5418
+ worker_log.warning(
5419
+ "[session-lock] Recorded session file is missing",
5420
+ extra={"session": str(rollout)},
5421
+ )
5422
+ _SESSION_LOCK_CACHE_MTIME = mtime
5423
+ _SESSION_LOCK_CACHE_VALUE = None
5424
+ return None
5425
+
5426
+ tmux_name = payload.get("tmux_session")
5427
+ if tmux_name and isinstance(tmux_name, str) and tmux_name.strip() and tmux_name.strip() != TMUX_SESSION:
5428
+ worker_log.info(
5429
+ "[session-lock] tmux mismatch, ignoring lock",
5430
+ extra={"lock_session": tmux_name.strip(), "tmux": TMUX_SESSION},
5431
+ )
5432
+ _SESSION_LOCK_CACHE_MTIME = mtime
5433
+ _SESSION_LOCK_CACHE_VALUE = None
5434
+ return None
5435
+
5436
+ _SESSION_LOCK_CACHE_MTIME = mtime
5437
+ _SESSION_LOCK_CACHE_VALUE = rollout
5438
+ return rollout
5439
+
5440
+
5441
+ def _sync_pointer_with_lock(pointer: Optional[Path], lock_path: Path) -> None:
5442
+ """Ensure pointer.txt matches the locked session path."""
5443
+
5444
+ if pointer is None:
5445
+ return
5446
+ target = str(lock_path)
5447
+ try:
5448
+ current = pointer.read_text(encoding="utf-8").strip()
5449
+ except OSError:
5450
+ current = ""
5451
+ if current == target:
5452
+ return
5453
+ _update_pointer(pointer, lock_path)
5454
+
5455
+
5271
5456
  def _read_pointer_path(pointer: Path) -> Optional[Path]:
5272
5457
  try:
5273
5458
  raw = pointer.read_text(encoding="utf-8").strip()
@@ -5483,7 +5668,7 @@ def _format_plan_update(arguments: Any, *, event_timestamp: Optional[str]) -> Op
5483
5668
  if not steps:
5484
5669
  return None
5485
5670
 
5486
- header = "currentTaskExecution plan:"
5671
+ header = "current task execution plan:"
5487
5672
  body_parts = [header]
5488
5673
  if lines:
5489
5674
  body_parts.extend(lines)
@@ -5945,7 +6130,7 @@ async def on_task_list(message: Message) -> None:
5945
6130
  await _handle_task_list_request(message)
5946
6131
 
5947
6132
 
5948
- @router.message(F.text == WORKER_MENU_BUTTON_TEXT)
6133
+ @router.message(F.text.in_(WORKER_MENU_BUTTON_TEXT_SET))
5949
6134
  async def on_task_list_button(message: Message) -> None:
5950
6135
  await _handle_task_list_request(message)
5951
6136
 
@@ -5980,7 +6165,7 @@ async def _dispatch_task_new_command(source_message: Message, actor: Optional[Us
5980
6165
  await dp.feed_update(bot_instance, update)
5981
6166
 
5982
6167
 
5983
- @router.message(F.text == WORKER_CREATE_TASK_BUTTON_TEXT)
6168
+ @router.message(F.text.in_(WORKER_CREATE_TASK_BUTTON_TEXT_SET))
5984
6169
  async def on_task_create_button(message: Message, state: FSMContext) -> None:
5985
6170
  await state.clear()
5986
6171
  try:
@@ -6667,6 +6852,10 @@ async def on_task_push_model(callback: CallbackQuery, state: FSMContext) -> None
6667
6852
  await callback.answer("Callback parameter error.", show_alert=True)
6668
6853
  return
6669
6854
  _, _, task_id = parts
6855
+ current_state = await state.get_state()
6856
+ existing_context: Dict[str, Any] = {}
6857
+ if current_state == TaskPushStates.waiting_supplement.state:
6858
+ existing_context = await state.get_data()
6670
6859
  task = await TASK_SERVICE.get_task(task_id)
6671
6860
  if task is None:
6672
6861
  await callback.answer("Task does not exist", show_alert=True)
@@ -6677,10 +6866,20 @@ async def on_task_push_model(callback: CallbackQuery, state: FSMContext) -> None
6677
6866
  actor = _actor_from_callback(callback)
6678
6867
  chat_id = callback.message.chat.id if callback.message else callback.from_user.id
6679
6868
  if task.status in MODEL_PUSH_SUPPLEMENT_STATUSES:
6869
+ origin_message = callback.message
6870
+ origin_message_id = origin_message.message_id if origin_message else None
6871
+ if (
6872
+ current_state == TaskPushStates.waiting_supplement.state
6873
+ and existing_context.get("task_id") == task_id
6874
+ and existing_context.get("origin_message_id") == origin_message_id
6875
+ ):
6876
+ await callback.answer(PUSH_MODEL_SUPPLEMENT_IN_PROGRESS_TEXT)
6877
+ return
6680
6878
  await state.clear()
6681
6879
  await state.update_data(
6682
6880
  task_id=task_id,
6683
- origin_message=callback.message,
6881
+ origin_message=origin_message,
6882
+ origin_message_id=origin_message_id,
6684
6883
  chat_id=chat_id,
6685
6884
  actor=actor,
6686
6885
  )
@@ -6780,16 +6979,31 @@ async def on_task_push_model_fill(callback: CallbackQuery, state: FSMContext) ->
6780
6979
  await callback.answer("Callback parameter error.", show_alert=True)
6781
6980
  return
6782
6981
  _, _, task_id = parts
6982
+ current_state = await state.get_state()
6983
+ existing_context: Dict[str, Any] = {}
6984
+ if current_state == TaskPushStates.waiting_supplement.state:
6985
+ existing_context = await state.get_data()
6783
6986
  task = await TASK_SERVICE.get_task(task_id)
6784
6987
  if task is None:
6785
6988
  await state.clear()
6786
6989
  await callback.answer("Task does not exist", show_alert=True)
6787
6990
  return
6788
6991
  actor = _actor_from_callback(callback)
6992
+ origin_message = callback.message
6993
+ origin_message_id = origin_message.message_id if origin_message else None
6994
+ if (
6995
+ current_state == TaskPushStates.waiting_supplement.state
6996
+ and existing_context.get("task_id") == task_id
6997
+ and existing_context.get("origin_message_id") == origin_message_id
6998
+ ):
6999
+ await callback.answer(PUSH_MODEL_SUPPLEMENT_IN_PROGRESS_TEXT)
7000
+ return
7001
+ await state.clear()
6789
7002
  await state.update_data(
6790
7003
  task_id=task_id,
6791
- origin_message=callback.message,
6792
- chat_id=callback.message.chat.id if callback.message else callback.from_user.id,
7004
+ origin_message=origin_message,
7005
+ origin_message_id=origin_message_id,
7006
+ chat_id=origin_message.chat.id if origin_message else callback.from_user.id,
6793
7007
  actor=actor,
6794
7008
  )
6795
7009
  await state.set_state(TaskPushStates.waiting_supplement)
@@ -7959,10 +8173,14 @@ async def on_start(m: Message):
7959
8173
  await m.answer(_format_env_issue_message())
7960
8174
 
7961
8175
  @router.message(F.text)
7962
- async def on_text(m: Message):
8176
+ async def on_text(m: Message, state: FSMContext | None = None):
7963
8177
  # Automatically record chat when first received message_id to state document
7964
8178
  _auto_record_chat_id(m.chat.id)
7965
8179
 
8180
+ current_state: Optional[str] = None
8181
+ if state is not None:
8182
+ current_state = await state.get_state()
8183
+
7966
8184
  prompt = (m.text or "").strip()
7967
8185
  if not prompt:
7968
8186
  return await m.answer("Please enter a non-empty prompt word")
@@ -7972,6 +8190,12 @@ async def on_text(m: Message):
7972
8190
  return
7973
8191
  if prompt.startswith("/"):
7974
8192
  return
8193
+ if current_state:
8194
+ worker_log.debug(
8195
+ "Suppressed model dispatch due to active wizard state",
8196
+ extra={**_session_extra(), "chat": m.chat.id, "state": current_state},
8197
+ )
8198
+ return
7975
8199
  await _handle_prompt_dispatch(m, prompt)
7976
8200
 
7977
8201
 
master.py CHANGED
@@ -2163,8 +2163,21 @@ async def _notify_restart_success(bot: Bot) -> None:
2163
2163
  except Exception as exc:
2164
2164
  log.error("Failed to send restart successful notification: %s", exc, extra={"chat": chat_id})
2165
2165
  else:
2166
- # After a successful restart, the project list will no longer be included to avoid extra noise during high-frequency restarts.
2166
+ # Restart succeeded; notify admins and push the refreshed project overview for quick status check.
2167
2167
  log.info("Restart successful notification has been sent", extra={"chat": chat_id, "duration": restart_duration})
2168
+ try:
2169
+ manager = await _ensure_manager()
2170
+ except RuntimeError as exc: # pragma: no cover - defensive guard if startup order changes
2171
+ log.warning("Manager unavailable when trying to push project overview: %s", exc)
2172
+ else:
2173
+ try:
2174
+ await _send_projects_overview_to_chat(bot, chat_id, manager)
2175
+ except Exception as exc: # pragma: no cover - avoid crashing startup hook
2176
+ log.error(
2177
+ "Failed to send project overview after restart notification: %s",
2178
+ exc,
2179
+ extra={"chat": chat_id},
2180
+ )
2168
2181
  finally:
2169
2182
  candidates = (signal_path, RESTART_SIGNAL_PATH, *LEGACY_RESTART_SIGNAL_PATHS)
2170
2183
  for candidate in candidates:
@@ -2198,7 +2211,8 @@ async def _process_restart_request(
2198
2211
 
2199
2212
  lock = _ensure_restart_lock()
2200
2213
  async with lock:
2201
- global_restart_in_progress
2214
+ # Use global so the restart flag is shared across concurrent handlers
2215
+ global _restart_in_progress
2202
2216
  if _restart_in_progress:
2203
2217
  await message.answer("A restart request is already being executed, please try again later. ")
2204
2218
  return
@@ -2258,7 +2272,8 @@ async def cmd_start(message: Message) -> None:
2258
2272
 
2259
2273
  async def _perform_restart(message: Message, start_script: Path) -> None:
2260
2274
  """Asynchronous execution ./start.sh,If it fails, roll back the mark and notify the administrator """
2261
- global_restart_in_progress
2275
+ # Use global so restart flag resets affect the module-level state
2276
+ global _restart_in_progress
2262
2277
  lock = _ensure_restart_lock()
2263
2278
  bot = message.bot
2264
2279
  chat_id = message.chat.id