Обновить сервис агента-кодера

This commit is contained in:
AidarKC 2026-05-24 09:21:50 +03:00
parent 4b371e142d
commit a83ec2c971
6 changed files with 174 additions and 24 deletions

View File

@ -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

View File

@ -15,6 +15,7 @@
- Сервис ведёт состояние активной задачи и текущего файла истории, а после рестарта продолжает незавершённую обработку с учётом сохранённого состояния. - Сервис ведёт состояние активной задачи и текущего файла истории, а после рестарта продолжает незавершённую обработку с учётом сохранённого состояния.
- Истории диалогов хранятся в JSONL; после команды `/new` старая история архивируется, а новая начинается отдельно. - Истории диалогов хранятся в JSONL; после команды `/new` старая история архивируется, а новая начинается отдельно.
- Дедупликация входящих Telegram update нужна, чтобы одно сообщение не попало в обработку повторно. - Дедупликация входящих Telegram update нужна, чтобы одно сообщение не попало в обработку повторно.
- Если Codex молчит во время активной задачи 2 минуты подряд, сервис отправляет аварийный статус с общим временем работы задачи; при дальнейшем молчании повторяет статус каждые 2 минуты.
## Локальный запуск и systemd ## Локальный запуск и systemd
- Основной запуск сервиса выполняется Python-скриптом `py_bot_service.py` из папки `SHiNE-agent-bot-coder/`. - Основной запуск сервиса выполняется Python-скриптом `py_bot_service.py` из папки `SHiNE-agent-bot-coder/`.
@ -22,6 +23,7 @@
- Для проверки Codex без Telegram можно использовать self-test режим сервиса. - Для проверки Codex без Telegram можно использовать self-test режим сервиса.
- Для постоянного локального запуска используется user-level systemd service `shine-agent-bot-coder`; скрипты установки лежат в `SHiNE-agent-bot-coder/scripts/systemd/`. - Для постоянного локального запуска используется user-level systemd service `shine-agent-bot-coder`; скрипты установки лежат в `SHiNE-agent-bot-coder/scripts/systemd/`.
- Если меняется логика сервиса, после изменений нужно проверить запуск локально и при необходимости перезапустить user systemd service. - Если меняется логика сервиса, после изменений нужно проверить запуск локально и при необходимости перезапустить user systemd service.
- Команда Telegram `/restart_service` перезапускает сервис через завершение процесса; systemd поднимает его заново. Короткий алиас: `/restart`.
## Правила ответа ## Правила ответа
- Пиши содержательно и коротко. - Пиши содержательно и коротко.

View File

@ -7,7 +7,8 @@
- обрабатывает задачи строго последовательно; - обрабатывает задачи строго последовательно;
- поддерживает текстовые и голосовые сообщения (voice/audio через OpenAI transcription); - поддерживает текстовые и голосовые сообщения (voice/audio через OpenAI transcription);
- вызывает Codex CLI и отправляет ответ в Telegram; - вызывает Codex CLI и отправляет ответ в Telegram;
- при рестарте восстанавливает незавершённые задачи. - при рестарте восстанавливает незавершённые задачи;
- отправляет аварийный статус только если Codex молчит 2 минуты подряд во время активной задачи.
## Структура ## Структура
- `.env` — локальные секреты и параметры запуска (не коммитится); - `.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` - `systemctl --user status shine-agent-bot-coder --no-pager`
- `journalctl --user -u shine-agent-bot-coder -f` - `journalctl --user -u shine-agent-bot-coder -f`
Перезапуск после изменений:
- `systemctl --user restart shine-agent-bot-coder`
## Telegram-команды
- `/status` — активная задача и размер очереди.
- `/queue` — список задач в очереди.
- `/stop` — остановить текущую задачу.
- `/cancel <id|all>` — удалить задачу по id/префиксу или очистить очередь.
- `/new` — архивировать текущую историю и начать новый диалог.
- `/restart_service` — перезапустить сервис; systemd должен поднять процесс заново.

View File

@ -181,6 +181,7 @@ class ShinePyBotService:
self.stop_current_job = False self.stop_current_job = False
self.lock_fd = None self.lock_fd = None
self.last_heartbeat_at: float = 0.0 self.last_heartbeat_at: float = 0.0
self.restart_requested = False
def run(self) -> None: def run(self) -> None:
self._ensure_dirs() self._ensure_dirs()
@ -449,6 +450,19 @@ class ShinePyBotService:
archived = self._rotate_history("command_new", username) archived = self._rotate_history("command_new", username)
self._safe_send(chat_id, f"История очищена. Новый диалог начат.\nАрхив: {archived.name}", reply_to=message_id) self._safe_send(chat_id, f"История очищена. Новый диалог начат.\nАрхив: {archived.name}", reply_to=message_id)
return 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": if lower == "/stop":
stopped = self._cancel_active_job("stopped_by_user") stopped = self._cancel_active_job("stopped_by_user")
if stopped: if stopped:
@ -483,6 +497,7 @@ class ShinePyBotService:
"/stop — остановить текущую задачу\n" "/stop — остановить текущую задачу\n"
"/cancel <id|all> — удалить задачу по id (префикс) или все\n" "/cancel <id|all> — удалить задачу по id (префикс) или все\n"
"/new — архивировать историю и начать новую\n" "/new — архивировать историю и начать новую\n"
"/restart_service — перезапустить сервис через systemd\n"
"/help — эта справка" "/help — эта справка"
) )
@ -654,9 +669,11 @@ class ShinePyBotService:
self.last_heartbeat_at = 0.0 self.last_heartbeat_at = 0.0
last_user_note = "" last_user_note = ""
last_user_note_at = 0.0 last_user_note_at = 0.0
codex_started_at = time.time()
last_job_message_at = codex_started_at
def on_line(line: str) -> None: 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) output_lines.append(line)
note = self._extract_codex_user_note(line) note = self._extract_codex_user_note(line)
now = time.time() now = time.time()
@ -664,9 +681,7 @@ class ShinePyBotService:
self._safe_send(chat_id, f"#{job_num}: {note}", reply_to=message_id) self._safe_send(chat_id, f"#{job_num}: {note}", reply_to=message_id)
last_user_note = note last_user_note = note
last_user_note_at = now last_user_note_at = now
if now - self.last_heartbeat_at > 60: last_job_message_at = now
self._safe_send(chat_id, f"#{job_num}: всё ещё выполняется...", reply_to=message_id)
self.last_heartbeat_at = now
reader_done = threading.Event() reader_done = threading.Event()
@ -682,11 +697,27 @@ class ShinePyBotService:
t.start() t.start()
try: try:
return_code = process.wait(timeout=self.cfg.codex_timeout_seconds) deadline = time.time() + self.cfg.codex_timeout_seconds
except subprocess.TimeoutExpired: return_code = None
process.kill() while return_code is None:
t.join(timeout=2) return_code = process.poll()
raise RuntimeError(f"Codex timeout after {self.cfg.codex_timeout_seconds}s") 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: finally:
with self.active_process_lock: with self.active_process_lock:
self.active_process = None self.active_process = None
@ -777,6 +808,18 @@ class ShinePyBotService:
except Exception as e: except Exception as e:
print(f"[py-bot] sendMessage error: {e}", flush=True) 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: def _transcribe_voice_job(self, job: dict[str, Any]) -> str:
if not self.cfg.openai_api_key: if not self.cfg.openai_api_key:
raise RuntimeError("Не задан OPENAI_API_KEY для распознавания voice") raise RuntimeError("Не задан OPENAI_API_KEY для распознавания voice")
@ -878,6 +921,17 @@ class ShinePyBotService:
return line return line
return "" 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: def run_selftest(config: BotConfig, prompt: str) -> int:
cmd = [ cmd = [

View File

@ -258,24 +258,39 @@ public class ShineAgentBot extends TelegramLongPollingBot {
private void processJob(QueueJob job) { private void processJob(QueueJob job) {
ScheduledFuture<?> heartbeat = null; ScheduledFuture<?> heartbeat = null;
AtomicLong lastJobNotificationAt = new AtomicLong(0L);
try { try {
log.info("Начало обработки jobId={}, type={}, chatId={}, attempts={}", job.id, job.type, job.chatId, job.attempts); log.info("Начало обработки jobId={}, type={}, chatId={}, attempts={}", job.id, job.type, job.chatId, job.attempts);
activeJobRef.set(job); activeJobRef.set(job);
activeJobStartedAt.set(System.currentTimeMillis()); long startedAt = System.currentTimeMillis();
activeJobStartedAt.set(startedAt);
safeSendText(job.chatId, "Задача " + shortId(job.id) + " взята в работу.", job.messageId); safeSendText(job.chatId, "Задача " + shortId(job.id) + " взята в работу.", job.messageId);
lastJobNotificationAt.set(startedAt);
String userText = resolveUserText(job); String userText = resolveUserText(job);
String prompt = buildPrompt(job, userText); String prompt = buildPrompt(job, userText);
historyManager.appendCodexRequest(job.id, prompt); historyManager.appendCodexRequest(job.id, prompt);
log.info("Вызов Codex для jobId={}", job.id); log.info("Вызов Codex для jobId={}", job.id);
heartbeat = heartbeatScheduler.scheduleAtFixedRate( heartbeat = heartbeatScheduler.scheduleAtFixedRate(
() -> notifier.submit(() -> () -> {
safeSendText(job.chatId, "Статус " + shortId(job.id) + ": в работе " + elapsedSeconds() + "с", job.messageId) long now = System.currentTimeMillis();
), long elapsed = now - startedAt;
30, 30, TimeUnit.SECONDS 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; String answer;
answer = codexClient.executePrompt(prompt, buildStatusListener(job)); answer = codexClient.executePrompt(prompt, buildStatusListener(job, lastJobNotificationAt));
log.info("Codex завершился для jobId={}, длина ответа={}", job.id, answer.length()); log.info("Codex завершился для jobId={}, длина ответа={}", job.id, answer.length());
safeSendText(job.chatId, "Codex завершил обработку, отправляю результат.", job.messageId); safeSendText(job.chatId, "Codex завершил обработку, отправляю результат.", job.messageId);
sendLongMessage(job.chatId, answer, job.messageId); sendLongMessage(job.chatId, answer, job.messageId);
@ -340,21 +355,19 @@ public class ShineAgentBot extends TelegramLongPollingBot {
return false; return false;
} }
private CodexStatusListener buildStatusListener(QueueJob job) { private CodexStatusListener buildStatusListener(QueueJob job, AtomicLong lastJobNotificationAt) {
AtomicReference<String> lastStatus = new AtomicReference<>(""); AtomicReference<String> lastStatus = new AtomicReference<>("");
AtomicLong lastSentAt = new AtomicLong(0L); AtomicLong lastSentAt = new AtomicLong(0L);
return status -> { return status -> {
long now = System.currentTimeMillis(); long now = System.currentTimeMillis();
String prev = lastStatus.get(); String prev = lastStatus.get();
boolean changed = !status.equals(prev); boolean changed = !status.equals(prev);
boolean heartbeatDue = now - lastSentAt.get() > 30_000; if (changed && now - lastSentAt.get() > 8_000) {
if (changed || heartbeatDue) { String text = "Статус " + shortId(job.id) + ": " + status;
String text = changed
? "Статус " + shortId(job.id) + ": " + status
: "Статус " + shortId(job.id) + ": в работе " + elapsedSeconds() + "с";
notifier.submit(() -> safeSendText(job.chatId, text, job.messageId)); notifier.submit(() -> safeSendText(job.chatId, text, job.messageId));
lastStatus.set(status); lastStatus.set(status);
lastSentAt.set(now); lastSentAt.set(now);
lastJobNotificationAt.set(now);
} }
}; };
} }
@ -429,6 +442,20 @@ public class ShineAgentBot extends TelegramLongPollingBot {
safeSendText(message.getChatId(), buildQueueText(), message.getMessageId()); safeSendText(message.getChatId(), buildQueueText(), message.getMessageId());
return true; 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)) { if ("/stop".equals(lower)) {
boolean stopped = codexClient.stopActiveProcess(); boolean stopped = codexClient.stopActiveProcess();
if (stopped) { if (stopped) {
@ -514,10 +541,25 @@ public class ShineAgentBot extends TelegramLongPollingBot {
/stop остановить текущую задачу /stop остановить текущую задачу
/cancel <id|all> удалить задачу по id (префикс) или все /cancel <id|all> удалить задачу по id (префикс) или все
/new архивировать историю и начать новую /new архивировать историю и начать новую
/restart_service перезапустить сервис через systemd
/help эта справка /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) { private void safeSendText(long chatId, String text, Integer replyToMessageId) {
try { try {
SendMessage message = new SendMessage(); SendMessage message = new SendMessage();
@ -575,6 +617,20 @@ public class ShineAgentBot extends TelegramLongPollingBot {
return (System.currentTimeMillis() - started) / 1000L; 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) { private String shortError(Throwable e) {
String message = e.getMessage(); String message = e.getMessage();
if (message == null || message.isBlank()) { if (message == null || message.isBlank()) {

View File

@ -1,2 +1,2 @@
client.version=1.2.84 client.version=1.2.85
server.version=1.2.78 server.version=1.2.79