Fix training agent heartbeat status
This commit is contained in:
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+16
-2
@@ -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)))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
Reference in New Issue
Block a user