Обновить сервис агента-кодера
This commit is contained in:
parent
4b371e142d
commit
a83ec2c971
@ -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
|
||||
@ -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`.
|
||||
|
||||
## Правила ответа
|
||||
- Пиши содержательно и коротко.
|
||||
|
||||
@ -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|all>` — удалить задачу по id/префиксу или очистить очередь.
|
||||
- `/new` — архивировать текущую историю и начать новый диалог.
|
||||
- `/restart_service` — перезапустить сервис; systemd должен поднять процесс заново.
|
||||
|
||||
@ -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|all> — удалить задачу по 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:
|
||||
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 = [
|
||||
|
||||
@ -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<String> 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|all> — удалить задачу по 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()) {
|
||||
|
||||
@ -1,2 +1,2 @@
|
||||
client.version=1.2.84
|
||||
server.version=1.2.78
|
||||
client.version=1.2.85
|
||||
server.version=1.2.79
|
||||
|
||||
Loading…
Reference in New Issue
Block a user