From a83ec2c971a65f864bcc46428512b9f7311703a65cee5d1764a4f50a26c1db5c Mon Sep 17 00:00:00 2001 From: AidarKC Date: Sun, 24 May 2026 09:21:50 +0300 Subject: [PATCH] =?UTF-8?q?=D0=9E=D0=B1=D0=BD=D0=BE=D0=B2=D0=B8=D1=82?= =?UTF-8?q?=D1=8C=20=D1=81=D0=B5=D1=80=D0=B2=D0=B8=D1=81=20=D0=B0=D0=B3?= =?UTF-8?q?=D0=B5=D0=BD=D1=82=D0=B0-=D0=BA=D0=BE=D0=B4=D0=B5=D1=80=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...-05-24_0818_agent-bot-heartbeat-restart.md | 26 ++++++ SHiNE-agent-bot-coder/AGENT.md | 2 + SHiNE-agent-bot-coder/README.md | 14 +++- SHiNE-agent-bot-coder/py_bot_service.py | 72 ++++++++++++++--- .../botcoder/telegram/ShineAgentBot.java | 80 ++++++++++++++++--- VERSION.properties | 4 +- 6 files changed, 174 insertions(+), 24 deletions(-) create mode 100644 Dev_Docs/Pending_Features/2026-05-24_0818_agent-bot-heartbeat-restart.md diff --git a/Dev_Docs/Pending_Features/2026-05-24_0818_agent-bot-heartbeat-restart.md b/Dev_Docs/Pending_Features/2026-05-24_0818_agent-bot-heartbeat-restart.md new file mode 100644 index 0000000..e6bbdea --- /dev/null +++ b/Dev_Docs/Pending_Features/2026-05-24_0818_agent-bot-heartbeat-restart.md @@ -0,0 +1,26 @@ +# Heartbeat и перезапуск агента-кодера + +## Краткое описание + +Изменена логика Telegram-сервиса агента-кодера: +- аварийное сообщение о долгой работе отправляется только после 2 минут молчания Codex; +- при дальнейшем молчании статус повторяется каждые 2 минуты; +- добавлена команда `/restart_service` с алиасом `/restart` для перезапуска сервиса через systemd. + +## Что проверить + +1. Запустить долгую задачу, в которой Codex регулярно отправляет промежуточные сообщения. +2. Убедиться, что дополнительное сообщение `всё ещё выполняется` не появляется без 2 минут молчания. +3. Запустить или смоделировать задачу, где Codex молчит больше 2 минут. +4. Проверить, что бот присылает статус с общим временем работы задачи и повторяет его каждые 2 минуты молчания. +5. Отправить `/restart_service` из разрешённого Telegram-аккаунта. +6. Проверить, что сервис завершился и был поднят systemd заново. +7. Проверить, что история JSONL сохранилась и не была очищена без команды `/new`. + +## Ожидаемый результат + +Сервис не шумит регулярными статусами при нормальной работе Codex, но сообщает о подозрительном молчании. Команда `/restart_service` перезапускает сервис без ручного входа в консоль. + +## Статус + +pending diff --git a/SHiNE-agent-bot-coder/AGENT.md b/SHiNE-agent-bot-coder/AGENT.md index ce4a16e..2d1e073 100644 --- a/SHiNE-agent-bot-coder/AGENT.md +++ b/SHiNE-agent-bot-coder/AGENT.md @@ -15,6 +15,7 @@ - Сервис ведёт состояние активной задачи и текущего файла истории, а после рестарта продолжает незавершённую обработку с учётом сохранённого состояния. - Истории диалогов хранятся в JSONL; после команды `/new` старая история архивируется, а новая начинается отдельно. - Дедупликация входящих Telegram update нужна, чтобы одно сообщение не попало в обработку повторно. +- Если Codex молчит во время активной задачи 2 минуты подряд, сервис отправляет аварийный статус с общим временем работы задачи; при дальнейшем молчании повторяет статус каждые 2 минуты. ## Локальный запуск и systemd - Основной запуск сервиса выполняется Python-скриптом `py_bot_service.py` из папки `SHiNE-agent-bot-coder/`. @@ -22,6 +23,7 @@ - Для проверки Codex без Telegram можно использовать self-test режим сервиса. - Для постоянного локального запуска используется user-level systemd service `shine-agent-bot-coder`; скрипты установки лежат в `SHiNE-agent-bot-coder/scripts/systemd/`. - Если меняется логика сервиса, после изменений нужно проверить запуск локально и при необходимости перезапустить user systemd service. +- Команда Telegram `/restart_service` перезапускает сервис через завершение процесса; systemd поднимает его заново. Короткий алиас: `/restart`. ## Правила ответа - Пиши содержательно и коротко. diff --git a/SHiNE-agent-bot-coder/README.md b/SHiNE-agent-bot-coder/README.md index aacd18b..77891f1 100644 --- a/SHiNE-agent-bot-coder/README.md +++ b/SHiNE-agent-bot-coder/README.md @@ -7,7 +7,8 @@ - обрабатывает задачи строго последовательно; - поддерживает текстовые и голосовые сообщения (voice/audio через OpenAI transcription); - вызывает Codex CLI и отправляет ответ в Telegram; -- при рестарте восстанавливает незавершённые задачи. +- при рестарте восстанавливает незавершённые задачи; +- отправляет аварийный статус только если Codex молчит 2 минуты подряд во время активной задачи. ## Структура - `.env` — локальные секреты и параметры запуска (не коммитится); @@ -42,3 +43,14 @@ python3 SHiNE-agent-bot-coder/py_bot_service.py --selftest-codex "Ответь Проверка: - `systemctl --user status shine-agent-bot-coder --no-pager` - `journalctl --user -u shine-agent-bot-coder -f` + +Перезапуск после изменений: +- `systemctl --user restart shine-agent-bot-coder` + +## Telegram-команды +- `/status` — активная задача и размер очереди. +- `/queue` — список задач в очереди. +- `/stop` — остановить текущую задачу. +- `/cancel ` — удалить задачу по id/префиксу или очистить очередь. +- `/new` — архивировать текущую историю и начать новый диалог. +- `/restart_service` — перезапустить сервис; systemd должен поднять процесс заново. diff --git a/SHiNE-agent-bot-coder/py_bot_service.py b/SHiNE-agent-bot-coder/py_bot_service.py index d40a998..e691d27 100644 --- a/SHiNE-agent-bot-coder/py_bot_service.py +++ b/SHiNE-agent-bot-coder/py_bot_service.py @@ -181,6 +181,7 @@ class ShinePyBotService: self.stop_current_job = False self.lock_fd = None self.last_heartbeat_at: float = 0.0 + self.restart_requested = False def run(self) -> None: self._ensure_dirs() @@ -449,6 +450,19 @@ class ShinePyBotService: archived = self._rotate_history("command_new", username) self._safe_send(chat_id, f"История очищена. Новый диалог начат.\nАрхив: {archived.name}", reply_to=message_id) return + if lower in ("/restart_service", "/restart"): + self._append_history_event("restart_service_requested", { + "chatId": chat_id, + "messageId": message_id, + "username": username, + }) + self._safe_send( + chat_id, + "Перезапускаю сервис. Если задача была активна, после старта она вернётся в очередь и продолжится.", + reply_to=message_id, + ) + self._schedule_self_restart() + return if lower == "/stop": stopped = self._cancel_active_job("stopped_by_user") if stopped: @@ -483,6 +497,7 @@ class ShinePyBotService: "/stop — остановить текущую задачу\n" "/cancel — удалить задачу по id (префикс) или все\n" "/new — архивировать историю и начать новую\n" + "/restart_service — перезапустить сервис через systemd\n" "/help — эта справка" ) @@ -654,9 +669,11 @@ class ShinePyBotService: self.last_heartbeat_at = 0.0 last_user_note = "" last_user_note_at = 0.0 + codex_started_at = time.time() + last_job_message_at = codex_started_at def on_line(line: str) -> None: - nonlocal last_user_note, last_user_note_at + nonlocal last_user_note, last_user_note_at, last_job_message_at output_lines.append(line) note = self._extract_codex_user_note(line) now = time.time() @@ -664,9 +681,7 @@ class ShinePyBotService: self._safe_send(chat_id, f"#{job_num}: {note}", reply_to=message_id) last_user_note = note last_user_note_at = now - if now - self.last_heartbeat_at > 60: - self._safe_send(chat_id, f"#{job_num}: всё ещё выполняется...", reply_to=message_id) - self.last_heartbeat_at = now + last_job_message_at = now reader_done = threading.Event() @@ -682,11 +697,27 @@ class ShinePyBotService: t.start() try: - return_code = process.wait(timeout=self.cfg.codex_timeout_seconds) - except subprocess.TimeoutExpired: - process.kill() - t.join(timeout=2) - raise RuntimeError(f"Codex timeout after {self.cfg.codex_timeout_seconds}s") + deadline = time.time() + self.cfg.codex_timeout_seconds + return_code = None + while return_code is None: + return_code = process.poll() + now = time.time() + if return_code is not None: + break + if now >= deadline: + process.kill() + t.join(timeout=2) + raise RuntimeError(f"Codex timeout after {self.cfg.codex_timeout_seconds}s") + if now - codex_started_at >= 120 and now - last_job_message_at >= 120: + elapsed = self._format_duration(int(now - codex_started_at)) + self._safe_send( + chat_id, + f"#{job_num}: задача ещё выполняется, работает уже {elapsed}. От Codex давно нет сообщений.", + reply_to=message_id, + ) + last_job_message_at = now + self.last_heartbeat_at = now + time.sleep(1) finally: with self.active_process_lock: self.active_process = None @@ -777,6 +808,18 @@ class ShinePyBotService: except Exception as e: print(f"[py-bot] sendMessage error: {e}", flush=True) + def _schedule_self_restart(self) -> None: + if self.restart_requested: + return + self.restart_requested = True + + def restart() -> None: + time.sleep(1.5) + print("[py-bot] restart requested by Telegram command", flush=True) + os._exit(0) + + threading.Thread(target=restart, name="shine-py-bot-self-restart", daemon=True).start() + def _transcribe_voice_job(self, job: dict[str, Any]) -> str: if not self.cfg.openai_api_key: raise RuntimeError("Не задан OPENAI_API_KEY для распознавания voice") @@ -878,6 +921,17 @@ class ShinePyBotService: return line return "" + @staticmethod + def _format_duration(seconds: int) -> str: + seconds = max(0, seconds) + minutes, sec = divmod(seconds, 60) + hours, minutes = divmod(minutes, 60) + if hours: + return f"{hours}ч {minutes}м {sec}с" + if minutes: + return f"{minutes}м {sec}с" + return f"{sec}с" + def run_selftest(config: BotConfig, prompt: str) -> int: cmd = [ diff --git a/SHiNE-agent-bot-coder/src/main/java/shine/agent/botcoder/telegram/ShineAgentBot.java b/SHiNE-agent-bot-coder/src/main/java/shine/agent/botcoder/telegram/ShineAgentBot.java index 97c11ed..546a77a 100644 --- a/SHiNE-agent-bot-coder/src/main/java/shine/agent/botcoder/telegram/ShineAgentBot.java +++ b/SHiNE-agent-bot-coder/src/main/java/shine/agent/botcoder/telegram/ShineAgentBot.java @@ -258,24 +258,39 @@ public class ShineAgentBot extends TelegramLongPollingBot { private void processJob(QueueJob job) { ScheduledFuture heartbeat = null; + AtomicLong lastJobNotificationAt = new AtomicLong(0L); try { log.info("Начало обработки jobId={}, type={}, chatId={}, attempts={}", job.id, job.type, job.chatId, job.attempts); activeJobRef.set(job); - activeJobStartedAt.set(System.currentTimeMillis()); + long startedAt = System.currentTimeMillis(); + activeJobStartedAt.set(startedAt); safeSendText(job.chatId, "Задача " + shortId(job.id) + " взята в работу.", job.messageId); + lastJobNotificationAt.set(startedAt); String userText = resolveUserText(job); String prompt = buildPrompt(job, userText); historyManager.appendCodexRequest(job.id, prompt); log.info("Вызов Codex для jobId={}", job.id); heartbeat = heartbeatScheduler.scheduleAtFixedRate( - () -> notifier.submit(() -> - safeSendText(job.chatId, "Статус " + shortId(job.id) + ": в работе " + elapsedSeconds() + "с", job.messageId) - ), - 30, 30, TimeUnit.SECONDS + () -> { + long now = System.currentTimeMillis(); + long elapsed = now - startedAt; + long silence = now - lastJobNotificationAt.get(); + if (elapsed >= 120_000L && silence >= 120_000L) { + lastJobNotificationAt.set(now); + notifier.submit(() -> safeSendText( + job.chatId, + "Статус " + shortId(job.id) + ": задача ещё выполняется, работает уже " + + formatDuration(elapsedSeconds()) + + ". От Codex давно нет сообщений.", + job.messageId + )); + } + }, + 120, 10, TimeUnit.SECONDS ); String answer; - answer = codexClient.executePrompt(prompt, buildStatusListener(job)); + answer = codexClient.executePrompt(prompt, buildStatusListener(job, lastJobNotificationAt)); log.info("Codex завершился для jobId={}, длина ответа={}", job.id, answer.length()); safeSendText(job.chatId, "Codex завершил обработку, отправляю результат.", job.messageId); sendLongMessage(job.chatId, answer, job.messageId); @@ -340,21 +355,19 @@ public class ShineAgentBot extends TelegramLongPollingBot { return false; } - private CodexStatusListener buildStatusListener(QueueJob job) { + private CodexStatusListener buildStatusListener(QueueJob job, AtomicLong lastJobNotificationAt) { AtomicReference lastStatus = new AtomicReference<>(""); AtomicLong lastSentAt = new AtomicLong(0L); return status -> { long now = System.currentTimeMillis(); String prev = lastStatus.get(); boolean changed = !status.equals(prev); - boolean heartbeatDue = now - lastSentAt.get() > 30_000; - if (changed || heartbeatDue) { - String text = changed - ? "Статус " + shortId(job.id) + ": " + status - : "Статус " + shortId(job.id) + ": в работе " + elapsedSeconds() + "с"; + if (changed && now - lastSentAt.get() > 8_000) { + String text = "Статус " + shortId(job.id) + ": " + status; notifier.submit(() -> safeSendText(job.chatId, text, job.messageId)); lastStatus.set(status); lastSentAt.set(now); + lastJobNotificationAt.set(now); } }; } @@ -429,6 +442,20 @@ public class ShineAgentBot extends TelegramLongPollingBot { safeSendText(message.getChatId(), buildQueueText(), message.getMessageId()); return true; } + if ("/restart_service".equals(lower) || "/restart".equals(lower)) { + historyManager.appendSystemEvent("restart_service_requested", Map.of( + "chatId", message.getChatId(), + "messageId", message.getMessageId(), + "timestamp", Instant.now().toString() + )); + safeSendText( + message.getChatId(), + "Перезапускаю сервис. Если задача была активна, после старта она вернётся в очередь и продолжится.", + message.getMessageId() + ); + scheduleSelfRestart(); + return true; + } if ("/stop".equals(lower)) { boolean stopped = codexClient.stopActiveProcess(); if (stopped) { @@ -514,10 +541,25 @@ public class ShineAgentBot extends TelegramLongPollingBot { /stop — остановить текущую задачу /cancel — удалить задачу по id (префикс) или все /new — архивировать историю и начать новую + /restart_service — перезапустить сервис через systemd /help — эта справка """; } + private void scheduleSelfRestart() { + Thread restartThread = new Thread(() -> { + try { + Thread.sleep(1500); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + log.info("Перезапуск сервиса запрошен командой Telegram"); + System.exit(0); + }, "shine-agent-self-restart"); + restartThread.setDaemon(true); + restartThread.start(); + } + private void safeSendText(long chatId, String text, Integer replyToMessageId) { try { SendMessage message = new SendMessage(); @@ -575,6 +617,20 @@ public class ShineAgentBot extends TelegramLongPollingBot { return (System.currentTimeMillis() - started) / 1000L; } + private String formatDuration(long seconds) { + long safeSeconds = Math.max(0, seconds); + long hours = safeSeconds / 3600; + long minutes = (safeSeconds % 3600) / 60; + long sec = safeSeconds % 60; + if (hours > 0) { + return hours + "ч " + minutes + "м " + sec + "с"; + } + if (minutes > 0) { + return minutes + "м " + sec + "с"; + } + return sec + "с"; + } + private String shortError(Throwable e) { String message = e.getMessage(); if (message == null || message.isBlank()) { diff --git a/VERSION.properties b/VERSION.properties index 36d0092..16d8b95 100644 --- a/VERSION.properties +++ b/VERSION.properties @@ -1,2 +1,2 @@ -client.version=1.2.84 -server.version=1.2.78 +client.version=1.2.85 +server.version=1.2.79