From b166013707a97e78cc148f2ea620ac4140a82166ed8879dd34a9742ad7ebfb8b Mon Sep 17 00:00:00 2001 From: AidarKC Date: Sun, 14 Jun 2026 20:30:17 +0400 Subject: [PATCH] =?UTF-8?q?=D0=91=D0=BE=D1=82:=20=D0=BF=D0=BE=D1=87=D0=B8?= =?UTF-8?q?=D0=BD=D0=B8=D1=82=D1=8C=20resume-=D0=B2=D1=8B=D0=B7=D0=BE?= =?UTF-8?q?=D0=B2=20Codex=20CLI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- SHiNE-agent-bot-coder/py_bot_service.py | 100 ++++++++++++++++++++++-- VERSION.properties | 4 +- 2 files changed, 95 insertions(+), 9 deletions(-) diff --git a/SHiNE-agent-bot-coder/py_bot_service.py b/SHiNE-agent-bot-coder/py_bot_service.py index ddececc..fa53d0d 100644 --- a/SHiNE-agent-bot-coder/py_bot_service.py +++ b/SHiNE-agent-bot-coder/py_bot_service.py @@ -656,13 +656,33 @@ class ShinePyBotService: self.state["current_history_file"] = str(history_file) self._persist_state() - def _current_history_file_for_user(self, username: str) -> Path: + def _user_session_state(self, username: str) -> dict[str, Any]: uname = normalize_username(username) or self.cfg.allowed_username self._ensure_user_session(uname) sessions = self.state.get("user_sessions") or {} - session = sessions.get(uname) or {} + session = sessions.get(uname) + if not isinstance(session, dict): + session = {} + sessions[uname] = session + return session + + def _current_history_file_for_user(self, username: str) -> Path: + session = self._user_session_state(username) return Path(session["current_history_file"]) + def _codex_thread_id_for_user(self, username: str) -> str: + thread_id = (self._user_session_state(username).get("codex_thread_id") or "").strip() + return thread_id + + def _set_codex_thread_id_for_user(self, username: str, thread_id: str) -> None: + session = self._user_session_state(username) + normalized = (thread_id or "").strip() + if normalized: + session["codex_thread_id"] = normalized + else: + session.pop("codex_thread_id", None) + self._persist_state() + def _create_new_history_file(self, reason: str, username: str) -> Path: ts = dt.datetime.now().strftime("%Y-%m-%d_%H%M%S") rnd = "".join(random.choices(string.hexdigits.lower(), k=8)) @@ -690,7 +710,12 @@ class ShinePyBotService: if not isinstance(sessions, dict): sessions = {} self.state["user_sessions"] = sessions + previous = sessions.get(uname) if isinstance(sessions.get(uname), dict) else {} sessions[uname] = {"current_history_file": str(new_file)} + if reason != "command_new" and isinstance(previous, dict): + thread_id = (previous.get("codex_thread_id") or "").strip() + if thread_id: + sessions[uname]["codex_thread_id"] = thread_id if uname == self.cfg.allowed_username: self.state["current_history_file"] = str(new_file) self._persist_state() @@ -926,7 +951,7 @@ class ShinePyBotService: text = ( f"Привет, {player_name}.\n" "Можно задавать вопросы по проекту, просить анализ, идеи и подготовку готового ТЗ.\n" - "Команда /new начинает новую сессию и архивирует текущую историю." + "Команда /new начинает новую Codex-сессию и архивирует текущую историю." ) reminder = self._task_center_counts_text(uname) if reminder: @@ -1449,7 +1474,7 @@ class ShinePyBotService: "/tasks — список ваших задач и предложений", "/stop — остановить текущую задачу", "/cancel — удалить задачу по id (префикс) или все", - "/new — архивировать историю и начать новую", + "/new — архивировать историю и начать новую Codex-сессию", "/help — эта справка", ] if is_owner: @@ -1680,9 +1705,31 @@ class ShinePyBotService: ) def _run_codex(self, prompt: str, job: dict[str, Any]) -> str: + username = job.get("username") or self.cfg.allowed_username + thread_id = self._codex_thread_id_for_user(username) + try: + return self._run_codex_once(prompt, job, thread_id=thread_id) + except RuntimeError as e: + if not thread_id or not self._is_missing_codex_session_error(str(e)): + raise + self._set_codex_thread_id_for_user(username, "") + self._append_history( + Path(job["history_file"]), + "system_event", + { + "event": "codex_thread_reset", + "reason": "missing_session", + "username": normalize_username(username), + "oldThreadId": thread_id, + }, + ) + return self._run_codex_once(prompt, job, thread_id="") + + def _run_codex_once(self, prompt: str, job: dict[str, Any], *, thread_id: str) -> str: output_lines: list[str] = [] job_id = str(job["id"]) job_num = job.get("num", "?") + username = job.get("username") or self.cfg.allowed_username with tempfile.NamedTemporaryFile(prefix="shine-codex-last-message-", suffix=".txt", delete=False) as tmp: output_file = Path(tmp.name) @@ -1693,9 +1740,12 @@ class ShinePyBotService: "--json", "-C", str(self.cfg.codex_workdir), "-o", str(output_file), - prompt, ] - print(f"[py-bot] codex exec start job={job_id[:8]}", flush=True) + if thread_id: + cmd.extend(["resume", thread_id]) + cmd.append(prompt) + mode = f"resume {thread_id}" if thread_id else "new" + print(f"[py-bot] codex exec start job={job_id[:8]} mode={mode}", flush=True) process = subprocess.Popen( cmd, stdin=subprocess.DEVNULL, @@ -1714,10 +1764,14 @@ class ShinePyBotService: last_user_note_at = 0.0 codex_started_at = time.time() last_job_message_at = codex_started_at + seen_thread_id = "" def on_line(line: str) -> None: - nonlocal last_user_note, last_user_note_at, last_job_message_at + nonlocal last_user_note, last_user_note_at, last_job_message_at, seen_thread_id output_lines.append(line) + current_thread_id = self._extract_codex_thread_id(line) + if current_thread_id: + seen_thread_id = current_thread_id note = self._extract_codex_user_note(line) now = time.time() if note and note != last_user_note and now - last_user_note_at > 8: @@ -1770,6 +1824,9 @@ class ShinePyBotService: tail = "\n".join(output_lines[-40:]) raise RuntimeError(f"Codex exited with code {return_code}. Output tail:\n{tail}") + if seen_thread_id and seen_thread_id != thread_id: + self._set_codex_thread_id_for_user(username, seen_thread_id) + if output_file.exists(): answer = output_file.read_text(encoding="utf-8").strip() try: @@ -2829,6 +2886,35 @@ class ShinePyBotService: return line return "" + @staticmethod + def _extract_codex_thread_id(line: str) -> str: + s = (line or "").strip() + if not s.startswith("{"): + return "" + try: + obj = json.loads(s) + except Exception: + return "" + if obj.get("type") != "thread.started": + return "" + thread_id = (obj.get("thread_id") or "").strip() + return thread_id + + @staticmethod + def _is_missing_codex_session_error(text: str) -> bool: + lowered = (text or "").lower() + markers = [ + "session not found", + "conversation not found", + "thread not found", + "no session found", + "invalid session", + "unknown session", + "no conversation found", + "unknown thread", + ] + return any(marker in lowered for marker in markers) + @staticmethod def _format_duration(seconds: int) -> str: seconds = max(0, seconds) diff --git a/VERSION.properties b/VERSION.properties index d2707ae..29012d9 100644 --- a/VERSION.properties +++ b/VERSION.properties @@ -1,2 +1,2 @@ -client.version=1.2.191 -server.version=1.2.180 +client.version=1.2.192 +server.version=1.2.181