Fix training agent heartbeat status

This commit is contained in:
Codex
2026-06-27 11:02:41 +03:00
parent b6d616ec2a
commit 83bffadc0a
5 changed files with 96 additions and 13 deletions
+2 -2
View File
@@ -10,7 +10,7 @@ android {
applicationId = "xyz.kusoft.tradebotmonitor" applicationId = "xyz.kusoft.tradebotmonitor"
minSdk = 26 minSdk = 26
targetSdk = 36 targetSdk = 36
versionCode = 7 versionCode = 8
versionName = "0.2.4" versionName = "0.2.5"
} }
} }
@@ -575,12 +575,26 @@ class MainActivity : Activity() {
private fun trainingComputerPanel(retrain: JSONObject): View = private fun trainingComputerPanel(retrain: JSONObject): View =
LinearLayout(this).apply { LinearLayout(this).apply {
val coordination = retrain.optJSONObject("coordination") ?: JSONObject() val coordination = retrain.optJSONObject("coordination") ?: JSONObject()
val agentOnline = coordination.optBoolean("agent_online", false) val activeJob = coordination.optJSONObject("active_job")
val agentRecentlySeen = coordination.optBoolean(
"agent_recently_seen",
coordination.optBoolean("agent_online", false),
)
val agentBusy = coordination.optBoolean(
"agent_busy",
activeJob?.optStringClean("status") == "running",
)
val connectionText = when {
agentRecentlySeen -> "онлайн"
agentBusy -> "занят обучением"
else -> "нет связи"
}
val connectionColor = if (agentRecentlySeen || agentBusy) palette.green else palette.amber
orientation = LinearLayout.VERTICAL orientation = LinearLayout.VERTICAL
addView(text("Компьютер обучения", 12f, Typeface.NORMAL, palette.muted)) addView(text("Компьютер обучения", 12f, Typeface.NORMAL, palette.muted))
addView(text(prefs.trainingComputerName, 18f, Typeface.BOLD, palette.green).top(dp(5))) addView(text(prefs.trainingComputerName, 18f, Typeface.BOLD, palette.green).top(dp(5)))
addView(text(prefs.trainingComputerPath, 12f, Typeface.NORMAL, palette.muted).top(dp(4))) addView(text(prefs.trainingComputerPath, 12f, Typeface.NORMAL, palette.muted).top(dp(4)))
addView(keyValueLine("Связь агента", if (agentOnline) "онлайн" else "нет связи", if (agentOnline) palette.green else palette.amber).top(dp(4))) addView(keyValueLine("Связь агента", connectionText, connectionColor).top(dp(4)))
addView(text("Бот ставит задания через tb.kusoft.xyz, а этот Windows-agent сам забирает их через интернет и возвращает результат.", 12f, Typeface.NORMAL, palette.muted).top(dp(8))) addView(text("Бот ставит задания через tb.kusoft.xyz, а этот Windows-agent сам забирает их через интернет и возвращает результат.", 12f, Typeface.NORMAL, palette.muted).top(dp(8)))
} }
+10 -1
View File
@@ -206,9 +206,18 @@ class TrainingCoordinator:
last_seen = _parse_time(str(worker.get("last_seen_at") or "")) last_seen = _parse_time(str(worker.get("last_seen_at") or ""))
active = self._active_job(state) active = self._active_job(state)
latest = _latest_job(state) latest = _latest_job(state)
recently_seen = bool(last_seen and datetime.now(UTC) - last_seen <= ONLINE_WINDOW)
agent_busy = bool(
active
and active.get("status") == "running"
and worker
and active.get("claimed_by") == worker.get("id")
)
return { return {
"available": True, "available": True,
"agent_online": bool(last_seen and datetime.now(UTC) - last_seen <= ONLINE_WINDOW), "agent_online": recently_seen or agent_busy,
"agent_recently_seen": recently_seen,
"agent_busy": agent_busy,
"worker": worker, "worker": worker,
"active_job": active, "active_job": active,
"latest_job": latest, "latest_job": latest,
+18
View File
@@ -2,6 +2,7 @@ from __future__ import annotations
import base64 import base64
import hashlib import hashlib
import json
from crypto_spot_bot.training_coordination import TrainingCoordinator from crypto_spot_bot.training_coordination import TrainingCoordinator
@@ -68,3 +69,20 @@ def test_training_coordinator_accepts_chunked_artifact_upload(tmp_path) -> None:
assert part_2["complete"] is True assert part_2["complete"] is True
assert (tmp_path / "lstm_forecaster.json").read_bytes() == payload assert (tmp_path / "lstm_forecaster.json").read_bytes() == payload
assert coordinator.status()["latest_job"]["artifacts"][0]["sha256"] == sha256 assert coordinator.status()["latest_job"]["artifacts"][0]["sha256"] == sha256
def test_running_claimed_job_keeps_agent_online_when_heartbeat_is_stale(tmp_path) -> None:
coordinator = TrainingCoordinator(tmp_path)
coordinator.request_retrain({"source": "android"})
coordinator.claim({"worker_id": "win-1", "name": "DESKTOP-TMFDL0H"})
state_path = tmp_path / "training_coordination.json"
state = json.loads(state_path.read_text(encoding="utf-8"))
state["worker"]["last_seen_at"] = "2026-01-01T00:00:00+00:00"
state_path.write_text(json.dumps(state), encoding="utf-8")
status = coordinator.status()
assert status["agent_recently_seen"] is False
assert status["agent_busy"] is True
assert status["agent_online"] is True
+50 -8
View File
@@ -6,8 +6,10 @@ import hashlib
import json import json
import os import os
import platform import platform
import queue
import subprocess import subprocess
import sys import sys
import threading
import time import time
from datetime import datetime from datetime import datetime
from pathlib import Path from pathlib import Path
@@ -107,6 +109,13 @@ def run_retrain(args: argparse.Namespace, job_id: str, job: dict[str, Any], repo
log(log_path, "Running retrain: " + " ".join(quote_for_log(part) for part in cmd)) log(log_path, "Running retrain: " + " ".join(quote_for_log(part) for part in cmd))
report_progress(args, job_id, "running", "training", 8, "PyTorch retrain запущен") report_progress(args, job_id, "running", "training", 8, "PyTorch retrain запущен")
line_count = 0 line_count = 0
output_queue: queue.Queue[str] = queue.Queue()
def read_output() -> None:
assert process.stdout is not None
for raw_line in process.stdout:
output_queue.put(raw_line.rstrip())
with subprocess.Popen( with subprocess.Popen(
cmd, cmd,
cwd=str(repo_root), cwd=str(repo_root),
@@ -116,14 +125,32 @@ def run_retrain(args: argparse.Namespace, job_id: str, job: dict[str, Any], repo
encoding="utf-8", encoding="utf-8",
errors="replace", errors="replace",
) as process: ) as process:
assert process.stdout is not None reader = threading.Thread(target=read_output, name="training-output-reader", daemon=True)
for line in process.stdout: reader.start()
message = line.rstrip() last_report_at = 0.0
log(log_path, message) last_message = "PyTorch retrain выполняется"
line_count += 1 while True:
if line_count == 1 or line_count % 12 == 0: got_line = False
progress = min(70, 8 + line_count // 3) try:
report_progress(args, job_id, "running", "training", progress, message[-220:]) message = output_queue.get(timeout=5)
got_line = True
log(log_path, message)
line_count += 1
if message:
last_message = message[-220:]
except queue.Empty:
pass
progress = min(70, 8 + line_count // 3)
now = time.monotonic()
if got_line or now - last_report_at >= 30:
safe_report_progress(args, job_id, "running", "training", progress, last_message, log_path)
last_report_at = now
if process.poll() is not None and output_queue.empty():
break
reader.join(timeout=2)
code = process.wait() code = process.wait()
if code != 0: if code != 0:
raise RuntimeError(f"retrain failed with exit code {code}") raise RuntimeError(f"retrain failed with exit code {code}")
@@ -173,6 +200,21 @@ def report_progress(
) )
def safe_report_progress(
args: argparse.Namespace,
job_id: str,
status: str,
phase: str,
progress_percent: int,
message: str,
log_path: Path,
) -> None:
try:
report_progress(args, job_id, status, phase, progress_percent, message)
except Exception as exc: # noqa: BLE001 - keep the local training process alive.
log(log_path, f"Progress report failed: {exc}")
def api_json(args: argparse.Namespace, path: str, payload: dict[str, Any], timeout: int = 30) -> dict[str, Any]: def api_json(args: argparse.Namespace, path: str, payload: dict[str, Any], timeout: int = 30) -> dict[str, Any]:
url = args.api_base_url.rstrip("/") + path url = args.api_base_url.rstrip("/") + path
body = json.dumps(payload, ensure_ascii=False).encode("utf-8") body = json.dumps(payload, ensure_ascii=False).encode("utf-8")