Compare commits
96
Commits
@@ -1,22 +0,0 @@
|
||||
{
|
||||
"mcpServers": {
|
||||
"mc2-memory": {
|
||||
"command": "python",
|
||||
"args": [
|
||||
"F:\\Coding Stuff\\mission-control-2\\mcp\\mcp_memory.py"
|
||||
]
|
||||
},
|
||||
"mc2-system": {
|
||||
"command": "python",
|
||||
"args": [
|
||||
"F:\\Coding Stuff\\mission-control-2\\mcp\\mcp_mc.py"
|
||||
]
|
||||
},
|
||||
"mc2-web": {
|
||||
"command": "python",
|
||||
"args": [
|
||||
"F:\\Coding Stuff\\mission-control-2\\mcp\\mcp_web.py"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
+55
-37
@@ -1,37 +1,55 @@
|
||||
{
|
||||
"version": "0.0.1",
|
||||
"configurations": [
|
||||
{
|
||||
"name": "mc2",
|
||||
"runtimeExecutable": "F:\\Coding Stuff\\mission-control-2\\backend\\.venv\\Scripts\\python.exe",
|
||||
"runtimeArgs": [
|
||||
"-m",
|
||||
"uvicorn",
|
||||
"app:app",
|
||||
"--app-dir",
|
||||
"F:\\Coding Stuff\\mission-control-2\\backend",
|
||||
"--port",
|
||||
"9000"
|
||||
],
|
||||
"port": 9000
|
||||
},
|
||||
{
|
||||
"name": "frontend",
|
||||
"runtimeExecutable": "npm",
|
||||
"runtimeArgs": ["run", "dev", "--", "--port", "5180", "--strictPort"],
|
||||
"cwd": "F:\\Coding Stuff\\mission-control-2\\frontend",
|
||||
"env": { "MC_API_TARGET": "http://192.168.178.151:9001" },
|
||||
"autoPort": false,
|
||||
"port": 5180
|
||||
},
|
||||
{
|
||||
"name": "frontend-mock",
|
||||
"runtimeExecutable": "npm",
|
||||
"runtimeArgs": ["run", "dev", "--", "--port", "5181", "--strictPort"],
|
||||
"cwd": "F:\\Coding Stuff\\mission-control-2\\frontend",
|
||||
"env": { "MC_API_TARGET": "http://127.0.0.1:9000" },
|
||||
"autoPort": false,
|
||||
"port": 5181
|
||||
}
|
||||
]
|
||||
}
|
||||
{
|
||||
"version": "0.0.1",
|
||||
"configurations": [
|
||||
{
|
||||
"name": "mc2",
|
||||
"runtimeExecutable": "F:\\Coding Stuff\\mission-control-2\\backend\\.venv\\Scripts\\python.exe",
|
||||
"runtimeArgs": [
|
||||
"-m",
|
||||
"uvicorn",
|
||||
"app:app",
|
||||
"--app-dir",
|
||||
"F:\\Coding Stuff\\mission-control-2\\backend",
|
||||
"--port",
|
||||
"9000"
|
||||
],
|
||||
"port": 9000
|
||||
},
|
||||
{
|
||||
"name": "frontend",
|
||||
"runtimeExecutable": "npm",
|
||||
"runtimeArgs": [
|
||||
"run",
|
||||
"dev",
|
||||
"--",
|
||||
"--port",
|
||||
"5180",
|
||||
"--strictPort"
|
||||
],
|
||||
"cwd": "F:\\Coding Stuff\\mission-control-2\\frontend",
|
||||
"env": {
|
||||
"MC_API_TARGET": "http://192.168.178.151:9001"
|
||||
},
|
||||
"autoPort": false,
|
||||
"port": 5180
|
||||
},
|
||||
{
|
||||
"name": "frontend-mock",
|
||||
"runtimeExecutable": "npm",
|
||||
"runtimeArgs": [
|
||||
"run",
|
||||
"dev",
|
||||
"--",
|
||||
"--port",
|
||||
"5181",
|
||||
"--strictPort"
|
||||
],
|
||||
"cwd": "F:\\Coding Stuff\\mission-control-2\\frontend",
|
||||
"env": {
|
||||
"MC_API_TARGET": "http://127.0.0.1:9000"
|
||||
},
|
||||
"autoPort": false,
|
||||
"port": 5181
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,100 +0,0 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"autoMode": {
|
||||
"allow": [
|
||||
"$defaults",
|
||||
"SSH/scp commands to hitonabi@192.168.178.151 that read or write files under ~/.hermes including ~/.hermes/hermes-agent (the Hermes runtime) — the user authorized direct patching of the box's Hermes runtime for the mission-control-2 project"
|
||||
]
|
||||
}
|
||||
"Bash(git fetch *)",
|
||||
"Bash(git push:*)",
|
||||
"Bash(git rev-list *)",
|
||||
"Bash(ssh hitonabi@192.168.178.151:*)",
|
||||
"Bash(grep -E \"\\\\.py$|^d\")",
|
||||
"Bash(grep -rn \"http://\\\\|https://\\\\|/srv/\\\\|/opt/\\\\|/etc/\" services/*.py routers/*.py)",
|
||||
"Bash(awk '/^\\(async \\)?def /{in_func=1; func=$0; line=NR} in_func {count++} /^\\(async \\)?def / && NR!=line {if\\(count>30\\) print \"Line \" line \": \" func \" \\(\" count \" lines\\)\"; count=0; func=$0; line=NR} END{if\\(count>30\\) print \"Line \" line \": \" func \" \\(\" count \" lines\\)\"}' routers/*.py services/*.py)",
|
||||
"Bash(grep -rhoE 'api\\(<[^>]+>\\)?\\\\\\(\"\\(/api|/v1\\)[^\"`]*' frontend/src/)",
|
||||
"Bash(sed -E 's/api\\(<[^>]+>\\)?\\\\\\(\"//')",
|
||||
"Bash(grep -rhoE 'api\\(<[^>]+>\\)?\\\\\\(`[^`]*`' frontend/src/)",
|
||||
"Bash(python -c ' *)",
|
||||
"Bash(echo \"tsc exit: $?\")",
|
||||
"WebSearch",
|
||||
"WebFetch(domain:lobehub.com)",
|
||||
"WebFetch(domain:docs.litellm.ai)",
|
||||
"WebFetch(domain:github.com)",
|
||||
"WebFetch(domain:runaihome.com)",
|
||||
"WebFetch(domain:docs.getbifrost.ai)",
|
||||
"WebFetch(domain:thefrontierlab.ai)",
|
||||
"WebFetch(domain:www.glukhov.org)",
|
||||
"WebFetch(domain:cognio.so)",
|
||||
"Bash(ssh -o BatchMode=yes -o ConnectTimeout=10 hitonabi@192.168.178.151 'curl -s http://127.0.0.1:8080/running; echo; echo \"--- after a quick hermes ping, whats running ---\"; curl -s http://127.0.0.1:8080/running')",
|
||||
"Bash(git checkout *)",
|
||||
"Bash(ssh -o BatchMode=yes -o ConnectTimeout=10 hitonabi@192.168.178.151 \"sed -n '46,75p' /etc/llama-swap/config.yaml\")",
|
||||
"Bash(ssh hitonabi@192.168.178.151 \"node --version 2>/dev/null || echo 'no-node'; docker --version 2>/dev/null || echo 'no-docker'; python3 --version; systemctl --user list-units --type=service --state=running 2>/dev/null | head -10\")",
|
||||
"Bash(ssh hitonabi@192.168.178.151 \"systemctl --user cat hermes-gateway.service 2>/dev/null; echo '---'; ls ~/hermes-gateway/ 2>/dev/null || ls ~/mission-control-v2/deploy/ | grep gateway\")",
|
||||
"Bash(ssh hitonabi@192.168.178.151 \"ls ~/.hermes/ && echo '---' && ls ~/.hermes/hermes-agent/ 2>/dev/null && echo '---' && cat ~/.hermes/config/config.yaml 2>/dev/null || cat ~/.hermes/config.yaml 2>/dev/null\")",
|
||||
"Bash(ssh hitonabi@192.168.178.151 \"journalctl --user -u hermes-gateway.service --no-pager -n 20\")",
|
||||
"Bash(ssh hitonabi@192.168.178.151 \"cat ~/.hermes/config.yaml | grep -A5 'api_server\\\\|api_key\\\\|gateway_api\\\\|secret' | head -30\")",
|
||||
"Bash(ssh hitonabi@192.168.178.151 \"grep -r 'gateway_api_key\\\\|api_server\\\\|HERMES_API_KEY\\\\|8642' ~/.hermes/config.yaml ~/.config/environment.d/ 2>/dev/null | head -20; echo '---'; cat ~/.config/environment.d/*.conf 2>/dev/null | grep -i hermes | head -20\")",
|
||||
"Bash(ssh hitonabi@192.168.178.151 \"grep -r 'hermes.*gateway\\\\|gateway.*url\\\\|8642\\\\|GATEWAY' ~/mission-control-v2/backend/ 2>/dev/null | grep -v '.pyc' | head -20\")",
|
||||
"Bash(ssh hitonabi@192.168.178.151 \"grep -A20 'def get_agent_status\\\\|def check\\\\|HERMES_API' ~/mission-control-v2/backend/services/agent.py | head -40\")",
|
||||
"Bash(ssh hitonabi@192.168.178.151 \"cat ~/.hermes/gateway_state.json 2>/dev/null | python3 -c 'import json,sys; d=json.load\\(sys.stdin\\); print\\(list\\(d.keys\\(\\)\\)\\); print\\(d.get\\(\\\\\"api_key\\\\\"\\) or d.get\\(\\\\\"key\\\\\"\\) or \\\\\"no key field\\\\\"\\)' 2>/dev/null; echo '---'; ls ~/.hermes/auth* ~/.hermes/pairing/ 2>/dev/null\")",
|
||||
"Bash(ssh hitonabi@192.168.178.151 \"~/.hermes/hermes-agent/venv/bin/python -m hermes_cli.main gateway status 2>&1 | head -20; echo '---'; curl -s http://127.0.0.1:8642/health 2>/dev/null || curl -s http://127.0.0.1:8642/ 2>/dev/null | head -5\")",
|
||||
"Bash(ssh hitonabi@192.168.178.151 \"cat ~/.hermes/gateway_state.json | python3 -c 'import json,sys; d=json.load\\(sys.stdin\\); print\\(json.dumps\\(d.get\\(\\\\\"platforms\\\\\", {}\\), indent=2\\)\\)'\")",
|
||||
"Bash(ssh hitonabi@192.168.178.151 \"bash ~/mission-control-v2/deploy/deploy.sh\")",
|
||||
"Bash(ssh hitonabi@192.168.178.151 \"~/.hermes/hermes-agent/venv/bin/python -m hermes_cli.main gateway connect --help 2>&1 | head -30\")",
|
||||
"Bash(ssh hitonabi@192.168.178.151 \"~/.hermes/hermes-agent/venv/bin/python -m hermes_cli.main gateway enroll --help 2>&1\")",
|
||||
"Bash(ssh hitonabi@192.168.178.151 \"~/.hermes/hermes-agent/venv/bin/python -m hermes_cli.main gateway setup --help 2>&1\")",
|
||||
"Bash(ssh hitonabi@192.168.178.151 \"~/.hermes/hermes-agent/venv/bin/python -m hermes_cli.main --help 2>&1 | head -40\")",
|
||||
"Bash(ssh hitonabi@192.168.178.151 \"cat ~/.hermes/.env 2>/dev/null; echo '---'; ~/ .hermes/hermes-agent/venv/bin/python -m hermes_cli.main config --help 2>&1 | head -20\")",
|
||||
"Bash(ssh hitonabi@192.168.178.151 \"sed -i 's|# TELEGRAM_BOT_TOKEN=|TELEGRAM_BOT_TOKEN=8908266336:AAElPDmdEJXZ5cs0gUzbAnm4a9glRY18Zac|' ~/.hermes/.env && grep TELEGRAM_BOT_TOKEN ~/.hermes/.env\")",
|
||||
"Bash(ssh hitonabi@192.168.178.151 \"systemctl --user restart hermes-gateway && sleep 3 && journalctl --user -u hermes-gateway --no-pager -n 15\")",
|
||||
"Bash(ssh hitonabi@192.168.178.151 \"sleep 4 && journalctl --user -u hermes-gateway --no-pager -n 20 --since '1 minute ago'\")",
|
||||
"Bash(ssh hitonabi@192.168.178.151 \"sleep 3 && journalctl --user -u hermes-gateway --no-pager -n 25 --since '2 minutes ago'\")",
|
||||
"Bash(ssh hitonabi@192.168.178.151 \"sed -i 's|# TELEGRAM_ALLOWED_USERS=.*|TELEGRAM_ALLOWED_USERS=6150562984|' ~/.hermes/.env && grep TELEGRAM_ALLOWED ~/.hermes/.env\")",
|
||||
"Bash(mkdir -p \"F:\\\\Coding Stuff\\\\mission-control-2\\\\docs\\\\memory\")",
|
||||
"Bash(cp \"C:\\\\Users\\\\TobisPC\\\\.claude\\\\projects\\\\F--Coding-Stuff-mission-control-2\\\\memory\\\\project-stack-state.md\" \"F:\\\\Coding Stuff\\\\mission-control-2\\\\docs\\\\memory\\\\\" && cp \"C:\\\\Users\\\\TobisPC\\\\.claude\\\\projects\\\\F--Coding-Stuff-mission-control-2\\\\memory\\\\project-hermes-setup.md\" \"F:\\\\Coding Stuff\\\\mission-control-2\\\\docs\\\\memory\\\\\")",
|
||||
"Bash(cp \"C:\\\\Users\\\\TobisPC\\\\.claude\\\\projects\\\\F--Coding-Stuff-mission-control-2\\\\memory\\\\project-mc2-architecture.md\" \"F:\\\\Coding Stuff\\\\mission-control-2\\\\docs\\\\memory\\\\\" && cp \"C:\\\\Users\\\\TobisPC\\\\.claude\\\\projects\\\\F--Coding-Stuff-mission-control-2\\\\memory\\\\project-pending-tasks.md\" \"F:\\\\Coding Stuff\\\\mission-control-2\\\\docs\\\\memory\\\\\")",
|
||||
"Bash(ssh -o BatchMode=yes -o ConnectTimeout=8 hitonabi@192.168.178.151 'echo \"=== ENGINE VERSION ===\"; /usr/local/bin/llama-server --version 2>&1 | head -3; echo \"=== LLAMA-SWAP VERSION ===\"; \\(llama-swap --version 2>&1 || /usr/local/bin/llama-swap --version 2>&1 || echo \"no --version\"\\) | head -3; echo \"=== RUNNING MODELS ===\"; curl -s http://127.0.0.1:8080/running 2>&1 | head -c 2000; echo; echo \"=== FREE MEM ===\"; free -g | head -3')",
|
||||
"Bash(ssh -o BatchMode=yes -o ConnectTimeout=8 hitonabi@192.168.178.151 'echo \"=== HERMES CONFIG ===\"; cat ~/.hermes/config.yaml 2>&1 | head -120; echo \"=== HERMES DIR ===\"; ls -la ~/.hermes/ 2>&1 | head -40')",
|
||||
"Bash(ssh -o BatchMode=yes -o ConnectTimeout=8 hitonabi@192.168.178.151 'echo \"=== HERMES CONFIG \\(rest\\) ===\"; sed -n \"120,400p\" ~/.hermes/config.yaml 2>&1; echo \"=== TOOLS COUNT \\(api/all\\) ===\"; cd ~/.hermes 2>/dev/null; \\(hermes tools list 2>&1 | tail -40\\) || echo \"hermes CLI not on PATH\"')",
|
||||
"Bash(ssh -o BatchMode=yes -o ConnectTimeout=8 hitonabi@192.168.178.151 ' *)",
|
||||
"Bash(xargs cat)",
|
||||
"Bash(ssh -o ConnectTimeout=8 -o BatchMode=yes hitonabi@192.168.178.151 'echo \"=== llama-server version ===\"; /usr/local/bin/llama-server --version 2>&1 | head -5; echo \"=== running models ===\"; curl -s http://127.0.0.1:8080/running 2>/dev/null | head -c 2000; echo; echo \"=== free ===\"; free -g')",
|
||||
"Bash(ssh -o ConnectTimeout=8 -o BatchMode=yes hitonabi@192.168.178.151 'echo \"=== config.yaml ===\"; cat /etc/llama-swap/config.yaml; echo; echo \"=== models on disk ===\"; du -sh /srv/models/* 2>/dev/null | sort -h')",
|
||||
"Bash(ssh -o ConnectTimeout=8 -o BatchMode=yes hitonabi@192.168.178.151 'echo \"=== Qwen3.6 MTP dir ===\"; ls -la /srv/models/Qwen3.6-35B-A3B-MTP-GGUF/ 2>/dev/null; echo \"=== drafts dir ===\"; ls -la /srv/models/drafts/ 2>/dev/null; echo \"=== disk free ===\"; df -h /srv 2>/dev/null; echo \"=== gemma remnant ===\"; ls -la /srv/models/gemma-4-26B-A4B-it-GGUF/ 2>/dev/null')",
|
||||
"Bash(ssh -o ConnectTimeout=8 -o BatchMode=yes hitonabi@192.168.178.151 ' *)",
|
||||
"Bash(ssh -o ConnectTimeout=12 -o BatchMode=yes hitonabi@192.168.178.151 ' *)",
|
||||
"WebFetch(domain:unsloth.ai)",
|
||||
"WebFetch(domain:huggingface.co)",
|
||||
"Bash(xargs ls -la)",
|
||||
"Bash(xargs wc -l)",
|
||||
"PowerShell(ssh -o StrictHostKeyChecking=accept-new -o ConnectTimeout=10 -o BatchMode=yes hitonabi@192.168.178.151 \"echo '=== engine ==='; /opt/llamacpp-vulkan/llama-server --version 2>&1 | head -3; echo; echo '=== llama-swap version ==='; /opt/llama-swap/llama-swap --version 2>/dev/null || llama-swap --version 2>/dev/null || ls -la /opt/llama-swap 2>/dev/null | head -5; echo; echo '=== running models ==='; curl -s http://127.0.0.1:8080/running; echo; echo '=== services ==='; for s in llama-swap mc2-backend hermes-api hermes-webui mem0-service voice-service; do printf '%s: ' $s; systemctl is-active $s 2>/dev/null; done; echo '=== uname/mem ==='; uname -r; free -g | head -2\")",
|
||||
"PowerShell(ssh -o ConnectTimeout=10 -o BatchMode=yes hitonabi@192.168.178.151 \"echo '=== ports ==='; ss -tlnp 2>/dev/null | grep -E ':\\(9001|8642|8650|8765|8787|7681|8080|8130\\)'; echo; echo '=== wer serviert 9001? ==='; systemctl list-units --all --type=service --no-pager --no-legend | grep -iE 'gateway|backend|control|api'; echo; echo '=== mem0 venv ==='; systemctl cat mem0-service 2>/dev/null | grep -E 'ExecStart|WorkingDirectory'; echo; echo '=== voice venv ==='; systemctl cat voice-service 2>/dev/null | grep -E 'ExecStart|WorkingDirectory'\")",
|
||||
"PowerShell(ssh -o ConnectTimeout=10 -o BatchMode=yes hitonabi@192.168.178.151 \"echo '=== 9001 Prozess ==='; cat /proc/11257/cmdline 2>/dev/null | tr '\\\\0' ' '; echo; readlink /proc/11257/cwd; ps -o etime= -p 11257; echo; echo '=== mc2 backend unit? ==='; ls /etc/systemd/system/ | grep -iE 'mc2|mission|backend'; echo; echo '=== mem0 pip ==='; MEMPY=/proc/7277/exe; readlink /proc/7277/cwd; readlink /proc/7277/exe; V=\\(dirname \\(readlink /proc/7277/exe\\)\\); echo; /srv/mc2/mem0-venv/bin/pip show mem0ai 2>/dev/null | head -2\")",
|
||||
"PowerShell(ssh -o ConnectTimeout=10 -o BatchMode=yes hitonabi@192.168.178.151 \"echo '=== 9001 Prozess ==='; tr '\\\\0' ' ' < /proc/11257/cmdline; echo; readlink /proc/11257/cwd; ps -o etime= -p 11257; echo '=== mc2 unit? ==='; ls /etc/systemd/system/ | grep -iE 'mc2|mission|backend|llama|hermes|mem0|voice'; echo '=== mem0 exe ==='; readlink /proc/7277/exe; readlink /proc/7277/cwd\")",
|
||||
"PowerShell(ssh -o ConnectTimeout=10 -o BatchMode=yes hitonabi@192.168.178.151 \"echo '=== mission-control.service ==='; systemctl is-enabled mission-control 2>&1; systemctl is-active mission-control 2>&1; grep -E 'ExecStart|WorkingDirectory' /etc/systemd/system/mission-control.service; echo; echo '=== mem0ai version ==='; ls /home/hitonabi/mission-control-v2/mem0_service/ | head; for p in /home/hitonabi/mission-control-v2/mem0_service/.venv/bin/pip /home/hitonabi/mission-control-v2/mem0_service/venv/bin/pip; do [ -x \\\\\"\\\\$p\\\\\" ] && \\\\\"\\\\$p\\\\\" show mem0ai chromadb 2>/dev/null | grep -E 'Name|Version'; done\")",
|
||||
"PowerShell(ssh -o ConnectTimeout=10 -o BatchMode=yes hitonabi@192.168.178.151 \"tr '\\\\0' '\\\\n' < /proc/7277/environ | grep -E 'VIRTUAL_ENV|PATH=' | head -3; tr '\\\\0' ' ' < /proc/7277/cmdline; echo; /home/hitonabi/.local/share/uv/python/cpython-3.12.13-linux-x86_64-gnu/bin/python3.12 -c 'import mem0; print\\(mem0.__version__\\)' 2>&1 | tail -1\")",
|
||||
"PowerShell(ssh -o ConnectTimeout=10 -o BatchMode=yes hitonabi@192.168.178.151 \"/home/hitonabi/.mem0/venv/bin/pip show mem0ai 2>/dev/null | grep -E 'Name|Version'; /home/hitonabi/.mem0/venv/bin/pip show chromadb 2>/dev/null | grep Version\")",
|
||||
"PowerShell(ssh -o ConnectTimeout=10 -o BatchMode=yes hitonabi@192.168.178.151 \"ls /home/hitonabi/.mem0/venv/bin/ | head -8; /home/hitonabi/.mem0/venv/bin/python -m pip show mem0ai chromadb 2>&1 | grep -E 'Name:|Version:'\")",
|
||||
"PowerShell(ssh -o ConnectTimeout=10 -o BatchMode=yes hitonabi@192.168.178.151 \"/home/hitonabi/.mem0/venv/bin/python -c 'import importlib.metadata as m; print\\(\\\\\"mem0ai\\\\\", m.version\\(\\\\\"mem0ai\\\\\"\\)\\); print\\(\\\\\"chromadb\\\\\", m.version\\(\\\\\"chromadb\\\\\"\\)\\)' 2>&1\")",
|
||||
"Bash(ssh -o ConnectTimeout=10 -o BatchMode=yes hitonabi@192.168.178.151 '/home/hitonabi/.mem0/venv/bin/python -c \"import importlib.metadata as m; print\\(\\\\\"mem0ai\\\\\", m.version\\(\\\\\"mem0ai\\\\\"\\)\\); print\\(\\\\\"chromadb\\\\\", m.version\\(\\\\\"chromadb\\\\\"\\)\\)\"')",
|
||||
"WebFetch(domain:docs.mem0.ai)",
|
||||
"PowerShell(Write-Output '=== Lucy-Prozesse lokal ==='; Get-Process | Where-Object {$_.ProcessName -match 'electron|python|node'} | Select-Object ProcessName, Id, WorkingSet64 | Format-Table; Write-Output '=== Port 8130 \\(Pocket-TTS\\) ==='; Get-NetTCPConnection -LocalPort 8130 -State Listen -ErrorAction SilentlyContinue | Select-Object LocalAddress, OwningProcess; Write-Output '=== GPU ==='; Get-CimInstance Win32_VideoController | Select-Object Name, DriverVersion | Format-Table)",
|
||||
"PowerShell($p = 'F:\\\\Coding Stuff\\\\mission-control-2\\\\client\\\\lucy-tts\\\\ptts-venv\\\\Scripts\\\\python.exe'; if \\(Test-Path $p\\) { & $p -c \"import importlib.metadata as m; [print\\(n, m.version\\(n\\)\\) for n in ['pocket-tts','torch','onnxruntime','fastapi','numpy'] if True]\" 2>&1 } else { Write-Output 'ptts-venv nicht gefunden'; Get-ChildItem 'F:\\\\Coding Stuff\\\\mission-control-2\\\\client\\\\lucy-tts' -Directory | Select-Object Name })",
|
||||
"Bash(ssh -o ConnectTimeout=10 -o BatchMode=yes hitonabi@192.168.178.151 'echo \"=== /v1/models ===\"; curl -s http://127.0.0.1:9001/v1/models | head -c 600; echo; echo \"=== voice metrics ===\"; curl -s http://127.0.0.1:9001/api/voice/metrics | head -c 1200')",
|
||||
"Bash(ssh -o ConnectTimeout=15 -o BatchMode=yes hitonabi@192.168.178.151 'curl -s -o /dev/null -w \"TTFB_chat_lane: %{time_starttransfer}s total: %{time_total}s\\\\n\" -N -X POST http://127.0.0.1:9001/v1/chat/completions -H \"Content-Type: application/json\" -d \"{\\\\\"model\\\\\":\\\\\"chat\\\\\",\\\\\"stream\\\\\":true,\\\\\"max_tokens\\\\\":24,\\\\\"messages\\\\\":[{\\\\\"role\\\\\":\\\\\"user\\\\\",\\\\\"content\\\\\":\\\\\"Sag nur: hallo\\\\\"}]}\"')",
|
||||
"Bash(ssh -o ConnectTimeout=30 -o BatchMode=yes hitonabi@192.168.178.151 'curl -s -N -X POST http://127.0.0.1:9001/v1/chat/completions -H \"Content-Type: application/json\" -d \"{\\\\\"model\\\\\":\\\\\"chat\\\\\",\\\\\"stream\\\\\":true,\\\\\"max_tokens\\\\\":24,\\\\\"messages\\\\\":[{\\\\\"role\\\\\":\\\\\"user\\\\\",\\\\\"content\\\\\":\\\\\"Sag nur: hallo\\\\\"}]}\" | head -c 500')",
|
||||
"Bash(ssh -o ConnectTimeout=60 -o BatchMode=yes hitonabi@192.168.178.151 'echo \"=== routing policy ===\"; curl -s http://127.0.0.1:9001/api/routing | head -c 800; echo; echo \"=== aliases in llama-swap config ===\"; grep -B1 -A3 \"aliases:\" /etc/llama-swap/config.yaml | head -40; echo \"=== direkt hermes ===\"; curl -s -o /dev/null -w \"TTFB: %{time_starttransfer}s\\\\n\" -N -X POST http://127.0.0.1:9001/v1/chat/completions -H \"Content-Type: application/json\" -d \"{\\\\\"model\\\\\":\\\\\"hermes\\\\\",\\\\\"stream\\\\\":true,\\\\\"max_tokens\\\\\":24,\\\\\"messages\\\\\":[{\\\\\"role\\\\\":\\\\\"user\\\\\",\\\\\"content\\\\\":\\\\\"Sag nur: hallo\\\\\"}]}\"')",
|
||||
"Bash(ssh -o ConnectTimeout=60 -o BatchMode=yes hitonabi@192.168.178.151 'curl -s -N -X POST http://127.0.0.1:9001/v1/chat/completions -H \"Content-Type: application/json\" -d \"{\\\\\"model\\\\\":\\\\\"hermes\\\\\",\\\\\"stream\\\\\":true,\\\\\"max_tokens\\\\\":24,\\\\\"messages\\\\\":[{\\\\\"role\\\\\":\\\\\"user\\\\\",\\\\\"content\\\\\":\\\\\"Sag nur: hallo\\\\\"}]}\" | head -c 400; echo; echo \"=== direkt an llama-swap :8080 ===\"; curl -s -o /dev/null -w \"TTFB: %{time_starttransfer}s total: %{time_total}s\\\\n\" -N -X POST http://127.0.0.1:8080/v1/chat/completions -H \"Content-Type: application/json\" -d \"{\\\\\"model\\\\\":\\\\\"hermes\\\\\",\\\\\"stream\\\\\":true,\\\\\"max_tokens\\\\\":24,\\\\\"messages\\\\\":[{\\\\\"role\\\\\":\\\\\"user\\\\\",\\\\\"content\\\\\":\\\\\"Sag nur: hallo\\\\\"}]}\"')",
|
||||
"WebFetch(domain:www.hermes-ai.net)",
|
||||
"WebFetch(domain:releases.electronjs.org)",
|
||||
"WebFetch(domain:dev.to)",
|
||||
"WebFetch(domain:kyuz0.github.io)",
|
||||
"WebFetch(domain:kmarble.dev)"
|
||||
"Bash(ssh -F /dev/null -o IdentitiesOnly=yes -i /c/Users/TobisPC/.ssh/id_rsa hitonabi@192.168.178.151:*)",
|
||||
"Bash(scp -F /dev/null -o IdentitiesOnly=yes -i /c/Users/TobisPC/.ssh/id_rsa:*)"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -1,76 +0,0 @@
|
||||
# Ampel-CI fuer MC2 — auf dieses Multi-Service-Monorepo zugeschnitten (24.07.2026).
|
||||
#
|
||||
# Die universelle Vorlage (deploy/ampel-ci.yml) passt fuer EIN-Service-Repos. MC2 hat
|
||||
# 5 Python-Dienste (backend/voice_service/mem0_service/mcp/client) mit schweren ML-
|
||||
# Abhaengigkeiten (Whisper/TTS/Embeddings) + ein Frontend. "pip install ALLER requirements
|
||||
# + pytest repo-weit" in einem stateless Container ist weder machbar (GB-schwere Wheels,
|
||||
# CUDA) noch aussagekraeftig — die echten Tests sind der Pruefstand (deploy/pruefstand)
|
||||
# und die Integration auf der Box mit LIVE-Diensten/Modellen, nicht isolierte Unit-Tests.
|
||||
#
|
||||
# Darum prueft die MC2-Ampel, was im Container EHRLICH gruen sein kann und trotzdem echte
|
||||
# Fehler faengt: Lint (ruff, Projekt-Politik in ruff.toml) + Import/Syntax (compileall) +
|
||||
# Frontend-Build inkl. TypeScript-Typecheck (tsc). Rot ist ein Ergebnis, kein Aergernis.
|
||||
name: Ampel
|
||||
on: [push, pull_request]
|
||||
|
||||
jobs:
|
||||
ampel:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Ampel — Lint + Import + Frontend-Build (MC2-Zuschnitt)
|
||||
shell: bash
|
||||
run: |
|
||||
# Runner-bash laeuft mit -e; abschalten und JEDEN Fehler selbst werten (rot=1),
|
||||
# am Ende EIN Klartext-Urteil (Lehre Ampel-Lauf #5).
|
||||
set -u +e
|
||||
rot=0
|
||||
|
||||
echo "== Python: Lint (ruff, Projekt-Politik aus ruff.toml) =="
|
||||
python3 -m venv /tmp/ampel-venv && . /tmp/ampel-venv/bin/activate || { echo "❌ venv kaputt"; exit 1; }
|
||||
pip install -q ruff || { echo "❌ ruff-Install kaputt"; exit 1; }
|
||||
ruff check . || rot=1
|
||||
|
||||
echo "== Python: Import/Syntax (compileall) =="
|
||||
python3 -m compileall -q backend voice_service mem0_service mcp client deploy hermes scripts || rot=1
|
||||
|
||||
echo "== Frontend: reproduzierbarer Build (npm ci + tsc + vite) =="
|
||||
if [ -f frontend/package.json ]; then
|
||||
if [ ! -f frontend/package-lock.json ]; then
|
||||
echo "❌ frontend/: kein package-lock.json — Build nicht reproduzierbar."
|
||||
rot=1
|
||||
else
|
||||
( cd frontend && npm ci --no-audit --no-fund && npm run build ) || rot=1
|
||||
|
||||
if [ $rot -eq 0 ]; then
|
||||
echo "== Frontend: Push nach release-dist =="
|
||||
git config --global user.name "Gitea Actions"
|
||||
git config --global user.email "actions@gitea.local"
|
||||
CURRENT_COMMIT=$(git rev-parse HEAD)
|
||||
# Erzeuge (oder hole) den orphan branch 'release-dist'
|
||||
git checkout --orphan release-dist 2>/dev/null || git checkout release-dist
|
||||
git rm -rf . >/dev/null 2>&1 || true
|
||||
|
||||
# Checkout nur frontend/dist vom aktuellen Stand
|
||||
git add -f frontend/dist
|
||||
|
||||
if ! git diff-index --quiet HEAD; then
|
||||
git commit -m "Automatischer Frontend Build ($CURRENT_COMMIT) [skip ci]"
|
||||
# Push to release-dist. Token auth from runner is expected to work for Gitea Actions.
|
||||
git push origin HEAD:release-dist -f
|
||||
else
|
||||
echo "Keine Änderungen am Frontend-Build."
|
||||
fi
|
||||
# Zurück zum originalen Branch für den Rest des Skripts
|
||||
git checkout $CURRENT_COMMIT
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ $rot -ne 0 ]; then
|
||||
echo "❌ AMPEL ROT — nichts heißt ‚fertig', solange das rot ist."
|
||||
else
|
||||
echo "✅ AMPEL GRÜN — Lint + Import + Frontend-Build sauber."
|
||||
fi
|
||||
exit $rot
|
||||
+34
-24
@@ -1,24 +1,34 @@
|
||||
# Python
|
||||
backend/.venv/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
|
||||
# Node / Vite
|
||||
frontend/node_modules/
|
||||
# frontend/dist wird committet (kein Node-Build auf der Box) — siehe deploy/
|
||||
|
||||
# Avatar-VRM (groß + lizenz-/redistributionssensibel) — liegt lokal + auf der Box, nicht in git.
|
||||
# Wird per Direkt-Deploy auf die Box gespielt (dist/avatar.vrm), nicht über git.
|
||||
frontend/public/avatar.vrm
|
||||
frontend/dist/avatar.vrm
|
||||
|
||||
# Env / local
|
||||
*.env
|
||||
.DS_Store
|
||||
|
||||
# Box-Recon-/Scratch-Skripte (lokale Diagnose, nicht fürs Repo)
|
||||
box_recon*
|
||||
gemma_swap*
|
||||
|
||||
# TypeScript-Inkrementalcache (reines Build-Artefakt, maschinenabhängig)
|
||||
frontend/tsconfig.tsbuildinfo
|
||||
# Python
|
||||
backend/.venv/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
|
||||
# Node / Vite
|
||||
frontend/node_modules/
|
||||
# frontend/dist wird committet (kein Node-Build auf der Box) — siehe deploy/
|
||||
|
||||
# Env / local
|
||||
*.env
|
||||
.DS_Store
|
||||
|
||||
# Box-Recon-/Scratch-Skripte (lokale Diagnose, nicht fürs Repo)
|
||||
box_recon*
|
||||
gemma_swap*
|
||||
|
||||
# TypeScript-Inkrementalcache (reines Build-Artefakt, maschinenabhängig)
|
||||
frontend/tsconfig.tsbuildinfo
|
||||
|
||||
# Lokale Claude-Code-Einstellungen (enthielten bis 24.09.2026 ein Token) und Worktree-Ordner
|
||||
.claude/settings.local.json
|
||||
.claude/worktrees/
|
||||
|
||||
# Caches und lokale Umgebungen, egal in welchem Ordner
|
||||
.ruff_cache/
|
||||
.pytest_cache/
|
||||
.venv/
|
||||
node_modules/
|
||||
|
||||
# Sicherungskopien von Hand
|
||||
*.bak
|
||||
*.bak-*
|
||||
*.orig
|
||||
|
||||
@@ -1,60 +1,74 @@
|
||||
# AGENTS.md — Mission Control 2.0
|
||||
|
||||
Projekt-Geschmack für Coding-Agenten (Kilo Code, Claude Code lesen diese Datei).
|
||||
Kurz gehalten, nur die Wahrheiten, die man sonst schmerzhaft lernt. Details: `README.md`, `docs/`.
|
||||
|
||||
## Was das ist
|
||||
Lokaler Local-AI-Stack für die Box (Bosgame M5, Strix Halo, Vulkan/RADV). Schichten:
|
||||
Engine (llama-swap) · Builtin-Routing-Gateway (`model: auto`) · MC2 (FastAPI + React/shadcn) ·
|
||||
**Hermes-Agent (das autonome Gehirn "Lucy")**. Backend Python (Box läuft Python 3.14), Frontend Vite/React/shadcn/Tailwind.
|
||||
|
||||
**Hardware-Spezifikationen der Box (WICHTIG für alle Agenten):**
|
||||
- **System:** Bosgame M5 (AMD Strix Halo APU)
|
||||
- **Arbeitsspeicher (RAM/VRAM):** 128 GB Shared Memory (ca. 122.7 GB nutzbar).
|
||||
- **Inference-Limit:** Modelle im GGUF-Format dürfen maximal ca. 100-110 GB groß sein (entspricht ca. 150B Parametern bei Q4_K_M). Größere Modelle (wie 200B+ oder 2T Parameter) **können lokal nicht ausgeführt werden** und sind auszuschließen, es sei denn, es handelt sich um stark quantisierte MoEs.
|
||||
**Gedächtnis & Dienste:** Hermes ist das autonome Agenten-Gehirn („Lucy"). Mem0 läuft als dedizierter semantischer Langzeit-Gedächtnis-Sidecar (`mem0-service` auf `:8765`) und wird von Hermes via MCP (`mcp_memory.py`) eingebunden. Governor, Zed-Workflows und alte Mittelschichten sind komplett gelöscht.
|
||||
|
||||
## Sprache (nicht verhandelbar)
|
||||
- **Alles User-Sichtbare ist Deutsch:** UI-Texte, Fehlermeldungen, Update-/Radar-Meldungen,
|
||||
Skills, LLM-Summaries, Commit-Messages, Code-Kommentare.
|
||||
- **Hermes' INTERNE System-Prompts bleiben Englisch** (fremde Software, wir forken sie nicht).
|
||||
Antwort-Sprache ≠ Prompt-Sprache — Deutsch kommt aus SOUL.md, nicht aus den Tool-Prompts.
|
||||
|
||||
## Build & Deploy (die häufigste Falle)
|
||||
- **`frontend/dist` WIRD committet.** Auf der Box läuft KEIN Node-Build; das Backend liefert die
|
||||
gebauten Assets direkt aus. Nach jeder Frontend-Änderung: `cd frontend && npm run build`, dann
|
||||
**das neue `frontend/dist` mit-committen**. Vergessen = Box zeigt alten Stand.
|
||||
(Ausnahme: `frontend/dist/avatar.vrm` ist bewusst nicht in git — siehe `.gitignore`.)
|
||||
- **Deploy macht `git reset --hard origin/main`** (`deploy/deploy.sh`). Heißt: **`main` muss vor
|
||||
dem Deploy auf Gitea liegen**, und uncommittete Box-Änderungen gehen verloren (Absicht).
|
||||
- **Nie direkt auf `main` arbeiten.** Immer Branch (`wartung/...`), Gate grün, dann Merge/Deploy.
|
||||
|
||||
## Agentic IDE & Vibe Coding
|
||||
- **Zero Middle-Layers:** Coding passiert zu 100% lokal auf dem Dev-PC in der **OpenCode Desktop IDE**.
|
||||
- Es gibt keinen Zed-Workflow, keine OpenCode-CLI-Mittelschicht und keinen Governor mehr.
|
||||
- Der Agent in OpenCode Desktop nutzt via MCP (`.agents/mcp_config.json`) die API der Box (`:9001/v1`), um autonom Projekte zu bauen.
|
||||
- **Mix-Ansatz beim Testen:** Der Agent testet lokal. Vor dem Push muss er prüfen, ob das Gitea-`VERIFY`-Skript fehlerfrei durchläuft.
|
||||
|
||||
## Zeit & Umgebung
|
||||
- **Box = Ubuntu, läuft in `Europe/Berlin`** (seit 03.07.2026; vorher UTC). Dev-PC = Windows.
|
||||
Naive/lokale Zeiten immer über `MC_LOCAL_TZ` (= `Europe/Berlin`) auflösen, nie `datetime.now()` ohne TZ annehmen
|
||||
(siehe `backend/services/reminders.py`).
|
||||
|
||||
## Backend-Konventionen
|
||||
- Router unter `backend/routers/` (`APIRouter(prefix="/api")`), Logik in `backend/services/`
|
||||
(Single Source of Truth — Router bleiben dünn). Beispiel-Lehre: Restart-Allowlist lebt NUR in
|
||||
`services.maintenance` (System-Dienste via `sudo -n`, User-Dienste via `systemctl --user`);
|
||||
keine zweite Allowlist in einem Router duplizieren.
|
||||
- **Gate vor Commit:** `python -m py_compile <geänderte .py>` muss durchlaufen.
|
||||
- Wartung ist **sudo-frei** gedacht (systemctl --user). Wo doch sudo nötig ist (llama-swap =
|
||||
System-Dienst), sauber über die NOPASSWD-Whitelist / `password_required`-Rückgabe, nie hart failen.
|
||||
- Lokal (Windows) müssen Box-Shell-Befehle **harmlos fehlschlagen** statt zu crashen.
|
||||
|
||||
## Grenzen (Verdikt, eingehalten)
|
||||
- **Hermes-Quellcode nie selbst patchen** (Fork verboten). Config/Deps ja, Code-Umbau nein.
|
||||
- **Security-Config** (approvals, Tokens, ufw, command_allowlist) **nie ohne explizites User-Ja.**
|
||||
- Alles reversibel halten: Branch + Backup + Pin.
|
||||
|
||||
## Gitea
|
||||
Remote = `Hitonabi/mission-control-v2`. Auth ist flatterhaft → **Push mit Retry**, nur über
|
||||
PowerShell/GCM. Neue Repos per API anlegen. Kein GitHub.
|
||||
# AGENTS.md — Homelab Orchestrator (vormals Mission Control 2.0)
|
||||
|
||||
Projekt-Geschmack für Coding-Agenten (Kilo Code, Claude Code lesen diese Datei).
|
||||
Kurz gehalten, nur die Wahrheiten, die man sonst schmerzhaft lernt. Details: `README.md`, `docs/`.
|
||||
|
||||
## Was das ist
|
||||
Lokaler Local-AI-Stack für die Box (Bosgame M5, Strix Halo, Vulkan/RADV). Schichten:
|
||||
Engine (llama-swap) · Builtin-Routing-Gateway (`model: auto`) · MC2 (FastAPI + React/shadcn) ·
|
||||
**Hermes-Agent (das autonome Gehirn "Lucy")**. Backend Python (Box läuft Python 3.14), Frontend Vite/React/shadcn/Tailwind.
|
||||
|
||||
**Hardware-Spezifikationen der Box (WICHTIG für alle Agenten):**
|
||||
- **System:** Bosgame M5 (AMD Strix Halo APU)
|
||||
- **Arbeitsspeicher (RAM/VRAM):** 128 GB Shared Memory (ca. 122.7 GB nutzbar).
|
||||
- **Inference-Limit:** Modelle im GGUF-Format dürfen maximal ca. 100-110 GB groß sein (entspricht ca. 150B Parametern bei Q4_K_M). Größere Modelle (wie 200B+ oder 2T Parameter) **können lokal nicht ausgeführt werden** und sind auszuschließen, es sei denn, es handelt sich um stark quantisierte MoEs.
|
||||
**Gedächtnis & Dienste:** Hermes ist das autonome Agenten-Gehirn („Lucy") **und die alleinige Gedächtnis-Wahrheit** — es führt sein Gedächtnis selbst. Der frühere Sidecar auf `:8765` samt Memory-MCP-Server und MC2-Memory-Plugin wurde am 07.08.2026 abgelöst und am 27.08.2026 restlos ausgebaut (`docs/wissen/VERDIKTE.md`): MC2 hat **keine** `/api/memory`-Routen mehr, nichts darf mehr dorthin greifen. Governor, Zed-Workflows und alte Mittelschichten sind komplett gelöscht.
|
||||
|
||||
## Sprache (nicht verhandelbar)
|
||||
- **Alles User-Sichtbare ist Deutsch:** UI-Texte, Fehlermeldungen, Update-/Radar-Meldungen,
|
||||
Skills, LLM-Summaries, Commit-Messages, Code-Kommentare.
|
||||
- **Hermes' INTERNE System-Prompts bleiben Englisch** (fremde Software, wir forken sie nicht).
|
||||
Antwort-Sprache ≠ Prompt-Sprache — Deutsch kommt aus SOUL.md, nicht aus den Tool-Prompts.
|
||||
|
||||
## Build & Deploy (die häufigste Falle)
|
||||
- **`frontend/dist` WIRD committet.** Auf der Box läuft KEIN Node-Build; das Backend liefert die
|
||||
gebauten Assets direkt aus. Nach jeder Frontend-Änderung: `cd frontend && npm run build`, dann
|
||||
**das neue `frontend/dist` mit-committen**. Vergessen = Box zeigt alten Stand.
|
||||
- **Deploy** (`bash ~/mission-control-v2/deploy/deploy.sh` auf der Box) holt `origin/main` per
|
||||
fast-forward — **`main` muss vorher auf Gitea liegen**, im Box-Checkout nie von Hand ändern.
|
||||
Zweistufig seit 24.09.2026: Stufe 1 holt den Stand und startet die NEUE Fassung des Skripts,
|
||||
Stufe 2 prüft (pruefen.sh), spielt Units/Cron-Skripte/Skills aus, startet neu und prüft nach.
|
||||
Scheitert etwas: automatisch zurück auf den alten Stand + dringende Meldung. Laufende Update-Jobs
|
||||
verschieben den Deploy. Die llama-swap-Config wird nur überschrieben, wenn sich der Repo-Abzug im
|
||||
selben Deploy geändert hat (sonst gewinnt die lebende Datei, die MC2 selbst schreibt).
|
||||
- **Nie direkt auf `main` arbeiten.** Immer Branch (`wartung/...`), Gate grün, dann Merge/Deploy.
|
||||
|
||||
## Agentic IDE & Vibe Coding
|
||||
- **Zero Middle-Layers:** Coding passiert zu 100% lokal auf dem Dev-PC in **OpenChamber** (mit eigener OpenCode-CLI).
|
||||
- Es gibt keinen Zed-Workflow, keine OpenCode-CLI-Mittelschicht und keinen Governor mehr.
|
||||
- Der Agent in OpenChamber nutzt die Modelle der Box über `http://192.168.178.151:9001/v1` (Provider `aibox`, nur Rollen-Aliase; Vorlage der PC-Config: `deploy/opencode-config/`).
|
||||
- **Prüftor:** `bash deploy/pruefen.sh` (Shell-/Python-Syntax, ruff, Importe, pytest) muss vor jedem Push grün sein.
|
||||
Der Deploy auf der Box führt es vor dem Umschalten noch einmal aus; rot = automatisch zurück auf den alten Stand.
|
||||
|
||||
## Zeit & Umgebung
|
||||
- **Box = Ubuntu, läuft in `Europe/Berlin`** (seit 03.07.2026; vorher UTC). Dev-PC = Windows.
|
||||
Naive/lokale Zeiten immer über `MC_LOCAL_TZ` (= `Europe/Berlin`) auflösen, nie `datetime.now()` ohne TZ annehmen
|
||||
(siehe `backend/services/reminders.py`).
|
||||
|
||||
## Backend-Konventionen
|
||||
- Router unter `backend/routers/` (`APIRouter(prefix="/api")`), Logik in `backend/services/`
|
||||
(Single Source of Truth — Router bleiben dünn). Beispiel-Lehre: Restart-Allowlist lebt NUR in
|
||||
`services.maintenance` (System-Dienste via `sudo -n`, User-Dienste via `systemctl --user`);
|
||||
keine zweite Allowlist in einem Router duplizieren.
|
||||
- **Gate vor Commit:** `bash deploy/pruefen.sh` (enthält `py_compile`, `ruff check .` und die Tests).
|
||||
- Wartung ist **sudo-frei** gedacht (systemctl --user). Wo doch sudo nötig ist (llama-swap =
|
||||
System-Dienst), sauber über die NOPASSWD-Whitelist / `password_required`-Rückgabe, nie hart failen.
|
||||
- ‼️ **Die feingranularen sudoers.d-Regeln sind faktisch wirkungslos.** In `/etc/sudoers` steht
|
||||
`hitonabi ALL=(ALL) NOPASSWD: ALL` — der Nutzer, unter dem alle MC2-Dienste laufen, darf ohnehin
|
||||
alles passwortlos. `sudoers.d/mc2-autonomie` und `sudoers.d/mission-control` dokumentieren also,
|
||||
was gebraucht *würde*, schränken aber nichts ein. Das ist eine bewusste Entscheidung für die
|
||||
Single-User-Appliance; verlasse dich beim Bauen nicht darauf, dass eine Whitelist dich bremst.
|
||||
Wer das wirklich härten will, kommt um einen eigenen Service-User nicht herum — die Pauschalzeile
|
||||
einfach zu ziehen, bricht OS-Update, Config-Sync und den wöchentlichen Auto-Neustart still.
|
||||
(Geprüft 27.08.2026; die doppelte Zeile wurde damals entfernt, Sicherung `/root/sudoers.bak-*`.)
|
||||
- Lokal (Windows) müssen Box-Shell-Befehle **harmlos fehlschlagen** statt zu crashen.
|
||||
|
||||
## Grenzen (Verdikt, eingehalten)
|
||||
- **Hermes-Quellcode nie selbst patchen** (Fork verboten). Config/Deps ja, Code-Umbau nein.
|
||||
- **Security-Config** (approvals, Tokens, ufw, command_allowlist) **nie ohne explizites User-Ja.**
|
||||
- Alles reversibel halten: Branch + Backup + Pin.
|
||||
|
||||
## Gitea
|
||||
Remote = `ssh://gitea@192.168.178.153:2222/Hitonabi/mission-control-v2.git` (SSH, Push aus
|
||||
Git-Bash geht). Bei Aussetzern **Push mit Retry**. Neue Repos per API anlegen. Kein GitHub.
|
||||
Ungemergtes, das weg soll, erst als Tag `archiv/<name>` sichern, dann löschen.
|
||||
|
||||
@@ -1,145 +1,90 @@
|
||||
# Mission Control 2.0
|
||||
# Homelab Orchestrator
|
||||
|
||||
Steuerzentrale des lokalen KI-Stacks auf dem **Bosgame M5** (AMD Ryzen AI MAX+ 395 „Strix Halo",
|
||||
122 GB Unified Memory, **keine dedizierte GPU** — llama.cpp über **Vulkan/RADV**). Läuft live als
|
||||
sudo-freier systemd-User-Dienst und ist auf **Autonomie** ausgelegt: Die Box wartet sich selbst,
|
||||
prüft sich selbst und meldet sich, wenn etwas nicht stimmt.
|
||||
Ein Steuerpult fürs Heimnetz, das sich selbst wartet. Zwei Bereiche, eine Oberfläche:
|
||||
|
||||
> **100 % lokal.** Kein Cloud-Modell, kein externer Dienst im Datenpfad.
|
||||
- **Box-Wart** betreut die KI-Box (Bosgame M5, AMD Ryzen AI MAX+ 395, 128 GB gemeinsamer Speicher, llama.cpp über
|
||||
Vulkan): hält sie aktuell, passt auf sie auf und sucht bessere Modelle. Live seit 23.09.2026.
|
||||
- **Homelab** soll den Proxmox-PC mit seinen Containern und Arcane (Docker) betreuen: offene Updates zeigen, per
|
||||
Knopf einspielen, danach die Erreichbarkeit prüfen. In Arbeit ([Fahrplan](docs/wissen/OFFENE-FAEDEN.md)).
|
||||
|
||||
## Die drei Bahnen
|
||||
Dieselbe Codebasis läuft als zwei Instanzen: auf der KI-Box (Rolle `box`) und später als Container auf dem
|
||||
Proxmox-PC (Rolle `homelab`); beide prüfen sich gegenseitig. Repo, Dienste und Dateien tragen noch den alten Namen
|
||||
„Mission Control 2" (`mission-control-v2`, `mission-control-2`, `mc2-*`). Alles läuft lokal, kein Cloud-Modell im
|
||||
Datenpfad.
|
||||
|
||||
| Bahn | Was sie tut | Wo |
|
||||
## Oberfläche
|
||||
|
||||
Im Heimnetz `http://192.168.178.151:9001`, am PC oder am Handy.
|
||||
|
||||
| Seite | Inhalt |
|
||||
|---|---|
|
||||
| **Start** | Warnlampen, Instrumente (Speicher, Temperatur, Platte, Laufzeit), Checkliste mit den Hinweisen des Wächters und ihren Knöpfen, Flugplan (was lief, was kommt), Radar-Kasten |
|
||||
| **Updates** | Bausteine Betriebssystem, Motor (llama.cpp), llama-swap und Hermes; Verlauf der Update-Läufe; Sicherungen |
|
||||
| **Modelle** | Speicher, Rollen Hirn und Coder, wer die Modelle nutzt, Modell-Radar, Aufräumen, Modelle selbst suchen |
|
||||
| oben rechts bzw. „Mehr" | Hermes-Dashboard, Dienste und Protokolle, Einstellungen |
|
||||
|
||||
Anleitung für den Alltag: [docs/BEDIENUNG.md](docs/BEDIENUNG.md).
|
||||
|
||||
## Was von allein läuft
|
||||
|
||||
- **Wächter** (jede Minute): Dienste, Timer, Hermes-Jobs, Kern, Platte, festgehaltene Updates und, sobald
|
||||
eingerichtet, die Partner-Instanz. Abgestürzte Dienste startet er selbst neu (höchstens 2× je Stunde), Rotes meldet
|
||||
er per Telegram.
|
||||
- **Updates** sonntags 04:30: llama-swap → Motor → Hermes, danach Prüfung; rot → zurück und festhalten. Die Box
|
||||
startet nur sonntags zwischen 04:00 und 06:59 neu.
|
||||
- **Modell-Radar** nachts 00:30–02:30: sucht Kandidaten für Hirn und Coder und testet höchstens einen pro Woche;
|
||||
übernommen wird nur per Knopf.
|
||||
- **Sicherung** täglich gegen 03:30, 14 Stück, Kopie auf dem Proxmox-Host.
|
||||
- **Meldungen** gehen an Telegram und in Lucys Briefkasten; nachts gesammelt, um 07:00 als eine Morgenmeldung,
|
||||
Dringendes sofort.
|
||||
|
||||
Alle Zeiten: [docs/wissen/STACK.md](docs/wissen/STACK.md), Abschnitt „Automatik".
|
||||
|
||||
## Dienste und Ports (KI-Box)
|
||||
|
||||
| Port | Unit | Aufgabe |
|
||||
|---|---|---|
|
||||
| **Steuerzentrale** | Modelle, Routing, Wartung, Gedächtnis, Ideen-Queue, Auftragsbuch | MC2 `:9001` |
|
||||
| **Coding** | Agentic IDE (z.B. OpenCode Desktop) auf Dev-PC via lokaler API + MCP | 100% lokal |
|
||||
| **Lucy** | Sprach-Companion mit 3D-Avatar, Augen und Ohren | eigenes Repo `Hitonabi/lucy` |
|
||||
| `9001` | `mission-control-2` | Oberfläche, `/api`, `/v1` für Lucy und OpenChamber, Hermes-Dashboard unter `/hermes-ui/` |
|
||||
| `9010` (nur lokal) | `mc2-gateway` | `/v1`-Datenpfad: `model: auto`, Bild-Weiche |
|
||||
| – | `mc2-steward` | Wächter und Re-Warm |
|
||||
| `8080` | `llama-swap` (System-Dienst) | Modell-Router, startet je Modell einen `llama-server` |
|
||||
| `8642` | `hermes-gateway` | Hermes Agent „Lucy", Telegram |
|
||||
| `9119` | `hermes-builtin-ui` | Hermes-Dashboard |
|
||||
| `8021` (nur lokal) | `lucy-stimme` | Lucys Stimme |
|
||||
| `8650` (nur lokal) | `voice-service` | Spracherkennung (schläft seit 24.09.) |
|
||||
| `7682` (nur lokal) | `box-console` | Web-Konsole (schläft seit 24.09.) |
|
||||
|
||||
Lucy holt ihr Hirn direkt über das MC2-Gateway (`:9010/v1`) mit nativer Bildweiche und Voice-Streaming.
|
||||
|
||||
## Ports & Dienste
|
||||
|
||||
| Port | Dienst | Erreichbar |
|
||||
|---|---|---|
|
||||
| `9001` | **MC2** (FastAPI + React) — Cockpit, API, Konsole | LAN |
|
||||
| `8080` | **llama-swap** — Modell-Router | LAN |
|
||||
| `9010` | `mc2-gateway` — `/v1`-Datenpfad (`model: auto`, Bild-Weiche) | loopback |
|
||||
| `8642` · `9119` | Hermes-Gateway · Hermes-Web-UI | loopback |
|
||||
| `8765` · `8650` | Mem0-Sidecar · Voice-Sidecar (STT/TTS) | loopback |
|
||||
| `7682` | ttyd — Box-Konsole, same-origin unter `/console/` | loopback |
|
||||
|
||||
Units in [`deploy/`](deploy/): `mission-control-2` · `mc2-gateway` · `mc2-steward` ·
|
||||
`mem0-service` · `voice-service` · `box-console` · `hermes-builtin-ui` + Timer für Backup,
|
||||
Auto-Update, Self-Smoke und Bagatell-Annahme. `llama-swap` läuft als System-Unit.
|
||||
|
||||
## Die Bau-Pipeline
|
||||
|
||||
```
|
||||
Idee in MC2 → Gitea-Repo (mit CI-Ampel + VERIFY) → in Agentic IDE bauen → Ampel → main
|
||||
└── autonomes Vibe Coding via OpenCode Desktop + MCP
|
||||
```
|
||||
|
||||
Jedes neue Repo wird von [`deploy/gitea-repo-create.sh`](deploy/gitea-repo-create.sh) **mit beiden
|
||||
Wächtern geboren**:
|
||||
|
||||
- **`.gitea/workflows/ci.yml`** — die Außen-Prüfung nach dem Push (Gitea Actions)
|
||||
- **`VERIFY`** — die Innen-Prüfung: eine Zeile, wie man das Projekt testet. Die Agentic IDE
|
||||
führt sie im Mix-Ansatz vor jedem Push aus, um Code-Fehler ("Quality Gap") abzufangen.
|
||||
Keine `VERIFY`-Datei = Prüf-Tor aus.
|
||||
|
||||
## Modelle & Rollen
|
||||
|
||||
Gemessen auf der Box (Stand: August 2026, Vulkan b10502):
|
||||
|
||||
| Rolle | Modell | Tempo | Aufgabe |
|
||||
|---|---|---|---|
|
||||
| `coder` | Qwen3-Coder-Next (80B-A3B) | **51,5 t/s** · Prefill **754 t/s** | Plant und baut — Kopf der Coding-Mannschaft (131k Kontext) |
|
||||
| `hermes` / `fast` | Qwen3.6-35B-A3B | **69,6–90 t/s** | Lucys Hirn **und** Sucher-Subagent. Immer warm |
|
||||
| `debugger` / `doctor` | Muse-Glimmer-30B | **40–50 t/s** (DFlash) | Runtime-Debugger & Fehler-Diagnostiker (Multimodal) |
|
||||
| `kritiker` | Devstral-Small-2-24B | **15,0 t/s** · Prefill **265–318 t/s** | Liest gegen — bewusst **fremde Modellfamilie** (Mistral statt Qwen, 16k Deckel) |
|
||||
| `vision` | Qwen3-VL-30B-A3B | auf Abruf | Lucys Augen (On-Demand) |
|
||||
| `scout` | GLM-4.6V-Flash | auf Abruf | Schneller Vision- und Tool-Allrounder |
|
||||
| `heavy` | gpt-oss-120b | nur nachts | Chef-Gutachter (4:30 Uhr). **Nie tagsüber** — verdrängt das warme Set |
|
||||
| `dense-planer` | Qwen3.8-27B | **12,7 t/s** (On-Demand) | Dichtes 27B-Modell für tiefes Reasoning & 262k Kontext |
|
||||
| `embed` · `reranker` | Qwen3 0.6B | immer warm | Gedächtnis + Feinsortierung |
|
||||
|
||||
‼️ **Der Kritiker ist bewusst auf 16k Kontext gedeckelt.** Devstral ist ein *dichtes* Modell —
|
||||
auf dieser bandbreitenbegrenzten Box bricht sein Prefill mit wachsendem Kontext ein (bei 32k
|
||||
gemessene 63 t/s ≈ **9 Minuten nur zum Lesen**). Mit dem 16k-Deckel bleibt der schlimmste Fall
|
||||
je Review unter einer Minute. Deshalb ist er der **Gegenleser für Etappen**, nicht der Coder —
|
||||
als Coder ist er auf dieser Box disqualifiziert (siehe VERDIKTE).
|
||||
|
||||
‼️ **Speicher-Regel:** `Warm-Set + größtes On-Demand-Modell ≤ ~115 GB`. Die ko-residente Gruppe
|
||||
(`groups.brains` in der llama-swap-Config) muss **`persistent: false`** sein — sonst räumt
|
||||
llama-swap vor einem großen Modell nicht ab und der Kernel schießt Prozesse ab. Details und
|
||||
Messwerte: [`docs/wissen/VERDIKTE.md`](docs/wissen/VERDIKTE.md).
|
||||
|
||||
## Qualitäts-Tore
|
||||
|
||||
1. **Prüf-Tor** — `VERIFY` nach jeder Etappe; rot → der Agent (OpenCode Desktop) repariert lokal nach.
|
||||
2. **CI-Ampel** — Lint (ruff) · Import/Syntax (compileall) · Frontend-Build. Läuft in Gitea.
|
||||
|
||||
Gemeinsame Lehre dahinter: **Wissen lebt in Datei + git, nicht im schrumpfenden Chat-Kontext.**
|
||||
Kontext-Komprimierung löscht still die Regeln mit.
|
||||
|
||||
## Selbsterhaltung
|
||||
|
||||
- **Health-Wächter** (`sentry`) + **Warm-Set-Wächter** (`warmer`, per Env abschaltbar)
|
||||
- **Updates mit Fangnetz** — Backup → Update → Postcheck → bei Fehler **Auto-Rollback + Pin**
|
||||
- **Melde-Briefkasten** (`/api/voice/announce`) — alles, was die Box von sich aus sagen will;
|
||||
Lucy pollt ihn und spricht es. Absender `loop` = Coding-Ereignisse
|
||||
- **Gedächtnis** — Mem0-Sidecar, auto-lernend, semantisch, mit Auto-Dedupe
|
||||
- Tägliche Self-Smokes, Zeitmaschine (Snapshot + Ein-Klick-Restore), Chronik der autonomen Taten
|
||||
|
||||
## API (Auswahl)
|
||||
|
||||
`health · models/* · discover · fit · groups · routing/* · system/* · maintenance/* · connect ·
|
||||
memory/* · agent/* · ideen/* · auftragsbuch/* · chronik · eigenleben · wissen ·
|
||||
zeitmaschine/* · reminders/* · voice/* · events (SSE)`
|
||||
OpenAI-kompatibel: `/v1/chat/completions`, `/v1/completions`. MCP-Server: [`mcp/`](mcp/).
|
||||
Dazu die Timer `mc2-radar`, `mc2-backup`, `mc2-morgenmeldung`, `mc2-autoupdate` und `projekte-sync`. Die Unit-Dateien
|
||||
liegen in [`deploy/`](deploy/).
|
||||
|
||||
## Entwickeln
|
||||
|
||||
```bash
|
||||
# Backend
|
||||
# Backend lokal auf :9000 (Windows)
|
||||
cd backend
|
||||
python -m venv .venv && .venv/Scripts/python -m pip install -r requirements.txt # Windows
|
||||
python -m venv .venv
|
||||
.venv/Scripts/python -m pip install -r requirements.txt -r requirements-dev.txt
|
||||
.venv/Scripts/python -m uvicorn app:app --port 9000
|
||||
|
||||
# Frontend (Dev, proxyt /api → :9000)
|
||||
cd frontend && npm install && npm run dev # http://localhost:5173
|
||||
|
||||
# Frontend (Build → wird vom Backend ausgeliefert)
|
||||
cd frontend && npm run build # → frontend/dist
|
||||
# Frontend (Vite leitet /api an :9000 weiter; MC_API_TARGET=http://192.168.178.151:9001 zeigt auf die Box)
|
||||
cd frontend && npm ci && npm run dev
|
||||
```
|
||||
|
||||
`frontend/dist` **wird mitcommittet** — die Box hat kein Node; Deploy ist ein `git pull` plus
|
||||
Dienst-Neustart. Vor jedem Commit lohnt der Ampel-Vorlauf: `ruff check .` und `npm run build`.
|
||||
Vor jedem Push: `bash deploy/pruefen.sh` (Shell- und Python-Syntax, `ruff check .`, Importe, `pytest backend/tests`);
|
||||
bei Frontend-Änderungen zusätzlich `npm run lint`, `npm test` und `npm run build` in `frontend/`, und das neue
|
||||
`frontend/dist` mitcommitten, denn die Box baut kein Frontend. Gearbeitet wird auf einem Branch; `main` liegt auf
|
||||
Gitea (`ssh://gitea@192.168.178.153:2222/Hitonabi/mission-control-v2.git`), danach auf der Box
|
||||
`bash ~/mission-control-v2/deploy/deploy.sh`. Regeln für Agenten: [AGENTS.md](AGENTS.md).
|
||||
|
||||
## Env-Vars (Auswahl)
|
||||
## Doku
|
||||
|
||||
| Variable | Default | Zweck |
|
||||
| Dokument | Für | Inhalt |
|
||||
|---|---|---|
|
||||
| `MC_PORT` | `9000` | MC-Backend-Port (**die Unit auf der Box setzt `9001`**) |
|
||||
| `MC_LLAMA_SWAP_URL` | `http://127.0.0.1:8080` | Engine/Router |
|
||||
| `MC_CONFIG_PATH` | `/etc/llama-swap/config.yaml` | llama-swap-Config |
|
||||
| `MC_V1_UPSTREAM` | – | gesetzt → `/v1` geht an `mc2-gateway` (`:9010`) statt in-process |
|
||||
|
||||
| `MC_ENGINE_PATH` | `/opt/llamacpp-vulkan` | aktive Engine (Vulkan-Build) |
|
||||
| `MC_REWARM_ENABLED` | `1` | Warm-Set-Wächter (auf der Box bewusst `0`) |
|
||||
| `MC_DRAFTS_DIR` · `MC_SPEC_TYPE` | `$MODELS/drafts` · `draft-simple` | Spekulatives Dekodieren |
|
||||
|
||||
|
||||
|
||||
## Weiterlesen
|
||||
|
||||
| Dokument | Inhalt |
|
||||
|---|---|
|
||||
| [`docs/BEDIENUNG.md`](docs/BEDIENUNG.md) | Wie man MC2 benutzt |
|
||||
| [`docs/RUNBOOK.md`](docs/RUNBOOK.md) · [`docs/DISASTER_RECOVERY.md`](docs/DISASTER_RECOVERY.md) | Betrieb, Störungen, Wiederherstellung |
|
||||
| [`docs/HERMES_SETUP.md`](docs/HERMES_SETUP.md) | Hermes-Agent, Profile, Crons |
|
||||
| [`docs/AUFTRAGSBUCH.md`](docs/AUFTRAGSBUCH.md) | Vorschlags-Inbox und das Ein-Klick-Gate |
|
||||
| [`docs/wissen/VERDIKTE.md`](docs/wissen/VERDIKTE.md) | **Finale Technik-Entscheide — nicht neu aufrollen** |
|
||||
| [`docs/wissen/FALLEN.md`](docs/wissen/FALLEN.md) · [`docs/wissen/OFFENE-FAEDEN.md`](docs/wissen/OFFENE-FAEDEN.md) | Stolpersteine · offene Punkte |
|
||||
|
||||
| [`AGENTS.md`](AGENTS.md) | Arbeitsregeln für Agenten in diesem Repo |
|
||||
| [docs/README.md](docs/README.md) | alle | Index, Lesereihenfolge, Pflegeregeln |
|
||||
| [docs/BEDIENUNG.md](docs/BEDIENUNG.md) | User | Oberfläche und Telegram-Nachrichten |
|
||||
| [docs/ARCHITEKTUR.md](docs/ARCHITEKTUR.md) | Agenten, Technik | Prozesse, Rollen, Datenwege, wer was schreibt |
|
||||
| [docs/BETRIEB.md](docs/BETRIEB.md) | Agenten, Technik | Handgriffe, Meldungen, Wächter, Deploy, Sicherung, Notfall |
|
||||
| [docs/UPDATES.md](docs/UPDATES.md) | Agenten, Technik | Sonntags-Update, Festhalten, Freigeben |
|
||||
| [docs/RADAR.md](docs/RADAR.md) | Agenten, Technik | Modell-Radar, Stack-Radar, Prüfstand |
|
||||
| [docs/WIEDERAUFBAU.md](docs/WIEDERAUFBAU.md) | Technik | die KI-Box von null aufbauen |
|
||||
| [docs/wissen/](docs/wissen/) | Agenten | Stand, Verdikte, Fallen, Arbeitsweise, offene Fäden |
|
||||
|
||||
+95
-70
@@ -1,9 +1,16 @@
|
||||
"""
|
||||
Mission Control 2.0 — dünner FastAPI-Einstieg.
|
||||
Homelab Orchestrator (vormals Mission Control 2.0) — dünner FastAPI-Einstieg.
|
||||
|
||||
Hängt die Router ein, liefert (in Prod) das gebaute React-Frontend aus und
|
||||
setzt eine no-cache-Middleware. Im Dev läuft das Frontend über den Vite-Dev-
|
||||
Server (proxyt /api hierher), daher CORS für localhost offen.
|
||||
|
||||
Zwei Rollen, derselbe Code (MC_ROLLE, kern/einstellungen.py, seit 24.09.2026):
|
||||
box — die KI-Box: Box-Wart (Updater, Wächter, Modell-Radar) plus die Schnittstellen,
|
||||
die Lucy-Desktop, OpenChamber und Hermes brauchen (Sprache, /v1, MCP-Werkzeuge)
|
||||
homelab — der Proxmox-PC: der Homelab-Teil
|
||||
Beide liefern dieselbe Oberfläche aus und reichen den Bereich der anderen Instanz unter
|
||||
/api/partner/… durch.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
@@ -15,34 +22,17 @@ from typing import Any
|
||||
|
||||
import httpx
|
||||
from config import FRONTEND_DIST, V1_UPSTREAM, VERSION
|
||||
from fastapi import FastAPI
|
||||
from fastapi import FastAPI, HTTPException
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import FileResponse
|
||||
from fastapi.responses import FileResponse, JSONResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from routers import (
|
||||
agent,
|
||||
auftragsbuch,
|
||||
chronik,
|
||||
connect,
|
||||
console,
|
||||
events,
|
||||
gateway_proxy,
|
||||
health,
|
||||
hermes_ui,
|
||||
ideen,
|
||||
maintenance,
|
||||
models,
|
||||
routing,
|
||||
skills,
|
||||
system,
|
||||
voice,
|
||||
wissen,
|
||||
zeitmaschine,
|
||||
)
|
||||
from routers import reminders as reminders_router
|
||||
from services import metrics_history, reminders, sentry, warmer
|
||||
from kern.einstellungen import einstellungen
|
||||
from routers import health, partner
|
||||
from services.herkunft import erlaubt
|
||||
from starlette.requests import Request
|
||||
|
||||
ROLLE = einstellungen().rolle
|
||||
|
||||
# Zentrales Logging — Level via MC_LOG_LEVEL (INFO default). Eine Konfiguration
|
||||
# für alle Module (logging.getLogger(__name__)).
|
||||
logging.basicConfig(
|
||||
@@ -52,10 +42,11 @@ logging.basicConfig(
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
|
||||
"""Hintergrund-Tasks an den App-Lebenszyklus binden: Re-Warm-Wächter fürs Agent-Hirn
|
||||
+ Health-Wächter (meldet Ausfälle/Erholung in den Lucy-Briefkasten und auf Telegram)."""
|
||||
def _box_hintergrund() -> list[asyncio.Task[Any]]:
|
||||
"""Hintergrund-Tasks der KI-Box. Der Wächter läuft NICHT hier, sondern im mc2-steward
|
||||
(eigener Prozess, steward.py) — ein totes MC2 könnte sich sonst nicht selbst melden."""
|
||||
from services import reminders, warmer
|
||||
|
||||
tasks: list[asyncio.Task[Any]] = []
|
||||
if warmer.ENABLED:
|
||||
tasks.append(asyncio.create_task(warmer.rewarm_loop()))
|
||||
@@ -63,11 +54,24 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
|
||||
"Hirn-Re-Warm-Wächter aktiv (Intervall %ss, Hirn dynamisch aus Hermes-Config)",
|
||||
warmer.INTERVAL,
|
||||
)
|
||||
if sentry.ENABLED:
|
||||
tasks.append(asyncio.create_task(sentry.sentry_loop()))
|
||||
tasks.append(asyncio.create_task(reminders.reminders_loop()))
|
||||
# 24-h-Metrik-Verlauf (Cockpit-Zeitachse): 10-s-Sampler, Ringpuffer, persistiert.
|
||||
tasks.append(asyncio.create_task(metrics_history.sampler_loop()))
|
||||
# Probelauf (neue Fassung neben der laufenden, Box-Wart-Umbau 09/2026): Erinnerungen
|
||||
# gehören dem echten MC2 — zwei Schreiber würden Erinnerungen doppelt feuern.
|
||||
if os.environ.get("MC_PROBELAUF", "") != "1":
|
||||
tasks.append(asyncio.create_task(reminders.reminders_loop()))
|
||||
else:
|
||||
log.info("Probelauf: Erinnerungen bleiben beim echten MC2.")
|
||||
return tasks
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
|
||||
"""Hintergrund-Tasks an den App-Lebenszyklus binden."""
|
||||
# Aufträge (Updates, Downloads) laufen als eigene Einheiten weiter, während MC2 neu startet —
|
||||
# hier werden sie wieder beobachtet. Ein Probelauf daneben fasst sie nicht an.
|
||||
if os.environ.get("MC_PROBELAUF", "") != "1":
|
||||
from services import jobengine
|
||||
await asyncio.to_thread(jobengine.wiederaufnehmen)
|
||||
tasks = _box_hintergrund() if ROLLE == "box" else []
|
||||
# Geteilter HTTP-Client zur lokalen Engine: Keep-Alive/Connection-Pooling statt neuer Client
|
||||
# pro /v1-Anfrage (spart Sockets/TIME_WAIT unter parallelen Agent-Strömen von Zed/Kilo).
|
||||
app.state.gw_client = httpx.AsyncClient(
|
||||
@@ -82,7 +86,7 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
|
||||
await app.state.gw_client.aclose()
|
||||
|
||||
|
||||
app = FastAPI(title="Mission Control 2.0", version=VERSION, lifespan=lifespan)
|
||||
app = FastAPI(title="Homelab Orchestrator", version=VERSION, lifespan=lifespan)
|
||||
|
||||
# Dev: Vite-Dev-Server (5173) ruft das Backend per /api auf.
|
||||
app.add_middleware(
|
||||
@@ -93,6 +97,15 @@ app.add_middleware(
|
||||
)
|
||||
|
||||
|
||||
# Herkunftsprüfung (24.09.2026, User-Entscheid: keine Anmeldung): Knöpfe fremder Webseiten werden
|
||||
# abgelehnt, Skripte, Desktop-Lucy und /v1 bleiben unberührt. Regeln in services/herkunft.py.
|
||||
@app.middleware("http")
|
||||
async def herkunft(request: Request, call_next: Callable[[Request], Awaitable[httpx.Response]]):
|
||||
if not erlaubt(request.method, request.url.path, request.headers.get("origin"), request.headers.get("host")):
|
||||
return JSONResponse({"detail": "Diese Aktion geht nur von der Orchestrator-Seite selbst aus."}, status_code=403)
|
||||
return await call_next(request)
|
||||
|
||||
|
||||
@app.middleware("http")
|
||||
async def no_cache(
|
||||
request: Request, call_next: Callable[[Request], Awaitable[httpx.Response]]
|
||||
@@ -103,42 +116,50 @@ async def no_cache(
|
||||
return resp
|
||||
|
||||
|
||||
app.include_router(health.router)
|
||||
app.include_router(models.router)
|
||||
app.include_router(routing.router)
|
||||
app.include_router(system.router)
|
||||
def _box_router(app: FastAPI) -> None:
|
||||
"""Die Schnittstellen der KI-Box (Box-Wart und alles, was Lucy, OpenChamber und Hermes brauchen)."""
|
||||
from routers import (
|
||||
boxwart,
|
||||
events,
|
||||
gateway_proxy,
|
||||
hermes_ui,
|
||||
maintenance,
|
||||
models,
|
||||
radar,
|
||||
routing,
|
||||
system,
|
||||
voice,
|
||||
zeitmaschine,
|
||||
)
|
||||
from routers import reminders as reminders_router
|
||||
|
||||
app.include_router(connect.router)
|
||||
app.include_router(agent.router)
|
||||
app.include_router(
|
||||
voice.router
|
||||
) # Sprache: STT/TTS-Proxy + Hermes-Agent-Chat (Voice-Tab)
|
||||
app.include_router(
|
||||
reminders_router.router
|
||||
) # Erinnerungen/Routinen (A3) — feuern in den Briefkasten
|
||||
# /v1-Datenpfad: Nach dem Gateway-Auszug (UMBAU v3 P1) läuft der eigentliche Gateway als
|
||||
# eigener Prozess (mc2-gateway, Loopback :9010) — MC2 reicht /v1 dann nur roh durch, damit
|
||||
# LAN-Clients (IDE-Lane) weiter über :9001 kommen. Ohne MC_V1_UPSTREAM (vor dem ersten
|
||||
# Deploy der neuen Unit / nach Rollback) bedient MC2 /v1 wie bisher selbst.
|
||||
if V1_UPSTREAM:
|
||||
from routers import gateway_forward
|
||||
app.include_router(gateway_forward.router) # dünner Roh-Weiterleiter → mc2-gateway
|
||||
else:
|
||||
app.include_router(gateway_proxy.router) # OpenAI-kompatibler /v1-Gateway (model:auto)
|
||||
app.include_router(maintenance.router)
|
||||
app.include_router(auftragsbuch.router) # Vorschlags-Inbox (Mensch-Gate als Klick)
|
||||
app.include_router(ideen.router) # Ideen-Queue (natives Hermes-Kanban) — Tür der Zentrale
|
||||
app.include_router(chronik.router) # Timeline der autonomen Taten (Announce-Store)
|
||||
app.include_router(events.router) # SSE-Eventstrom /api/events (P3a) — Invalidation-Bus
|
||||
app.include_router(wissen.router) # Wissens-Vault (Traum-Notizen) read-only
|
||||
app.include_router(zeitmaschine.router) # Snapshots ansehen + Ein-Klick-Restore (detached)
|
||||
app.include_router(skills.router) # Skills & Jobs Dashboard
|
||||
app.include_router(
|
||||
console.router
|
||||
) # Box-Konsole (ttyd) same-origin durchreichen — VOR dem SPA-Catch-all
|
||||
app.include_router(
|
||||
hermes_ui.router
|
||||
) # Eingebaute Hermes-Web-GUI (hermes serve) same-origin unter /hermes-ui/ — VOR dem SPA-Catch-all
|
||||
app.include_router(boxwart.router) # Cockpit: Start, Hinweise, Modell-Nutzung, Zeitplan
|
||||
app.include_router(radar.router) # Modell-Radar: Kandidaten, Nachttests, Übernehmen/Verwerfen
|
||||
app.include_router(models.router)
|
||||
app.include_router(routing.router)
|
||||
app.include_router(system.router)
|
||||
app.include_router(voice.router) # Sprache: STT/TTS-Proxy + Hermes-Agent-Chat
|
||||
app.include_router(reminders_router.router) # Erinnerungen/Routinen — feuern in den Briefkasten
|
||||
# /v1-Datenpfad: Nach dem Gateway-Auszug (UMBAU v3 P1) läuft der eigentliche Gateway als
|
||||
# eigener Prozess (mc2-gateway, Loopback :9010) — MC2 reicht /v1 dann nur roh durch, damit
|
||||
# LAN-Clients (IDE-Lane) weiter über :9001 kommen. Ohne MC_V1_UPSTREAM (vor dem ersten
|
||||
# Deploy der neuen Unit / nach Rollback) bedient MC2 /v1 wie bisher selbst.
|
||||
if V1_UPSTREAM:
|
||||
from routers import gateway_forward
|
||||
app.include_router(gateway_forward.router) # dünner Roh-Weiterleiter → mc2-gateway
|
||||
else:
|
||||
app.include_router(gateway_proxy.router) # OpenAI-kompatibler /v1-Gateway (model:auto)
|
||||
app.include_router(maintenance.router)
|
||||
app.include_router(events.router) # SSE-Strom /api/stream — Messwerte + Invalidation
|
||||
app.include_router(zeitmaschine.router) # Sicherungen ansehen + zurückspielen (detached)
|
||||
# Eingebaute Hermes-Web-GUI (hermes dashboard) same-origin unter /hermes-ui/ — VOR dem SPA-Catch-all
|
||||
app.include_router(hermes_ui.router)
|
||||
|
||||
|
||||
app.include_router(health.router)
|
||||
app.include_router(partner.router) # zweite Instanz: Lebenszeichen + Durchreiche
|
||||
if ROLLE == "box":
|
||||
_box_router(app)
|
||||
|
||||
|
||||
# Prod: gebautes Frontend ausliefern (falls vorhanden). SPA-Fallback auf index.html.
|
||||
@@ -149,6 +170,10 @@ if FRONTEND_DIST.exists():
|
||||
|
||||
@app.get("/{full_path:path}")
|
||||
def spa(full_path: str):
|
||||
# Unbekannte /api-Pfade sind ein echter 404, keine Startseite: Sonst bekommt die
|
||||
# Oberfläche HTML statt JSON und stürzt ab (erster Probelauf 23.09., /api/radar).
|
||||
if full_path == "api" or full_path.startswith("api/"):
|
||||
raise HTTPException(status_code=404, detail="Diese Schnittstelle gibt es nicht.")
|
||||
# Datei direkt aus FRONTEND_DIST ausliefern (manifest.webmanifest, favicon.ico, …) — aber
|
||||
# NUR innerhalb des dist-Ordners: Pfad auflösen + Traversal (../, absolute Pfade) hart raus.
|
||||
try:
|
||||
|
||||
+125
-136
@@ -1,136 +1,125 @@
|
||||
"""
|
||||
Zentrale Konfiguration für Mission Control 2.0.
|
||||
|
||||
Eine Quelle der Wahrheit für Pfade, URLs und Defaults — alles über Env-Vars
|
||||
überschreibbar. Bewusst schlank: MC 2.0 ist ein Glue-Cockpit, das vorhandene
|
||||
Dienste (llama-swap, LiteLLM-Gateway, Hermes) steuert, statt sie nachzubauen.
|
||||
"""
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
from ruamel.yaml import YAML
|
||||
|
||||
# --- Engine (llama-swap) -----------------------------------------------------
|
||||
LLAMA_SWAP_URL = os.environ.get("MC_LLAMA_SWAP_URL", "http://127.0.0.1:8080").rstrip("/")
|
||||
CONFIG_PATH = Path(os.environ.get("MC_CONFIG_PATH", "/etc/llama-swap/config.yaml"))
|
||||
MODELS_DIR = Path(os.environ.get("MC_MODELS_DIR", "/srv/models"))
|
||||
# Cache der Modell-Entdeckung ("aktuell beste Modelle", live von HuggingFace).
|
||||
# Persistent neben den Modellen (übersteht Deploys). TTL = Frische-Fenster.
|
||||
DISCOVER_CACHE_PATH = Path(os.environ.get("MC_DISCOVER_CACHE", str(MODELS_DIR / "mc2-discover.json")))
|
||||
DISCOVER_TTL = int(os.environ.get("MC_DISCOVER_TTL", "43200")) # 12 h
|
||||
# Geteiltes Gedächtnis (SQLite, WAL). Persistent neben den Modellen.
|
||||
# Hinweis: nur noch für die einmalige Mem0-Migration relevant — das aktive Gedächtnis
|
||||
# liegt jetzt in Mem0/Chroma hinter dem Sidecar (siehe MEM0_SERVICE_URL).
|
||||
MEMORY_DB = Path(os.environ.get("MC_MEMORY_DB", str(MODELS_DIR / "mc2-memory.db")))
|
||||
# Mem0-Sidecar (auto-lernendes, semantisches Gedächtnis). Läuft im ~/.mem0/venv (Python 3.12),
|
||||
# weil mem0+chromadb unter dem 3.14-Backend nicht laufen. MC2 spricht ihn lokal per HTTP an.
|
||||
MEM0_SERVICE_URL = os.environ.get("MC_MEM0_SERVICE_URL", "http://127.0.0.1:8765").rstrip("/")
|
||||
# Befehl-Vorlage für llama-swap: {model}=GGUF-Pfad, {ctx}=Kontext, ${PORT} bleibt stehen.
|
||||
# Hinweis: --prompt-cache/--prompt-cache-all sind llama-CLI-Flags, NICHT llama-server —
|
||||
# llama-server lehnt sie ab ("invalid argument") und startet dann nicht. Prompt-Caching
|
||||
# macht llama-server ohnehin automatisch pro Slot (KV-Reuse).
|
||||
_DEFAULT_CMD_TEMPLATE = (
|
||||
"llama-server -m {model} --host 127.0.0.1 --port ${PORT} "
|
||||
"-c {ctx} -ngl 999 -fa on --no-mmap"
|
||||
)
|
||||
CMD_TEMPLATE = os.environ.get("MC_CMD_TEMPLATE", _DEFAULT_CMD_TEMPLATE)
|
||||
if "{model}" not in CMD_TEMPLATE:
|
||||
CMD_TEMPLATE = _DEFAULT_CMD_TEMPLATE
|
||||
DEFAULT_TTL = int(os.environ.get("MC_DEFAULT_TTL", "300"))
|
||||
# Verzeichnis mit Draft-Modellen für Speculative Decoding. Beim Hinzufügen eines
|
||||
# fast/coder-Modells wird hieraus automatisch ein **vocab-kompatibler** Draft gewählt
|
||||
# (Vocab-Check via services.gguf_meta; ein inkompatibler Draft lässt llama.cpp scheitern).
|
||||
DRAFTS_DIR = Path(os.environ.get("MC_DRAFTS_DIR", str(MODELS_DIR / "drafts")))
|
||||
# Optionaler expliziter Default-Draft (leer = Auto-Erkennung aus DRAFTS_DIR). Wird nur
|
||||
# verwendet, wenn er zum Ziel-Modell vocab-kompatibel ist. (Früher fix qwen2.5 → entfernt,
|
||||
# weil das mit neueren Vocabs wie Qwen3.6 inkompatibel ist und Spec stillschweigend brach.)
|
||||
SPEC_DRAFT_MODEL_PATH = os.environ.get("MC_SPEC_DRAFT_MODEL", "")
|
||||
# Speculative-Decoding-Typ (llama.cpp dieser Generation braucht --spec-type zusätzlich
|
||||
# zu --spec-draft-model, sonst ist Spec inaktiv).
|
||||
SPEC_TYPE = os.environ.get("MC_SPEC_TYPE", "draft-simple")
|
||||
# MTP-Speculative-Decoding (Multi-Token-Prediction): manche Modelle bringen einen eigenen
|
||||
# MTP-Kopf mit (z.B. gemma-4 → arch 'gemma4-assistant', Datei 'mtp-*.gguf'). Der wird mit
|
||||
# `--model-draft <mtp.gguf> --spec-type draft-mtp --spec-draft-n-max N` geladen (NICHT
|
||||
# --spec-draft-model/draft-simple). 1,5–2× Durchsatz bei null Qualitätsverlust.
|
||||
SPEC_DRAFT_N_MAX = int(os.environ.get("MC_SPEC_DRAFT_N_MAX", "4"))
|
||||
# Env für HuggingFace-Downloads: XET deaktivieren (Hänger bei ~6 MB, siehe v1-Gotcha).
|
||||
HF_DOWNLOAD_ENV = {"HF_HUB_DISABLE_XET": "1"}
|
||||
|
||||
# --- Routing-Gateway (builtin in MC2, model: auto) ---------------------------
|
||||
# MC2 IST der Gateway (services/gateway.py + routers/gateway_proxy.py). KEIN externer
|
||||
# LiteLLM-Dienst (scheitert auf Python 3.14). Daher keine Gateway-Config-Datei mehr.
|
||||
GATEWAY_URL = os.environ.get("MC_GATEWAY_URL", f"http://127.0.0.1:{os.environ.get('MC_PORT', '9000')}").rstrip("/")
|
||||
# Gateway-Auszug (UMBAU v3 P1): Ist MC_V1_UPSTREAM gesetzt (Unit-Env, z. B.
|
||||
# http://127.0.0.1:9010), bedient der eigenständige mc2-gateway-Prozess den /v1-Pfad
|
||||
# und MC2 reicht /v1 nur noch roh durch (routers/gateway_forward.py). Leer = altes
|
||||
# Verhalten, MC2 bedient /v1 selbst — die Zeile aus der Unit nehmen ist der Rollback.
|
||||
V1_UPSTREAM = os.environ.get("MC_V1_UPSTREAM", "").rstrip("/")
|
||||
|
||||
# --- Hermes Agent (eigener Dienst auf der Box) -------------------------------
|
||||
# Gateway (OpenAI-API des Agenten) + interaktives Web-Terminal (ttyd → `hermes chat`).
|
||||
HERMES_API_URL = os.environ.get("HERMES_API_URL", "http://127.0.0.1:8642").rstrip("/")
|
||||
# API-Key der Hermes-`api_server`-Plattform (~/.hermes/.env: API_SERVER_KEY). Nötig für
|
||||
# /v1/chat/completions (Voice-Pipeline) — Bearer-Auth, sonst 401. Derselbe volle Agent
|
||||
# (Tools + geteiltes Mem0) wie CLI/Telegram, nur über HTTP.
|
||||
def _read_hermes_env(key: str) -> str:
|
||||
"""Liest einen Schlüssel aus ~/.hermes/.env (Fallback, falls nicht in der Prozess-Env).
|
||||
Der MC2-Dienst erbt die Hermes-Secrets sonst nicht."""
|
||||
try:
|
||||
env_path = Path(os.path.expanduser(os.environ.get("HERMES_HOME", "~/.hermes"))) / ".env"
|
||||
for line in env_path.read_text(encoding="utf-8").splitlines():
|
||||
line = line.strip()
|
||||
if line.startswith(f"{key}="):
|
||||
return line.split("=", 1)[1].strip().strip('"').strip("'")
|
||||
except OSError:
|
||||
pass
|
||||
return ""
|
||||
|
||||
|
||||
HERMES_API_KEY = (
|
||||
os.environ.get("HERMES_API_KEY")
|
||||
or os.environ.get("API_SERVER_KEY")
|
||||
or _read_hermes_env("API_SERVER_KEY")
|
||||
)
|
||||
# Modellfeld im OpenAI-Request; die api_server-Plattform nutzt ihr konfiguriertes Hirn,
|
||||
# das Feld ist i.d.R. kosmetisch. Override via Env, falls die Plattform strikt prüft.
|
||||
HERMES_API_MODEL = os.environ.get("HERMES_API_MODEL", "hermes")
|
||||
|
||||
# --- Voice-Sidecar (STT faster-whisper + TTS Piper/Chatterbox) ---------------
|
||||
# Eigenes Python-3.12-venv (~/.voice/venv), analog Mem0-Sidecar. MC2 proxyt nach außen.
|
||||
VOICE_SERVICE_URL = os.environ.get("MC_VOICE_SERVICE_URL", "http://127.0.0.1:8650").rstrip("/")
|
||||
# Box-Konsole: ttyd-Web-Terminal (echte Login-Shell). Bindet NUR an Loopback
|
||||
# (127.0.0.1:7682, base-path /console) und wird von MC2 über den ohnehin offenen Port 9001
|
||||
# rückwärts geproxyt (routers/console.py → same-origin /console/). So braucht die Konsole
|
||||
# KEINE eigene Firewall-Freigabe (Port 7682 ist von außen dicht) und keinen sudo-Eingriff.
|
||||
# Direkter, SSH-artiger Zugriff, kein Passwort — gleiches LAN-Trust-Modell wie das Dashboard.
|
||||
BOX_CONSOLE_UPSTREAM = os.environ.get("MC_BOX_CONSOLE_UPSTREAM", "http://127.0.0.1:7682").rstrip("/")
|
||||
# Öffentlicher, gleicher-Ursprung-Pfad, unter dem MC2 die Konsole ausliefert (iframe-Ziel).
|
||||
BOX_CONSOLE_PATH = "/console/"
|
||||
# Eingebaute Hermes-Web-GUI (`hermes serve`/`dashboard` — die richtige Agent-Oberfläche mit
|
||||
# Threads/Tool-Calls, die auch die Electron-Desktop-App umhüllt). Bindet NUR an Loopback
|
||||
# (127.0.0.1:9119, kein Login — LAN-Trust wie Terminal/Konsole) und wird von MC2 same-origin unter
|
||||
# /hermes-ui/ durchgereicht (routers/hermes_ui.py). Anders als das ttyd-Terminal ist es eine volle
|
||||
# SPA → das Bundle wird mit Vite-base=/hermes-ui/ gebaut, damit Assets/API/WS unter dem Präfix
|
||||
# liegen; der Proxy streift /hermes-ui ab. Kein eigener Firewall-Port nötig.
|
||||
HERMES_BUILTIN_UI_UPSTREAM = os.environ.get("MC_HERMES_BUILTIN_UI_UPSTREAM", "http://127.0.0.1:9119").rstrip("/")
|
||||
HERMES_BUILTIN_UI_PATH = "/hermes-ui/"
|
||||
# GitHub-Repo für Update-Checks.
|
||||
HERMES_AGENT_REPO = os.environ.get("MC_HERMES_AGENT_REPO", "NousResearch/hermes-agent")
|
||||
HERMES_HOME = Path(os.path.expanduser(os.environ.get("HERMES_HOME", "~/.hermes")))
|
||||
# PC Executor — läuft auf dem Windows-PC, erreichbar über LAN.
|
||||
PC_EXECUTOR_URL = os.environ.get("MC_PC_EXECUTOR_URL", "http://192.168.178.98:7777").rstrip("/")
|
||||
|
||||
# --- Server ------------------------------------------------------------------
|
||||
HOST = os.environ.get("MC_HOST", "0.0.0.0")
|
||||
PORT = int(os.environ.get("MC_PORT", "9000"))
|
||||
# Gebautes React-Frontend (frontend/dist). In Prod liefert FastAPI es statisch aus;
|
||||
# im Dev läuft der Vite-Dev-Server separat und proxyt /api hierher.
|
||||
FRONTEND_DIST = Path(os.environ.get("MC_FRONTEND_DIST", str(Path(__file__).resolve().parent.parent / "frontend" / "dist")))
|
||||
|
||||
# Version (Phase 0 — Greenfield-Skeleton).
|
||||
VERSION = "2.0.0-w8"
|
||||
|
||||
# Gemeinsame YAML-Instanz (preserve_quotes hält Kommentare/Quotes in config.yaml).
|
||||
yaml = YAML()
|
||||
yaml.preserve_quotes = True
|
||||
"""
|
||||
Zentrale Konfiguration für Mission Control 2.0.
|
||||
|
||||
Eine Quelle der Wahrheit für Pfade, URLs und Defaults — alles über Env-Vars
|
||||
überschreibbar. Bewusst schlank: MC 2.0 ist ein Glue-Cockpit, das vorhandene
|
||||
Dienste (llama-swap, LiteLLM-Gateway, Hermes) steuert, statt sie nachzubauen.
|
||||
"""
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
# --- Engine (llama-swap) -----------------------------------------------------
|
||||
LLAMA_SWAP_URL = os.environ.get("MC_LLAMA_SWAP_URL", "http://127.0.0.1:8080").rstrip("/")
|
||||
CONFIG_PATH = Path(os.environ.get("MC_CONFIG_PATH", "/etc/llama-swap/config.yaml"))
|
||||
MODELS_DIR = Path(os.environ.get("MC_MODELS_DIR", "/srv/models"))
|
||||
# Cache der Modell-Entdeckung ("aktuell beste Modelle", live von HuggingFace).
|
||||
# Persistent neben den Modellen (übersteht Deploys). TTL = Frische-Fenster.
|
||||
DISCOVER_CACHE_PATH = Path(os.environ.get("MC_DISCOVER_CACHE", str(MODELS_DIR / "mc2-discover.json")))
|
||||
DISCOVER_TTL = int(os.environ.get("MC_DISCOVER_TTL", "43200")) # 12 h
|
||||
# Gedächtnis: MC2 hält KEINS mehr. Bis August 2026 lief hier erst eine eigene SQLite-DB,
|
||||
# dann ein semantischer Sidecar auf :8765 — beides ist abgelöst, der Port ist tot.
|
||||
# Einzige Gedächtnis-Wahrheit ist jetzt Hermes selbst (HERMES_API_URL, siehe unten).
|
||||
# Befehl-Vorlage für llama-swap: {model}=GGUF-Pfad, {ctx}=Kontext, ${PORT} bleibt stehen.
|
||||
# Hinweis: --prompt-cache/--prompt-cache-all sind llama-CLI-Flags, NICHT llama-server —
|
||||
# llama-server lehnt sie ab ("invalid argument") und startet dann nicht. Prompt-Caching
|
||||
# macht llama-server ohnehin automatisch pro Slot (KV-Reuse).
|
||||
# --load-mode none = das frühere --no-mmap; das alte Flag ist seit llama.cpp b10936 weg
|
||||
# ("invalid argument", jedes Modell stirbt beim Start — gelernt 13./17.09.2026).
|
||||
_DEFAULT_CMD_TEMPLATE = (
|
||||
"llama-server -m {model} --host 127.0.0.1 --port ${PORT} "
|
||||
"-c {ctx} -ngl 999 -fa on --load-mode none"
|
||||
)
|
||||
CMD_TEMPLATE = os.environ.get("MC_CMD_TEMPLATE", _DEFAULT_CMD_TEMPLATE)
|
||||
if "{model}" not in CMD_TEMPLATE:
|
||||
CMD_TEMPLATE = _DEFAULT_CMD_TEMPLATE
|
||||
DEFAULT_TTL = int(os.environ.get("MC_DEFAULT_TTL", "300"))
|
||||
# Verzeichnis mit Draft-Modellen für Speculative Decoding. Beim Hinzufügen eines
|
||||
# fast/coder-Modells wird hieraus automatisch ein **vocab-kompatibler** Draft gewählt
|
||||
# (Vocab-Check via services.gguf_meta; ein inkompatibler Draft lässt llama.cpp scheitern).
|
||||
DRAFTS_DIR = Path(os.environ.get("MC_DRAFTS_DIR", str(MODELS_DIR / "drafts")))
|
||||
# Optionaler expliziter Default-Draft (leer = Auto-Erkennung aus DRAFTS_DIR). Wird nur
|
||||
# verwendet, wenn er zum Ziel-Modell vocab-kompatibel ist. (Früher fix qwen2.5 → entfernt,
|
||||
# weil das mit neueren Vocabs wie Qwen3.6 inkompatibel ist und Spec stillschweigend brach.)
|
||||
SPEC_DRAFT_MODEL_PATH = os.environ.get("MC_SPEC_DRAFT_MODEL", "")
|
||||
# Speculative-Decoding-Typ (llama.cpp dieser Generation braucht --spec-type zusätzlich
|
||||
# zu --spec-draft-model, sonst ist Spec inaktiv).
|
||||
SPEC_TYPE = os.environ.get("MC_SPEC_TYPE", "draft-simple")
|
||||
# MTP-Speculative-Decoding (Multi-Token-Prediction): manche Modelle bringen einen eigenen
|
||||
# MTP-Kopf mit (z.B. gemma-4 → arch 'gemma4-assistant', Datei 'mtp-*.gguf'). Der wird mit
|
||||
# `--model-draft <mtp.gguf> --spec-type draft-mtp --spec-draft-n-max N` geladen (NICHT
|
||||
# --spec-draft-model/draft-simple). 1,5–2× Durchsatz bei null Qualitätsverlust.
|
||||
SPEC_DRAFT_N_MAX = int(os.environ.get("MC_SPEC_DRAFT_N_MAX", "4"))
|
||||
# Env für HuggingFace-Downloads: XET deaktivieren (Hänger bei ~6 MB, siehe v1-Gotcha).
|
||||
HF_DOWNLOAD_ENV = {"HF_HUB_DISABLE_XET": "1"}
|
||||
|
||||
# --- Routing-Gateway (builtin in MC2, model: auto) ---------------------------
|
||||
# MC2 IST der Gateway (services/gateway.py + routers/gateway_proxy.py). KEIN externer
|
||||
# LiteLLM-Dienst (scheitert auf Python 3.14). Daher keine Gateway-Config-Datei mehr.
|
||||
GATEWAY_URL = os.environ.get("MC_GATEWAY_URL", f"http://127.0.0.1:{os.environ.get('MC_PORT', '9000')}").rstrip("/")
|
||||
# Gateway-Auszug (UMBAU v3 P1): Ist MC_V1_UPSTREAM gesetzt (Unit-Env, z. B.
|
||||
# http://127.0.0.1:9010), bedient der eigenständige mc2-gateway-Prozess den /v1-Pfad
|
||||
# und MC2 reicht /v1 nur noch roh durch (routers/gateway_forward.py). Leer = altes
|
||||
# Verhalten, MC2 bedient /v1 selbst — die Zeile aus der Unit nehmen ist der Rollback.
|
||||
V1_UPSTREAM = os.environ.get("MC_V1_UPSTREAM", "").rstrip("/")
|
||||
|
||||
# --- Hermes Agent (eigener Dienst auf der Box) -------------------------------
|
||||
# Gateway (OpenAI-API des Agenten) + interaktives Web-Terminal (ttyd → `hermes chat`).
|
||||
HERMES_API_URL = os.environ.get("HERMES_API_URL", "http://127.0.0.1:8642").rstrip("/")
|
||||
# API-Key der Hermes-`api_server`-Plattform (~/.hermes/.env: API_SERVER_KEY). Nötig für
|
||||
# /v1/chat/completions (Voice-Pipeline) — Bearer-Auth, sonst 401. Derselbe volle Agent
|
||||
# (Tools + eigenes Gedächtnis) wie CLI/Telegram, nur über HTTP.
|
||||
def _read_hermes_env(key: str) -> str:
|
||||
"""Liest einen Schlüssel aus ~/.hermes/.env (Fallback, falls nicht in der Prozess-Env).
|
||||
Der MC2-Dienst erbt die Hermes-Secrets sonst nicht."""
|
||||
try:
|
||||
env_path = Path(os.path.expanduser(os.environ.get("HERMES_HOME", "~/.hermes"))) / ".env"
|
||||
for line in env_path.read_text(encoding="utf-8").splitlines():
|
||||
line = line.strip()
|
||||
if line.startswith(f"{key}="):
|
||||
return line.split("=", 1)[1].strip().strip('"').strip("'")
|
||||
except OSError:
|
||||
pass
|
||||
return ""
|
||||
|
||||
|
||||
HERMES_API_KEY = (
|
||||
os.environ.get("HERMES_API_KEY")
|
||||
or os.environ.get("API_SERVER_KEY")
|
||||
or _read_hermes_env("API_SERVER_KEY")
|
||||
)
|
||||
# Modellfeld im OpenAI-Request; die api_server-Plattform nutzt ihr konfiguriertes Hirn,
|
||||
# das Feld ist i.d.R. kosmetisch. Override via Env, falls die Plattform strikt prüft.
|
||||
HERMES_API_MODEL = os.environ.get("HERMES_API_MODEL", "hermes")
|
||||
|
||||
# --- Voice-Sidecar (STT faster-whisper + TTS Piper/Chatterbox) ---------------
|
||||
# Eigenes Python-3.12-venv (~/.voice/venv), weil Parakeet/Piper nicht ins 3.14-Backend-venv
|
||||
# passen. MC2 proxyt nach außen.
|
||||
VOICE_SERVICE_URL = os.environ.get("MC_VOICE_SERVICE_URL", "http://127.0.0.1:8650").rstrip("/")
|
||||
# --- Lucys Stimme (lucy-stimme.service, pocket-tts german_24l, Klon aus ref.mp3) -----------
|
||||
# Eigener User-Dienst in ~/.lucy-stimme (nur Loopback :8021). Spricht die Telegram-Sprachnachrichten
|
||||
# UND — seit dem Raphael-Umbau der Desktop-Lucy (04.09.2026) — auch den PC: MC2 reicht ihn unter
|
||||
# /api/lucy/stimme/* ins LAN (routers/voice.py). EINE Stimme für alle Türen. :8650 ist NICHT Lucy.
|
||||
LUCY_STIMME_URL = os.environ.get("MC_LUCY_STIMME_URL", "http://127.0.0.1:8021").rstrip("/")
|
||||
# Eingebaute Hermes-Web-GUI (`hermes serve`/`dashboard` — die richtige Agent-Oberfläche mit
|
||||
# Threads/Tool-Calls, die auch die Electron-Desktop-App umhüllt). ‼️ Sie bindet NICHT auf Loopback:
|
||||
# ein systemd-Drop-in überschreibt `--host 127.0.0.1` mit `0.0.0.0` und setzt dafür ein
|
||||
# Session-Token. Dass sie trotzdem nicht im LAN hängt, liegt allein an ufw (9119 ist nicht
|
||||
# freigegeben — am 27.08.2026 von einem zweiten Rechner aus gegengeprüft). Wer die Firewall
|
||||
# anfasst, öffnet damit auch diese Oberfläche. MC2 erreicht sie über Loopback und reicht sie
|
||||
# same-origin unter
|
||||
# /hermes-ui/ durchgereicht (routers/hermes_ui.py). Anders als das ttyd-Terminal ist es eine volle
|
||||
# SPA → das Bundle wird mit Vite-base=/hermes-ui/ gebaut, damit Assets/API/WS unter dem Präfix
|
||||
# liegen; der Proxy streift /hermes-ui ab. Kein eigener Firewall-Port nötig.
|
||||
HERMES_BUILTIN_UI_UPSTREAM = os.environ.get("MC_HERMES_BUILTIN_UI_UPSTREAM", "http://127.0.0.1:9119").rstrip("/")
|
||||
HERMES_BUILTIN_UI_PATH = "/hermes-ui/"
|
||||
HERMES_HOME = Path(os.path.expanduser(os.environ.get("HERMES_HOME", "~/.hermes")))
|
||||
|
||||
# --- Server ------------------------------------------------------------------
|
||||
PORT = int(os.environ.get("MC_PORT", "9000"))
|
||||
# Gebautes React-Frontend (frontend/dist). In Prod liefert FastAPI es statisch aus;
|
||||
# im Dev läuft der Vite-Dev-Server separat und proxyt /api hierher.
|
||||
FRONTEND_DIST = Path(os.environ.get("MC_FRONTEND_DIST", str(Path(__file__).resolve().parent.parent / "frontend" / "dist")))
|
||||
|
||||
# Version (Phase 0 — Greenfield-Skeleton).
|
||||
VERSION = "2.0.0-w8"
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
"""Kern des Homelab Orchestrators (Phase 2, ab 24.09.2026): was beide Instanzen teilen —
|
||||
Einstellungen, Zeitzone, Partner-Instanz. Neue Module lesen ihre Einstellungen hier; ältere
|
||||
Module ziehen nach und nach um."""
|
||||
@@ -0,0 +1,40 @@
|
||||
"""Zentrale Einstellungen einer Instanz des Homelab Orchestrators.
|
||||
|
||||
Zwei Rollen, derselbe Code (User-Entscheid 24.09.2026, „zwei Instanzen, eine Oberfläche“):
|
||||
box — auf der KI-Box: Box-Wart (Updates, Wächter, Modelle, Radar, Gateway für Lucy)
|
||||
homelab — auf dem Proxmox-PC: Homelab-Teil (Proxmox-Host, Container, Arcane)
|
||||
Beide kennen die jeweils andere Instanz als Partner: die Oberfläche zeigt beide Bereiche, und
|
||||
jede Instanz prüft, ob die andere noch antwortet.
|
||||
"""
|
||||
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
from functools import lru_cache
|
||||
from pathlib import Path
|
||||
|
||||
ROLLEN = ("box", "homelab")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Einstellungen:
|
||||
rolle: str # "box" | "homelab"
|
||||
instanz: str # Anzeigename dieser Instanz
|
||||
daten_dir: Path # Zustandsdateien dieser Instanz
|
||||
partner_url: str # Basis-URL der anderen Instanz, leer = keine
|
||||
partner_name: str # Anzeigename der anderen Instanz
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def einstellungen() -> Einstellungen:
|
||||
rolle = os.environ.get("MC_ROLLE", "box").strip().lower() or "box"
|
||||
if rolle not in ROLLEN:
|
||||
raise ValueError(f"MC_ROLLE muss eine von {ROLLEN} sein, nicht {rolle!r}.")
|
||||
box = rolle == "box"
|
||||
standard_daten = os.environ.get("MC_MODELS_DIR", "/srv/models") if box else "/var/lib/mc2"
|
||||
return Einstellungen(
|
||||
rolle=rolle,
|
||||
instanz=os.environ.get("MC_INSTANZ", "Box-Wart" if box else "Homelab"),
|
||||
daten_dir=Path(os.environ.get("MC_DATEN_DIR", standard_daten)),
|
||||
partner_url=os.environ.get("MC_PARTNER_URL", "").strip().rstrip("/"),
|
||||
partner_name=os.environ.get("MC_PARTNER_NAME", "Homelab" if box else "Box-Wart"),
|
||||
)
|
||||
@@ -0,0 +1,53 @@
|
||||
"""Die Partner-Instanz (User-Entscheid 24.09.2026: zwei Instanzen, eine Oberfläche).
|
||||
|
||||
Box-Wart läuft auf der KI-Box, der Homelab-Teil in einem Container auf dem Proxmox-PC. Jede
|
||||
Instanz kennt die andere über MC_PARTNER_URL. Sie fragt deren Lebenszeichen ab (für den Wächter
|
||||
und die Kopfzeile der Oberfläche) und reicht Anfragen der Oberfläche unter /api/partner/… durch,
|
||||
damit eine Seite beide Bereiche zeigt — egal, auf welcher Instanz man sie öffnet.
|
||||
"""
|
||||
|
||||
import threading
|
||||
import time
|
||||
|
||||
import httpx
|
||||
|
||||
from kern.einstellungen import einstellungen
|
||||
|
||||
ZEITLIMIT_S = 5.0
|
||||
CACHE_S = 15.0
|
||||
|
||||
_lock = threading.Lock()
|
||||
_cache: dict = {"ts": 0.0, "daten": None}
|
||||
|
||||
|
||||
def _kurz(exc: Exception) -> str:
|
||||
if isinstance(exc, httpx.TimeoutException):
|
||||
return "antwortet nicht rechtzeitig"
|
||||
if isinstance(exc, httpx.ConnectError):
|
||||
return "nicht erreichbar"
|
||||
if isinstance(exc, httpx.HTTPStatusError):
|
||||
return f"antwortet mit HTTP {exc.response.status_code}"
|
||||
return f"{exc.__class__.__name__}: {exc}"[:160]
|
||||
|
||||
|
||||
def status(frisch: bool = False) -> dict:
|
||||
"""Lebenszeichen der Partner-Instanz. Ohne MC_PARTNER_URL: {"eingerichtet": False}."""
|
||||
e = einstellungen()
|
||||
if not e.partner_url:
|
||||
return {"eingerichtet": False}
|
||||
with _lock:
|
||||
if not frisch and _cache["daten"] and time.time() - _cache["ts"] < CACHE_S:
|
||||
return dict(_cache["daten"])
|
||||
daten: dict = {"eingerichtet": True, "name": e.partner_name, "url": e.partner_url,
|
||||
"erreichbar": False, "rolle": None, "instanz": None, "version": None, "fehler": None}
|
||||
try:
|
||||
r = httpx.get(f"{e.partner_url}/api/health", timeout=ZEITLIMIT_S)
|
||||
r.raise_for_status()
|
||||
h = r.json()
|
||||
daten.update(erreichbar=True, rolle=h.get("rolle"), instanz=h.get("instanz"), version=h.get("version"))
|
||||
except Exception as exc:
|
||||
daten["fehler"] = _kurz(exc)
|
||||
daten["geprueft"] = time.time()
|
||||
with _lock:
|
||||
_cache.update(ts=time.time(), daten=daten)
|
||||
return dict(daten)
|
||||
@@ -0,0 +1,7 @@
|
||||
"""Die eine Zeitzone für alles, was Zeiten anzeigt oder naive Zeiten deutet (Projektregel:
|
||||
nie datetime.now() ohne Zone). Vorher stand dieselbe Zeile in sieben Modulen."""
|
||||
|
||||
import os
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
LOCAL_TZ = ZoneInfo(os.environ.get("MC_LOCAL_TZ", "Europe/Berlin"))
|
||||
@@ -1,57 +0,0 @@
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# Add backend directory to sys.path so we can import services
|
||||
sys.path.append(str(Path(__file__).resolve().parent))
|
||||
|
||||
from config import CONFIG_PATH
|
||||
from services.llamaswap import _PATH_RE, read_config, spec_draft_flags, write_config
|
||||
|
||||
|
||||
def migrate():
|
||||
print(f"Reading config from {CONFIG_PATH}...")
|
||||
if not CONFIG_PATH.exists():
|
||||
print(f"Config path {CONFIG_PATH} does not exist. Skipping.")
|
||||
return
|
||||
|
||||
cfg = read_config()
|
||||
models = cfg.get("models", {})
|
||||
|
||||
for name, spec in models.items():
|
||||
if not isinstance(spec, dict):
|
||||
continue
|
||||
cmd = spec.get("cmd", "")
|
||||
if not cmd:
|
||||
continue
|
||||
|
||||
print(f"Migrating model: {name}")
|
||||
|
||||
# 1. Defektes --prompt-cache/--prompt-cache-all entfernen (llama-CLI-Flags,
|
||||
# die llama-server ablehnt → Start scheitert). Caching macht llama-server
|
||||
# automatisch pro Slot.
|
||||
cmd = cmd.replace(" --prompt-cache-all", "").replace(" --prompt-cache", "")
|
||||
|
||||
# 2. Extract aliases/role
|
||||
aliases = spec.get("aliases", [])
|
||||
role = aliases[0] if aliases else None
|
||||
|
||||
# 3. Add parallel + (nur vocab-kompatibles) Speculative Decoding für fast/coder.
|
||||
# spec_draft_flags() prüft die Vocab-Kompatibilität und hängt --spec-type an;
|
||||
# ein inkompatibler Draft (z.B. qwen2.5 ↔ Qwen3.6) wird NICHT gesetzt.
|
||||
if role in ("fast", "coder"):
|
||||
if "--parallel" not in cmd:
|
||||
cmd = cmd.strip() + " --parallel 2"
|
||||
if "--spec-draft-model" not in cmd:
|
||||
target = mt.group(1) if (mt := _PATH_RE.search(cmd)) else ""
|
||||
cmd = cmd.strip() + spec_draft_flags(target)
|
||||
|
||||
# Update cmd
|
||||
from ruamel.yaml.scalarstring import LiteralScalarString
|
||||
spec["cmd"] = LiteralScalarString(cmd.strip() + "\n")
|
||||
|
||||
print(f"Writing updated config back to {CONFIG_PATH}...")
|
||||
write_config(cfg)
|
||||
print("Migration completed successfully!")
|
||||
|
||||
if __name__ == "__main__":
|
||||
migrate()
|
||||
@@ -0,0 +1,112 @@
|
||||
"""
|
||||
Nächtlicher Lauf des Modell-Radars (Box-Wart, 09/2026) — gestartet von mc2-radar.timer um 00:30.
|
||||
|
||||
Warum ein eigener Einstieg: Der Test lädt ein bis zu ~85 GB großes Modell neben Lucys Hirn. Das
|
||||
darf nur nachts passieren und muss um 02:30 sicher vorbei sein, weil um 03:00 der NerdQuiz-
|
||||
Nachtlauf das Hirn braucht. Ablauf:
|
||||
|
||||
1. Suchen — höchstens einmal am Tag (Merkliste + Entdeckung).
|
||||
2. Offene Urteile nachholen („getestet“: damals fehlte die Vergleichsmessung).
|
||||
3. Testen — nur im Fenster 00:30–02:30 und höchstens ein neuer Kandidat pro Woche.
|
||||
|
||||
Die Messungen prüfen die Frist selbst. Hängt trotzdem etwas, zieht fünf Minuten nach der Frist die
|
||||
Notbremse: Kandidaten-Server und Download werden gestoppt, der Prozess endet hart. systemd
|
||||
(RuntimeMaxSec) ist die letzte Sicherung. Ist nichts fällig, endet der Lauf sofort mit Code 0.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import signal
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
from services import radar
|
||||
|
||||
logging.basicConfig(
|
||||
level=os.environ.get("MC_LOG_LEVEL", "INFO").upper(),
|
||||
format="%(asctime)s %(levelname)-7s %(name)s: %(message)s",
|
||||
)
|
||||
log = logging.getLogger("radar_lauf")
|
||||
|
||||
NOTBREMSE_S = int(os.environ.get("MC_RADAR_NOTBREMSE_S", "300"))
|
||||
|
||||
|
||||
def _beim_beenden(signum, _frame) -> None:
|
||||
"""SIGTERM (systemd stop, RuntimeMaxSec) → normal auslaufen, damit der Kandidaten-Server gestoppt wird."""
|
||||
raise SystemExit(f"Signal {signum}")
|
||||
|
||||
|
||||
def _notbremse(frist: float) -> threading.Timer:
|
||||
def ziehen() -> None:
|
||||
log.error("Radar: Notbremse – %s s nach der Frist läuft noch etwas, der Lauf wird hart beendet.", NOTBREMSE_S)
|
||||
try:
|
||||
radar.notstopp()
|
||||
finally:
|
||||
os._exit(3)
|
||||
|
||||
bremse = threading.Timer(max(frist - time.time(), 0) + NOTBREMSE_S, ziehen)
|
||||
bremse.daemon = True
|
||||
bremse.start()
|
||||
return bremse
|
||||
|
||||
|
||||
NOTIFY = Path(__file__).resolve().parents[1] / "deploy" / "notify.sh"
|
||||
|
||||
|
||||
def melde_bestanden(test: dict) -> None:
|
||||
"""Ein bestandener Kandidat wartet auf eine Entscheidung — einmal Bescheid geben, sonst liegt er
|
||||
unbemerkt herum (Lehre der Pins vom September). notify.sh sammelt Nachtmeldungen für den Morgen."""
|
||||
k = radar.lade_stand()["kandidaten"].get(test.get("kandidat")) or {}
|
||||
rolle = "das Hirn" if test.get("rolle") == "hirn" else "den Coder"
|
||||
text = (f"{k.get('name') or test.get('kandidat')} hat den Nachttest für {rolle} bestanden. "
|
||||
f"{str(test.get('zusammenfassung') or '')[:300]} "
|
||||
"In MC2 unter Modelle kannst du ihn übernehmen oder verwerfen.")
|
||||
try:
|
||||
subprocess.run(["bash", str(NOTIFY), "-s", "[Modell-Radar]", text], timeout=120, check=False,
|
||||
stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||||
except (OSError, subprocess.SubprocessError):
|
||||
log.warning("Radar: Meldung über den bestandenen Kandidaten ging nicht raus.", exc_info=True)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
beginn = radar.jetzt()
|
||||
if radar.suche_faellig(beginn):
|
||||
try:
|
||||
ergebnis = radar.suche()
|
||||
log.info("Radar: Suche – %s neu, %s warten", len(ergebnis.get("neu") or []), ergebnis.get("wartend"))
|
||||
except Exception:
|
||||
log.warning("Radar: Suche fehlgeschlagen", exc_info=True)
|
||||
else:
|
||||
log.info("Radar: heute schon gesucht.")
|
||||
|
||||
if not radar.im_fenster(radar.jetzt()):
|
||||
log.info("Radar: außerhalb des Test-Fensters %s–%s, nichts zu testen.", radar.FENSTER_START,
|
||||
radar.FENSTER_ENDE)
|
||||
return 0
|
||||
|
||||
frist = radar.fenster_ende(radar.jetzt()).timestamp()
|
||||
signal.signal(signal.SIGTERM, _beim_beenden)
|
||||
bremse = _notbremse(frist)
|
||||
try:
|
||||
if (nachgeholt := radar.nachurteilen(frist)):
|
||||
log.info("Radar: %s offene Urteile nachgeholt.", nachgeholt)
|
||||
plan = radar.plane_test(radar.lade_stand(), radar.jetzt())
|
||||
if not plan.get("kandidat"):
|
||||
log.info("Radar: nichts fällig (%s).", plan.get("grund"))
|
||||
return 0
|
||||
log.info("Radar: teste %s bis spätestens %s.", plan.get("grund"), radar.FENSTER_ENDE)
|
||||
test = radar.teste(plan["kandidat"], frist)
|
||||
if test:
|
||||
log.info("Radar: %s – %s", test.get("ergebnis"), test.get("zusammenfassung"))
|
||||
if test.get("ergebnis") == "bestanden":
|
||||
melde_bestanden(test)
|
||||
return 0
|
||||
finally:
|
||||
bremse.cancel()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,2 @@
|
||||
# Nur fürs Prüftor (deploy/pruefen.sh) — die Dienste selbst brauchen das nicht.
|
||||
pytest>=8
|
||||
@@ -1,41 +0,0 @@
|
||||
"""Agent-Endpoint: Hermes-Status + WebUI-Link (MC verlinkt nur, betreibt nicht)."""
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from pydantic import BaseModel
|
||||
from services.agent import agent_status, hermes_brain_info, set_agent_brain, update_brain_model
|
||||
|
||||
router = APIRouter(prefix="/api")
|
||||
|
||||
|
||||
class BrainReq(BaseModel):
|
||||
model: str
|
||||
|
||||
|
||||
class SetBrainReq(BaseModel):
|
||||
model_id: str
|
||||
|
||||
|
||||
@router.get("/agent/status")
|
||||
def status() -> dict:
|
||||
return agent_status()
|
||||
|
||||
|
||||
@router.get("/agent/brain")
|
||||
def brain_info() -> dict:
|
||||
"""Aktuelles Agent-Hirn (hermes) + bestes NousResearch-Hermes-Update."""
|
||||
return hermes_brain_info()
|
||||
|
||||
|
||||
@router.post("/agent/brain/set")
|
||||
def set_brain(body: SetBrainReq) -> dict:
|
||||
"""Setzt ein installiertes Modell als Agent-Hirn (Alias + warm-Gruppe + Config)."""
|
||||
res = set_agent_brain(body.model_id)
|
||||
if not res.get("ok"):
|
||||
raise HTTPException(400, res.get("reason", "Fehler beim Setzen des Agent-Hirns"))
|
||||
return res
|
||||
|
||||
|
||||
@router.post("/agent/brain")
|
||||
def set_brain_model(body: BrainReq) -> dict:
|
||||
ok = update_brain_model(body.model)
|
||||
return {"ok": ok}
|
||||
@@ -1,63 +0,0 @@
|
||||
"""Auftragsbuch-Endpoints (Vorschlags-Inbox) — dünner REST-Layer über services/auftragsbuch.py.
|
||||
LAN-only wie alle MC2-Endpoints; das Gate ist der Klick des Commanders."""
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from pydantic import BaseModel
|
||||
from services import auftragsbuch
|
||||
|
||||
router = APIRouter(prefix="/api")
|
||||
|
||||
|
||||
class BranchIn(BaseModel):
|
||||
branch: str
|
||||
repo: str = "mc2" # "mc2" (Box-Stack) oder "lucy" (Desktop-App, Annahme baut am PC)
|
||||
grund: str = "" # nur beim Ablehnen: optionaler Grund → Lern-Gedächtnis des Kreislaufs
|
||||
|
||||
|
||||
class KandidatIn(BaseModel):
|
||||
file: str
|
||||
|
||||
|
||||
@router.get("/auftragsbuch")
|
||||
def list_proposals() -> dict:
|
||||
return auftragsbuch.list_proposals()
|
||||
|
||||
|
||||
@router.get("/auftragsbuch/diff")
|
||||
def diff(branch: str, repo: str = "mc2") -> dict:
|
||||
res = auftragsbuch.diff_of(branch, repo)
|
||||
if not res.get("ok"):
|
||||
raise HTTPException(400, res.get("error", "Diff nicht verfügbar."))
|
||||
return res
|
||||
|
||||
|
||||
@router.post("/auftragsbuch/annehmen")
|
||||
def accept(body: BranchIn) -> dict:
|
||||
res = auftragsbuch.accept(body.branch, body.repo)
|
||||
if not res.get("ok"):
|
||||
raise HTTPException(400, res.get("error", "Annehmen fehlgeschlagen."))
|
||||
return res
|
||||
|
||||
|
||||
@router.post("/auftragsbuch/ablehnen")
|
||||
def reject(body: BranchIn) -> dict:
|
||||
res = auftragsbuch.reject(body.branch, body.repo, body.grund)
|
||||
if not res.get("ok"):
|
||||
raise HTTPException(400, res.get("error", "Ablehnen fehlgeschlagen."))
|
||||
return res
|
||||
|
||||
|
||||
@router.post("/auftragsbuch/skill/annehmen")
|
||||
def skill_accept(body: KandidatIn) -> dict:
|
||||
res = auftragsbuch.skill_accept(body.file)
|
||||
if not res.get("ok"):
|
||||
raise HTTPException(400, res.get("error", "Beauftragen fehlgeschlagen."))
|
||||
return res
|
||||
|
||||
|
||||
@router.post("/auftragsbuch/skill/ablehnen")
|
||||
def skill_reject(body: KandidatIn) -> dict:
|
||||
res = auftragsbuch.skill_reject(body.file)
|
||||
if not res.get("ok"):
|
||||
raise HTTPException(400, res.get("error", "Verwerfen fehlgeschlagen."))
|
||||
return res
|
||||
@@ -0,0 +1,255 @@
|
||||
"""
|
||||
Box-Wart-Schnittstellen (Umbau 09/2026): alles, was der Cockpit-Startbildschirm braucht.
|
||||
|
||||
GET /api/start Zustand, Hinweise, Warnlampen, Box-Werte, Updates, Flugplan
|
||||
GET /api/hinweise Hinweise + Verlauf des Wächters
|
||||
POST /api/hinweise/{id}/aktion/{aktion} Knopf eines Hinweises ausführen
|
||||
GET /api/modelle/nutzung Wer nutzt die Modelle (7 Tage, 24 h je Stunde)
|
||||
GET /api/updates/verlauf Was die letzten Update-Läufe wirklich gebracht haben
|
||||
POST /api/updates/festgehalten/{b}/freigeben Festgehaltenen Baustein wieder freigeben
|
||||
GET /api/modelle/aufraeumen Was auf der Modell-Platte ungenutzt Platz belegt
|
||||
POST /api/modelle/aufraeumen/loeschen Einen Kandidaten löschen (nur auf Knopfdruck)
|
||||
|
||||
Dünn: die Logik liegt in services/waechter.py, modell_nutzung.py, zeitplan.py, update_verlauf.py.
|
||||
"""
|
||||
|
||||
import threading
|
||||
import time
|
||||
from datetime import datetime
|
||||
|
||||
import psutil
|
||||
from config import MODELS_DIR
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from kern.zeit import LOCAL_TZ
|
||||
from pydantic import BaseModel
|
||||
from services import (
|
||||
aufraeumen,
|
||||
backup,
|
||||
llamaswap,
|
||||
maintenance,
|
||||
modell_nutzung,
|
||||
system,
|
||||
update_verlauf,
|
||||
waechter,
|
||||
zeitplan,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/api", tags=["boxwart"])
|
||||
|
||||
_updates_lock = threading.Lock()
|
||||
_updates_cache: dict = {"ts": 0.0, "daten": None, "laeuft": False, "stand": -1}
|
||||
UPDATES_CACHE_S = 600
|
||||
|
||||
|
||||
def _updates_holen() -> None:
|
||||
stand = maintenance.update_stand
|
||||
try:
|
||||
daten = maintenance.updates()
|
||||
except Exception:
|
||||
daten = None
|
||||
with _updates_lock:
|
||||
if daten is not None or _updates_cache["daten"] is None:
|
||||
_updates_cache["daten"] = daten or {}
|
||||
_updates_cache["ts"] = time.time()
|
||||
_updates_cache["stand"] = stand
|
||||
_updates_cache["laeuft"] = False
|
||||
|
||||
|
||||
def _updates_gecacht() -> dict:
|
||||
"""maintenance.updates() fragt GitHub, apt und Hugging Face und braucht nach einem Neustart
|
||||
gern zehn Sekunden und mehr. Der Start wartet darauf nie: Er bekommt den letzten Stand (oder
|
||||
zunächst nichts), frisch geholt wird im Hintergrund."""
|
||||
with _updates_lock:
|
||||
# Nach jedem abgeschlossenen Update zählt maintenance.update_stand hoch — dann sofort neu holen,
|
||||
# statt bis zu 10 Minuten „Aktualisieren“ für schon eingespielte Updates zu zeigen (24.09.2026).
|
||||
veraltet = (_updates_cache["daten"] is None or time.time() - _updates_cache["ts"] > UPDATES_CACHE_S
|
||||
or _updates_cache["stand"] != maintenance.update_stand)
|
||||
if veraltet and not _updates_cache["laeuft"]:
|
||||
_updates_cache["laeuft"] = True
|
||||
threading.Thread(target=_updates_holen, name="updates-holen", daemon=True).start()
|
||||
return _updates_cache["daten"] or {}
|
||||
|
||||
|
||||
def _bausteine(u: dict) -> list[dict]:
|
||||
"""Welche Bausteine haben Neues? In der Reihenfolge, in der auch das Update läuft."""
|
||||
liste = []
|
||||
if u.get("os"):
|
||||
liste.append({"id": "os", "name": "Betriebssystem", "neu": f"{u['os']} Pakete"})
|
||||
if u.get("engine"):
|
||||
liste.append({"id": "engine", "name": "Motor", "neu": "neuer Build"})
|
||||
if u.get("swap"):
|
||||
liste.append({"id": "swap", "name": "llama-swap", "neu": "neue Version"})
|
||||
for c in u.get("components") or []:
|
||||
if c.get("key") == "hermes_agent" and c.get("update"):
|
||||
liste.append({"id": "hermes", "name": "Hermes", "neu": f"{c.get('behind', '?')} Änderungen"})
|
||||
return liste
|
||||
|
||||
|
||||
NAMEN_UNKLAR = {"os": "Betriebssystem", "engine": "Motor", "swap": "llama-swap", "hermes": "Hermes"}
|
||||
|
||||
|
||||
def _unklar(u: dict) -> list[dict]:
|
||||
"""Bausteine, deren Update-Prüfung scheiterte — ehrlich „unbekannt“ statt „aktuell“."""
|
||||
return [{"id": k, "name": NAMEN_UNKLAR.get(k, k), "grund": grund}
|
||||
for k, grund in (u.get("pruef_fehler") or {}).items() if grund]
|
||||
|
||||
|
||||
def _zeit_kurz(ts: float | None) -> str:
|
||||
if not ts:
|
||||
return "–"
|
||||
dt = datetime.fromtimestamp(ts, LOCAL_TZ)
|
||||
heute = datetime.now(LOCAL_TZ).date()
|
||||
if dt.date() == heute:
|
||||
return f"heute {dt:%H:%M}"
|
||||
if (heute - dt.date()).days == 1:
|
||||
return f"gestern {dt:%H:%M}"
|
||||
return f"{dt:%d.%m. %H:%M}"
|
||||
|
||||
|
||||
def _lampen(stand: dict, u: dict) -> list[dict]:
|
||||
ids = {h.get("id", "") for h in stand["hinweise"]}
|
||||
versionen = system.check_versions_cached()
|
||||
|
||||
def lampe(lid: str, label: str, zustand: str, wert: str) -> dict:
|
||||
return {"id": lid, "label": label, "zustand": zustand, "wert": wert}
|
||||
|
||||
engine_ok = llamaswap.engine_reachable()
|
||||
build = (versionen.get("engine") or {}).get("version_text", "")
|
||||
lampen = [lampe("motor", "Motor", "ok" if engine_ok else "fehler", build if engine_ok else "antwortet nicht")]
|
||||
|
||||
hirn = llamaswap.brain_status() if engine_ok else {}
|
||||
lampen.append(lampe("hirn", "Hirn", "ok" if hirn.get("ready") else "fehler",
|
||||
"geladen" if hirn.get("ready") else "lädt nicht"))
|
||||
|
||||
laufend = set(llamaswap.get_running_models()) if engine_ok else set()
|
||||
coder = next((m for m in llamaswap.list_models() if "coder" in (m.get("aliases") or [])), None)
|
||||
coder_an = bool(coder and coder.get("name") in laufend)
|
||||
lampen.append(lampe("coder", "Coder", "ok" if coder_an else "aus", "geladen" if coder_an else "auf Abruf"))
|
||||
|
||||
hermes_weg = any(i in ids for i in ("kern:hermes", "dienst:hermes-gateway"))
|
||||
lampen.append(lampe("hermes", "Hermes", "fehler" if hermes_weg else "ok",
|
||||
"antwortet nicht" if hermes_weg else "läuft"))
|
||||
|
||||
job_hinweise = [h for h in stand["hinweise"] if h.get("id", "").startswith(("job:", "timer:"))]
|
||||
rot = any(h.get("stufe") == "rot" for h in job_hinweise)
|
||||
lampen.append(lampe("jobs", "Jobs", "fehler" if rot else ("warn" if job_hinweise else "ok"),
|
||||
f"{len(job_hinweise)} Hinweis{'e' if len(job_hinweise) != 1 else ''}"
|
||||
if job_hinweise else "alles gelaufen"))
|
||||
|
||||
try:
|
||||
sicherungen = backup.list_backups()
|
||||
except Exception:
|
||||
sicherungen = []
|
||||
letzte = max((s.get("mtime") or s.get("ts") or 0 for s in sicherungen), default=0)
|
||||
zu_alt = not letzte or time.time() - letzte > 36 * 3600
|
||||
lampen.append(lampe("sicherung", "Sicherung", "warn" if zu_alt else "ok", _zeit_kurz(letzte)))
|
||||
|
||||
try:
|
||||
platte = psutil.disk_usage(str(MODELS_DIR) if MODELS_DIR.exists() else ".").percent
|
||||
except Exception:
|
||||
platte = None
|
||||
zustand = "ok" if platte is None or platte < waechter.DISK_GELB_PCT else (
|
||||
"warn" if platte < waechter.DISK_ROT_PCT else "fehler")
|
||||
lampen.append(lampe("platte", "Platte", zustand, f"{platte:.0f} %" if platte is not None else "–"))
|
||||
|
||||
n = len(_bausteine(u))
|
||||
fest = len(update_verlauf.festgehalten())
|
||||
unklar = len(_unklar(u))
|
||||
if fest:
|
||||
lampen.append(lampe("updates", "Updates", "warn", f"{fest} festgehalten"))
|
||||
elif _updates_cache["daten"] is None:
|
||||
lampen.append(lampe("updates", "Updates", "aus", "wird geprüft"))
|
||||
elif n:
|
||||
lampen.append(lampe("updates", "Updates", "info", f"{n} bereit"))
|
||||
elif unklar:
|
||||
lampen.append(lampe("updates", "Updates", "warn", "Prüfung unklar"))
|
||||
else:
|
||||
lampen.append(lampe("updates", "Updates", "ok", "aktuell"))
|
||||
return lampen
|
||||
|
||||
|
||||
def _box() -> dict:
|
||||
p = system.metrik_punkt()
|
||||
try:
|
||||
du = psutil.disk_usage(str(MODELS_DIR) if MODELS_DIR.exists() else ".")
|
||||
platte = {"used": du.used, "total": du.total, "percent": du.percent}
|
||||
except Exception:
|
||||
platte = None
|
||||
return {"ram_used": p.get("ram_used"), "ram_total": p.get("ram_total"),
|
||||
"temp_cpu": p.get("temp_cpu"), "temp_gpu": p.get("temp_gpu"),
|
||||
"platte": platte, "uptime_s": p.get("uptime_s")}
|
||||
|
||||
|
||||
@router.get("/start")
|
||||
def start() -> dict:
|
||||
stand = waechter.lese_stand()
|
||||
u = _updates_gecacht()
|
||||
rot = sum(1 for h in stand["hinweise"] if h.get("stufe") == "rot")
|
||||
anzahl = len(stand["hinweise"])
|
||||
wach = bool(stand["stand"]) and time.time() - float(stand["stand"] or 0) < 5 * 60
|
||||
return {
|
||||
"zustand": {
|
||||
"stufe": "rot" if rot else ("gelb" if anzahl else "ok"),
|
||||
"anzahl": anzahl,
|
||||
"waechter_wach": wach,
|
||||
"stand": stand["stand"],
|
||||
"update_laeuft": stand["update_laeuft"],
|
||||
},
|
||||
"hinweise": stand["hinweise"],
|
||||
"verlauf": stand["verlauf"][:20],
|
||||
"lampen": _lampen(stand, u),
|
||||
"box": _box(),
|
||||
"updates": {"bausteine": _bausteine(u), "unklar": _unklar(u),
|
||||
"festgehalten": update_verlauf.festgehalten(),
|
||||
"gelesen": _updates_cache["daten"] is not None},
|
||||
"flugplan": zeitplan.flugplan(),
|
||||
}
|
||||
|
||||
|
||||
@router.get("/hinweise")
|
||||
def hinweise() -> dict:
|
||||
return waechter.lese_stand()
|
||||
|
||||
|
||||
@router.post("/hinweise/{hinweis_id}/aktion/{aktion_id}")
|
||||
def hinweis_aktion(hinweis_id: str, aktion_id: str) -> dict:
|
||||
ergebnis = waechter.fuehre_aktion_aus(hinweis_id, aktion_id)
|
||||
if ergebnis.get("detail") and not ergebnis.get("ok"):
|
||||
raise HTTPException(status_code=409, detail=ergebnis["detail"])
|
||||
return ergebnis
|
||||
|
||||
|
||||
@router.get("/modelle/nutzung")
|
||||
def nutzung() -> dict:
|
||||
return modell_nutzung.nutzung()
|
||||
|
||||
|
||||
@router.get("/updates/verlauf")
|
||||
def updates_verlauf(anzahl: int = 8) -> dict:
|
||||
"""Die letzten Update-Läufe mit dem Ergebnis je Baustein (aus dem Meldeprotokoll der Box)."""
|
||||
return {"laeufe": update_verlauf.laeufe(max(1, min(anzahl, 30)))}
|
||||
|
||||
|
||||
@router.post("/updates/festgehalten/{baustein}/freigeben")
|
||||
def festgehalten_freigeben(baustein: str) -> dict:
|
||||
if not update_verlauf.freigeben(baustein):
|
||||
raise HTTPException(status_code=404, detail="Dieser Baustein ist nicht festgehalten.")
|
||||
return {"ok": True, "text": update_verlauf.FREIGEGEBEN_TEXT}
|
||||
|
||||
|
||||
class LoeschAuftrag(BaseModel):
|
||||
art: str # "eintrag" | "ordner"
|
||||
name: str
|
||||
|
||||
|
||||
@router.get("/modelle/aufraeumen")
|
||||
def aufraeumen_liste() -> dict:
|
||||
return {"kandidaten": aufraeumen.kandidaten()}
|
||||
|
||||
|
||||
@router.post("/modelle/aufraeumen/loeschen")
|
||||
def aufraeumen_loeschen(auftrag: LoeschAuftrag) -> dict:
|
||||
ergebnis = aufraeumen.loeschen(auftrag.art, auftrag.name)
|
||||
if not ergebnis.get("ok"):
|
||||
raise HTTPException(status_code=409, detail=ergebnis.get("detail") or "Löschen ging nicht.")
|
||||
return ergebnis
|
||||
@@ -1,28 +0,0 @@
|
||||
"""Chronik — die lesbare Timeline dessen, was die Box von allein getan und gemeldet hat.
|
||||
Quelle ist der persistente Melde-Briefkasten (services/announce.py): Health-Wächter,
|
||||
Auto-Updates, Erinnerungen, Radar/Traum/Chef-Gutachter-Crons, Auftragsbuch — alle
|
||||
autonomen Kanäle laufen dort bereits durch. Hier wird nichts Neues erhoben, nur erzählt."""
|
||||
|
||||
from fastapi import APIRouter
|
||||
from pydantic import BaseModel
|
||||
from services import announce
|
||||
|
||||
router = APIRouter(prefix="/api")
|
||||
|
||||
|
||||
class ChronikItem(BaseModel):
|
||||
id: int
|
||||
ts: float
|
||||
subject: str
|
||||
text: str
|
||||
source: str
|
||||
priority: str
|
||||
|
||||
|
||||
class ChronikResponse(BaseModel):
|
||||
items: list[ChronikItem]
|
||||
|
||||
|
||||
@router.get("/chronik", response_model=ChronikResponse)
|
||||
def chronik(limit: int = 150) -> dict:
|
||||
return {"items": announce.list_recent(limit)}
|
||||
@@ -1,20 +0,0 @@
|
||||
"""Connect-Endpoint: erzeugt IDE-/Agent-Snippets (auf den Gateway + Memory-MCP)."""
|
||||
|
||||
from fastapi import APIRouter
|
||||
from services.connect import DEFAULT_HOST, build_snippets, check_health
|
||||
|
||||
router = APIRouter(prefix="/api")
|
||||
|
||||
|
||||
@router.get("/connect")
|
||||
def connect(host: str = DEFAULT_HOST, mcp_path: str | None = None) -> dict:
|
||||
kwargs = {}
|
||||
if mcp_path:
|
||||
kwargs["mcp_script_path"] = mcp_path
|
||||
return build_snippets(host=host, **kwargs)
|
||||
|
||||
|
||||
@router.get("/connect/health")
|
||||
def connect_health() -> dict:
|
||||
"""Live-Status der zwei Leitungen (Gateway + Gedächtnis) für den Verbinden-Tab."""
|
||||
return check_health()
|
||||
@@ -1,87 +0,0 @@
|
||||
"""
|
||||
ttyd-Reverse-Proxy für die eingebetteten Web-Terminals (Box-Konsole + Hermes-Terminal).
|
||||
|
||||
Beide ttyd-Dienste binden NUR an Loopback (--base-path /<name>) und werden hier über
|
||||
den ohnehin offenen MC2-Port (9001) same-origin durchgereicht — HTTP (index.html/token)
|
||||
+ WebSocket (/<name>/ws). Vorteil: keine eigene Firewall-Freigabe je Terminal nötig und
|
||||
alles läuft same-origin zum Dashboard. Reiner Byte-Durchreicher; ttyds `tty`-Subprotokoll
|
||||
wird auf beiden Seiten ausgehandelt. Der Login-Schutz sitzt IM Terminal (ttyd startet die
|
||||
Shell über `su` → fragt das Box-Passwort ab), nicht im Proxy.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
|
||||
import httpx
|
||||
import websockets
|
||||
from config import BOX_CONSOLE_UPSTREAM
|
||||
from fastapi import APIRouter, Request, WebSocket
|
||||
from starlette.responses import Response
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _register(base: str, upstream: str) -> None:
|
||||
"""Registriert HTTP- + WS-Proxy-Routen für eine ttyd-Instanz (base-path `/<base>`)."""
|
||||
ws_upstream = upstream.replace("http://", "ws://").replace("https://", "wss://")
|
||||
|
||||
@router.websocket(f"/{base}/ws")
|
||||
async def _proxy_ws(ws: WebSocket) -> None:
|
||||
await ws.accept(subprotocol="tty")
|
||||
try:
|
||||
async with websockets.connect(
|
||||
f"{ws_upstream}/{base}/ws", subprotocols=["tty"],
|
||||
open_timeout=10, max_size=None, ping_interval=None,
|
||||
) as up:
|
||||
async def c2u() -> None:
|
||||
while True:
|
||||
msg = await ws.receive()
|
||||
if msg.get("type") == "websocket.disconnect":
|
||||
return
|
||||
if (t := msg.get("text")) is not None:
|
||||
await up.send(t)
|
||||
elif (b := msg.get("bytes")) is not None:
|
||||
await up.send(b)
|
||||
|
||||
async def u2c() -> None:
|
||||
async for m in up:
|
||||
if isinstance(m, (bytes, bytearray)):
|
||||
await ws.send_bytes(bytes(m))
|
||||
else:
|
||||
await ws.send_text(m)
|
||||
|
||||
_done, pending = await asyncio.wait(
|
||||
[asyncio.create_task(c2u()), asyncio.create_task(u2c())],
|
||||
return_when=asyncio.FIRST_COMPLETED,
|
||||
)
|
||||
for task in pending:
|
||||
task.cancel()
|
||||
except Exception:
|
||||
log.debug("ttyd-proxy ws (%s) beendet/fehlgeschlagen", base, exc_info=True)
|
||||
finally:
|
||||
try:
|
||||
await ws.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
async def _proxy_http(request: Request, path: str = "") -> Response:
|
||||
url = f"{upstream}/{base}/{path}"
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10.0) as c:
|
||||
r = await c.request(request.method, url, content=await request.body())
|
||||
return Response(content=r.content, status_code=r.status_code,
|
||||
media_type=r.headers.get("content-type"))
|
||||
except httpx.HTTPError as exc:
|
||||
log.debug("ttyd-proxy http (%s) nicht erreichbar: %s", base, exc)
|
||||
return Response(content=b"Terminal (ttyd) nicht erreichbar.", status_code=502,
|
||||
media_type="text/plain")
|
||||
|
||||
router.add_api_route(f"/{base}", _proxy_http, methods=["GET"], name=f"{base}_root")
|
||||
router.add_api_route(f"/{base}/{{path:path}}", _proxy_http, methods=["GET", "POST"],
|
||||
name=f"{base}_path")
|
||||
|
||||
|
||||
# Box-Konsole (Login-Shell) — Loopback-ttyd. (Das Hermes-Terminal-ttyd ist mit dem
|
||||
# Umzug auf Hermes Desktop entfallen; die Agent-Oberfläche ist die Desktop-App bzw. /hermes-ui/.)
|
||||
_register("console", BOX_CONSOLE_UPSTREAM)
|
||||
+80
-52
@@ -1,25 +1,26 @@
|
||||
"""SSE-Eventstrom (UMBAU v3 P3a) — ein Kanal sagt der Zentrale, WANN neu laden lohnt.
|
||||
"""Ereignisstrom — ein Kanal sagt der Zentrale, WANN neu laden lohnt, und schickt Metriken.
|
||||
|
||||
GET /api/events liefert Server-Sent Events. Ein Sammler prüft alle paar Sekunden
|
||||
billige Fingerabdrücke der ereignishaften Quellen und schickt NUR bei Änderung ein
|
||||
`invalidate`-Event mit den React-Query-Keys. Die Wahrheit bleibt in den bestehenden
|
||||
Endpunkten — der Strom ist ein reiner Invalidation-Bus, kein zweites Zustandsmodell.
|
||||
GET /api/stream (v3-Umbau P4, 28.08.2026) — zwei Ereignisarten:
|
||||
· `invalidate` Nur bei Änderung, mit den betroffenen React-Query-Schlüsseln.
|
||||
· `metrik` Jede Sekunde ein Messpunkt (CPU/RAM/GPU/Temp/Token-Zähler).
|
||||
|
||||
Quellen: Briefkasten/Chronik (in-process-Cursor), Ideen-Queue ((id,status)-Paare),
|
||||
Auftragsbuch + Erinnerungen (Datei-mtimes), geladene Modelle (Running-Set — Idee aus
|
||||
der Werkstatt-Karte feature/sse-backend-v1). BEWUSST NICHT dabei: System-/Token-
|
||||
Metriken (ändern sich jede Sekunde — da ist Polling das richtige Werkzeug und ein
|
||||
invalidate-Event nur Lärm).
|
||||
Der alte Kanal /api/events ist mit dem Box-Wart-Umbau (09/2026) entfallen.
|
||||
|
||||
Versöhnt 15.07. abends: Die angenommene Werkstatt-Version nutzte `type:` statt
|
||||
`event:` (ungültiges SSE-Framing → EventSource-Listener feuert NIE), einen globalen
|
||||
Snapshot über alle Clients und einen nicht existierenden Ideen-Endpunkt — Kern
|
||||
wieder die getestete Hand-Implementierung (E2E: Announce → invalidate binnen
|
||||
Sekunden), Modell-Quelle aus der Karte übernommen.
|
||||
WARUM METRIKEN JETZT MITKOMMEN: Bis P4 pollte das Frontend `/api/system/status` und
|
||||
`/api/system/token-stats` im 3-Sekunden-Takt — zwei Dauer-Anfragen, unabhängig davon, ob
|
||||
sich etwas geändert hat, plus sechs weitere langsamere Poller auf der Startseite. Der
|
||||
Messpunkt kostet hier 0,2 ms (gemessen); `system_status()` würde 100 ms kosten, weil
|
||||
`psutil.cpu_percent(interval=0.1)` wartet. Deshalb der eigene, leichte `metrik_punkt()`.
|
||||
|
||||
Frontend-Gegenstück: frontend/src/lib/events.ts (EventSource, invalidiert die
|
||||
Caches, entspannt die Fallback-Poller ×5; reißt der Strom, reconnectet EventSource
|
||||
selbst und bis dahin pollt die UI wie bisher).
|
||||
WAS BEWUSST NICHT DRIN IST: Ein `agent`-Thema für Lucys Denkschritte. MC2 kann Hermes'
|
||||
interne Schritte nicht sehen, ohne dessen Quellcode zu patchen — und das ist per AGENTS.md
|
||||
verboten. Eine leere Leitung zu bauen, wäre eine Zusage, die keiner einlöst.
|
||||
|
||||
Die Wahrheit bleibt in den bestehenden Endpunkten: `invalidate` ist ein reiner
|
||||
Anstoß-Bus, kein zweites Zustandsmodell. `metrik` ist die einzige Ausnahme — es ist der
|
||||
Wert selbst, weil ein Anstoß für eine Zahl, die sich jede Sekunde ändert, nur Lärm wäre.
|
||||
|
||||
Frontend-Gegenstück: frontend/src/lib/events.ts
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
@@ -27,7 +28,6 @@ import json
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
from config import MODELS_DIR
|
||||
from fastapi import APIRouter, Request
|
||||
from fastapi.responses import StreamingResponse
|
||||
|
||||
@@ -35,7 +35,8 @@ log = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/api")
|
||||
|
||||
TICK_S = 3.0 # Prüf-Takt des Sammlers (nur Fingerabdrücke, kein Neuberechnen)
|
||||
METRIK_S = 1.0 # Takt der Messpunkte
|
||||
ABDRUCK_S = 3.0 # Takt der Änderungs-Prüfung (nur Fingerabdrücke, kein Neuberechnen)
|
||||
KEEPALIVE_S = 20.0 # Kommentar-Ping, damit Proxies/Browser die Verbindung halten
|
||||
|
||||
|
||||
@@ -50,15 +51,9 @@ def _fingerprints() -> dict[str, object]:
|
||||
"""Billige Änderungs-Signale je Quelle → React-Query-Key. Fehler einer Quelle
|
||||
dürfen den Strom nie reißen (dann bleibt ihr Abdruck einfach stehen)."""
|
||||
fp: dict[str, object] = {}
|
||||
try: # Briefkasten trägt Chronik UND Kontext-Limit-Warnungen — in-process, spottbillig
|
||||
from services import announce
|
||||
fp["chronik"] = announce.list_after(None)["latest"]
|
||||
except Exception:
|
||||
pass
|
||||
try: # Queue: (id,status)-Paare; list_queue cached selbst ~15 s, der Tick kostet nichts
|
||||
from services import ideen
|
||||
d = ideen.list_queue()
|
||||
fp["ideen"] = json.dumps([(i.get("id"), i.get("status")) for i in d.get("items") or []])
|
||||
try: # Wächter-Stand (mc2-steward schreibt ihn jede Minute): Hinweise → Startseite neu laden
|
||||
from services import waechter
|
||||
fp["start"] = _mtime(waechter.STORE_PATH)
|
||||
except Exception:
|
||||
pass
|
||||
try: # Geladene Modelle (Running-Set): Laden/Entladen soll die Modelle-Ansicht anstoßen
|
||||
@@ -66,35 +61,68 @@ def _fingerprints() -> dict[str, object]:
|
||||
fp["models"] = json.dumps(sorted(str(m) for m in llamaswap.get_running_models()))
|
||||
except Exception:
|
||||
pass
|
||||
# Auftragsbuch (Annahme-Status + Karten-Meldungen) & Erinnerungen: Datei-mtimes
|
||||
fp["auftragsbuch"] = (_mtime(MODELS_DIR / "mc2-auftragsbuch.json"),
|
||||
_mtime(MODELS_DIR / "mc2-announce-branches.json"))
|
||||
fp["reminders"] = _mtime(MODELS_DIR / "mc2-reminders.json")
|
||||
try: # Jobs (Downloads, Wartung): Zustand + Fortschritt — spart den 3-s-Poller der Schublade
|
||||
from services import jobengine
|
||||
fp["jobs"] = json.dumps(
|
||||
[(j.get("id"), j.get("state"), j.get("progress")) for j in jobengine.public_jobs(mit_log=False)]
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
return fp
|
||||
|
||||
|
||||
@router.get("/events")
|
||||
async def events(request: Request) -> StreamingResponse:
|
||||
async def strom():
|
||||
# Basislinie JE VERBINDUNG (der Client hat beim Verbinden frisch geladen) —
|
||||
# ein globaler Snapshot würde bei mehreren Clients Events verschlucken.
|
||||
alt = _fingerprints()
|
||||
yield ": verbunden\n\n"
|
||||
seit_ping = 0.0
|
||||
while True:
|
||||
if await request.is_disconnected():
|
||||
return
|
||||
await asyncio.sleep(TICK_S)
|
||||
seit_ping += TICK_S
|
||||
neu = _fingerprints()
|
||||
async def _strom(request: Request, mit_metrik: bool):
|
||||
"""Gemeinsamer Kern beider Endpunkte.
|
||||
|
||||
Die Basislinie entsteht JE VERBINDUNG (der Client hat beim Verbinden frisch geladen) —
|
||||
ein globaler Snapshot würde bei mehreren Clients Events verschlucken.
|
||||
"""
|
||||
# Die Abdrücke fragen u. a. llama-swap ab (bis 2 s). Im Event-Loop blockierte das jede andere
|
||||
# Anfrage dieses Prozesses, auch Lucys /v1-Weiterleitung — seit 24.09.2026 im Thread.
|
||||
alt = await asyncio.to_thread(_fingerprints)
|
||||
yield ": verbunden\n\n"
|
||||
|
||||
seit_abdruck = 0.0
|
||||
seit_ping = 0.0
|
||||
takt = METRIK_S if mit_metrik else ABDRUCK_S
|
||||
|
||||
while True:
|
||||
if await request.is_disconnected():
|
||||
return
|
||||
await asyncio.sleep(takt)
|
||||
seit_abdruck += takt
|
||||
seit_ping += takt
|
||||
|
||||
if mit_metrik:
|
||||
try:
|
||||
from services.system import metrik_punkt
|
||||
punkt = await asyncio.to_thread(metrik_punkt)
|
||||
yield f"event: metrik\ndata: {json.dumps(punkt)}\n\n"
|
||||
seit_ping = 0.0
|
||||
except Exception:
|
||||
# Ein kaputter Messpunkt darf den Strom nicht reißen — die Ansicht fällt
|
||||
# dann auf ihre Poller zurück, das ist besser als eine tote Leitung.
|
||||
log.warning("Messpunkt fehlgeschlagen", exc_info=True)
|
||||
|
||||
if seit_abdruck >= ABDRUCK_S:
|
||||
seit_abdruck = 0.0
|
||||
neu = await asyncio.to_thread(_fingerprints)
|
||||
keys = [k for k, v in neu.items() if k in alt and v != alt[k]]
|
||||
alt.update(neu)
|
||||
if keys:
|
||||
yield f"event: invalidate\ndata: {json.dumps({'keys': keys})}\n\n"
|
||||
seit_ping = 0.0
|
||||
elif seit_ping >= KEEPALIVE_S:
|
||||
yield ": ping\n\n"
|
||||
seit_ping = 0.0
|
||||
|
||||
return StreamingResponse(strom(), media_type="text/event-stream",
|
||||
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"})
|
||||
if seit_ping >= KEEPALIVE_S:
|
||||
yield ": ping\n\n"
|
||||
seit_ping = 0.0
|
||||
|
||||
|
||||
_KOPF = {"Cache-Control": "no-cache", "X-Accel-Buffering": "no"}
|
||||
|
||||
|
||||
@router.get("/stream")
|
||||
async def stream(request: Request) -> StreamingResponse:
|
||||
"""Anstöße UND Messpunkte."""
|
||||
return StreamingResponse(_strom(request, mit_metrik=True),
|
||||
media_type="text/event-stream", headers=_KOPF)
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from collections import OrderedDict
|
||||
|
||||
import httpx
|
||||
from config import LLAMA_SWAP_URL
|
||||
from fastapi import APIRouter, Request
|
||||
from fastapi.responses import JSONResponse, StreamingResponse
|
||||
from services.gateway_stream import record_stream_chunk, record_usage, warn_truncation
|
||||
from services.router_logic import IMAGE_PART_TYPES, VISION_CAPABLE, choose_for_lane, has_image
|
||||
from services.router_logic import VISION_CAPABLE, bild_ziel, bilder_aufteilen, choose_for_lane, has_image
|
||||
from services.routing_policy import load_policy
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
@@ -22,8 +25,15 @@ _LANG_DIRECTIVE = os.environ.get(
|
||||
"Quellcode, Bezeichner und Shell-Befehle bleiben unverändert.")
|
||||
|
||||
|
||||
# Bild-Beschreibung für Coder-Ziele: Prompt bewusst auf wörtliche Wiedergabe von
|
||||
# Code/Fehlermeldungen getrimmt — der Coder arbeitet nur mit diesem Text weiter.
|
||||
# Deckel für die Bild-Beschreibungen. Ohne ihn wächst die Wartezeit linear mit der Bildzahl:
|
||||
# je Bild bis zu 2 Versuche à _BILD_TIMEOUT_S, und der Client hängt so lange am offenen
|
||||
# Request. Sind mehr als _BILD_MAX Bilder NEU zu beschreiben, bleiben alle Bilder drin und die
|
||||
# Anfrage geht an den Bild-Zwilling (ehrlich langsam statt scheinbar hängend).
|
||||
_BILD_TIMEOUT_S = float(os.environ.get("MC_CODER_IMAGE_TIMEOUT_S", "240"))
|
||||
_BILD_MAX = int(os.environ.get("MC_CODER_IMAGE_MAX", "4"))
|
||||
|
||||
# Beschreibung älterer Bilder: Prompt bewusst auf wörtliche Wiedergabe von Code/Fehlermeldungen
|
||||
# getrimmt — das schnelle Modell arbeitet in den Folgeschritten nur noch mit diesem Text.
|
||||
_BILD_BESCHREIB_PROMPT = os.environ.get(
|
||||
"MC_CODER_IMAGE_PROMPT",
|
||||
"Beschreibe dieses Bild vollstaendig und praezise auf Deutsch: sichtbarer Text, "
|
||||
@@ -63,57 +73,74 @@ def _apply_no_think_marker(body: dict) -> None:
|
||||
body["chat_template_kwargs"] = {"enable_thinking": False}
|
||||
|
||||
|
||||
def _ist_coder_alias(alias: str) -> bool:
|
||||
"""Coder-Ziele (coder, rohe Qwen-Coder-IDs) bekommen Bild-BESCHREIBUNGEN statt der
|
||||
stumpfen Vision-Umleitung — die Code-Frage bleibt beim Spezialisten."""
|
||||
return "coder" in (alias or "").lower()
|
||||
# Bild-Weiche v3 (24.09.2026). llama.cpp kann Draft-Beschleunigung und Bilder nicht zusammen (HTTP 500
|
||||
# „failed to process speculative batch“, b11057 und b11157 geprüft). Darum haben Hirn und Coder je einen
|
||||
# Bild-Zwilling: dieselben Gewichte mit Bild-Projektor, ohne Draft (vision, coder-bild).
|
||||
# • Bild im aktuellen Schritt → der Zwilling der Rolle sieht es selbst, auch mitten in einer Agenten-Aufgabe.
|
||||
# • Bild aus früheren Schritten → einmal von vision beschrieben (je Bild gemerkt) und als Text mitgeschickt,
|
||||
# damit der Rest der Aufgabe wieder beim schnellen Modell mit Draft läuft.
|
||||
_BESCHREIBUNGEN: OrderedDict[str, str] = OrderedDict()
|
||||
_BESCHREIBUNGEN_MAX = 64
|
||||
|
||||
|
||||
async def _bilder_fuer_coder_beschreiben(body: dict, client, vision_alias: str) -> bool:
|
||||
"""Ersetzt jeden Bild-Part durch eine Text-Beschreibung vom Vision-Modell.
|
||||
def _bild_schluessel(part: dict) -> str:
|
||||
return hashlib.sha1(json.dumps(part, sort_keys=True).encode("utf-8")).hexdigest()
|
||||
|
||||
Idee aus einem verwaisten, nie verdrahteten Patch in der Hermes-Quelle
|
||||
(gateway/platforms/api_server.py, 07/2026) — bei der Projekt-Review 15.07.
|
||||
regelkonform hierher umgezogen (Archiv: docs/archiv/). True = alle Bilder
|
||||
ersetzt; False = eine Analyse scheiterte, Aufrufer nutzt die normale
|
||||
Vision-Umleitung als Fallback.
|
||||
"""
|
||||
fundstellen: list[tuple[dict, int, dict]] = []
|
||||
for m in body.get("messages") or []:
|
||||
if not isinstance(m, dict) or not isinstance(m.get("content"), list):
|
||||
continue
|
||||
for i, part in enumerate(m["content"]):
|
||||
if isinstance(part, dict) and part.get("type") in IMAGE_PART_TYPES:
|
||||
fundstellen.append((m, i, part))
|
||||
for nr, (msg, idx, part) in enumerate(fundstellen, start=1):
|
||||
frage = {
|
||||
"model": vision_alias,
|
||||
"stream": False,
|
||||
"max_tokens": 700,
|
||||
"messages": [{"role": "user",
|
||||
"content": [part, {"type": "text", "text": _BILD_BESCHREIB_PROMPT}]}],
|
||||
}
|
||||
# Eigener Kurzzeit-Client statt des gepoolten (Befund 15.07.: ReadTimeout nach
|
||||
# Sekunden trotz timeout=240 — llama-swap kappt beim Modell-Swap gern alte
|
||||
# Keep-Alive-Sockets, der Pool reicht sie trotzdem wieder aus) + 1 Retry.
|
||||
text = ""
|
||||
for versuch in (1, 2):
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=240.0) as c:
|
||||
r = await c.post(f"{LLAMA_SWAP_URL}/v1/chat/completions", json=frage)
|
||||
if r.status_code == 200:
|
||||
text = ((r.json().get("choices") or [{}])[0].get("message") or {}).get("content") or ""
|
||||
if text.strip():
|
||||
break
|
||||
log.warning("Bild-Weiche v2 (Versuch %s): Beschreibung leer/HTTP %s — %.200s",
|
||||
versuch, r.status_code, r.text)
|
||||
except Exception:
|
||||
log.warning("Bild-Weiche v2 (Versuch %s): Vision-Aufruf scheiterte", versuch, exc_info=True)
|
||||
if not text.strip():
|
||||
|
||||
async def _beschreibe(part: dict, vision_alias: str) -> str:
|
||||
"""Beschreibung eines Bild-Parts; Hermes und OpenCode schicken den ganzen Verlauf mit jedem Schritt
|
||||
neu, darum wird jedes Bild nur einmal beschrieben."""
|
||||
schluessel = _bild_schluessel(part)
|
||||
if schluessel in _BESCHREIBUNGEN:
|
||||
_BESCHREIBUNGEN.move_to_end(schluessel)
|
||||
return _BESCHREIBUNGEN[schluessel]
|
||||
frage = {
|
||||
"model": vision_alias,
|
||||
"stream": False,
|
||||
"max_tokens": 700,
|
||||
# Denken aus: sonst frisst die Denkphase das Token-Budget und die Beschreibung bleibt leer.
|
||||
"chat_template_kwargs": {"enable_thinking": False},
|
||||
"messages": [{"role": "user", "content": [part, {"type": "text", "text": _BILD_BESCHREIB_PROMPT}]}],
|
||||
}
|
||||
text = ""
|
||||
# Eigener Kurzzeit-Client statt des gepoolten (Befund 15.07.: ReadTimeout nach Sekunden trotz
|
||||
# timeout=240 — llama-swap kappt beim Modell-Swap gern alte Keep-Alive-Sockets) + 1 Retry.
|
||||
for versuch in (1, 2):
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=_BILD_TIMEOUT_S) as c:
|
||||
r = await c.post(f"{LLAMA_SWAP_URL}/v1/chat/completions", json=frage)
|
||||
if r.status_code == 200:
|
||||
text = ((r.json().get("choices") or [{}])[0].get("message") or {}).get("content") or ""
|
||||
if text.strip():
|
||||
break
|
||||
log.warning("Bild-Weiche (Versuch %s): Beschreibung leer/HTTP %s — %.200s",
|
||||
versuch, r.status_code, r.text)
|
||||
except Exception:
|
||||
log.warning("Bild-Weiche (Versuch %s): Beschreibung scheiterte", versuch, exc_info=True)
|
||||
text = text.strip()
|
||||
if text:
|
||||
_BESCHREIBUNGEN[schluessel] = text
|
||||
while len(_BESCHREIBUNGEN) > _BESCHREIBUNGEN_MAX:
|
||||
_BESCHREIBUNGEN.popitem(last=False)
|
||||
return text
|
||||
|
||||
|
||||
async def _alte_bilder_beschreiben(alt: list[tuple[dict, int]], vision_alias: str) -> bool:
|
||||
"""Bilder aus früheren Schritten durch ihre Beschreibung ersetzen. False = ging nicht (zu viele neue
|
||||
oder eine Beschreibung scheiterte) — dann bleiben alle Bilder drin."""
|
||||
neu = {_bild_schluessel(msg["content"][idx]) for msg, idx in alt} - set(_BESCHREIBUNGEN)
|
||||
if len(neu) > _BILD_MAX:
|
||||
log.warning("Bild-Weiche: %s ältere Bilder neu zu beschreiben (Deckel %s) — Anfrage geht mit allen "
|
||||
"Bildern an den Bild-Zwilling", len(neu), _BILD_MAX)
|
||||
return False
|
||||
texte = []
|
||||
for msg, idx in alt:
|
||||
text = await _beschreibe(msg["content"][idx], vision_alias)
|
||||
if not text:
|
||||
return False
|
||||
msg["content"][idx] = {"type": "text", "text": (
|
||||
f"[Bild {nr}: dem Request lag ein Bild bei — {vision_alias} beschreibt es so:\n"
|
||||
f"{text.strip()}]")}
|
||||
texte.append((msg, idx, text))
|
||||
for msg, idx, text in texte:
|
||||
msg["content"][idx] = {"type": "text", "text": f"[Bild aus einem früheren Schritt — so sah es aus:\n{text}]"}
|
||||
return True
|
||||
|
||||
|
||||
@@ -134,11 +161,29 @@ def _inject_language(body: dict, alias: str) -> None:
|
||||
elif isinstance(first_sys.get("content"), list):
|
||||
first_sys["content"].append({"type": "text", "text": _LANG_DIRECTIVE})
|
||||
|
||||
def _upstream_fehler(text: str, status: int = 502, headers: dict | None = None) -> JSONResponse:
|
||||
"""Fehler im OpenAI-Format statt eines nackten 500 (24.09.2026): Hermes, OpenChamber und Lucy
|
||||
können damit umgehen und zeigen den Grund an."""
|
||||
return JSONResponse({"error": {"message": text, "type": "upstream_error"}},
|
||||
status_code=status, headers=headers)
|
||||
|
||||
|
||||
@router.get("/models")
|
||||
async def models(request: Request):
|
||||
client = request.app.state.gw_client # geteilter Keep-Alive-Client (siehe app.py lifespan)
|
||||
r = await client.get(f"{LLAMA_SWAP_URL}/v1/models", timeout=10.0)
|
||||
data = r.json()
|
||||
try:
|
||||
r = await client.get(f"{LLAMA_SWAP_URL}/v1/models", timeout=10.0)
|
||||
except httpx.HTTPError as exc:
|
||||
return _upstream_fehler(f"Engine nicht erreichbar: {exc.__class__.__name__}")
|
||||
try:
|
||||
data = r.json()
|
||||
except ValueError:
|
||||
# Engine antwortet, aber nicht mit JSON (Startphase/Fehlerseite) — ehrlicher 502
|
||||
# statt eines 500ers aus dem Parser.
|
||||
log.warning("/v1/models: Engine-Antwort ist kein JSON (HTTP %s): %.200s", r.status_code, r.text)
|
||||
return JSONResponse(
|
||||
{"error": {"message": "Engine lieferte keine gültige Modell-Liste.",
|
||||
"type": "upstream_error"}}, status_code=502)
|
||||
# Aufgeräumt (07/2026): Die Router-Lanes (coding/chat) werden NICHT mehr als „Modell"
|
||||
# angeboten — sie verwirrten (coding vs. coder) und seit dem Zed-Aus wählt sie niemand
|
||||
# mehr. Das Routing bleibt erhalten (siehe _proxy), sie stehen nur nicht mehr in der Liste.
|
||||
@@ -187,7 +232,20 @@ async def models(request: Request):
|
||||
|
||||
|
||||
async def _proxy(path: str, request: Request):
|
||||
body = await request.json()
|
||||
# Ungültige Payloads gehören dem Client, nicht dem Server: ohne diese Prüfung wirft
|
||||
# request.json() bzw. body.get(...) durch und der Aufrufer bekommt einen 500er
|
||||
# (gemessen 27.08.2026: Rohtext, leerer Body, JSON-Liste, JSON-String und null — 5/5 mal 500).
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
return JSONResponse(
|
||||
{"error": {"message": "Ungültiger Request-Body: JSON erwartet.",
|
||||
"type": "invalid_request_error"}}, status_code=400)
|
||||
if not isinstance(body, dict):
|
||||
return JSONResponse(
|
||||
{"error": {"message": "Ungültiger Request-Body: JSON-Objekt erwartet, "
|
||||
f"'{type(body).__name__}' bekommen.",
|
||||
"type": "invalid_request_error"}}, status_code=400)
|
||||
_apply_no_think_marker(body) # Lucy-Voice-Schnellspur: Marker strippen + Thinking aus
|
||||
requested = str(body.get("model") or "auto")
|
||||
if requested.lower() in ("auto", "chat", "coding"):
|
||||
@@ -201,31 +259,36 @@ async def _proxy(path: str, request: Request):
|
||||
|
||||
pol = load_policy()
|
||||
client = request.app.state.gw_client # geteilter Keep-Alive-Client (siehe app.py lifespan)
|
||||
# Bild-Weiche (Faden 11): Requests mit Bild-Anhang automatisch ans Vision-Modell umleiten,
|
||||
# sofern das Ziel nicht ohnehin bildfähig ist. Das MTP-Hirn (fast/hermes) kann keine Bilder —
|
||||
# so bleibt Lucy schnell, ohne dass jemand manuell das Modell wechselt (Entscheid Weg A).
|
||||
# Coder-Sonderweg (15.07.): Coder-Ziele behalten den Request — die Bilder werden vorab vom
|
||||
# Vision-Modell BESCHRIEBEN und als Text injiziert (Screenshot-Debugging bleibt beim Coder).
|
||||
angefragt = alias
|
||||
# Bild-Weiche v3 (siehe oben): Bild im aktuellen Schritt → Bild-Zwilling der Rolle; ältere Bilder →
|
||||
# Beschreibung als Text, die Anfrage bleibt beim schnellen Modell.
|
||||
vision_alias = pol.get("vision")
|
||||
if vision_alias and alias not in VISION_CAPABLE and has_image(body):
|
||||
if _ist_coder_alias(alias) and await _bilder_fuer_coder_beschreiben(body, client, vision_alias):
|
||||
routed = {"x-mc-routed-to": alias,
|
||||
"x-mc-route-reason": "Bild beschrieben (Vision) -> bleibt beim Coder",
|
||||
"x-mc-lane": routed.get("x-mc-lane", "-")}
|
||||
else:
|
||||
alias = vision_alias
|
||||
frisch, alt = bilder_aufteilen(body)
|
||||
beschrieben = bool(alt) and await _alte_bilder_beschreiben(alt, vision_alias)
|
||||
lane = routed.get("x-mc-lane", "-")
|
||||
if frisch or not beschrieben:
|
||||
alias = bild_ziel(alias, vision_alias, pol.get("coder_vision") or None)
|
||||
body["model"] = alias
|
||||
# HTTP-Header-Werte muessen latin-1 sein — kein '→' o.ae. (sonst 500, 13.07.).
|
||||
routed = {"x-mc-routed-to": alias, "x-mc-route-reason": "Bild erkannt -> Vision-Modell",
|
||||
"x-mc-lane": routed.get("x-mc-lane", "-")}
|
||||
# fast-Spur: Thinking aus für flotte Antworten (sofern Client es nicht selbst setzt).
|
||||
if pol["fast_no_think"] and alias == pol["fast"] and "chat_template_kwargs" not in body:
|
||||
routed = {"x-mc-routed-to": alias, "x-mc-route-reason": "Bild im aktuellen Schritt -> Bild-Zwilling",
|
||||
"x-mc-lane": lane}
|
||||
else:
|
||||
routed = {"x-mc-routed-to": alias,
|
||||
"x-mc-route-reason": "aeltere Bilder beschrieben -> bleibt beim schnellen Modell",
|
||||
"x-mc-lane": lane}
|
||||
# fast-Spur: Thinking aus für flotte Antworten (sofern Client es nicht selbst setzt) — auch wenn die
|
||||
# Anfrage wegen eines Bildes beim Hirn-Zwilling landet (gleiches Hirn, gleiche Spur).
|
||||
if pol["fast_no_think"] and angefragt == pol["fast"] and "chat_template_kwargs" not in body:
|
||||
body["chat_template_kwargs"] = {"enable_thinking": False}
|
||||
_inject_language(body, alias)
|
||||
url = f"{LLAMA_SWAP_URL}{path}"
|
||||
if body.get("stream"):
|
||||
req = client.build_request("POST", url, json=body, timeout=None)
|
||||
r = await client.send(req, stream=True)
|
||||
try:
|
||||
r = await client.send(req, stream=True)
|
||||
except httpx.HTTPError as exc:
|
||||
return _upstream_fehler(f"Engine nicht erreichbar: {exc.__class__.__name__}", headers=routed)
|
||||
if r.status_code != 200:
|
||||
await r.aread()
|
||||
try:
|
||||
@@ -243,8 +306,15 @@ async def _proxy(path: str, request: Request):
|
||||
await r.aclose()
|
||||
return StreamingResponse(gen(), media_type="text/event-stream", headers=routed)
|
||||
|
||||
r = await client.post(url, json=body, timeout=600.0)
|
||||
resp_json = r.json()
|
||||
try:
|
||||
r = await client.post(url, json=body, timeout=600.0)
|
||||
except httpx.HTTPError as exc:
|
||||
return _upstream_fehler(f"Engine nicht erreichbar: {exc.__class__.__name__}", headers=routed)
|
||||
try:
|
||||
resp_json = r.json()
|
||||
except ValueError:
|
||||
log.warning("%s: Engine-Antwort ist kein JSON (HTTP %s): %.200s", path, r.status_code, r.text)
|
||||
return _upstream_fehler(f"Engine lieferte keine gültige Antwort (HTTP {r.status_code}).", headers=routed)
|
||||
if isinstance(resp_json, dict):
|
||||
record_usage(resp_json.get("usage"), alias)
|
||||
if any(isinstance(c, dict) and c.get("finish_reason") == "length"
|
||||
|
||||
+24
-15
@@ -1,9 +1,12 @@
|
||||
"""Health-/Status-Endpoint — schlanker Lebenszeichen-Check für MC 2.0."""
|
||||
"""Health-/Status-Endpoint — schlanker Lebenszeichen-Check einer Instanz.
|
||||
|
||||
Seit 24.09.2026 nennt er auch Rolle und Namen der Instanz: Die Partner-Instanz liest daran ab,
|
||||
wer ihr antwortet. Engine, Gateway und Hirn gibt es nur auf der KI-Box."""
|
||||
|
||||
from config import VERSION
|
||||
from fastapi import APIRouter
|
||||
from kern.einstellungen import einstellungen
|
||||
from pydantic import BaseModel
|
||||
from services import gateway, llamaswap
|
||||
|
||||
router = APIRouter(prefix="/api")
|
||||
|
||||
@@ -11,19 +14,25 @@ router = APIRouter(prefix="/api")
|
||||
class HealthResponse(BaseModel):
|
||||
status: str
|
||||
version: str
|
||||
engine_reachable: bool
|
||||
gateway_reachable: bool
|
||||
brain: dict
|
||||
rolle: str
|
||||
instanz: str
|
||||
engine_reachable: bool | None = None
|
||||
gateway_reachable: bool | None = None
|
||||
brain: dict | None = None
|
||||
|
||||
|
||||
@router.get("/health", response_model=HealthResponse)
|
||||
@router.get("/health", response_model=HealthResponse, response_model_exclude_none=True)
|
||||
def health() -> dict:
|
||||
return {
|
||||
"status": "ok",
|
||||
"version": VERSION,
|
||||
"engine_reachable": llamaswap.engine_reachable(),
|
||||
"gateway_reachable": gateway.gateway_reachable(),
|
||||
# Echte Hirn-Bereitschaft: Engine kann erreichbar sein, das Agent-Hirn ('fast') aber tot
|
||||
# (Crash/OOM nach Engine-Update). Das wäre sonst ein silent fail (App-Fehler statt Status).
|
||||
"brain": llamaswap.brain_status(),
|
||||
}
|
||||
e = einstellungen()
|
||||
antwort: dict = {"status": "ok", "version": VERSION, "rolle": e.rolle, "instanz": e.instanz}
|
||||
if e.rolle == "box":
|
||||
from services import gateway, llamaswap
|
||||
|
||||
antwort.update(
|
||||
engine_reachable=llamaswap.engine_reachable(),
|
||||
gateway_reachable=gateway.gateway_reachable(),
|
||||
# Echte Hirn-Bereitschaft: Engine kann erreichbar sein, das Agent-Hirn aber tot
|
||||
# (Crash/OOM nach Engine-Update). Das wäre sonst ein silent fail.
|
||||
brain=llamaswap.brain_status(),
|
||||
)
|
||||
return antwort
|
||||
|
||||
@@ -6,14 +6,19 @@ Hermes-GUI eine volle Single-Page-App: HTML + gehashte Assets + JSON-API + MEHRE
|
||||
(/api/ws, /api/console, /api/pty, /api/events). Deshalb hier ein GENERISCHER Reverse-Proxy für
|
||||
alles unter `/hermes-ui/*` (alle HTTP-Methoden + beliebige WebSockets).
|
||||
|
||||
Der Backend-Server läuft NUR auf Loopback (127.0.0.1:9119, `hermes serve --skip-build`, kein
|
||||
Login — gleiches LAN-Trust-Modell wie Konsole/Terminal) und wird über den ohnehin offenen
|
||||
MC2-Port (9001) same-origin durchgereicht → keine eigene Firewall-Freigabe, kein CORS.
|
||||
Der Backend-Server (`hermes dashboard`, :9119) wird über den MC2-Port (9001) same-origin
|
||||
durchgereicht → keine eigene Firewall-Freigabe, kein CORS.
|
||||
|
||||
Damit die absoluten Pfade der SPA (`/assets`, `/api`, `/api/ws`) nicht mit MC2s eigenen (`/assets`,
|
||||
`/api`) kollidieren, wird das Hermes-Bundle mit Vite-`base=/hermes-ui/` gebaut (deploy.sh) — dann
|
||||
liegen ALLE seine Pfade unter `/hermes-ui/`. Dieser Proxy streift das Präfix ab und leitet an
|
||||
`:9119/...` weiter.
|
||||
|
||||
Seit Hermes v0.21 hat das Dashboard eine eigene Anmeldung: `/` leitet auf `/login?next=…` um,
|
||||
und die Anmeldeseite schickt an `/auth/password-login`. Beide Pfade sind absolut und kennen
|
||||
das Präfix nicht — MC2 reichte die Umleitung unverändert weiter, und der Knopf „Hermes-GUI
|
||||
öffnen“ landete auf MC2s eigener „Diese Seite gibt es nicht“ (gefunden 23.09.2026). Deshalb
|
||||
schreibt der Proxy Umleitungen und diese Pfade im HTML auf `/hermes-ui/…` um.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
@@ -91,6 +96,24 @@ async def _proxy_ws(ws: WebSocket, path: str) -> None:
|
||||
_BASE_PATH_RE = re.compile(rb'window\.__HERMES_BASE_PATH__\s*=\s*"[^"]*"')
|
||||
_BASE_PATH_SET = b'window.__HERMES_BASE_PATH__="/' + _BASE.encode() + b'"'
|
||||
_HEAD_INJECT = b"<head><script>" + _BASE_PATH_SET + b";</script>"
|
||||
# Absolute Anmelde-Pfade in der (server-gerenderten) Login-Seite: "/auth/…", "/login", "/logout".
|
||||
_ANMELDE_PFADE = re.compile(rb'(["\'(=])/(auth|login|logout)(?=[/?"\')\s])')
|
||||
|
||||
|
||||
def praefixiere_ort(ort: str) -> str:
|
||||
"""Umleitungsziel unter /hermes-ui/ holen. Fremde Ziele (http…, //host) bleiben."""
|
||||
if ort.startswith("/") and not ort.startswith(("//", f"/{_BASE}/")):
|
||||
return f"/{_BASE}{ort}"
|
||||
return ort
|
||||
|
||||
|
||||
def praefixiere_html(body: bytes) -> bytes:
|
||||
"""Basis-Pfad der SPA setzen und die absoluten Anmelde-Pfade unter /hermes-ui/ legen."""
|
||||
if _BASE_PATH_RE.search(body):
|
||||
body = _BASE_PATH_RE.sub(_BASE_PATH_SET, body)
|
||||
elif b"<head>" in body:
|
||||
body = body.replace(b"<head>", _HEAD_INJECT, 1)
|
||||
return _ANMELDE_PFADE.sub(rb"\1/" + _BASE.encode() + rb"/\2", body)
|
||||
|
||||
|
||||
async def _proxy_http(request: Request, path: str = "") -> Response:
|
||||
@@ -113,12 +136,18 @@ async def _proxy_http(request: Request, path: str = "") -> Response:
|
||||
)
|
||||
body = r.content
|
||||
if "text/html" in r.headers.get("content-type", "").lower():
|
||||
if _BASE_PATH_RE.search(body):
|
||||
body = _BASE_PATH_RE.sub(_BASE_PATH_SET, body)
|
||||
elif b"<head>" in body:
|
||||
body = body.replace(b"<head>", _HEAD_INJECT, 1)
|
||||
resp_headers = {k: v for k, v in r.headers.items() if k.lower() not in _DROP}
|
||||
return Response(content=body, status_code=r.status_code, headers=resp_headers)
|
||||
body = praefixiere_html(body)
|
||||
resp = Response(content=body, status_code=r.status_code)
|
||||
# multi_items statt dict: Die Anmeldung setzt Cookies, und mehrere Set-Cookie-Zeilen
|
||||
# dürfen nicht zu einer zusammenfallen.
|
||||
for k, v in r.headers.multi_items():
|
||||
kl = k.lower()
|
||||
if kl in _DROP:
|
||||
continue
|
||||
if kl == "location":
|
||||
v = praefixiere_ort(v)
|
||||
resp.raw_headers.append((kl.encode("latin-1"), v.encode("latin-1")))
|
||||
return resp
|
||||
|
||||
|
||||
@router.get(f"/{_BASE}")
|
||||
|
||||
@@ -1,154 +0,0 @@
|
||||
"""Ideen-Queue-Endpoints — dünner REST-Layer über services/ideen.py (natives Hermes-Kanban).
|
||||
LAN-only wie alle MC2-Endpoints; das Mensch-Gate bleibt die Karte im Auftragsbuch."""
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from pydantic import BaseModel
|
||||
from services import ideen
|
||||
|
||||
router = APIRouter(prefix="/api")
|
||||
|
||||
|
||||
class IdeeIn(BaseModel):
|
||||
titel: str
|
||||
notiz: str = ""
|
||||
welt: str = "box" # "box" = Werkstatt baut · "ide" = vorbereiten für Agentic IDE
|
||||
tiefe: str = "" # nur ide: "simpel" | "gruendlich" (leer = Box schätzt selbst)
|
||||
ziel: str = "" # nur ide-Projekt: "lxc" | "docker" (leer = Box entscheidet)
|
||||
art: str = "projekt" # nur ide: "projekt" (Repo+Roadmap+Bau) | "idee" (nur durchdenken)
|
||||
|
||||
|
||||
class TaskIn(BaseModel):
|
||||
id: str
|
||||
|
||||
|
||||
class AntwortIn(BaseModel):
|
||||
id: str
|
||||
antwort: str
|
||||
|
||||
|
||||
class ArchivAlleIn(BaseModel):
|
||||
# Leer = alle fertigen Karten der Queue; gesetzt = nur die dieses Projekts.
|
||||
projekt: str = ""
|
||||
|
||||
|
||||
class EinordnenIn(BaseModel):
|
||||
text: str
|
||||
|
||||
|
||||
@router.get("/ideen")
|
||||
def list_queue() -> dict:
|
||||
return ideen.list_queue()
|
||||
|
||||
|
||||
@router.post("/ideen")
|
||||
def add_idea(body: IdeeIn) -> dict:
|
||||
res = ideen.add_idea(body.titel, body.notiz, welt=body.welt, tiefe=body.tiefe,
|
||||
ziel=body.ziel, art=body.art)
|
||||
if not res.get("ok"):
|
||||
raise HTTPException(400, res.get("error", "Idee konnte nicht angelegt werden."))
|
||||
return res
|
||||
|
||||
|
||||
@router.post("/ideen/antwort")
|
||||
def answer(body: AntwortIn) -> dict:
|
||||
res = ideen.answer_task(body.id, body.antwort)
|
||||
if not res.get("ok"):
|
||||
raise HTTPException(400, res.get("error", "Antwort konnte nicht gesendet werden."))
|
||||
return res
|
||||
|
||||
|
||||
class PrioIn(BaseModel):
|
||||
id: str
|
||||
richtung: str # hoch | runter
|
||||
|
||||
|
||||
@router.post("/ideen/stopp")
|
||||
def stop(body: TaskIn) -> dict:
|
||||
res = ideen.stop_task(body.id)
|
||||
if not res.get("ok"):
|
||||
raise HTTPException(400, res.get("error", "Stoppen fehlgeschlagen."))
|
||||
return res
|
||||
|
||||
|
||||
@router.post("/ideen/retry")
|
||||
def retry(body: TaskIn) -> dict:
|
||||
res = ideen.retry_task(body.id)
|
||||
if not res.get("ok"):
|
||||
raise HTTPException(400, res.get("error", "Neuer Versuch fehlgeschlagen."))
|
||||
return res
|
||||
|
||||
|
||||
@router.post("/ideen/prio")
|
||||
def prio(body: PrioIn) -> dict:
|
||||
res = ideen.prio_task(body.id, body.richtung)
|
||||
if not res.get("ok"):
|
||||
raise HTTPException(400, res.get("error", "Prio-Änderung fehlgeschlagen."))
|
||||
return res
|
||||
|
||||
|
||||
class WeiterIn(BaseModel):
|
||||
id: str
|
||||
ziel: str = "" # "lxc" | "docker" (leer = Box entscheidet)
|
||||
tiefe: str = "" # "simpel" | "gruendlich" (leer = Box schätzt selbst)
|
||||
|
||||
|
||||
@router.post("/ideen/weiterfuehren")
|
||||
def weiterfuehren(body: WeiterIn) -> dict:
|
||||
"""„Konzept gefällt mir" → IDE-Projekt-Karte mit dem fertigen Konzept als Vorlage."""
|
||||
res = ideen.projekt_aus_idee(body.id, ziel=body.ziel, tiefe=body.tiefe)
|
||||
if not res.get("ok"):
|
||||
raise HTTPException(400, res.get("error", "Projekt konnte nicht angelegt werden."))
|
||||
return res
|
||||
|
||||
|
||||
class NachschaerfenIn(BaseModel):
|
||||
id: str
|
||||
hinweis: str
|
||||
|
||||
|
||||
@router.post("/ideen/nachschaerfen")
|
||||
def nachschaerfen(body: NachschaerfenIn) -> dict:
|
||||
"""„Punkt X passt nicht" → Überarbeitungs-Runde am bestehenden Konzept."""
|
||||
res = ideen.konzept_ueberarbeiten(body.id, body.hinweis)
|
||||
if not res.get("ok"):
|
||||
raise HTTPException(400, res.get("error", "Überarbeitung konnte nicht gestartet werden."))
|
||||
return res
|
||||
|
||||
|
||||
@router.post("/ideen/archivieren")
|
||||
def archive(body: TaskIn) -> dict:
|
||||
res = ideen.archive_task(body.id)
|
||||
if not res.get("ok"):
|
||||
raise HTTPException(400, res.get("error", "Archivieren fehlgeschlagen."))
|
||||
return res
|
||||
|
||||
|
||||
@router.post("/ideen/einordnen")
|
||||
def einordnen(body: EinordnenIn) -> dict:
|
||||
"""Einordnungs-VORSCHLAG beim Tippen. Wirft nie — scheitert es, kommt `ok: false`
|
||||
und die Oberfläche behält ihre Voreinstellung."""
|
||||
return ideen.einordnen(body.text)
|
||||
|
||||
|
||||
@router.post("/ideen/archivieren-alle")
|
||||
def archive_alle(body: ArchivAlleIn) -> dict:
|
||||
"""Fertige Karten sammelweise wegräumen. Fasst ausschließlich `done` an."""
|
||||
res = ideen.archive_done(body.projekt)
|
||||
if not res.get("ok"):
|
||||
raise HTTPException(400, res.get("error", "Archivieren fehlgeschlagen."))
|
||||
return res
|
||||
|
||||
|
||||
@router.get("/ideen/{id}/log")
|
||||
def get_log(id: str) -> dict:
|
||||
return ideen.log_of(id)
|
||||
|
||||
|
||||
@router.get("/ideen/{id}/ergebnis")
|
||||
def get_ergebnis(id: str) -> dict:
|
||||
return ideen.ergebnis_of(id)
|
||||
|
||||
|
||||
@router.get("/ideen/{id}/konzept")
|
||||
def get_konzept(id: str) -> dict:
|
||||
return ideen.konzept_of(id)
|
||||
@@ -1,19 +1,28 @@
|
||||
"""Wartungs-Endpoints: Update-Badge, OS-/Engine-Update, Reboot, Restart, Logs."""
|
||||
"""Wartungs-Endpoints: Update-Badge, OS-/Engine-Update, Reboot, Restart, Logs.
|
||||
|
||||
from fastapi import APIRouter, Header, HTTPException
|
||||
v3-Umbau P1 (28.08.2026): Das Sudo-Passwort ist hier ersatzlos entfallen. Auf der Box
|
||||
gemessen — `sudo -n true` läuft durch, weil `/etc/sudoers` den Dienst-Nutzer mit
|
||||
`NOPASSWD: ALL` führt. Das Passwort wurde also nie gebraucht, lag aber im
|
||||
`localStorage` des Browsers und reiste bei jedem mutierenden Request mit. Sollte die
|
||||
sudoers-Zeile je fallen, meldet `services.maintenance` sauber `password_required`
|
||||
statt still zu scheitern — das ist dann ein Konfigurations-Signal und nichts, was man
|
||||
mit einem im Browser geparkten Geheimnis übertüncht.
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from pydantic import BaseModel
|
||||
from services import maintenance
|
||||
from services import geheimnisse, maintenance
|
||||
|
||||
router = APIRouter(prefix="/api")
|
||||
|
||||
|
||||
class SudoReq(BaseModel):
|
||||
sudo_password: str | None = None
|
||||
|
||||
|
||||
class RestartReq(BaseModel):
|
||||
service: str
|
||||
sudo_password: str | None = None
|
||||
|
||||
|
||||
class GeheimnisReq(BaseModel):
|
||||
schluessel: str
|
||||
wert: str | None = None
|
||||
|
||||
|
||||
@router.get("/maintenance/updates")
|
||||
@@ -28,32 +37,32 @@ def update_details(kind: str) -> dict:
|
||||
return maintenance.update_details(kind)
|
||||
|
||||
@router.post("/maintenance/check-updates")
|
||||
def check_updates(body: SudoReq) -> dict:
|
||||
res = maintenance.check_updates_job(body.sudo_password)
|
||||
def check_updates() -> dict:
|
||||
res = maintenance.check_updates_job()
|
||||
if isinstance(res, dict) and not res.get("ok", True):
|
||||
return res
|
||||
return res
|
||||
|
||||
|
||||
@router.post("/maintenance/os-update")
|
||||
def os_update(body: SudoReq) -> dict:
|
||||
res = maintenance.os_update_job(body.sudo_password)
|
||||
def os_update() -> dict:
|
||||
res = maintenance.os_update_job()
|
||||
if isinstance(res, dict) and not res.get("ok", True):
|
||||
return res
|
||||
return res
|
||||
|
||||
|
||||
@router.post("/maintenance/engine-update")
|
||||
def engine_update(body: SudoReq) -> dict:
|
||||
res = maintenance.engine_update_job(body.sudo_password)
|
||||
def engine_update() -> dict:
|
||||
res = maintenance.engine_update_job()
|
||||
if not res:
|
||||
raise HTTPException(400, "Kein Engine-Update-Befehl gesetzt (MC_ENGINE_UPDATE_CMD).")
|
||||
return res
|
||||
|
||||
|
||||
@router.post("/maintenance/swap-update")
|
||||
def swap_update(body: SudoReq) -> dict:
|
||||
res = maintenance.swap_update_job(body.sudo_password)
|
||||
def swap_update() -> dict:
|
||||
res = maintenance.swap_update_job()
|
||||
if not res:
|
||||
raise HTTPException(400, "Kein Router-Update-Befehl gesetzt (MC_SWAP_UPDATE_CMD).")
|
||||
return res
|
||||
@@ -65,20 +74,29 @@ def hermes_update() -> dict:
|
||||
|
||||
|
||||
@router.post("/maintenance/update-all")
|
||||
def update_all(body: SudoReq) -> dict:
|
||||
return maintenance.update_all_job(body.sudo_password)
|
||||
|
||||
|
||||
@router.post("/maintenance/reboot")
|
||||
def reboot(body: SudoReq) -> dict:
|
||||
return maintenance.reboot(body.sudo_password)
|
||||
def update_all() -> dict:
|
||||
return maintenance.update_all_job()
|
||||
|
||||
|
||||
@router.post("/maintenance/restart")
|
||||
def restart(body: RestartReq) -> dict:
|
||||
return maintenance.restart_service(body.service, body.sudo_password)
|
||||
return maintenance.restart_service(body.service)
|
||||
|
||||
|
||||
@router.get("/maintenance/logs")
|
||||
def logs(service: str, lines: int = 200, x_sudo_password: str | None = Header(None)) -> dict:
|
||||
return maintenance.logs(service, lines, x_sudo_password)
|
||||
def logs(service: str, lines: int = 200) -> dict:
|
||||
return maintenance.logs(service, lines)
|
||||
|
||||
|
||||
@router.get("/maintenance/geheimnisse")
|
||||
def geheimnisse_status() -> dict:
|
||||
"""Nur der Zustand — ob ein Token gesetzt ist, niemals sein Wert."""
|
||||
return geheimnisse.status()
|
||||
|
||||
|
||||
@router.post("/maintenance/geheimnisse")
|
||||
def geheimnisse_setzen(body: GeheimnisReq) -> dict:
|
||||
"""Setzt (oder löscht bei leerem Wert) ein Geheimnis in der Box-Ablage."""
|
||||
if not geheimnisse.setzen(body.schluessel, body.wert):
|
||||
raise HTTPException(400, f"Geheimnis '{body.schluessel}' konnte nicht gespeichert werden.")
|
||||
return {"ok": True, **geheimnisse.status()}
|
||||
|
||||
+33
-118
@@ -1,11 +1,12 @@
|
||||
"""Modelle-Endpoints: Liste (mit Caps), Discover, Fit, Register, Groups."""
|
||||
"""Modelle-Endpoints: Liste, Discover, Suche und Installation bei Hugging Face, Jobs, Rollen,
|
||||
Laden/Entladen/Löschen. Fit-, Kontext-, Draft- und Gruppen-Routen sind am 24.09.2026 entfallen
|
||||
(ohne Nutzer seit dem Box-Wart-Umbau)."""
|
||||
|
||||
import psutil
|
||||
from config import HF_DOWNLOAD_ENV, MODELS_DIR
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from pydantic import BaseModel
|
||||
from services import budget, discover, hf, jobengine, llamaswap
|
||||
from services.fit import evaluate_fit, max_ctx_for
|
||||
from services import budget, discover, geheimnisse, hf, jobengine, llamaswap
|
||||
|
||||
router = APIRouter(prefix="/api")
|
||||
|
||||
@@ -29,27 +30,6 @@ def discover_models(force: bool = False) -> dict:
|
||||
return {**data, "sys_ram_gb": round(ram, 1)}
|
||||
|
||||
|
||||
@router.get("/fit")
|
||||
def fit(params_b: float = 0, quant: str = "Q4_K_M", ctx: int = 8192,
|
||||
name: str = "", role: str = "") -> dict:
|
||||
"""Hardware-Fit-Vorschau. params_b<=0 → aus KATALOG (echte Metadaten, MoE-bewusst)
|
||||
oder sonst aus dem Namen geschätzt. assigned_ctx = der ctx, der TATSÄCHLICH vergeben
|
||||
würde: SETUP-BEWUSST (neben Hirn/warmem Set), nicht nur gegen den Gesamt-RAM.
|
||||
So sieht die 'Erweiterte Ansicht' vor dem Download Ampel + echten ctx."""
|
||||
ram = _ram_gb()
|
||||
pb = params_b if params_b > 0 else budget.params_b_for(name)
|
||||
saw = budget.setup_aware_ctx(pb, quant, role=role or None)
|
||||
return {
|
||||
"params_b": round(pb, 1),
|
||||
"fit": evaluate_fit(pb, quant, ctx, ram, name=name),
|
||||
"optimal_ctx": max_ctx_for(pb, quant, ram), # Roh-Obergrenze (Modell allein)
|
||||
"assigned_ctx": saw["ctx"], # setup-bewusst vergeben
|
||||
"budget": {"gtt_gb": saw["gtt_gb"], "reserved_gb": saw["reserved_gb"],
|
||||
"budget_gb": saw["budget_gb"], "mode": saw["mode"]},
|
||||
"sys_ram_gb": round(ram, 1),
|
||||
}
|
||||
|
||||
|
||||
class RegisterReq(BaseModel):
|
||||
model_path: str
|
||||
role: str | None = None
|
||||
@@ -61,6 +41,10 @@ class RegisterReq(BaseModel):
|
||||
|
||||
@router.post("/models/register")
|
||||
def register(req: RegisterReq) -> dict:
|
||||
# Nur Dateien aus dem Modellordner (24.09.2026): der Pfad landet im Startbefehl der Engine.
|
||||
for pfad in (req.model_path, req.mmproj_path):
|
||||
if pfad and not llamaswap.im_modellordner(pfad):
|
||||
raise HTTPException(400, f"Pfad liegt nicht im Modellordner ({MODELS_DIR}): {pfad}")
|
||||
try:
|
||||
model_id = llamaswap.register_model(
|
||||
req.model_path, role=req.role, ctx=req.ctx, ttl=req.ttl,
|
||||
@@ -71,13 +55,29 @@ def register(req: RegisterReq) -> dict:
|
||||
return {"ok": True, "model_id": model_id}
|
||||
|
||||
|
||||
@jobengine.nacharbeit("modell:rolle")
|
||||
def _rolle_uebernehmen(model_id: str, role: str) -> None:
|
||||
"""Nach dem Download die gewünschte Rolle setzen. hermes geht dabei durch den warm-bewussten
|
||||
Hirn-Flow (brains-Gruppe, ttl 0, Hermes-Config, Gateway-Restart) — der rohe Alias-Move hatte
|
||||
diesen Flow bisher umgangen."""
|
||||
if role == "hermes":
|
||||
from services.agent import set_agent_brain
|
||||
res = set_agent_brain(model_id)
|
||||
if not res.get("ok"):
|
||||
raise RuntimeError(res.get("reason", "Hirn-Wechsel fehlgeschlagen"))
|
||||
else:
|
||||
llamaswap.set_role(model_id, role)
|
||||
|
||||
|
||||
class InstallReq(BaseModel):
|
||||
repo: str
|
||||
role: str | None = None
|
||||
quant: str = "Q4_K_M"
|
||||
ctx: int | None = None
|
||||
jinja: bool = False
|
||||
hf_token: str | None = None
|
||||
# Kein hf_token mehr im Request (v3-Umbau P1): der Token lag frueher im localStorage
|
||||
# des Browsers und reiste hier mit. Jetzt liegt er auf der Box (services.geheimnisse)
|
||||
# und wird unten von dort gelesen — die Oberflaeche sieht ihn nie wieder.
|
||||
|
||||
|
||||
@router.get("/hf/search")
|
||||
@@ -123,20 +123,9 @@ def install(req: InstallReq) -> dict:
|
||||
except PermissionError as exc:
|
||||
raise HTTPException(500, str(exc))
|
||||
|
||||
# Rolle erst NACH erfolgreichem Download übernehmen. hermes geht dabei durch den
|
||||
# warm-bewussten Hirn-Flow (brains-Gruppe, ttl 0, Hermes-Config, Gateway-Restart) —
|
||||
# der rohe Alias-Move hatte diesen Flow bisher umgangen.
|
||||
# Rolle erst NACH erfolgreichem Download übernehmen (_rolle_uebernehmen).
|
||||
role = (req.role or "").strip().lower()
|
||||
|
||||
def _apply_role() -> None:
|
||||
if role == "hermes":
|
||||
from services.agent import set_agent_brain
|
||||
res = set_agent_brain(model_id)
|
||||
if not res.get("ok"):
|
||||
raise RuntimeError(res.get("reason", "Hirn-Wechsel fehlgeschlagen"))
|
||||
else:
|
||||
llamaswap.set_role(model_id, role)
|
||||
|
||||
# Download-Job: alle GGUF-Teile (+ mmproj) per --include holen.
|
||||
args = [hf.hf_bin(), "download", repo]
|
||||
args.extend(info["files"])
|
||||
@@ -144,10 +133,13 @@ def install(req: InstallReq) -> dict:
|
||||
args.append(info["mmproj"])
|
||||
args += ["--local-dir", str(target)]
|
||||
env = dict(HF_DOWNLOAD_ENV)
|
||||
if req.hf_token:
|
||||
env["HF_TOKEN"] = req.hf_token
|
||||
job_id = jobengine.start_job(args, f"download {req.repo}", env=env,
|
||||
on_done=_apply_role if role else None)
|
||||
if token := geheimnisse.hf_token():
|
||||
env["HF_TOKEN"] = token
|
||||
# Gruppe „download“: so taucht der Download in der Oberfläche mit Fortschritt und Abbrechen auf.
|
||||
# Der Download läuft als eigener Auftrag weiter, auch wenn MC2 zwischendurch neu startet.
|
||||
job_id = jobengine.start_job(args, f"Download {repo}", env=env, group="download", zeitlimit_s=6 * 3600,
|
||||
nacharbeit="modell:rolle" if role else None,
|
||||
nacharbeit_daten={"model_id": model_id, "role": role} if role else None)
|
||||
jobengine.attach_download_progress(job_id, str(target), info["total_bytes"])
|
||||
return {"ok": True, "job_id": job_id, "model_id": model_id, "model_path": model_path,
|
||||
"total_bytes": info["total_bytes"], "files": len(info["files"])}
|
||||
@@ -167,14 +159,6 @@ class RoleReq(BaseModel):
|
||||
role: str | None = None
|
||||
|
||||
|
||||
@router.get("/roles/{role}/recommend")
|
||||
def recommend_role(role: str) -> dict:
|
||||
"""Welches installierte Modell passt am besten auf diese Rolle? (Capability + setup-
|
||||
bewusster Fit). Basis für 'Empfohlen'-Hinweis + Auto-Pick im Rollen-Zuweisungs-Modal."""
|
||||
from services import roles
|
||||
return roles.recommend_for_role(role)
|
||||
|
||||
|
||||
@router.post("/models/{model_id}/role")
|
||||
def set_model_role(model_id: str, body: RoleReq) -> dict:
|
||||
new_role = (body.role or "").strip().lower()
|
||||
@@ -199,54 +183,6 @@ def set_model_role(model_id: str, body: RoleReq) -> dict:
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
class CtxReq(BaseModel):
|
||||
ctx: int
|
||||
|
||||
|
||||
@router.get("/models/{model_id}/ctx/auto")
|
||||
def auto_ctx(model_id: str) -> dict:
|
||||
"""Setup-bewusster Optimal-ctx für ein bestehendes Modell (Rolle/Params/Quant +
|
||||
aktuelles Setup). Basis für den 'Auto'-Button an der Modellkarte."""
|
||||
m = next((x for x in llamaswap.list_models() if x["name"] == model_id), None)
|
||||
if not m:
|
||||
raise HTTPException(404, "Modell nicht gefunden")
|
||||
saw = budget.setup_aware_ctx_for_model(m)
|
||||
return {"model_id": model_id, "current_ctx": m.get("ctx"),
|
||||
"params_b": round(budget.params_of_model(m), 1), "quant": m.get("quant"),
|
||||
"role": m.get("role"), **saw}
|
||||
|
||||
|
||||
@router.post("/models/{model_id}/ctx")
|
||||
def set_model_ctx(model_id: str, body: CtxReq) -> dict:
|
||||
if not llamaswap.set_ctx(model_id, body.ctx):
|
||||
raise HTTPException(404, "Modell nicht gefunden")
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@router.get("/models/drafts")
|
||||
def list_drafts(target: str = "") -> dict:
|
||||
"""Verfügbare Draft-Modelle + ihre Vocab-Kompatibilität zum Ziel-Modell
|
||||
(target = GGUF-Pfad). Basis für die idiotensichere Spec-Draft-Auswahl im UI."""
|
||||
return llamaswap.drafts_for(target)
|
||||
|
||||
|
||||
class DraftReq(BaseModel):
|
||||
draft_path: str | None = None
|
||||
|
||||
|
||||
@router.post("/models/{model_id}/draft")
|
||||
def set_model_draft(model_id: str, body: DraftReq) -> dict:
|
||||
"""Setzt/entfernt den Speculative-Decoding-Draft eines Modells. Inkompatible
|
||||
(oder nicht prüfbare) Drafts werden serverseitig abgelehnt."""
|
||||
try:
|
||||
res = llamaswap.set_spec_draft(model_id, body.draft_path)
|
||||
except PermissionError as exc:
|
||||
raise HTTPException(500, str(exc))
|
||||
if not res["ok"]:
|
||||
raise HTTPException(400 if "kompatib" in res["reason"].lower() else 404, res["reason"])
|
||||
return res
|
||||
|
||||
|
||||
@router.post("/models/unload")
|
||||
def unload_all_models() -> dict:
|
||||
import httpx
|
||||
@@ -295,24 +231,3 @@ def delete(model_id: str) -> dict:
|
||||
if not llamaswap.delete_model(model_id):
|
||||
raise HTTPException(404, "Modell nicht gefunden")
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@router.get("/groups")
|
||||
def groups() -> dict:
|
||||
return {"groups": llamaswap.list_groups()}
|
||||
|
||||
|
||||
class GroupReq(BaseModel):
|
||||
group: str
|
||||
members: list[str]
|
||||
swap: bool = False
|
||||
persist: bool = False
|
||||
|
||||
|
||||
@router.put("/groups")
|
||||
def set_group(req: GroupReq) -> dict:
|
||||
try:
|
||||
llamaswap.set_group(req.group, req.members, swap=req.swap, persist=req.persist)
|
||||
except PermissionError as exc:
|
||||
raise HTTPException(500, str(exc))
|
||||
return {"ok": True}
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
"""Partner-Instanz: Lebenszeichen und Durchreiche (zwei Instanzen, eine Oberfläche, 24.09.2026).
|
||||
|
||||
Die Oberfläche fragt den Bereich der anderen Instanz über /api/partner/<pfad> ab; hier wird
|
||||
daraus <partner>/api/<pfad>. Die Herkunftsprüfung (services/herkunft.py) greift wie bei jeder
|
||||
anderen schreibenden Anfrage schon hier, bevor etwas weitergereicht wird.
|
||||
"""
|
||||
|
||||
import httpx
|
||||
from fastapi import APIRouter, HTTPException, Request, Response
|
||||
from kern import partner
|
||||
from kern.einstellungen import einstellungen
|
||||
|
||||
router = APIRouter(prefix="/api", tags=["partner"])
|
||||
|
||||
# Nicht durchreichen: den Partner-Weg des Partners (sonst reichen sich zwei Instanzen eine Anfrage
|
||||
# endlos zu) und den Live-Strom (SSE hält die Verbindung offen; der Partner-Bereich fragt ab).
|
||||
_GESPERRT = ("partner", "stream")
|
||||
_ZEITLIMIT = httpx.Timeout(30.0, connect=5.0)
|
||||
|
||||
|
||||
@router.get("/instanz")
|
||||
def instanz() -> dict:
|
||||
"""Wer liefert die Seite aus? Ohne Netzabfragen, damit die Oberfläche sofort weiß, welche Anfragen
|
||||
sie selbst beantwortet und welche über /api/partner/… an die andere Instanz gehen."""
|
||||
e = einstellungen()
|
||||
return {"rolle": e.rolle, "instanz": e.instanz,
|
||||
"partner": {"eingerichtet": bool(e.partner_url), "name": e.partner_name}}
|
||||
|
||||
|
||||
@router.get("/partner")
|
||||
def partner_status() -> dict:
|
||||
return partner.status()
|
||||
|
||||
|
||||
@router.api_route("/partner/{pfad:path}", methods=["GET", "POST", "PUT", "PATCH", "DELETE"], include_in_schema=False)
|
||||
async def weiterreichen(pfad: str, request: Request) -> Response:
|
||||
e = einstellungen()
|
||||
if not e.partner_url:
|
||||
raise HTTPException(status_code=404, detail="Es ist keine zweite Instanz eingerichtet.")
|
||||
if pfad.split("/", 1)[0] in _GESPERRT:
|
||||
raise HTTPException(status_code=404, detail="Diese Schnittstelle wird nicht weitergereicht.")
|
||||
kopf = {"X-MC-Von": e.instanz}
|
||||
if request.headers.get("content-type"):
|
||||
kopf["Content-Type"] = request.headers["content-type"]
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=_ZEITLIMIT) as c:
|
||||
r = await c.request(request.method, f"{e.partner_url}/api/{pfad}",
|
||||
params=request.query_params, content=await request.body(), headers=kopf)
|
||||
except httpx.HTTPError:
|
||||
raise HTTPException(status_code=502, detail=f"{e.partner_name} ist gerade nicht erreichbar.") from None
|
||||
return Response(content=r.content, status_code=r.status_code,
|
||||
media_type=r.headers.get("content-type", "application/json"))
|
||||
@@ -0,0 +1,44 @@
|
||||
"""
|
||||
Modell-Radar-Schnittstellen (Box-Wart, Umbau 09/2026).
|
||||
|
||||
GET /api/radar Nächster und letzter Test, Kandidaten, Test-Verlauf
|
||||
POST /api/radar/suche Jetzt suchen (Merkliste + Hugging-Face-Entdeckung)
|
||||
POST /api/radar/{id}/uebernehmen Bestandenen Kandidaten in Betrieb nehmen (Modell-Tausch)
|
||||
POST /api/radar/{id}/verwerfen Kandidaten verwerfen (Dateien weg, nie wieder testen)
|
||||
|
||||
Dünn: die Logik liegt in services/radar.py. Getestet wird nur nachts (backend/radar_lauf.py).
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from services import radar
|
||||
|
||||
router = APIRouter(prefix="/api", tags=["radar"])
|
||||
|
||||
|
||||
def _antwort(ergebnis: dict) -> dict:
|
||||
if not ergebnis.get("ok"):
|
||||
raise HTTPException(status_code=int(ergebnis.get("status") or 409),
|
||||
detail=ergebnis.get("detail") or "Das ging nicht.")
|
||||
return ergebnis
|
||||
|
||||
|
||||
@router.get("/radar")
|
||||
def radar_stand() -> dict:
|
||||
return radar.uebersicht()
|
||||
|
||||
|
||||
@router.post("/radar/suche")
|
||||
def radar_suche() -> dict:
|
||||
"""Sucht sofort (Netz: Hugging Face) und liefert danach den neuen Stand."""
|
||||
ergebnis = radar.suche()
|
||||
return {**radar.uebersicht(), "suche": ergebnis}
|
||||
|
||||
|
||||
@router.post("/radar/{kandidat_id}/uebernehmen")
|
||||
def radar_uebernehmen(kandidat_id: str) -> dict:
|
||||
return _antwort(radar.uebernehmen(kandidat_id))
|
||||
|
||||
|
||||
@router.post("/radar/{kandidat_id}/verwerfen")
|
||||
def radar_verwerfen(kandidat_id: str) -> dict:
|
||||
return _antwort(radar.verwerfen(kandidat_id))
|
||||
@@ -1,9 +1,8 @@
|
||||
"""Routing-Endpoints: Lane-Summary (chat/coding) + UI-editierbare Policy (hot-reload)."""
|
||||
"""Routing-Übersicht (Lanes, Gateway erreichbar). Der Policy-Editor ist seit dem Box-Wart-Umbau
|
||||
aus der Oberfläche raus; die Policy selbst liest der Gateway weiter aus mc2-routing.json."""
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from pydantic import BaseModel
|
||||
from fastapi import APIRouter
|
||||
from services import gateway
|
||||
from services.routing_policy import policy_meta, save_policy
|
||||
|
||||
router = APIRouter(prefix="/api")
|
||||
|
||||
@@ -11,32 +10,3 @@ router = APIRouter(prefix="/api")
|
||||
@router.get("/routing")
|
||||
def routing() -> dict:
|
||||
return {**gateway.routing_summary(), "gateway_reachable": gateway.gateway_reachable()}
|
||||
|
||||
|
||||
@router.get("/routing/policy")
|
||||
def get_policy() -> dict:
|
||||
"""Aktuelle Policy + Defaults (für „Zurücksetzen“) + Feld-Spezifikation für den Editor."""
|
||||
return policy_meta()
|
||||
|
||||
|
||||
class PolicyPatch(BaseModel):
|
||||
fast: str | None = None
|
||||
heavy: str | None = None
|
||||
coder: str | None = None
|
||||
coder_lite: str | None = None
|
||||
heavy_chars: int | None = None
|
||||
coding_escalate_chars: int | None = None
|
||||
fast_no_think: bool | None = None
|
||||
|
||||
|
||||
@router.put("/routing/policy")
|
||||
def put_policy(patch: PolicyPatch) -> dict:
|
||||
"""Teil-Update der Routing-Policy. Validiert, persistiert atomar, sofort wirksam (hot-reload)."""
|
||||
fields = {k: v for k, v in patch.model_dump().items() if v is not None}
|
||||
if not fields:
|
||||
raise HTTPException(status_code=400, detail="Keine Felder zum Aktualisieren.")
|
||||
try:
|
||||
new_policy = save_policy(fields)
|
||||
except (ValueError, TypeError) as e:
|
||||
raise HTTPException(status_code=400, detail=f"Ungültige Policy: {e}")
|
||||
return {"policy": new_policy}
|
||||
|
||||
@@ -1,83 +0,0 @@
|
||||
import os
|
||||
import subprocess
|
||||
|
||||
import psutil
|
||||
from fastapi import APIRouter
|
||||
from pydantic import BaseModel
|
||||
|
||||
router = APIRouter(prefix="/api")
|
||||
|
||||
class RunSkillRequest(BaseModel):
|
||||
skill_name: str
|
||||
|
||||
@router.get("/skills")
|
||||
def list_skills():
|
||||
"""Listet alle verfügbaren Skills aus dem deploy/skills Verzeichnis auf."""
|
||||
# Pfad relativ zu dieser Datei (backend/routers/skills.py)
|
||||
current_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
repo_root = os.path.dirname(os.path.dirname(current_dir))
|
||||
skills_dir = os.path.join(repo_root, "deploy", "skills")
|
||||
skills = []
|
||||
|
||||
# Check currently running hermes skill processes
|
||||
running_skills = set()
|
||||
for p in psutil.process_iter(['name', 'cmdline']):
|
||||
try:
|
||||
cmdline = p.info.get('cmdline')
|
||||
if cmdline and any("hermes" in arg for arg in cmdline):
|
||||
for arg in cmdline:
|
||||
if "Führe den " in arg and " Skill aus" in arg:
|
||||
# Extract skill name from "Führe den 'skill_name' Skill aus"
|
||||
import re
|
||||
match = re.search(r"Führe den '(.+?)' Skill aus", arg)
|
||||
if match:
|
||||
running_skills.add(match.group(1))
|
||||
except (psutil.NoSuchProcess, psutil.AccessDenied, psutil.ZombieProcess):
|
||||
pass
|
||||
|
||||
if os.path.exists(skills_dir) and os.path.isdir(skills_dir):
|
||||
for entry in os.scandir(skills_dir):
|
||||
if entry.is_dir():
|
||||
# Suche nach SKILL.md für Metadaten
|
||||
skill_md_path = os.path.join(entry.path, "SKILL.md")
|
||||
description = ""
|
||||
if os.path.exists(skill_md_path):
|
||||
try:
|
||||
with open(skill_md_path, "r", encoding="utf-8") as f:
|
||||
# Lese erste Zeilen für Description (YAML Frontmatter)
|
||||
lines = f.readlines()[:10]
|
||||
for line in lines:
|
||||
if "description:" in line.lower():
|
||||
description = line.split(":", 1)[1].strip().strip('"\'')
|
||||
break
|
||||
elif "author:" in line.lower() and not description:
|
||||
description = line.strip()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
skills.append({
|
||||
"name": entry.name,
|
||||
"description": description or "Keine Beschreibung verfügbar.",
|
||||
"running": entry.name in running_skills
|
||||
})
|
||||
return {"skills": skills}
|
||||
|
||||
@router.post("/skills/run")
|
||||
def run_skill(req: RunSkillRequest):
|
||||
"""Startet einen autonomen Skill via Hermes im Hintergrund."""
|
||||
hermes_bin = os.path.expanduser("~/.local/bin/hermes")
|
||||
if not os.path.exists(hermes_bin):
|
||||
# Fallback falls lokal (auf Windows) entwickelt wird
|
||||
return {"ok": False, "err": "Hermes CLI nicht gefunden (~/.local/bin/hermes fehlt). Bist du lokal unterwegs?"}
|
||||
|
||||
# Entspricht: hermes -z "Führe den 'X' Skill aus" chat
|
||||
cmd = [hermes_bin, "-z", f"Führe den '{req.skill_name}' Skill aus", "chat"]
|
||||
try:
|
||||
log_path = os.path.expanduser(f"~/.hermes/logs/skill_{req.skill_name.replace('/', '_')}.log")
|
||||
os.makedirs(os.path.dirname(log_path), exist_ok=True)
|
||||
with open(log_path, "a") as f:
|
||||
f.write(f"\n--- Starting Skill: {req.skill_name} ---\n")
|
||||
subprocess.Popen(cmd, stdout=f, stderr=subprocess.STDOUT)
|
||||
return {"ok": True, "msg": f"Skill '{req.skill_name}' erfolgreich angestoßen."}
|
||||
except Exception as e:
|
||||
return {"ok": False, "err": str(e)}
|
||||
+15
-46
@@ -1,4 +1,4 @@
|
||||
"""System-Endpoints: Live-Status + Wartung (Restart/Self-Update — auf der Box).
|
||||
"""System-Endpoints: Live-Status, Dienste-Liste, Sicherung, Neustart, Token-Verbrauch.
|
||||
|
||||
Wartung läuft als systemd-USER-Dienst → KEIN sudo/Passwort (Nordstern).
|
||||
Lokal (Windows) schlagen die Shell-Befehle harmlos fehl und werden als Fehler
|
||||
@@ -10,11 +10,11 @@ import os
|
||||
import subprocess
|
||||
|
||||
import httpx
|
||||
from config import GATEWAY_URL, HERMES_API_URL, LLAMA_SWAP_URL, MEM0_SERVICE_URL, V1_UPSTREAM, VOICE_SERVICE_URL
|
||||
from config import GATEWAY_URL, HERMES_API_URL, LLAMA_SWAP_URL, V1_UPSTREAM, VOICE_SERVICE_URL
|
||||
from fastapi import APIRouter
|
||||
from pydantic import BaseModel
|
||||
from services import backup as backup_svc
|
||||
from services import maintenance
|
||||
from services import maintenance, waechter
|
||||
from services.agent import agent_status
|
||||
from services.gateway import gateway_reachable
|
||||
from services.llamaswap import engine_reachable, list_models
|
||||
@@ -26,29 +26,11 @@ log = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/api")
|
||||
|
||||
# Quelle für Self-Update (auf der Box ~/mission-control-v2).
|
||||
SOURCE_DIR = os.path.expanduser(os.environ.get("MC2_SOURCE_DIR", "~/mission-control-v2"))
|
||||
|
||||
|
||||
@router.get("/system/status")
|
||||
def status() -> dict:
|
||||
return system_status()
|
||||
|
||||
|
||||
@router.get("/system/history")
|
||||
def history(minutes: int = 60) -> dict:
|
||||
"""Metrik-Verlauf (max. 24 h) für die Cockpit-Zeitachse — s. services/metrics_history."""
|
||||
from services import metrics_history
|
||||
return metrics_history.history(minutes)
|
||||
|
||||
|
||||
def _mem0_reachable() -> bool:
|
||||
try:
|
||||
return httpx.get(f"{MEM0_SERVICE_URL}/health", timeout=2).status_code == 200
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def _voice_reachable() -> bool:
|
||||
try:
|
||||
return httpx.get(f"{VOICE_SERVICE_URL}/health", timeout=2).status_code == 200
|
||||
@@ -87,7 +69,7 @@ def services() -> dict:
|
||||
"ok": gateway_reachable(), "scope": "user"})
|
||||
# MC2 selbst antwortet gerade auf diesen Request — ok=True ist hier ehrlich;
|
||||
# die Zeile existiert für Restart/Logs, nicht als Erreichbarkeits-Orakel.
|
||||
rows.append({"name": "Steuerpult (MC2)", "unit": "mission-control-2", "url": gw_url,
|
||||
rows.append({"name": "Box-Wart (MC2)", "unit": "mission-control-2", "url": gw_url,
|
||||
"ok": True, "scope": "user"})
|
||||
else:
|
||||
rows.append({"name": "Gateway (integriert)", "unit": "mission-control-2", "url": gw_url,
|
||||
@@ -97,15 +79,20 @@ def services() -> dict:
|
||||
"ok": _user_unit_active("mc2-steward"), "scope": "user"},
|
||||
{"name": "Hermes-Gateway (Agent & Gedächtnis)", "unit": "hermes-gateway", "url": HERMES_API_URL,
|
||||
"ok": a["gateway_reachable"], "scope": "user"},
|
||||
{"name": "Hermes-GUI (Desktop-Gateway)", "unit": "hermes-builtin-ui", "url": a["hermes_ui_url"],
|
||||
{"name": "Hermes-Dashboard", "unit": "hermes-builtin-ui", "url": a["hermes_ui_url"],
|
||||
"ok": a["hermes_ui_reachable"], "scope": "user"},
|
||||
{"name": "Voice (STT/TTS)", "unit": "voice-service", "url": VOICE_SERVICE_URL,
|
||||
{"name": "Spracherkennung (Desktop-Lucy)", "unit": "voice-service", "url": VOICE_SERVICE_URL,
|
||||
"ok": _voice_reachable(), "scope": "user"},
|
||||
# ttyd hinter dem /console/-Proxy — war als einziger UI-Dienst NICHT in der Liste,
|
||||
# das Konsole-Overlay schickte den User deshalb ins SSH statt zum Restart-Knopf.
|
||||
{"name": "Box-Konsole (ttyd)", "unit": "box-console", "url": "/console/",
|
||||
"ok": bool(a.get("box_console_reachable")), "scope": "user"},
|
||||
# Lucys Stimme (pocket-tts) spricht Telegram und Lucy-Desktop — bis 09/2026 fehlte
|
||||
# sie hier; die Box-Konsole ist mit dem Box-Wart-Umbau aus MC2 raus.
|
||||
{"name": "Lucys Stimme", "unit": "lucy-stimme", "url": "",
|
||||
"ok": _user_unit_active("lucy-stimme"), "scope": "user"},
|
||||
]
|
||||
# Bewusst abgeschaltete Dienste (24.09.2026: Spracherkennung bis zur Android-App) sind kein
|
||||
# Fehler — die Oberfläche zeigt sie als „schläft“ mit Knopf zum Wecken statt rot.
|
||||
for row in rows:
|
||||
row["schlaeft"] = (not row["ok"] and row["scope"] == "user"
|
||||
and waechter.schlaeft(waechter.dienst_zustand(row["unit"])))
|
||||
return {
|
||||
"services": rows,
|
||||
"links": {
|
||||
@@ -126,15 +113,6 @@ def backups() -> dict:
|
||||
return {"backups": backup_svc.list_backups()}
|
||||
|
||||
|
||||
def _run(cmd: list[str], cwd: str | None = None) -> dict:
|
||||
try:
|
||||
p = subprocess.run(cmd, cwd=cwd, capture_output=True, text=True, timeout=180)
|
||||
return {"ok": p.returncode == 0, "code": p.returncode,
|
||||
"out": (p.stdout or "")[-2000:], "err": (p.stderr or "")[-2000:]}
|
||||
except Exception as exc:
|
||||
return {"ok": False, "code": -1, "out": "", "err": str(exc)}
|
||||
|
||||
|
||||
class RestartReq(BaseModel):
|
||||
service: str
|
||||
|
||||
@@ -149,15 +127,6 @@ def restart(req: RestartReq) -> dict:
|
||||
return maintenance.restart_service(req.service)
|
||||
|
||||
|
||||
@router.post("/system/self-update")
|
||||
def self_update() -> dict:
|
||||
"""git pull (Source) → venv-Deps → Dienst-Restart. Auf der Box; lokal Fehler."""
|
||||
pull = _run(["git", "fetch", "--all"], cwd=SOURCE_DIR)
|
||||
reset = _run(["git", "reset", "--hard", "origin/main"], cwd=SOURCE_DIR)
|
||||
restart_res = _run(["systemctl", "--user", "restart", "mission-control-2"])
|
||||
return {"pull": pull, "reset": reset, "restart": restart_res}
|
||||
|
||||
|
||||
@router.get("/system/token-stats")
|
||||
def token_stats() -> dict:
|
||||
"""Token-Verbrauch + Cloud-Ersparnis. Logik im pricing-Service (SSoT)."""
|
||||
|
||||
+289
-357
@@ -1,357 +1,289 @@
|
||||
"""
|
||||
Voice-Endpoints für „Mit Hermes reden" (Browser-Voice + 3D-Avatar).
|
||||
|
||||
Dünner Layer: STT/TTS werden zum Voice-Sidecar (:8650) geproxyt; der Chat geht an den
|
||||
Hermes-`api_server` (:8642, OpenAI-kompatibel) — denselben vollen Agenten mit Tools +
|
||||
geteiltem Mem0 wie CLI/Telegram. Mit stabilem `X-Hermes-Session-Id` hält die Plattform den
|
||||
Transcript server-seitig, daher schickt der Client je Turn nur die neue User-Nachricht.
|
||||
|
||||
LAN-only (kein Token in der 2.0-Phase), wie die übrigen MC2-Endpoints.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
|
||||
# Injection-Schutz (Stufe 0): guard.py liegt im mcp/-Verzeichnis. Per Pfad laden (eigene MC2-Venv).
|
||||
import sys as _sys
|
||||
import time
|
||||
|
||||
import httpx
|
||||
from config import HERMES_API_KEY, HERMES_API_MODEL, HERMES_API_URL, LLAMA_SWAP_URL, VOICE_SERVICE_URL
|
||||
from fastapi import APIRouter, File, Form, HTTPException, UploadFile
|
||||
from fastapi.responses import Response, StreamingResponse
|
||||
from pydantic import BaseModel
|
||||
from services import announce
|
||||
from services.voice_metrics import ( # Per-Stage-Latenz + Per-Turn-Trace (intern)
|
||||
Timer,
|
||||
TurnTrace,
|
||||
get_metrics,
|
||||
get_trace,
|
||||
park,
|
||||
record_stage,
|
||||
)
|
||||
|
||||
_GUARD_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))), "mcp")
|
||||
if _GUARD_DIR not in _sys.path:
|
||||
_sys.path.insert(0, _GUARD_DIR)
|
||||
try:
|
||||
from guard import wrap_untrusted
|
||||
except Exception: # den Voice-Pfad nie wegen des Filters lahmlegen
|
||||
def wrap_untrusted(text: str, label: str = "") -> str:
|
||||
return text
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
router = APIRouter(prefix="/api")
|
||||
|
||||
# Bildschirm-Sicht: das DEDIZIERTE Vision-Modell (Qwen3-VL-8B) beschreibt das Bild; die Beschreibung
|
||||
# geht als TEXT an Hermes -> Lucy behält ihr volles Hirn/Gedächtnis UND nutzt das bessere VL-Modell
|
||||
# (statt der schwächeren Vision der fast-MoE). Per Env abschaltbar/umstellbar.
|
||||
VISION_MODEL = os.environ.get("MC_VISION_MODEL", "vision")
|
||||
# Knappe Beschreibung = schnellere VL-Generierung UND weniger Hermes-Kontext-Bloat (B2).
|
||||
VISION_MAX_TOKENS = int(os.environ.get("MC_VISION_MAX_TOKENS", "280"))
|
||||
|
||||
|
||||
async def _describe_images(image_urls: list[str], hint: str) -> str:
|
||||
"""Lässt das Vision-Modell die Screenshots (1 je Monitor) knapp beschreiben (Deutsch).
|
||||
Mehrere Bilder gehen in EINER Nachricht ans VL-Modell. Leerer String bei Fehler."""
|
||||
multi = len(image_urls) > 1
|
||||
intro = (f"Hier sind {len(image_urls)} Screenshots (je ein Monitor). Beschreibe auf Deutsch in höchstens "
|
||||
"5 kurzen Sätzen das Wesentliche (pro Monitor: App/Fenster, wichtige Inhalte, sichtbarer Text/Code). "
|
||||
"Keine Einleitung, keine Wiederholung der Frage. "
|
||||
if multi else
|
||||
"Beschreibe auf Deutsch in höchstens 5 kurzen Sätzen das Wesentliche auf diesem Screenshot "
|
||||
"(App/Fenster, wichtige Inhalte, sichtbarer Text/Code). Keine Einleitung. ")
|
||||
content: list = [{"type": "text", "text": intro + "Frage des Nutzers dazu: " + hint}]
|
||||
for u in image_urls:
|
||||
content.append({"type": "image_url", "image_url": {"url": u}})
|
||||
try:
|
||||
# 45 s statt 120 s: Qwen3-VL braucht warm ~5 s; wenn es 45 s nicht schafft, ist etwas
|
||||
# kaputt und Lucy soll lieber ohne Bildschirm-Kontext antworten als ewig hängen.
|
||||
async with httpx.AsyncClient(timeout=httpx.Timeout(float(os.environ.get("MC_VISION_TIMEOUT", "45")), connect=5.0)) as client:
|
||||
r = await client.post(f"{LLAMA_SWAP_URL}/v1/chat/completions", json={
|
||||
"model": VISION_MODEL, "max_tokens": VISION_MAX_TOKENS, "stream": False,
|
||||
"messages": [{"role": "user", "content": content}],
|
||||
})
|
||||
r.raise_for_status()
|
||||
return (r.json().get("choices") or [{}])[0].get("message", {}).get("content", "").strip()
|
||||
except Exception as exc:
|
||||
log.warning("Vision-Beschreibung fehlgeschlagen: %s", exc)
|
||||
return ""
|
||||
|
||||
_TIMEOUT = httpx.Timeout(120.0, connect=5.0) # Chatterbox-TTS auf CPU darf dauern
|
||||
|
||||
|
||||
class TTSIn(BaseModel):
|
||||
text: str
|
||||
engine: str = "piper"
|
||||
voice: str = ""
|
||||
language: str = ""
|
||||
ref_path: str = ""
|
||||
|
||||
|
||||
class ChatIn(BaseModel):
|
||||
text: str # die neue User-Äußerung (STT-Ergebnis)
|
||||
session_id: str # stabiler Voice-Faden → server-seitiger Transcript
|
||||
session_key: str = "" # optional: Langzeit-Memory-Scope
|
||||
system: str = "" # optionaler ephemerer System-Prompt (z.B. „antworte knapp/gesprochen")
|
||||
model: str = ""
|
||||
images: list[str] = [] # optionale Bildschirm-Sicht: ein data:-URL je Monitor (Lucys „Augen")
|
||||
|
||||
|
||||
class AnnounceIn(BaseModel):
|
||||
text: str # die Meldung (wird von Lucy gesprochen)
|
||||
subject: str = "" # kurze Betreffzeile (z.B. "[Update]")
|
||||
source: str = "" # Absender (sentry/notify/cron …) — nur fürs Log/Panel
|
||||
priority: str = "normal" # 'silent' = nur im Verlauf zeigen, nicht sprechen
|
||||
|
||||
|
||||
class AlarmIn(BaseModel):
|
||||
text: str # die Alarm-Meldung
|
||||
subject: str = "[Alarm]" # Betreff (Telegram-Präfix)
|
||||
source: str = "alarm" # Absender fürs Log/Panel (z.B. "lucy-watchdog")
|
||||
|
||||
|
||||
@router.post("/alarm")
|
||||
def alarm(body: AlarmIn) -> dict:
|
||||
"""Lucy-UNABHÄNGIGER Alarm-Weg: schickt direkt auf Telegram (und legt die Meldung in den
|
||||
Briefkasten). Für Absender, die NICHT auf die sprechende Lucy zählen können — allen voran
|
||||
der PC-seitige Lucy-Watchdog, wenn die Desktop-App selbst hängt (dann nützt der Briefkasten
|
||||
nichts, weil niemand ihn vorliest → Telegram ist der einzige verlässliche Kanal). LAN-only
|
||||
wie alle MC2-Endpoints."""
|
||||
text = (body.text or "").strip()
|
||||
if not text:
|
||||
raise HTTPException(400, "Leere Meldung.")
|
||||
subject = (body.subject or "[Alarm]").strip()
|
||||
try:
|
||||
item = announce.add(text, subject, body.source or "alarm", "normal")
|
||||
except ValueError as exc:
|
||||
raise HTTPException(400, str(exc))
|
||||
announce.notify_telegram(subject, text) # best-effort Telegram (posix/bash; Windows = No-op)
|
||||
return {"ok": True, "item": item}
|
||||
|
||||
|
||||
@router.post("/voice/announce")
|
||||
def voice_announce(body: AnnounceIn) -> dict:
|
||||
"""Meldung in den Briefkasten legen (Lucy-Proaktivität). Absender: Health-Wächter,
|
||||
notify.sh (Updates/Radar/Telegram-Spiegel), Hermes-cron. LAN-only wie alle MC2-Endpoints."""
|
||||
try:
|
||||
return {"ok": True, "item": announce.add(body.text, body.subject, body.source, body.priority)}
|
||||
except ValueError as exc:
|
||||
raise HTTPException(400, str(exc))
|
||||
|
||||
|
||||
@router.get("/voice/announcements")
|
||||
def voice_announcements(after: int | None = None, limit: int = 20) -> dict:
|
||||
"""Neue Meldungen nach Cursor `after` abholen (Lucy pollt). Ohne `after` nur den
|
||||
aktuellen Cursor-Stand (latest) — Erststart plappert so keine alten Meldungen nach."""
|
||||
return announce.list_after(after, limit)
|
||||
|
||||
|
||||
@router.get("/voice/health")
|
||||
def voice_health() -> dict:
|
||||
"""Erreichbarkeit des Voice-Sidecars + ob der Hermes-API-Key gesetzt ist."""
|
||||
out: dict = {"sidecar": False, "hermes_key": bool(HERMES_API_KEY)}
|
||||
try:
|
||||
r = httpx.get(f"{VOICE_SERVICE_URL}/health", timeout=httpx.Timeout(5.0))
|
||||
out["sidecar"] = r.status_code == 200
|
||||
out["detail"] = r.json() if r.status_code == 200 else None
|
||||
except Exception as exc:
|
||||
out["error"] = str(exc)
|
||||
return out
|
||||
|
||||
|
||||
@router.get("/voice/metrics")
|
||||
def voice_metrics() -> dict:
|
||||
"""Rollende Latenz-Stats je Stufe (avg/p50/p95/last, ms). Quelle u.a. für selbstkritik-feed.sh."""
|
||||
return get_metrics()
|
||||
|
||||
|
||||
@router.get("/voice/trace")
|
||||
def voice_trace(limit: int = 20) -> dict:
|
||||
"""Per-Turn-Trace: die letzten `limit` Chat-Turns mit Stufen-Breakdown (STT · Vision · Hirn ·
|
||||
Generierung, Mem0 als Unter-Detail). Neueste zuerst. Für die Latenz-Ansicht im Cockpit —
|
||||
damit man den EINEN langsamen Turn sieht, den ein Durchschnitt verschluckt."""
|
||||
return {"turns": get_trace(limit)}
|
||||
|
||||
|
||||
@router.get("/voice/voices")
|
||||
def voice_voices() -> dict:
|
||||
try:
|
||||
r = httpx.get(f"{VOICE_SERVICE_URL}/voices", timeout=httpx.Timeout(10.0))
|
||||
r.raise_for_status()
|
||||
return r.json()
|
||||
except Exception as exc:
|
||||
raise HTTPException(502, f"Voice-Sidecar nicht erreichbar: {exc}")
|
||||
|
||||
|
||||
@router.post("/voice/stt")
|
||||
async def voice_stt(audio: UploadFile = File(...), language: str = Form(default="")) -> dict:
|
||||
"""Mikro-Audio → Text (Proxy auf Sidecar /stt)."""
|
||||
data = await audio.read()
|
||||
if not data:
|
||||
raise HTTPException(400, "Leeres Audio.")
|
||||
files = {"audio": (audio.filename or "rec.webm", data, audio.content_type or "audio/webm")}
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=_TIMEOUT) as client:
|
||||
_t0 = time.perf_counter()
|
||||
r = await client.post(f"{VOICE_SERVICE_URL}/stt", files=files, data={"language": language})
|
||||
_ms = (time.perf_counter() - _t0) * 1000.0
|
||||
record_stage("stt", _ms)
|
||||
park("stt", _ms) # der folgende /voice/chat-Turn sammelt die STT-Dauer für seinen Trace ein
|
||||
r.raise_for_status()
|
||||
return r.json()
|
||||
except httpx.HTTPError as exc:
|
||||
raise HTTPException(502, f"STT fehlgeschlagen: {exc}")
|
||||
|
||||
|
||||
@router.post("/voice/turn")
|
||||
async def voice_turn(audio: UploadFile = File(...)) -> dict:
|
||||
"""Semantische Turn-Detection (Smart Turn v3): war die Äußerung fertig? Proxy → Sidecar."""
|
||||
data = await audio.read()
|
||||
if not data:
|
||||
raise HTTPException(400, "Leeres Audio.")
|
||||
files = {"audio": (audio.filename or "rec.wav", data, audio.content_type or "audio/wav")}
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=httpx.Timeout(10.0, connect=3.0)) as client:
|
||||
with Timer("turn"):
|
||||
r = await client.post(f"{VOICE_SERVICE_URL}/turn", files=files)
|
||||
r.raise_for_status()
|
||||
return r.json()
|
||||
except httpx.HTTPError as exc:
|
||||
# Turn-Check ist eine Optimierung — bei Ausfall lieber sofort antworten als hängen.
|
||||
log.warning("Turn-Check fehlgeschlagen: %s", exc)
|
||||
return {"complete": True, "probability": 1.0, "engine": "fallback"}
|
||||
|
||||
|
||||
@router.post("/voice/reference")
|
||||
async def voice_set_reference(audio: UploadFile = File(...)) -> dict:
|
||||
"""Klon-Referenz (z.B. ElevenLabs-Erzeugnis) hochladen → Chatterbox nutzt sie. Proxy → Sidecar."""
|
||||
data = await audio.read()
|
||||
if not data:
|
||||
raise HTTPException(400, "Leeres Audio.")
|
||||
files = {"audio": (audio.filename or "ref.wav", data, audio.content_type or "audio/mpeg")}
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=_TIMEOUT) as client:
|
||||
r = await client.post(f"{VOICE_SERVICE_URL}/reference", files=files)
|
||||
r.raise_for_status()
|
||||
return r.json()
|
||||
except httpx.HTTPError as exc:
|
||||
raise HTTPException(502, f"Referenz-Upload fehlgeschlagen: {exc}")
|
||||
|
||||
|
||||
@router.get("/voice/reference")
|
||||
def voice_get_reference() -> dict:
|
||||
try:
|
||||
r = httpx.get(f"{VOICE_SERVICE_URL}/reference", timeout=httpx.Timeout(8.0))
|
||||
r.raise_for_status()
|
||||
return r.json()
|
||||
except Exception as exc:
|
||||
return {"active": False, "error": str(exc)}
|
||||
|
||||
|
||||
@router.delete("/voice/reference")
|
||||
def voice_clear_reference() -> dict:
|
||||
try:
|
||||
r = httpx.delete(f"{VOICE_SERVICE_URL}/reference", timeout=httpx.Timeout(8.0))
|
||||
r.raise_for_status()
|
||||
return r.json()
|
||||
except httpx.HTTPError as exc:
|
||||
raise HTTPException(502, f"Löschen fehlgeschlagen: {exc}")
|
||||
|
||||
|
||||
@router.post("/voice/tts")
|
||||
async def voice_tts(body: TTSIn) -> Response:
|
||||
"""Text → Sprache (Proxy auf Sidecar /tts), liefert WAV-Bytes."""
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=_TIMEOUT) as client:
|
||||
with Timer("tts"):
|
||||
r = await client.post(f"{VOICE_SERVICE_URL}/tts", json=body.model_dump())
|
||||
r.raise_for_status()
|
||||
return Response(content=r.content, media_type=r.headers.get("content-type", "audio/wav"))
|
||||
except httpx.HTTPError as exc:
|
||||
raise HTTPException(502, f"TTS fehlgeschlagen: {exc}")
|
||||
|
||||
|
||||
@router.post("/voice/chat")
|
||||
async def voice_chat(body: ChatIn) -> StreamingResponse:
|
||||
"""Neue User-Äußerung → Hermes-Agent (api_server, streamend). SSE wird 1:1 durchgereicht.
|
||||
|
||||
Mit `X-Hermes-Session-Id` hält die Plattform den Verlauf — wir senden nur die neue Nachricht.
|
||||
Auth per Bearer (API_SERVER_KEY); ohne Key liefert :8642 ein 401."""
|
||||
if not HERMES_API_KEY:
|
||||
raise HTTPException(503, "HERMES_API_KEY/API_SERVER_KEY nicht gesetzt — Agent-Auth fehlt.")
|
||||
|
||||
headers = {
|
||||
"Authorization": f"Bearer {HERMES_API_KEY}",
|
||||
"X-Hermes-Session-Id": body.session_id,
|
||||
}
|
||||
if body.session_key:
|
||||
headers["X-Hermes-Session-Key"] = body.session_key
|
||||
|
||||
async def gen():
|
||||
# Per-Turn-Trace: sammelt STT (davor, geparkt) + Vision + Hirn-TTFT + Generierung + Mem0
|
||||
# (Rückruf während) zu EINEM Datensatz -> die Latenz-Ansicht zeigt den einzelnen Hänger.
|
||||
trace = TurnTrace(session_id=body.session_id, kind="voice")
|
||||
first = True
|
||||
first_content = True
|
||||
committed = False
|
||||
|
||||
def _commit() -> None:
|
||||
nonlocal committed
|
||||
if not committed:
|
||||
committed = True
|
||||
trace.commit()
|
||||
|
||||
try:
|
||||
# Bildschirm-Sicht INNERHALB des Streams (C2-Fix): so startet die SSE-Antwort sofort und
|
||||
# der Client bekommt ein Progress-Event (-> Lucy kann eine Warte-Ansage sprechen), statt
|
||||
# dass der Request bis zu 120 s "tot" hängt, während das Vision-Modell beschreibt.
|
||||
user_text = body.text
|
||||
imgs = [u for u in (body.images or []) if u]
|
||||
trace.had_images = bool(imgs)
|
||||
if imgs:
|
||||
yield b'event: hermes.vision.progress\ndata: {"note": "Bildschirm wird angeschaut"}\n\n'
|
||||
_tv = time.perf_counter()
|
||||
desc = await _describe_images(imgs, body.text)
|
||||
trace.note_vision((time.perf_counter() - _tv) * 1000.0)
|
||||
if desc:
|
||||
safe_desc = wrap_untrusted(desc, "BILDSCHIRM")
|
||||
user_text = f"[Bildschirm-Sicht — das ist gerade auf dem/den Schirm(en) zu sehen:\n{safe_desc}\n]\n\n{body.text}"
|
||||
messages = []
|
||||
if body.system:
|
||||
messages.append({"role": "system", "content": body.system})
|
||||
messages.append({"role": "user", "content": user_text})
|
||||
trace.mark_brain_start() # ab hier zählt die Hirn-Zeit (Vision ist schon abgeschlossen)
|
||||
payload = {"model": body.model or HERMES_API_MODEL, "messages": messages, "stream": True}
|
||||
# Lucys Hirn (Qwen3.6) ist ein Thinking-Modell -> für die gesprochene Assistentin Thinking AUS,
|
||||
# sonst generiert es tausende Reasoning-Token VOR der kurzen Antwort (gemessen: 11k Token, ~30s TTFB).
|
||||
# Gleiches Muster wie die fast-Spur im Gateway (gateway_proxy.py) und die Mem0-Extraktion.
|
||||
if os.environ.get("MC_VOICE_NO_THINK", "1") not in ("0", "false", "False"):
|
||||
payload["chat_template_kwargs"] = {"enable_thinking": False}
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=httpx.Timeout(None, connect=5.0)) as client:
|
||||
async with client.stream(
|
||||
"POST", f"{HERMES_API_URL}/v1/chat/completions", json=payload, headers=headers,
|
||||
) as r:
|
||||
if r.status_code != 200:
|
||||
detail = (await r.aread()).decode("utf-8", "replace")[:500]
|
||||
trace.error = f"Hermes {r.status_code}"
|
||||
yield f"data: {{\"error\": \"Hermes {r.status_code}: {detail}\"}}\n\n".encode()
|
||||
return
|
||||
async for chunk in r.aiter_raw():
|
||||
if first: # Time-To-First-Byte des Hermes-Streams (Verbindungs-Overhead)
|
||||
trace.note_ttfb()
|
||||
first = False
|
||||
# Erster CONTENT-Delta = echte Hirn-Latenz (Agent-Overhead + Mem0 + LLM-TTFT) —
|
||||
# chat_ttfb misst nur den SSE-Start (~5 ms) und ist dafür blind.
|
||||
if first_content and b'"content"' in chunk:
|
||||
trace.note_first_content()
|
||||
first_content = False
|
||||
yield chunk
|
||||
except httpx.HTTPError as exc:
|
||||
trace.error = "verbindung"
|
||||
yield f"data: {{\"error\": \"Verbindung zu Hermes fehlgeschlagen: {exc}\"}}\n\n".encode()
|
||||
finally:
|
||||
_commit() # Turn immer verbuchen (auch bei Fehler/Abbruch)
|
||||
|
||||
return StreamingResponse(gen(), media_type="text/event-stream")
|
||||
"""
|
||||
Sprach- und Melde-Endpunkte für Lucy (Desktop-App, Telegram-Spiegel, Wächter).
|
||||
|
||||
Dünner Layer: STT und die Turn-Erkennung gehen an den Voice-Sidecar (:8650, schläft seit
|
||||
24.09.2026 bis zur Android-App), der Chat an den Hermes-`api_server` (:8642, OpenAI-kompatibel) —
|
||||
derselbe volle Agent mit Werkzeugen und Gedächtnis wie Telegram. Mit stabilem
|
||||
`X-Hermes-Session-Id` hält die Plattform den Verlauf, der Client schickt je Turn nur die neue
|
||||
Nachricht. Lucys Stimme (pocket-tts, :8021) wird ins LAN gereicht.
|
||||
|
||||
Abgebaut am 24.09.2026 (ohne Nutzer): Voice-Health, Latenz-Metriken und -Trace, Stimmenliste,
|
||||
Klon-Referenz und das alte Piper/Chatterbox-TTS.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
|
||||
# Injection-Schutz (Stufe 0): guard.py liegt im mcp/-Verzeichnis. Per Pfad laden (eigene MC2-Venv).
|
||||
import sys as _sys
|
||||
|
||||
import httpx
|
||||
from config import HERMES_API_KEY, HERMES_API_MODEL, HERMES_API_URL, LLAMA_SWAP_URL, LUCY_STIMME_URL, VOICE_SERVICE_URL
|
||||
from fastapi import APIRouter, File, Form, HTTPException, UploadFile
|
||||
from fastapi.responses import Response, StreamingResponse
|
||||
from pydantic import BaseModel
|
||||
from services import announce
|
||||
|
||||
_GUARD_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))), "mcp")
|
||||
if _GUARD_DIR not in _sys.path:
|
||||
_sys.path.insert(0, _GUARD_DIR)
|
||||
try:
|
||||
from guard import wrap_untrusted
|
||||
except Exception: # den Voice-Pfad nie wegen des Filters lahmlegen
|
||||
def wrap_untrusted(text: str, label: str = "") -> str:
|
||||
return text
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
router = APIRouter(prefix="/api")
|
||||
|
||||
# Bildschirm-Sicht: das Bild-Modell beschreibt die Screenshots; die Beschreibung geht als TEXT an
|
||||
# Hermes -> Lucy behält ihr volles Hirn und Gedächtnis. Per Env umstellbar.
|
||||
VISION_MODEL = os.environ.get("MC_VISION_MODEL", "vision")
|
||||
# Knappe Beschreibung = schnellere Generierung UND weniger Kontext für Hermes.
|
||||
VISION_MAX_TOKENS = int(os.environ.get("MC_VISION_MAX_TOKENS", "280"))
|
||||
_STT_TIMEOUT = httpx.Timeout(120.0, connect=5.0)
|
||||
|
||||
|
||||
def _sse_fehler(text: str) -> bytes:
|
||||
"""SSE-Fehlerzeile als gültiges JSON (Anführungszeichen im Text brachen früher den Lucy-Client)."""
|
||||
return f"data: {json.dumps({'error': text}, ensure_ascii=False)}\n\n".encode()
|
||||
|
||||
|
||||
async def _describe_images(image_urls: list[str], hint: str) -> str:
|
||||
"""Lässt das Bild-Modell die Screenshots (1 je Monitor) knapp beschreiben (Deutsch).
|
||||
Mehrere Bilder gehen in EINER Nachricht ans Modell. Leerer String bei Fehler."""
|
||||
multi = len(image_urls) > 1
|
||||
intro = (f"Hier sind {len(image_urls)} Screenshots (je ein Monitor). Beschreibe auf Deutsch in höchstens "
|
||||
"5 kurzen Sätzen das Wesentliche (pro Monitor: App/Fenster, wichtige Inhalte, sichtbarer Text/Code). "
|
||||
"Keine Einleitung, keine Wiederholung der Frage. "
|
||||
if multi else
|
||||
"Beschreibe auf Deutsch in höchstens 5 kurzen Sätzen das Wesentliche auf diesem Screenshot "
|
||||
"(App/Fenster, wichtige Inhalte, sichtbarer Text/Code). Keine Einleitung. ")
|
||||
content: list = [{"type": "text", "text": intro + "Frage des Nutzers dazu: " + hint}]
|
||||
for u in image_urls:
|
||||
content.append({"type": "image_url", "image_url": {"url": u}})
|
||||
try:
|
||||
# 45 s: Wenn das Bild-Modell so lange braucht, ist etwas kaputt, und Lucy soll lieber ohne
|
||||
# Bildschirm-Kontext antworten als ewig hängen.
|
||||
async with httpx.AsyncClient(timeout=httpx.Timeout(float(os.environ.get("MC_VISION_TIMEOUT", "45")), connect=5.0)) as client:
|
||||
r = await client.post(f"{LLAMA_SWAP_URL}/v1/chat/completions", json={
|
||||
"model": VISION_MODEL, "max_tokens": VISION_MAX_TOKENS, "stream": False,
|
||||
"messages": [{"role": "user", "content": content}],
|
||||
})
|
||||
r.raise_for_status()
|
||||
return (r.json().get("choices") or [{}])[0].get("message", {}).get("content", "").strip()
|
||||
except Exception as exc:
|
||||
log.warning("Vision-Beschreibung fehlgeschlagen: %s", exc)
|
||||
return ""
|
||||
|
||||
|
||||
class ChatIn(BaseModel):
|
||||
text: str # die neue User-Äußerung (STT-Ergebnis)
|
||||
session_id: str # stabiler Gesprächsfaden → server-seitiger Verlauf
|
||||
session_key: str = "" # optional an Hermes durchgereicht (X-Hermes-Session-Key)
|
||||
system: str = "" # optionaler ephemerer System-Prompt (z.B. „antworte knapp/gesprochen")
|
||||
model: str = ""
|
||||
images: list[str] = [] # optionale Bildschirm-Sicht: ein data:-URL je Monitor (Lucys „Augen")
|
||||
|
||||
|
||||
class AnnounceIn(BaseModel):
|
||||
text: str # die Meldung (wird von Lucy gesprochen)
|
||||
subject: str = "" # kurze Betreffzeile (z.B. "[Update]")
|
||||
source: str = "" # Absender (sentry/notify/cron …) — nur fürs Log/Panel
|
||||
priority: str = "normal" # 'silent' = nur im Verlauf zeigen, nicht sprechen
|
||||
|
||||
|
||||
class AlarmIn(BaseModel):
|
||||
text: str # die Alarm-Meldung
|
||||
subject: str = "[Alarm]" # Betreff (Telegram-Präfix)
|
||||
source: str = "alarm" # Absender fürs Log/Panel (z.B. "lucy-watchdog")
|
||||
|
||||
|
||||
@router.post("/alarm")
|
||||
def alarm(body: AlarmIn) -> dict:
|
||||
"""Lucy-UNABHÄNGIGER Alarm-Weg: schickt direkt auf Telegram (und legt die Meldung in den
|
||||
Briefkasten). Für Absender, die NICHT auf die sprechende Lucy zählen können — allen voran
|
||||
der PC-seitige Lucy-Watchdog, wenn die Desktop-App selbst hängt. Der Betreff „[Alarm]“ geht
|
||||
auch nachts sofort raus (notify.sh)."""
|
||||
text = (body.text or "").strip()
|
||||
if not text:
|
||||
raise HTTPException(400, "Leere Meldung.")
|
||||
subject = (body.subject or "[Alarm]").strip()
|
||||
try:
|
||||
item = announce.add(text, subject, body.source or "alarm", "normal")
|
||||
except ValueError as exc:
|
||||
raise HTTPException(400, str(exc))
|
||||
announce.notify_telegram(subject, text) # best-effort Telegram (posix/bash; Windows = No-op)
|
||||
return {"ok": True, "item": item}
|
||||
|
||||
|
||||
@router.post("/voice/announce")
|
||||
def voice_announce(body: AnnounceIn) -> dict:
|
||||
"""Meldung in den Briefkasten legen (Lucy-Proaktivität). Absender: Wächter, notify.sh
|
||||
(Updates/Radar/Telegram-Spiegel), Hermes-Cron."""
|
||||
try:
|
||||
return {"ok": True, "item": announce.add(body.text, body.subject, body.source, body.priority)}
|
||||
except ValueError as exc:
|
||||
raise HTTPException(400, str(exc))
|
||||
|
||||
|
||||
@router.get("/voice/announcements")
|
||||
def voice_announcements(after: int | None = None, limit: int = 20) -> dict:
|
||||
"""Neue Meldungen nach Cursor `after` abholen (Lucy pollt). Ohne `after` nur den
|
||||
aktuellen Cursor-Stand (latest) — Erststart plappert so keine alten Meldungen nach."""
|
||||
return announce.list_after(after, limit)
|
||||
|
||||
|
||||
@router.post("/voice/stt")
|
||||
async def voice_stt(audio: UploadFile = File(...), language: str = Form(default="")) -> dict:
|
||||
"""Mikro-Audio → Text (Proxy auf Sidecar /stt)."""
|
||||
data = await audio.read()
|
||||
if not data:
|
||||
raise HTTPException(400, "Leeres Audio.")
|
||||
files = {"audio": (audio.filename or "rec.webm", data, audio.content_type or "audio/webm")}
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=_STT_TIMEOUT) as client:
|
||||
r = await client.post(f"{VOICE_SERVICE_URL}/stt", files=files, data={"language": language})
|
||||
r.raise_for_status()
|
||||
return r.json()
|
||||
except httpx.HTTPError as exc:
|
||||
raise HTTPException(502, f"STT fehlgeschlagen: {exc}")
|
||||
|
||||
|
||||
@router.post("/voice/turn")
|
||||
async def voice_turn(audio: UploadFile = File(...)) -> dict:
|
||||
"""Semantische Turn-Detection (Smart Turn v3): war die Äußerung fertig? Proxy → Sidecar."""
|
||||
data = await audio.read()
|
||||
if not data:
|
||||
raise HTTPException(400, "Leeres Audio.")
|
||||
files = {"audio": (audio.filename or "rec.wav", data, audio.content_type or "audio/wav")}
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=httpx.Timeout(10.0, connect=3.0)) as client:
|
||||
r = await client.post(f"{VOICE_SERVICE_URL}/turn", files=files)
|
||||
r.raise_for_status()
|
||||
return r.json()
|
||||
except httpx.HTTPError as exc:
|
||||
# Turn-Check ist eine Optimierung — bei Ausfall lieber sofort antworten als hängen.
|
||||
log.warning("Turn-Check fehlgeschlagen: %s", exc)
|
||||
return {"complete": True, "probability": 1.0, "engine": "fallback"}
|
||||
|
||||
|
||||
@router.post("/voice/chat")
|
||||
async def voice_chat(body: ChatIn) -> StreamingResponse:
|
||||
"""Neue User-Äußerung → Hermes-Agent (api_server, streamend). SSE wird 1:1 durchgereicht.
|
||||
|
||||
Mit `X-Hermes-Session-Id` hält die Plattform den Verlauf — wir senden nur die neue Nachricht.
|
||||
Auth per Bearer (API_SERVER_KEY); ohne Key liefert :8642 ein 401."""
|
||||
if not HERMES_API_KEY:
|
||||
raise HTTPException(503, "HERMES_API_KEY/API_SERVER_KEY nicht gesetzt — Agent-Auth fehlt.")
|
||||
|
||||
headers = {
|
||||
"Authorization": f"Bearer {HERMES_API_KEY}",
|
||||
"X-Hermes-Session-Id": body.session_id,
|
||||
}
|
||||
if body.session_key:
|
||||
headers["X-Hermes-Session-Key"] = body.session_key
|
||||
|
||||
async def gen():
|
||||
# Bildschirm-Sicht INNERHALB des Streams: so startet die SSE-Antwort sofort und der Client
|
||||
# bekommt ein Progress-Event (-> Lucy kann eine Warte-Ansage sprechen), statt dass der
|
||||
# Request hängt, während das Bild-Modell beschreibt.
|
||||
user_text = body.text
|
||||
imgs = [u for u in (body.images or []) if u]
|
||||
if imgs:
|
||||
yield b'event: hermes.vision.progress\ndata: {"note": "Bildschirm wird angeschaut"}\n\n'
|
||||
desc = await _describe_images(imgs, body.text)
|
||||
if desc:
|
||||
safe_desc = wrap_untrusted(desc, "BILDSCHIRM")
|
||||
user_text = f"[Bildschirm-Sicht — das ist gerade auf dem/den Schirm(en) zu sehen:\n{safe_desc}\n]\n\n{body.text}"
|
||||
messages = []
|
||||
if body.system:
|
||||
messages.append({"role": "system", "content": body.system})
|
||||
messages.append({"role": "user", "content": user_text})
|
||||
payload = {"model": body.model or HERMES_API_MODEL, "messages": messages, "stream": True}
|
||||
# Lucys Hirn ist ein Thinking-Modell -> für die gesprochene Assistentin Thinking AUS, sonst
|
||||
# generiert es tausende Reasoning-Token VOR der kurzen Antwort (gemessen: 11k Token, ~30 s).
|
||||
if os.environ.get("MC_VOICE_NO_THINK", "1") not in ("0", "false", "False"):
|
||||
payload["chat_template_kwargs"] = {"enable_thinking": False}
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=httpx.Timeout(None, connect=5.0)) as client:
|
||||
async with client.stream(
|
||||
"POST", f"{HERMES_API_URL}/v1/chat/completions", json=payload, headers=headers,
|
||||
) as r:
|
||||
if r.status_code != 200:
|
||||
detail = (await r.aread()).decode("utf-8", "replace")[:500]
|
||||
yield _sse_fehler(f"Hermes {r.status_code}: {detail}")
|
||||
return
|
||||
async for chunk in r.aiter_raw():
|
||||
yield chunk
|
||||
except httpx.HTTPError as exc:
|
||||
yield _sse_fehler(f"Verbindung zu Hermes fehlgeschlagen: {exc}")
|
||||
|
||||
return StreamingResponse(gen(), media_type="text/event-stream")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------------------------
|
||||
# Lucys Stimme ins LAN reichen (Raphael-Umbau 04.09.2026). lucy-stimme.service (:8021, pocket-tts
|
||||
# german_24l) bindet nur Loopback; die Desktop-Lucy am PC spricht mit DIESEM — dieselbe Stimme wie
|
||||
# die Telegram-Sprachnachrichten. Dünner Proxy, API 1:1 (pocket_server: /health, /tts -> WAV,
|
||||
# /tts/stream -> PCM16 + X-Sample-Rate).
|
||||
|
||||
class LucyTtsIn(BaseModel):
|
||||
text: str
|
||||
emo: str | None = None # Stimmungs-Profil (pocket_server EMO_PROFILES); Raphael-Lucy setzt keins
|
||||
|
||||
|
||||
@router.get("/lucy/stimme/health")
|
||||
def lucy_stimme_health() -> dict:
|
||||
"""Bereitschaft von Lucys Stimme (pocket_server /health: status ok|loading)."""
|
||||
try:
|
||||
r = httpx.get(f"{LUCY_STIMME_URL}/health", timeout=httpx.Timeout(5.0))
|
||||
r.raise_for_status()
|
||||
return r.json()
|
||||
except Exception as exc:
|
||||
raise HTTPException(502, f"Lucys Stimme (:8021) nicht erreichbar: {exc}")
|
||||
|
||||
|
||||
@router.post("/lucy/stimme/tts")
|
||||
async def lucy_stimme_tts(body: LucyTtsIn) -> Response:
|
||||
"""Text -> WAV (ganzer Text). Warm-up der Desktop-Lucy + Jobs, die eine Datei brauchen."""
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=httpx.Timeout(600.0, connect=5.0)) as client:
|
||||
r = await client.post(f"{LUCY_STIMME_URL}/tts", json=body.model_dump(exclude_none=True))
|
||||
r.raise_for_status()
|
||||
return Response(content=r.content, media_type=r.headers.get("content-type", "audio/wav"),
|
||||
headers={k: v for k, v in r.headers.items() if k.lower().startswith("x-")})
|
||||
except httpx.HTTPStatusError as exc:
|
||||
raise HTTPException(exc.response.status_code, f"Lucys Stimme: {exc.response.text[:200]}")
|
||||
except httpx.HTTPError as exc:
|
||||
raise HTTPException(502, f"Lucys Stimme nicht erreichbar: {exc}")
|
||||
|
||||
|
||||
@router.post("/lucy/stimme/tts/stream")
|
||||
async def lucy_stimme_tts_stream(body: LucyTtsIn) -> StreamingResponse:
|
||||
"""Text -> rohes PCM16-mono, satzweise gestreamt (Samplerate im Header X-Sample-Rate).
|
||||
Der Live-Pfad der Desktop-Lucy: erstes Audio nach dem ersten Satz. Der Upstream-Stream bleibt
|
||||
offen, solange der Client liest — bricht der Client ab (Barge-in), schließt httpx den Upstream."""
|
||||
client = httpx.AsyncClient(timeout=httpx.Timeout(None, connect=5.0))
|
||||
try:
|
||||
req = client.build_request("POST", f"{LUCY_STIMME_URL}/tts/stream", json=body.model_dump(exclude_none=True))
|
||||
upstream = await client.send(req, stream=True)
|
||||
except httpx.HTTPError as exc:
|
||||
await client.aclose()
|
||||
raise HTTPException(502, f"Lucys Stimme nicht erreichbar: {exc}")
|
||||
if upstream.status_code != 200:
|
||||
detail = (await upstream.aread()).decode("utf-8", "replace")[:200]
|
||||
await upstream.aclose(); await client.aclose()
|
||||
raise HTTPException(upstream.status_code, f"Lucys Stimme: {detail}")
|
||||
|
||||
async def gen():
|
||||
try:
|
||||
async for chunk in upstream.aiter_raw():
|
||||
yield chunk
|
||||
finally:
|
||||
await upstream.aclose()
|
||||
await client.aclose()
|
||||
|
||||
return StreamingResponse(gen(), media_type="application/octet-stream",
|
||||
headers={"X-Sample-Rate": upstream.headers.get("x-sample-rate", "24000")})
|
||||
|
||||
@@ -1,151 +0,0 @@
|
||||
"""Wissens-Vault-Reader: die nächtlichen Traum-Notizen (~/wissens-vault) als klickbares
|
||||
Wiki in der Zentrale. Bewusst READ-ONLY — geschrieben wird der Vault nur vom Traum-Cron
|
||||
(und via Auftragsbuch-Entscheidungen); hier wird nur gelesen und navigiert."""
|
||||
|
||||
import os
|
||||
import re
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from pydantic import BaseModel
|
||||
|
||||
router = APIRouter(prefix="/api")
|
||||
|
||||
VAULT = Path(os.environ.get("MC_VAULT_DIR", "~/wissens-vault")).expanduser()
|
||||
|
||||
|
||||
def _available() -> bool:
|
||||
return VAULT.is_dir()
|
||||
|
||||
|
||||
def _title_of(p: Path) -> str:
|
||||
"""Erste nicht-leere Zeile (ohne Markdown-#) als Anzeigename."""
|
||||
try:
|
||||
with p.open(encoding="utf-8", errors="replace") as fh:
|
||||
for line in fh:
|
||||
s = line.strip()
|
||||
if s:
|
||||
return s.lstrip("# ").strip()[:160]
|
||||
except OSError:
|
||||
pass
|
||||
return p.stem
|
||||
|
||||
|
||||
class VaultFile(BaseModel):
|
||||
path: str
|
||||
name: str
|
||||
dir: str
|
||||
title: str
|
||||
mtime: float
|
||||
neu: bool
|
||||
|
||||
|
||||
class WissenListResponse(BaseModel):
|
||||
available: bool
|
||||
files: list[VaultFile]
|
||||
|
||||
|
||||
class WissenFileResponse(BaseModel):
|
||||
path: str
|
||||
content: str
|
||||
mtime: float
|
||||
|
||||
|
||||
@router.get("/wissen", response_model=WissenListResponse)
|
||||
def list_vault() -> dict:
|
||||
"""Alle Markdown-Notizen des Vaults (relativer Pfad, Titel, Ordner, Alter)."""
|
||||
if not _available():
|
||||
return {"available": False, "files": []}
|
||||
files = []
|
||||
now = time.time()
|
||||
for p in sorted(VAULT.rglob("*.md")):
|
||||
if ".git" in p.parts or "traeume" in p.parts:
|
||||
continue
|
||||
rel = p.relative_to(VAULT).as_posix()
|
||||
st = p.stat()
|
||||
files.append({
|
||||
"path": rel,
|
||||
"name": p.stem,
|
||||
"dir": p.parent.relative_to(VAULT).as_posix() if p.parent != VAULT else "",
|
||||
"title": _title_of(p),
|
||||
"mtime": st.st_mtime,
|
||||
"neu": (now - st.st_mtime) < 36 * 3600, # „neu seit gestern Nacht"
|
||||
})
|
||||
files.sort(key=lambda f: f["mtime"], reverse=True)
|
||||
return {"available": True, "files": files}
|
||||
|
||||
|
||||
@router.get("/wissen/datei", response_model=WissenFileResponse)
|
||||
def read_file(pfad: str) -> dict:
|
||||
"""Inhalt einer Vault-Notiz — Traversal hart geblockt (resolve + is_relative_to)."""
|
||||
if not _available():
|
||||
raise HTTPException(404, "Wissens-Vault liegt auf der Box (hier nicht verfügbar).")
|
||||
try:
|
||||
target = (VAULT / pfad).resolve()
|
||||
if not target.is_relative_to(VAULT.resolve()) or target.suffix != ".md" or not target.is_file():
|
||||
raise HTTPException(404, "Notiz nicht gefunden.")
|
||||
return {"path": pfad, "content": target.read_text(encoding="utf-8", errors="replace")[:400_000],
|
||||
"mtime": target.stat().st_mtime}
|
||||
except HTTPException:
|
||||
raise
|
||||
except (ValueError, OSError):
|
||||
raise HTTPException(404, "Notiz nicht lesbar.")
|
||||
|
||||
|
||||
@router.get("/wissen/graph")
|
||||
def graph_vault() -> dict:
|
||||
"""Obsidian-artiger Graph des Vaults (Nodes = Dateien, Edges = Wiki-Links)."""
|
||||
if not _available():
|
||||
return {"nodes": [], "edges": []}
|
||||
|
||||
nodes = []
|
||||
edges = []
|
||||
# Für schnelle Link-Auflösung (case-insensitive Name -> relativer Pfad als ID)
|
||||
name_to_id = {}
|
||||
|
||||
# 1. Alle Nodes sammeln
|
||||
for p in VAULT.rglob("*.md"):
|
||||
if ".git" in p.parts or "traeume" in p.parts:
|
||||
continue
|
||||
rel = p.relative_to(VAULT).as_posix()
|
||||
name_to_id[p.stem.lower()] = rel
|
||||
|
||||
# Kategorie aus dem Ordner ableiten
|
||||
cat = "knowledge"
|
||||
parent = p.parent.name if p.parent != VAULT else ""
|
||||
if parent == "traeume":
|
||||
cat = "events"
|
||||
elif parent == "muster":
|
||||
cat = "rules"
|
||||
elif parent == "skill-kandidaten":
|
||||
cat = "identity"
|
||||
|
||||
nodes.append({
|
||||
"id": rel,
|
||||
"content": p.stem, # stem ist oft kürzer/prägnanter als der Titel
|
||||
"category": cat,
|
||||
"source": "wiki"
|
||||
})
|
||||
|
||||
# 2. Edges extrahieren (Wiki-Links [[Name]])
|
||||
link_rx = re.compile(r"\[\[([^\]]+)\]\]")
|
||||
for n in nodes:
|
||||
try:
|
||||
target = VAULT / n["id"]
|
||||
content = target.read_text(encoding="utf-8", errors="replace")
|
||||
# Set, um mehrfache Links auf dieselbe Datei zu entduplizieren
|
||||
found_links = {m.group(1).strip().lower() for m in link_rx.finditer(content)}
|
||||
|
||||
for link in found_links:
|
||||
target_id = name_to_id.get(link)
|
||||
if target_id and target_id != n["id"]:
|
||||
edges.append({
|
||||
"source": n["id"],
|
||||
"target": target_id,
|
||||
"weight": 1.0
|
||||
})
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
return {"nodes": nodes, "edges": edges}
|
||||
@@ -31,17 +31,6 @@ def list_snapshots() -> dict:
|
||||
return {"available": os.name == "posix", "backups": backup_svc.list_backups()}
|
||||
|
||||
|
||||
@router.get("/zeitmaschine/inhalt")
|
||||
def snapshot_contents(file: str) -> dict:
|
||||
"""Top-Level-Komponenten eines Snapshots (mem0, hermes, llama-swap, MANIFEST …)."""
|
||||
if not _FILE_RX.match(file):
|
||||
raise HTTPException(400, "Ungültiger Snapshot-Name.")
|
||||
p = backup_svc.BACKUP_DIR / file
|
||||
if not p.is_file():
|
||||
raise HTTPException(404, "Snapshot nicht gefunden.")
|
||||
return {"file": file, "components": backup_svc.snapshot_components(p)}
|
||||
|
||||
|
||||
@router.post("/zeitmaschine/restore")
|
||||
def restore(body: RestoreIn) -> dict:
|
||||
"""Wiederherstellung starten (detached). restore.sh macht VORHER selbst ein
|
||||
|
||||
@@ -1,92 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Parse systemd timeout errors for mission-control-2.service from journalctl.
|
||||
|
||||
Captures lines containing "State 'stop-sigterm' timed out" and appends them
|
||||
to ~/logs/mc2-timeout.log with ISO timestamps.
|
||||
|
||||
Permission errors are handled gracefully.
|
||||
|
||||
Uses user journal (systemctl --user) to query logs.
|
||||
"""
|
||||
|
||||
import subprocess
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
LOG_DIR = Path.home() / "logs"
|
||||
LOG_FILE = LOG_DIR / "mc2-timeout.log"
|
||||
SERVICE_NAME = "mission-control-2.service"
|
||||
SEARCH_PATTERN = "State 'stop-sigterm' timed out"
|
||||
JOURNALCTL_LIMIT = 1000
|
||||
|
||||
|
||||
def ensure_log_dir() -> None:
|
||||
"""Create log directory if it doesn't exist."""
|
||||
LOG_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
|
||||
def get_timeout_entries() -> list[str]:
|
||||
"""Run journalctl --user and return lines matching the timeout pattern."""
|
||||
cmd = [
|
||||
"journalctl",
|
||||
"--user",
|
||||
"-u", SERVICE_NAME,
|
||||
"--no-pager",
|
||||
"-n", str(JOURNALCTL_LIMIT),
|
||||
]
|
||||
try:
|
||||
result = subprocess.run(
|
||||
cmd,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=30,
|
||||
)
|
||||
except subprocess.TimeoutExpired:
|
||||
print("journalctl timed out")
|
||||
return []
|
||||
except PermissionError:
|
||||
print("Permission denied: journalctl requires access to user logs")
|
||||
return []
|
||||
|
||||
if result.returncode != 0:
|
||||
if "Permission denied" in result.stderr:
|
||||
print("Permission denied: journalctl requires access to user logs")
|
||||
else:
|
||||
print(f"journalctl failed: {result.stderr.strip()}")
|
||||
return []
|
||||
|
||||
return [
|
||||
line
|
||||
for line in result.stdout.splitlines()
|
||||
if SEARCH_PATTERN in line
|
||||
]
|
||||
|
||||
|
||||
def write_to_log(entries: list[str]) -> int:
|
||||
"""Append entries to log file with ISO timestamps. Returns count written."""
|
||||
ensure_log_dir()
|
||||
|
||||
timestamp = datetime.now(timezone.utc).isoformat()
|
||||
written = 0
|
||||
|
||||
with LOG_FILE.open("a", encoding="utf-8") as f:
|
||||
for entry in entries:
|
||||
f.write(f"[{timestamp}] {entry}\n")
|
||||
written += 1
|
||||
|
||||
return written
|
||||
|
||||
|
||||
def main() -> None:
|
||||
entries = get_timeout_entries()
|
||||
if not entries:
|
||||
print("No timeout entries found.")
|
||||
return
|
||||
|
||||
count = write_to_log(entries)
|
||||
print(f"Appended {count} timeout entry/ies to {LOG_FILE}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
+49
-102
@@ -1,36 +1,22 @@
|
||||
"""
|
||||
Hermes-Agent-Status (Control-Plane-Read). MC betreibt Hermes NICHT — es zeigt nur
|
||||
Status + verlinkt das standalone hermes-webui. Voller Zugriff + Tools/MCP werden in
|
||||
Hermes' eigener Config verdrahtet (siehe docs/HERMES_SETUP.md).
|
||||
Hermes-Agent: Status und Hirn-Umstellung. MC betreibt Hermes NICHT — Werkzeuge und MCP werden in
|
||||
Hermes' eigener Config verdrahtet (siehe docs/ARCHITEKTUR.md, „Wer schreibt was").
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
|
||||
import httpx
|
||||
from config import (
|
||||
BOX_CONSOLE_PATH,
|
||||
BOX_CONSOLE_UPSTREAM,
|
||||
HERMES_API_URL,
|
||||
HERMES_BUILTIN_UI_PATH,
|
||||
HERMES_BUILTIN_UI_UPSTREAM,
|
||||
HERMES_HOME,
|
||||
PC_EXECUTOR_URL,
|
||||
)
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _hermes_version(name: str) -> float | None:
|
||||
"""Versionszahl aus 'Hermes-4.3', 'Hermes-4', 'Nous-Hermes-2' → 4.3/4.0/2.0."""
|
||||
low = (name or "").lower()
|
||||
if "hermes" not in low:
|
||||
return None
|
||||
m = re.search(r"hermes[-_ ]?(\d+(?:\.\d+)?)", low)
|
||||
return float(m.group(1)) if m else None
|
||||
|
||||
|
||||
def _active_brain_name() -> str:
|
||||
"""Aktives Agent-Hirn aus Hermes' Config: model.default (sonst model.model)."""
|
||||
try:
|
||||
@@ -106,67 +92,18 @@ def _reach(url: str, path: str = "") -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def _count_enabled_mcp_servers() -> int:
|
||||
config_path = HERMES_HOME / "config.yaml"
|
||||
if not config_path.exists():
|
||||
return 0
|
||||
try:
|
||||
from ruamel.yaml import YAML
|
||||
r_yaml = YAML()
|
||||
with config_path.open("r", encoding="utf-8") as f:
|
||||
cfg = r_yaml.load(f) or {}
|
||||
mcp_servers = cfg.get("mcp_servers", {}) if isinstance(cfg, dict) else {}
|
||||
if not isinstance(mcp_servers, dict):
|
||||
return 0
|
||||
return sum(1 for v in mcp_servers.values() if isinstance(v, dict) and v.get("enabled", True))
|
||||
except Exception:
|
||||
log.debug("_count_enabled_mcp_servers: Fehler", exc_info=True)
|
||||
return 0
|
||||
|
||||
|
||||
def agent_status() -> dict:
|
||||
"""Erreichbarkeit von Gateway (:8642) + WebUI (:8787) + lokale Hinweise."""
|
||||
home = HERMES_HOME
|
||||
brain_model = "auto"
|
||||
config_path = home / "config.yaml"
|
||||
if config_path.exists():
|
||||
try:
|
||||
from ruamel.yaml import YAML
|
||||
r_yaml = YAML()
|
||||
with config_path.open("r", encoding="utf-8") as f:
|
||||
cfg = r_yaml.load(f) or {}
|
||||
if isinstance(cfg, dict):
|
||||
# Hermes nutzt model.default als aktives Modell (model.model = Provider-Param).
|
||||
m = cfg.get("model", {}) or {}
|
||||
brain_model = m.get("default") or m.get("model") or "auto"
|
||||
except Exception:
|
||||
log.debug("agent_status: Hermes-config.yaml nicht lesbar", exc_info=True)
|
||||
|
||||
"""Erreichbarkeit des Hermes-Gateways (:8642) und des Hermes-Dashboards — für die Dienste-Liste.
|
||||
|
||||
Bis 24.09.2026 fragte die Funktion bei jedem Aufruf auch die abgebaute Konsole und den
|
||||
PC-Ausführer ab (3 s Zeitlimit) und las die Hermes-Config, obwohl nur diese Felder gebraucht
|
||||
werden. Seit der PC-Ausführer schläft, hätte das jede Dienste-Abfrage um 3 s verzögert."""
|
||||
return {
|
||||
"gateway_url": HERMES_API_URL,
|
||||
# Box-Konsole (ttyd → Login-Shell): same-origin über MC2 geproxyt (/console/).
|
||||
"box_console_url": BOX_CONSOLE_PATH,
|
||||
# Eingebaute Hermes-Web-GUI (hermes serve): same-origin über MC2 geproxyt (/hermes-ui/).
|
||||
# Seit dem Umzug auf Hermes Desktop ist :9119 zugleich das Desktop-Gateway.
|
||||
"hermes_ui_url": HERMES_BUILTIN_UI_PATH,
|
||||
"gateway_reachable": _reach(HERMES_API_URL, "/health"),
|
||||
# Erreichbarkeit des lokalen ttyd-Upstreams (Loopback, base-path /console).
|
||||
"box_console_reachable": _reach(BOX_CONSOLE_UPSTREAM, "/console/"),
|
||||
# Erreichbarkeit der eingebauten Hermes-GUI / des Desktop-Gateways (Loopback :9119).
|
||||
"hermes_ui_reachable": _reach(HERMES_BUILTIN_UI_UPSTREAM, "/"),
|
||||
"home_exists": home.exists(),
|
||||
"brain_model": brain_model,
|
||||
# Best-effort: welche Verdrahtung lokal sichtbar ist (auf der Box aussagekräftig).
|
||||
"has_config": (home / "config.yaml").exists() or (home / "config.json").exists(),
|
||||
"has_skills": (home / "skills").exists(),
|
||||
"has_memories": (home / "memories").exists(),
|
||||
# Neue Felder: Telegram, MCP-Server-Anzahl, PC-Executor-Erreichbarkeit.
|
||||
"telegram_enabled": bool(os.environ.get("TELEGRAM_BOT_TOKEN", "")),
|
||||
"mcp_server_count": _count_enabled_mcp_servers(),
|
||||
"pc_executor_reachable": _reach(PC_EXECUTOR_URL, "/health"),
|
||||
# Konfigurierte Adresse mitliefern, damit die UI sie ANZEIGT statt hartcodiert (Review 15.07.).
|
||||
"pc_executor_url": PC_EXECUTOR_URL,
|
||||
}
|
||||
|
||||
|
||||
@@ -220,49 +157,59 @@ def set_agent_brain(model_id: str) -> dict:
|
||||
|
||||
|
||||
def update_brain_model(new_model: str) -> bool:
|
||||
"""Setzt Lucys Hirn in Hermes' config.yaml (model.default + model.model) und startet den
|
||||
Hermes-Gateway neu.
|
||||
|
||||
Seit 24.09.2026 vorsichtig: Ist die Datei nicht lesbar (halb geschrieben, Syntaxfehler), wird
|
||||
NICHTS geschrieben — früher entstand dann eine neue Datei nur mit dem model-Block, und der Rest
|
||||
von Hermes' Konfiguration wäre weg gewesen. Geschrieben wird atomar (tmp + os.replace), vorher
|
||||
liegt eine Sicherung config.yaml.bak-hirn-<Zeitstempel> daneben."""
|
||||
import shutil
|
||||
import time as _time
|
||||
|
||||
from config import HERMES_HOME
|
||||
home = HERMES_HOME
|
||||
config_path = home / "config.yaml"
|
||||
|
||||
# Ensure home directory exists
|
||||
home.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
cfg = {}
|
||||
if config_path.exists():
|
||||
try:
|
||||
from ruamel.yaml import YAML
|
||||
r_yaml = YAML()
|
||||
with config_path.open("r", encoding="utf-8") as f:
|
||||
cfg = r_yaml.load(f) or {}
|
||||
except Exception:
|
||||
log.debug("update_brain_model: bestehende config.yaml nicht lesbar", exc_info=True)
|
||||
cfg = {}
|
||||
|
||||
from ruamel.yaml import YAML
|
||||
|
||||
config_path = HERMES_HOME / "config.yaml"
|
||||
if not config_path.exists():
|
||||
log.warning("update_brain_model: %s fehlt — Hirn wird nicht umgestellt", config_path)
|
||||
return False
|
||||
r_yaml = YAML()
|
||||
r_yaml.preserve_quotes = True
|
||||
r_yaml.width = 100
|
||||
r_yaml.indent(mapping=2, sequence=4, offset=2)
|
||||
try:
|
||||
with config_path.open("r", encoding="utf-8") as f:
|
||||
cfg = r_yaml.load(f)
|
||||
except Exception:
|
||||
log.warning("update_brain_model: config.yaml nicht lesbar — nichts geschrieben", exc_info=True)
|
||||
return False
|
||||
if not isinstance(cfg, dict):
|
||||
cfg = {}
|
||||
|
||||
log.warning("update_brain_model: config.yaml hat unerwarteten Inhalt — nichts geschrieben")
|
||||
return False
|
||||
|
||||
if "model" not in cfg or not isinstance(cfg["model"], dict):
|
||||
cfg["model"] = {}
|
||||
|
||||
# Hermes liest model.default als aktives Modell; model.model ist der Provider-Param.
|
||||
# Beide setzen, sonst greift die Umschaltung nicht (latenter Bug: nur model.model gesetzt).
|
||||
# Beide setzen, sonst greift die Umschaltung nicht.
|
||||
cfg["model"]["default"] = new_model
|
||||
cfg["model"]["model"] = new_model
|
||||
|
||||
tmp = config_path.with_suffix(".yaml.neu")
|
||||
try:
|
||||
from ruamel.yaml import YAML
|
||||
r_yaml = YAML()
|
||||
with config_path.open("w", encoding="utf-8") as f:
|
||||
shutil.copy2(config_path, config_path.with_name(f"config.yaml.bak-hirn-{_time.strftime('%Y%m%d-%H%M%S')}"))
|
||||
with tmp.open("w", encoding="utf-8") as f:
|
||||
r_yaml.dump(cfg, f)
|
||||
|
||||
# Restart the user-space service to apply changes
|
||||
try:
|
||||
from services import maintenance
|
||||
maintenance.restart_service("hermes-gateway")
|
||||
except Exception:
|
||||
log.warning("update_brain_model: hermes-gateway-Restart fehlgeschlagen", exc_info=True)
|
||||
|
||||
return True
|
||||
YAML(typ="safe").load(tmp.read_text(encoding="utf-8")) # muss wieder lesbar sein
|
||||
os.replace(tmp, config_path)
|
||||
except Exception:
|
||||
log.warning("update_brain_model: Schreiben der config.yaml fehlgeschlagen", exc_info=True)
|
||||
tmp.unlink(missing_ok=True)
|
||||
return False
|
||||
|
||||
try:
|
||||
from services import maintenance
|
||||
maintenance.restart_service("hermes-gateway")
|
||||
except Exception:
|
||||
log.warning("update_brain_model: hermes-gateway-Restart fehlgeschlagen", exc_info=True)
|
||||
return True
|
||||
|
||||
@@ -112,14 +112,6 @@ def notify_telegram(subject: str, text: str) -> None:
|
||||
log.warning("notify_telegram: notify.sh fehlgeschlagen", exc_info=True)
|
||||
|
||||
|
||||
def list_recent(limit: int = 150) -> list[dict]:
|
||||
"""Die jüngsten Einträge (neueste zuerst) — Datenquelle der Chronik: alles, was die Box
|
||||
dem Commander je aktiv gemeldet hat (Health-Wächter, Updates, Radar, Träume, Alarme)."""
|
||||
with _lock:
|
||||
state = _load()
|
||||
return list(reversed(state["items"][-max(1, min(limit, MAX_ITEMS)):]))
|
||||
|
||||
|
||||
def list_after(after: int | None, limit: int = 20) -> dict:
|
||||
"""Einträge NACH Cursor `after` (aufsteigend). Ohne Cursor nur den aktuellen
|
||||
Stand liefern (latest) — so initialisiert Lucy ihren Cursor, ohne Altes nachzuplappern."""
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
"""
|
||||
Aufräumen der Modell-Platte (Box-Wart, Umbau 09/2026): was belegt Platz, ohne gebraucht zu werden?
|
||||
|
||||
Zwei Arten von Kandidaten:
|
||||
• Einträge ohne Rolle llama-swap-Einträge, die keine der heutigen Rollen tragen (Hirn, Coder, ihre
|
||||
Bild-Zwillinge, Einbettung, Reranker) und in keiner immer-warmen Gruppe stehen.
|
||||
• Ordner ohne Eintrag Unterordner von MODELS_DIR, auf die kein Eintrag mehr zeigt (alte Originale,
|
||||
Reserve-Modelle, Drafts aus früheren Versuchen).
|
||||
|
||||
Gelöscht wird nur auf Knopfdruck des Nutzers (mit Rückfrage in der Oberfläche), nie automatisch
|
||||
(User-Entscheid 23.09.: „nach der Umstellung löschen“ — die Entscheidung je Modell bleibt beim Nutzer).
|
||||
Ein Eintrag wird aus der Config genommen, danach verschwinden nur Ordner, auf die NICHTS mehr zeigt:
|
||||
Die Bild-Zwillinge teilen sich ihre Gewichte mit Hirn und Coder, geteilte Dateien bleiben immer liegen.
|
||||
"""
|
||||
|
||||
import os
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
|
||||
from config import MODELS_DIR
|
||||
|
||||
from services import llamaswap
|
||||
|
||||
AKTIVE_ROLLEN = {"hermes", "fast", "coder", "heavy", "vision", "coder-bild", "embed", "reranker"}
|
||||
SYSTEM_ORDNER = {"mc2-backups", "radar", "pruefstand-cache", "lost+found"}
|
||||
AUF_DER_BOX = os.name == "posix" # auf dem Windows-PC (Entwicklung) wird nie gelöscht
|
||||
|
||||
# Was der Nutzer über bekannte Ordner wissen sollte, bevor er löscht (Stand 24.09.2026).
|
||||
HINWEISE = {
|
||||
"Qwen3.6-35B-A3B-MTP-GGUF": "Original des Hirns vor der Uncensored-Variante (17.09.) — der Rückweg, falls das Hirn "
|
||||
"einmal Probleme macht.",
|
||||
"Qwen3.8-27B-GGUF": "Original des Coders vor der Uncensored-Variante (17.09.) — der Rückweg für den Coder.",
|
||||
"Nemotron-3.5-Lightning-30B-A3B-GGUF": "Reserve-Hirn aus dem Prüfstand vom 17.09. (gleich gut bewertet, "
|
||||
"nie in Betrieb).",
|
||||
"DFlash-drafts": "Draft-Modelle aus früheren Versuchen, heute ungenutzt.",
|
||||
"drafts": "Draft-Modelle aus früheren Versuchen, heute ungenutzt.",
|
||||
"gpt-oss-120b": "Früheres heavy-Modell, hat seit dem 04.09. keine Rolle mehr.",
|
||||
"Qwen3-VL-30B-A3B-Instruct": "Früheres Bild-Modell, seit dem 24.09. durch die Bild-Zwillinge abgelöst.",
|
||||
"Muse-Glimmer-30B": "Debugger — die Rolle entfällt (Entscheid 23.09.: höchstens Hirn und Coder).",
|
||||
}
|
||||
|
||||
|
||||
def _ordner_von(pfad: str) -> str | None:
|
||||
"""Oberster Ordner unter MODELS_DIR, in dem eine Datei liegt (oder None, wenn sie woanders liegt)."""
|
||||
if not os.path.isabs(pfad):
|
||||
return None
|
||||
try:
|
||||
rel = Path(pfad).resolve().relative_to(Path(MODELS_DIR).resolve())
|
||||
except (ValueError, OSError):
|
||||
return None
|
||||
return rel.parts[0] if len(rel.parts) > 1 else None
|
||||
|
||||
|
||||
def _genutzte_ordner(models: dict) -> dict[str, set[str]]:
|
||||
"""Ordner → Einträge, deren Befehl eine Datei darin nennt (Gewichte, Projektor, Draft)."""
|
||||
genutzt: dict[str, set[str]] = {}
|
||||
for name, spec in models.items():
|
||||
if not isinstance(spec, dict):
|
||||
continue
|
||||
for wort in str(spec.get("cmd") or "").split(): # jedes Wort, das unter MODELS_DIR liegt
|
||||
if (ordner := _ordner_von(wort.strip("'\""))):
|
||||
genutzt.setdefault(ordner, set()).add(name)
|
||||
return genutzt
|
||||
|
||||
|
||||
def _groesse(pfad: Path) -> int:
|
||||
summe = 0
|
||||
for wurzel, _, dateien in os.walk(pfad):
|
||||
for datei in dateien:
|
||||
try:
|
||||
summe += os.path.getsize(os.path.join(wurzel, datei))
|
||||
except OSError:
|
||||
pass
|
||||
return summe
|
||||
|
||||
|
||||
def _immer_warm(cfg: dict) -> set[str]:
|
||||
return {m for g in (cfg.get("groups") or {}).values()
|
||||
if isinstance(g, dict) and (g.get("persistent") or g.get("persist")) for m in g.get("members") or []}
|
||||
|
||||
|
||||
def kandidaten() -> list[dict]:
|
||||
"""Alles, was sich löschen ließe — größte zuerst. Löscht nichts."""
|
||||
cfg = llamaswap.read_config()
|
||||
models = cfg.get("models") or {}
|
||||
warm = _immer_warm(cfg)
|
||||
genutzt = _genutzte_ordner(models)
|
||||
liste: list[dict] = []
|
||||
for name, spec in models.items():
|
||||
aliase = {str(a).lower() for a in (spec or {}).get("aliases") or []}
|
||||
if aliase & AKTIVE_ROLLEN or name in warm:
|
||||
continue
|
||||
eigene = {o for o, wer in genutzt.items() if wer == {name}} # nur von diesem Eintrag genutzt
|
||||
groesse = sum(_groesse(Path(MODELS_DIR) / o) for o in eigene)
|
||||
rolle = f"Rolle „{', '.join(sorted(aliase))}“ gibt es nicht mehr." if aliase else "Hat keine Rolle."
|
||||
liste.append({"art": "eintrag", "name": name, "groesse_gb": round(groesse / 1e9, 1),
|
||||
"hinweis": HINWEISE.get(name, rolle)})
|
||||
try:
|
||||
ordner = sorted(p for p in Path(MODELS_DIR).iterdir() if p.is_dir())
|
||||
except OSError:
|
||||
ordner = []
|
||||
for pfad in ordner:
|
||||
if pfad.name in genutzt or pfad.name in SYSTEM_ORDNER or pfad.name.startswith("."):
|
||||
continue
|
||||
liste.append({"art": "ordner", "name": pfad.name, "groesse_gb": round(_groesse(pfad) / 1e9, 1),
|
||||
"hinweis": HINWEISE.get(pfad.name, "Kein Eintrag nutzt diesen Ordner.")})
|
||||
return sorted(liste, key=lambda k: -k["groesse_gb"])
|
||||
|
||||
|
||||
def _ordner_loeschen(name: str) -> int:
|
||||
"""Einen Ordner direkt unter MODELS_DIR löschen; gibt die freigewordenen Bytes zurück."""
|
||||
wurzel = Path(MODELS_DIR).resolve()
|
||||
pfad = (wurzel / name).resolve()
|
||||
if pfad.parent != wurzel or not pfad.is_dir() or name in SYSTEM_ORDNER or name.startswith("."):
|
||||
raise ValueError(f"{name} ist kein löschbarer Modell-Ordner.")
|
||||
groesse = _groesse(pfad)
|
||||
shutil.rmtree(pfad)
|
||||
return groesse
|
||||
|
||||
|
||||
def loeschen(art: str, name: str) -> dict:
|
||||
"""Knopf „Löschen“: nur, was gerade als Kandidat gilt. Gibt {ok, text} oder {ok: False, detail} zurück."""
|
||||
if not any(k["art"] == art and k["name"] == name for k in kandidaten()):
|
||||
return {"ok": False, "detail": f"{name} ist nicht (mehr) zum Löschen frei."}
|
||||
if not AUF_DER_BOX:
|
||||
return {"ok": False, "detail": "Löschen geht nur auf der Box."}
|
||||
frei = 0
|
||||
try:
|
||||
if art == "ordner":
|
||||
frei = _ordner_loeschen(name)
|
||||
else:
|
||||
with llamaswap.config_sperre():
|
||||
cfg = llamaswap.read_config()
|
||||
models = cfg.get("models") or {}
|
||||
vorher = _genutzte_ordner(models)
|
||||
del models[name]
|
||||
for gruppe in (cfg.get("groups") or {}).values():
|
||||
if isinstance(gruppe, dict) and name in (gruppe.get("members") or []):
|
||||
gruppe["members"] = [m for m in gruppe["members"] if m != name]
|
||||
llamaswap.write_config(cfg)
|
||||
nachher = _genutzte_ordner(models)
|
||||
for ordner in sorted(set(vorher) - set(nachher)): # nur Ordner, auf die jetzt nichts mehr zeigt
|
||||
frei += _ordner_loeschen(ordner)
|
||||
except (OSError, ValueError) as e:
|
||||
return {"ok": False, "detail": f"Löschen ging nicht: {e}"}
|
||||
return {"ok": True, "text": f"{name} gelöscht, {frei / 1e9:.1f} GB frei."}
|
||||
@@ -1,525 +0,0 @@
|
||||
"""
|
||||
Auftragsbuch — die Vorschlags-Inbox der Box (das Mensch-Gate als Klick statt Git-Handarbeit).
|
||||
|
||||
Quellen der Karten:
|
||||
• Vorschlags-Branches auf Gitea (wartung/*, orchestrator/*, doku/*) — die Werkstatt und der
|
||||
Orchestrator arbeiten propose-only und lassen ihre Ergebnisse dort liegen.
|
||||
Seit S3 (lucy-pipeline) aus ZWEI Repos: mission-control-v2 (Box-Stack) UND lucy (Desktop-App).
|
||||
• Skill-Kandidaten aus dem Wissens-Vault (~/wissens-vault/skill-kandidaten/) — Vorschläge des
|
||||
nächtlichen Traum-Crons, Gate war bisher „Commander sagt mach".
|
||||
|
||||
Annehmen (Branch) startet den Repo-eigenen Runner als EIGENE systemd-Unit (detached):
|
||||
• mc2: deploy/auftrag-annehmen.sh — Merge im Worktree → Push main → Deploy → Health → Revert bei Rot.
|
||||
• lucy: deploy/lucy-annahme.sh — Merge im Worktree → Push main → PC baut dist (via PC-Executor)
|
||||
und startet Lucy neu, wenn sie lief → bei Rot Revert auf main (die laufende Lucy bleibt die alte).
|
||||
Detached, weil deploy.sh mission-control-2 neu startet — ein Kind des Backends stürbe mittendrin.
|
||||
Der Fortschritt landet in STATUS_PATH (JSON), das Skript schreibt, die API liest nur.
|
||||
Status-Schlüssel: mc2 = Branch-Name pur (Bestand), lucy = "lucy:<branch>" (kollisionsfrei).
|
||||
|
||||
Lokal (Windows-Dev) ist alles harmlos: available=False, Aktionen geben Fehler statt zu crashen.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import time
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
|
||||
from config import MODELS_DIR
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
REPO = Path(os.environ.get("MC2_SOURCE_DIR", "~/mission-control-v2")).expanduser()
|
||||
LUCY = Path(os.environ.get("MC2_LUCY_DIR", "~/lucy")).expanduser()
|
||||
VAULT = Path(os.environ.get("MC_VAULT_DIR", "~/wissens-vault")).expanduser()
|
||||
STATUS_PATH = Path(os.environ.get("MC2_AUFTRAG_STATUS", str(MODELS_DIR / "mc2-auftragsbuch.json")))
|
||||
# Empfehlungs-Stempel des nächtlichen Karten-Gutachters (deploy/karten-gutachter.sh schreibt,
|
||||
# die API liest nur) und das Lern-Gedächtnis der Ablehnungen (Radar/Specifier lesen es).
|
||||
GUTACHTEN_PATH = Path(os.environ.get("MC2_KARTEN_GUTACHTEN", str(MODELS_DIR / "mc2-karten-gutachten.json")))
|
||||
ABLEHNUNGEN_PATH = Path(os.environ.get("MC2_ABLEHNUNGEN", str(MODELS_DIR / "mc2-ablehnungen.jsonl")))
|
||||
|
||||
# Karten-Quellen. Die Runner-Skripte liegen IMMER im MC2-Checkout (deploy/) — auch der
|
||||
# Lucy-Runner, denn er läuft auf der Box; nur der Build passiert am PC.
|
||||
REPOS: dict[str, dict] = {
|
||||
"mc2": {"path": REPO, "runner": "auftrag-annehmen.sh", "key": ""},
|
||||
"lucy": {"path": LUCY, "runner": "lucy-annahme.sh", "key": "lucy:"},
|
||||
}
|
||||
|
||||
# Persistenz für gemeldete Branches (Idempotenz: nur EINMAL pro Branch pingen).
|
||||
# Muster wie mc2-announce.json unter MODELS_DIR.
|
||||
ANNOUNCE_BRANCHES_PATH = Path(os.environ.get("MC2_ANNOUNCE_BRANCHES", str(MODELS_DIR / "mc2-announce-branches.json")))
|
||||
|
||||
# Nur diese Branch-Familien sind Vorschläge (main/HEAD & Fremdes bleiben draußen).
|
||||
PREFIXES = ("wartung/", "orchestrator/", "doku/", "feature/")
|
||||
# Branch-Namen kommen vom Client zurück → hart validieren (keine Shell-/Git-Injektion).
|
||||
_BRANCH_RX = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._/-]{0,120}$")
|
||||
_KANDIDAT_RX = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._ -]{0,120}\.md$")
|
||||
|
||||
_fetch_cache: dict = {}
|
||||
_FETCH_EVERY = 30.0 # s — Gitea nicht bei jedem UI-Poll anfragen
|
||||
_CI_CACHE: dict = {} # { (repo, branch, head_sha): {"status": "success", "ts": time} }
|
||||
|
||||
def _gitea_creds() -> tuple | None:
|
||||
# 1. Fallback: Versuch via git credential manager (zukunftsfähig & plattformübergreifend)
|
||||
try:
|
||||
import subprocess
|
||||
p = subprocess.run(
|
||||
["git", "credential", "fill"],
|
||||
input=b"protocol=https\nhost=git.tobisniceshomelab.ddnsfree.com\n",
|
||||
capture_output=True,
|
||||
check=True
|
||||
)
|
||||
out = p.stdout.decode(errors="replace")
|
||||
u_match = re.search(r"username=(.+)", out)
|
||||
p_match = re.search(r"password=(.+)", out)
|
||||
if u_match and p_match:
|
||||
return u_match.group(1).strip(), p_match.group(1).strip()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 2. Fallback: Datei (hauptsächlich auf der AI-Box genutzt)
|
||||
try:
|
||||
cred = Path("~/.git-credentials").expanduser()
|
||||
for line in cred.read_text(encoding="utf-8").splitlines():
|
||||
m = re.match(r"https://([^:]+):([^@]+)@git\.tobisniceshomelab", line.strip())
|
||||
if m: return m.group(1), m.group(2)
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
def _fetch_ci_status(repo: str, branch: str, head_sha: str) -> str | None:
|
||||
# Nur auf der Box (wo creds liegen) sinnvoll
|
||||
creds = _gitea_creds()
|
||||
if not creds: return None
|
||||
_user, token = creds
|
||||
cache_key = (repo, branch, head_sha)
|
||||
cached = _CI_CACHE.get(cache_key)
|
||||
if cached and (time.time() - cached["ts"] < 30 or cached["status"] in ("success", "failure", "skipped")):
|
||||
return cached["status"]
|
||||
|
||||
full_repo = "Hitonabi/mission-control-v2" if repo == "mc2" else "Hitonabi/lucy"
|
||||
url = f"http://192.168.178.153:3000/api/v1/repos/{full_repo}/actions/runs?branch={urllib.parse.quote(branch)}&limit=1"
|
||||
try:
|
||||
req = urllib.request.Request(url, headers={"Authorization": "token " + token})
|
||||
with urllib.request.urlopen(req, timeout=5) as r:
|
||||
data = json.load(r)
|
||||
runs = data if isinstance(data, list) else data.get("runs", data.get("workflow_runs", []))
|
||||
if runs:
|
||||
run = runs[0]
|
||||
if run.get("head_sha", "").startswith(head_sha[:7]):
|
||||
st = run.get("status")
|
||||
if st:
|
||||
_CI_CACHE[cache_key] = {"status": st, "ts": time.time()}
|
||||
return st
|
||||
except Exception as e:
|
||||
log.warning("auftragsbuch: CI-Status fetch fehler: %s", e)
|
||||
return None
|
||||
|
||||
|
||||
|
||||
def _available() -> bool:
|
||||
return os.name == "posix" and (REPO / ".git").exists()
|
||||
|
||||
|
||||
def _repo_ok(repo: str) -> bool:
|
||||
r = REPOS.get(repo)
|
||||
return bool(r) and os.name == "posix" and (r["path"] / ".git").exists()
|
||||
|
||||
|
||||
def _git(repo: str, args: list[str], timeout: int = 20) -> subprocess.CompletedProcess:
|
||||
return subprocess.run(["git", "-C", str(REPOS[repo]["path"]), *args],
|
||||
capture_output=True, text=True, timeout=timeout)
|
||||
|
||||
|
||||
def _status_key(repo: str, branch: str) -> str:
|
||||
return f"{REPOS[repo]['key']}{branch}"
|
||||
|
||||
|
||||
def _fetch_throttled(repo: str) -> None:
|
||||
now = time.time()
|
||||
if now - _fetch_cache.get(repo, 0.0) < _FETCH_EVERY:
|
||||
return
|
||||
_fetch_cache[repo] = now
|
||||
try:
|
||||
_git(repo, ["fetch", "-q", "--prune", "origin"], timeout=30)
|
||||
except Exception:
|
||||
log.warning("auftragsbuch: git fetch (%s) fehlgeschlagen", repo, exc_info=True)
|
||||
|
||||
|
||||
def _load_announce_branches() -> set:
|
||||
"""Liefert die Menge der bereits gemeldeten Branch-Namen (Idempotenz)."""
|
||||
try:
|
||||
data = json.loads(ANNOUNCE_BRANCHES_PATH.read_text(encoding="utf-8"))
|
||||
return set(data) if isinstance(data, list) else set()
|
||||
except Exception:
|
||||
return set()
|
||||
|
||||
|
||||
def _save_announce_branches(branches: set) -> None:
|
||||
"""Speichert die Menge gemelder Branch-Namen."""
|
||||
try:
|
||||
tmp = ANNOUNCE_BRANCHES_PATH.with_suffix(".tmp")
|
||||
tmp.write_text(json.dumps(sorted(branches), ensure_ascii=False), encoding="utf-8")
|
||||
tmp.replace(ANNOUNCE_BRANCHES_PATH)
|
||||
except OSError:
|
||||
log.warning("auftragsbuch: Announce-Branches %s nicht schreibbar", ANNOUNCE_BRANCHES_PATH, exc_info=True)
|
||||
|
||||
|
||||
def _statuses() -> dict:
|
||||
try:
|
||||
return (json.loads(STATUS_PATH.read_text(encoding="utf-8")) or {}).get("branches", {})
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
|
||||
def _set_status(key: str, state: str, detail: str) -> None:
|
||||
try:
|
||||
try:
|
||||
data = json.loads(STATUS_PATH.read_text(encoding="utf-8"))
|
||||
except Exception:
|
||||
data = {}
|
||||
data.setdefault("branches", {})[key] = {"state": state, "detail": detail, "ts": time.time()}
|
||||
tmp = STATUS_PATH.with_suffix(".tmp")
|
||||
tmp.write_text(json.dumps(data, ensure_ascii=False), encoding="utf-8")
|
||||
tmp.replace(STATUS_PATH)
|
||||
except OSError:
|
||||
log.warning("auftragsbuch: Status %s nicht schreibbar", STATUS_PATH, exc_info=True)
|
||||
|
||||
|
||||
def _gutachten() -> dict:
|
||||
"""Alle Karten-Stempel ("<repo>:<branch>" → {empfehlung, satz, richter, commit_ts, ts})."""
|
||||
try:
|
||||
data = json.loads(GUTACHTEN_PATH.read_text(encoding="utf-8"))
|
||||
return data if isinstance(data, dict) else {}
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
|
||||
def _remote_branches(repo: str) -> list[str]:
|
||||
r = _git(repo, ["for-each-ref", "--format=%(refname:short)", "refs/remotes/origin"])
|
||||
out = []
|
||||
for line in (r.stdout or "").splitlines():
|
||||
name = line.strip().removeprefix("origin/")
|
||||
if name and name != "HEAD" and name.startswith(PREFIXES):
|
||||
out.append(name)
|
||||
return out
|
||||
|
||||
|
||||
def _valid_branch(branch: str) -> bool:
|
||||
return bool(_BRANCH_RX.match(branch)) and ".." not in branch and branch.startswith(PREFIXES)
|
||||
|
||||
|
||||
def _repo_items(repo: str, statuses: dict, gutachten: dict) -> list[dict]:
|
||||
items = []
|
||||
for branch in _remote_branches(repo):
|
||||
ref = f"origin/{branch}"
|
||||
status = statuses.get(_status_key(repo, branch))
|
||||
try:
|
||||
ahead = int((_git(repo, ["rev-list", "--count", f"origin/main..{ref}"]).stdout or "0").strip() or 0)
|
||||
if ahead == 0 and ((status or {}).get("state") not in ("laeuft", "rollback")):
|
||||
continue # bereits in main enthalten → keine offene Entscheidung mehr
|
||||
behind = int((_git(repo, ["rev-list", "--count", f"{ref}..origin/main"]).stdout or "0").strip() or 0)
|
||||
# Kaputt aufgesetzte Branches entlarven (Worker machte git init/Shallow statt zu
|
||||
# klonen): ohne gemeinsamen Vorfahren kann git NIE mergen — Annehmen wäre ein
|
||||
# garantierter Fehllauf („refusing to merge unrelated histories").
|
||||
verwaist = _git(repo, ["merge-base", "origin/main", ref]).returncode != 0
|
||||
show = _git(repo, ["show", "-s", "--format=%s%x1f%b%x1f%ct%x1f%an", ref])
|
||||
subject, body, cts, author = ((show.stdout or "").split("\x1f") + ["", "", "", ""])[:4]
|
||||
ts_val = int(cts) if cts.strip().isdigit() else None
|
||||
# Stempel nur zeigen, wenn er zum AKTUELLEN Commit gehört (nachgeschobener
|
||||
# Commit macht das alte Gutachten ungültig — der Nacht-Lauf stempelt neu).
|
||||
stempel = gutachten.get(f"{repo}:{branch}")
|
||||
if not (isinstance(stempel, dict) and stempel.get("commit_ts") == ts_val):
|
||||
stempel = None
|
||||
|
||||
head_sha = (_git(repo, ["rev-parse", ref]).stdout or "").strip()
|
||||
ci_status = _fetch_ci_status(repo, branch, head_sha) if head_sha else None
|
||||
|
||||
stat = (_git(repo, ["diff", "--shortstat", f"origin/main...{ref}"]).stdout or "").strip()
|
||||
files_raw = (_git(repo, ["diff", "--name-status", f"origin/main...{ref}"]).stdout or "").splitlines()
|
||||
files = []
|
||||
for line in files_raw[:60]:
|
||||
parts = line.split("\t")
|
||||
if len(parts) >= 2:
|
||||
files.append({"status": parts[0][:2], "path": parts[-1]})
|
||||
paths = [f["path"] for f in files]
|
||||
frontend_src = any(p.startswith("frontend/src") for p in paths)
|
||||
frontend_dist = any(p.startswith("frontend/dist") for p in paths)
|
||||
items.append({
|
||||
"repo": repo,
|
||||
"branch": branch,
|
||||
"kind": branch.split("/", 1)[0],
|
||||
"subject": subject.strip(),
|
||||
"body": body.strip()[:2000],
|
||||
"author": author.strip(),
|
||||
"ts": ts_val,
|
||||
"empfehlung": stempel,
|
||||
"ahead": ahead,
|
||||
"behind": behind,
|
||||
"verwaist": verwaist,
|
||||
# Diff gegen main ist leer → der Branch brächte nichts (Inhalt schon in main
|
||||
# oder Worker hat am Repo vorbei gearbeitet). Bei verwaist ist der Diff
|
||||
# technisch leer (kein merge-base) — dann zählt nur das verwaist-Flag.
|
||||
"leer": not verwaist and ahead > 0 and not files_raw,
|
||||
"shortstat": stat,
|
||||
"files": files,
|
||||
"files_truncated": len(files_raw) > 60,
|
||||
# Nur mc2: Frontend-Quelltext ohne gebautes Bundle — die Box deployt dist so, wie
|
||||
# es im Repo liegt. Lucy wird dagegen IMMER am PC gebaut (kein dist im Repo).
|
||||
"frontend_ohne_build": repo == "mc2" and frontend_src and not frontend_dist,
|
||||
"status": None if (status or {}).get("state") == "eingespielt" and ahead > 0 else status,
|
||||
"ci_status": ci_status,
|
||||
})
|
||||
except Exception:
|
||||
log.warning("auftragsbuch: Branch %s (%s) nicht lesbar", branch, repo, exc_info=True)
|
||||
return items
|
||||
|
||||
|
||||
def list_proposals() -> dict:
|
||||
"""Alle offenen Vorschläge: Branches aus beiden Repos (mit Commit-/Diff-Zusammenfassung +
|
||||
Status) und Skill-Kandidaten aus dem Vault. Eine Antwort für die ganze Auftragsbuch-Seite.
|
||||
|
||||
Nebenbei: Pinge neu auftauchende Vorschlags-Branches per Telegram und füge sie dem
|
||||
Briefkasten hinzu (Idempotenz: nur EINMAL pro Branch)."""
|
||||
if not _available():
|
||||
return {"available": False, "items": [], "skill_kandidaten": [], "open_count": 0}
|
||||
|
||||
statuses = _statuses()
|
||||
gutachten = _gutachten()
|
||||
items = []
|
||||
for repo in REPOS:
|
||||
if not _repo_ok(repo):
|
||||
continue # z. B. ~/lucy (noch) nicht geklont → Karten dieses Repos einfach weglassen
|
||||
_fetch_throttled(repo)
|
||||
items.extend(_repo_items(repo, statuses, gutachten))
|
||||
items.sort(key=lambda i: i.get("ts") or 0, reverse=True)
|
||||
|
||||
# --- Telegram-Ping für NEUE Vorschlags-Branches (Idempotenz) ---
|
||||
# Alle aktuellen Branch-Namen aus beiden Repos sammeln
|
||||
aktuelle_branches = set()
|
||||
for repo in REPOS:
|
||||
if _repo_ok(repo):
|
||||
aktuelle_branches.update(_remote_branches(repo))
|
||||
|
||||
# Bereits gemeldete Branches laden
|
||||
gemeldet = _load_announce_branches()
|
||||
|
||||
# Nur wirklich NEUE Branches pingen
|
||||
neue = aktuelle_branches - gemeldet
|
||||
for branch in neue:
|
||||
# NotifyTelegram best-effort, niemals crashen
|
||||
try:
|
||||
from services import announce
|
||||
subject = "[Auftragsbuch]"
|
||||
text = f"Neue Karte wartet auf deinen Klick: {branch}"
|
||||
announce.notify_telegram(subject, text)
|
||||
# Und ein Eintrag für den Briefkasten (damit Lucy es spricht)
|
||||
announce.add(text, subject, "auftragsbuch", "normal")
|
||||
except Exception:
|
||||
log.warning("auftragsbuch: Telegram-Ping für %s fehlgeschlagen", branch, exc_info=True)
|
||||
|
||||
# Als gemeldet speichern — aber NUR bei echter Änderung. Ein unbedingtes Schreiben
|
||||
# bumpte die mtime bei jedem Aufruf, der SSE-Sammler (events.py, Fingerabdruck =
|
||||
# Datei-mtime) meldete daraufhin „geändert", die UI holte neu, schrieb wieder …
|
||||
# → 3-s-Endlosschleife auf /api/auftragsbuch je offenem Client (gefunden 15.07. nachts).
|
||||
if aktuelle_branches != gemeldet:
|
||||
_save_announce_branches(aktuelle_branches)
|
||||
|
||||
kandidaten = list_skill_kandidaten()
|
||||
open_count = sum(1 for i in items
|
||||
if (i.get("status") or {}).get("state") not in ("eingespielt",)) + len(kandidaten)
|
||||
return {"available": True, "items": items, "skill_kandidaten": kandidaten, "open_count": open_count}
|
||||
|
||||
|
||||
def diff_of(branch: str, repo: str = "mc2") -> dict:
|
||||
if not _repo_ok(repo):
|
||||
return {"ok": False, "error": "Nur auf der Box verfügbar."}
|
||||
if not _valid_branch(branch) or branch not in _remote_branches(repo):
|
||||
return {"ok": False, "error": f"Unbekannter Vorschlags-Branch: {branch}"}
|
||||
r = _git(repo, ["diff", f"origin/main...origin/{branch}"], timeout=30)
|
||||
text = r.stdout or ""
|
||||
truncated = len(text) > 200_000
|
||||
return {"ok": True, "diff": text[:200_000], "truncated": truncated}
|
||||
|
||||
|
||||
def accept(branch: str, repo: str = "mc2") -> dict:
|
||||
"""Annehmen: detached Runner starten. mc2: Merge→Push→Deploy→Health→ggf. Rollback.
|
||||
lucy: Merge→Push→PC-Build+Neustart→ggf. Revert (der Runner spricht den PC-Executor an)."""
|
||||
if not _repo_ok(repo):
|
||||
return {"ok": False, "error": "Annehmen geht nur auf der Box."}
|
||||
if not _valid_branch(branch) or branch not in _remote_branches(repo):
|
||||
return {"ok": False, "error": f"Unbekannter Vorschlags-Branch: {branch}"}
|
||||
key = _status_key(repo, branch)
|
||||
state = (_statuses().get(key) or {}).get("state")
|
||||
if state in ("laeuft", "rollback"):
|
||||
return {"ok": False, "error": "Für diesen Vorschlag läuft bereits ein Annahme-Lauf."}
|
||||
# Kaputt aufgesetzter Branch (kein gemeinsamer Vorfahre) → Merge kann NIE gelingen;
|
||||
# gar nicht erst einen Runner starten (die UI blendet den Knopf aus, die API hält dicht).
|
||||
if _git(repo, ["merge-base", "origin/main", f"origin/{branch}"]).returncode != 0:
|
||||
return {"ok": False, "error": "Dieser Branch hat keinen gemeinsamen Ursprung mit main "
|
||||
"(kaputt aufgesetzt, z. B. git init statt Klonen) — Annehmen ist technisch unmöglich. "
|
||||
"Bitte ablehnen (gern mit Grund) und die Idee neu in die Queue geben."}
|
||||
|
||||
# Runner als /tmp-Kopie starten: deploy.sh resettet das Repo hart — das Original-Skript
|
||||
# würde einem laufenden bash unter den Füßen getauscht (bekannte Selbst-Reset-Falle).
|
||||
src = REPO / "deploy" / REPOS[repo]["runner"]
|
||||
if not src.is_file():
|
||||
return {"ok": False, "error": f"Runner fehlt im Checkout: {src.name}"}
|
||||
runner = Path(f"/tmp/mc2-auftrag-runner-{int(time.time())}.sh")
|
||||
try:
|
||||
shutil.copyfile(src, runner)
|
||||
except OSError as exc:
|
||||
return {"ok": False, "error": f"Runner nicht kopierbar: {exc}"}
|
||||
|
||||
slug = re.sub(r"[^a-z0-9-]+", "-", f"{repo}-{branch}".lower())[:40].strip("-")
|
||||
unit = f"mc2-auftrag-{slug}-{int(time.time())}"
|
||||
r = subprocess.run(
|
||||
["systemd-run", "--user", "--collect", f"--unit={unit}",
|
||||
"/bin/bash", str(runner), branch],
|
||||
capture_output=True, text=True, timeout=20)
|
||||
if r.returncode != 0:
|
||||
return {"ok": False, "error": f"Start fehlgeschlagen: {(r.stderr or r.stdout).strip()[:300]}"}
|
||||
_set_status(key, "laeuft", "Annahme-Lauf gestartet")
|
||||
return {"ok": True, "unit": unit}
|
||||
|
||||
|
||||
def reject(branch: str, repo: str = "mc2", grund: str = "") -> dict:
|
||||
"""Ablehnen: Remote-Branch löschen (die Arbeit bleibt in der Gitea-Historie referenzierbar,
|
||||
aber die Entscheidung ist gefallen). Der optionale GRUND ist das Lern-Gedächtnis des
|
||||
Kreislaufs: er landet in ABLEHNUNGEN_PATH (jsonl), und Idle-Radar/Analysten bekommen
|
||||
ihn als „NIE wieder vorschlagen"-Material vorgelegt. Meldung in den Briefkasten."""
|
||||
if not _repo_ok(repo):
|
||||
return {"ok": False, "error": "Ablehnen geht nur auf der Box."}
|
||||
if not _valid_branch(branch) or branch not in _remote_branches(repo):
|
||||
return {"ok": False, "error": f"Unbekannter Vorschlags-Branch: {branch}"}
|
||||
grund = (grund or "").strip()[:400]
|
||||
# Betreff VOR dem Löschen sichern — danach ist der Ref weg.
|
||||
subject = (_git(repo, ["show", "-s", "--format=%s", f"origin/{branch}"]).stdout or "").strip()[:200]
|
||||
r = _git(repo, ["push", "origin", "--delete", branch], timeout=30)
|
||||
if r.returncode != 0:
|
||||
return {"ok": False, "error": f"Löschen fehlgeschlagen: {(r.stderr or '').strip()[:300]}"}
|
||||
_git(repo, ["fetch", "-q", "--prune", "origin"], timeout=30)
|
||||
detail = "Vom Commander abgelehnt — Branch gelöscht"
|
||||
if grund:
|
||||
detail += f" (Grund: {grund})"
|
||||
_set_status(_status_key(repo, branch), "abgelehnt", detail)
|
||||
try:
|
||||
with ABLEHNUNGEN_PATH.open("a", encoding="utf-8") as f:
|
||||
f.write(json.dumps({"ts": int(time.time()), "repo": repo, "branch": branch,
|
||||
"subject": subject, "grund": grund}, ensure_ascii=False) + "\n")
|
||||
except OSError:
|
||||
log.warning("auftragsbuch: Ablehn-Gedächtnis %s nicht schreibbar", ABLEHNUNGEN_PATH, exc_info=True)
|
||||
try:
|
||||
from services import announce
|
||||
wo = "im Lucy-Repo " if repo == "lucy" else ""
|
||||
warum = f" Grund: {grund}" if grund else ""
|
||||
announce.add(f"Vorschlag '{branch}' {wo}wurde abgelehnt und der Branch gelöscht.{warum}",
|
||||
"[Auftragsbuch]", "auftragsbuch", "silent")
|
||||
except Exception:
|
||||
pass
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
# ── Skill-Kandidaten (Wissens-Vault) ─────────────────────────────────────────
|
||||
|
||||
def _kandidaten_dir() -> Path:
|
||||
return VAULT / "skill-kandidaten"
|
||||
|
||||
|
||||
def list_skill_kandidaten() -> list[dict]:
|
||||
d = _kandidaten_dir()
|
||||
if os.name != "posix" or not d.is_dir():
|
||||
return []
|
||||
out = []
|
||||
for p in sorted(d.glob("*.md"), key=lambda x: x.stat().st_mtime, reverse=True):
|
||||
try:
|
||||
text = p.read_text(encoding="utf-8", errors="replace")
|
||||
first = next((ln.strip().lstrip("# ") for ln in text.splitlines() if ln.strip()), p.stem)
|
||||
out.append({"file": p.name, "title": first[:160],
|
||||
"preview": text[:3000], "mtime": p.stat().st_mtime})
|
||||
except OSError:
|
||||
continue
|
||||
return out
|
||||
|
||||
|
||||
def _vault_git(args: list[str]) -> None:
|
||||
try:
|
||||
subprocess.run(["git", "-C", str(VAULT), *args], capture_output=True, text=True, timeout=15)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _kandidat_path(fname: str) -> Path | None:
|
||||
if not _KANDIDAT_RX.match(fname):
|
||||
return None
|
||||
p = (_kandidaten_dir() / fname).resolve()
|
||||
if not p.is_relative_to(_kandidaten_dir().resolve()) or not p.is_file():
|
||||
return None
|
||||
return p
|
||||
|
||||
|
||||
def skill_accept(fname: str) -> dict:
|
||||
"""Skill-Kandidat beauftragen: Inhalt als Werkstatt-Auftrag an die bewährte
|
||||
`hermes -z`-CLI-Lane (detached — der Lauf dauert Minuten und braucht das Backend nicht).
|
||||
Ergebnis ist wieder propose-only: ein neuer Branch, der hier als Karte auftaucht."""
|
||||
if not _available():
|
||||
return {"ok": False, "error": "Beauftragen geht nur auf der Box."}
|
||||
p = _kandidat_path(fname)
|
||||
if not p:
|
||||
return {"ok": False, "error": f"Kandidat nicht gefunden: {fname}"}
|
||||
|
||||
beauftragt = _kandidaten_dir() / "beauftragt"
|
||||
beauftragt.mkdir(parents=True, exist_ok=True)
|
||||
target = beauftragt / p.name
|
||||
try:
|
||||
content = p.read_text(encoding="utf-8", errors="replace")
|
||||
p.rename(target)
|
||||
except OSError as exc:
|
||||
return {"ok": False, "error": f"Kandidat nicht verschiebbar: {exc}"}
|
||||
_vault_git(["add", "-A"])
|
||||
_vault_git(["commit", "-q", "-m", f"Skill-Kandidat beauftragt: {p.name}"])
|
||||
|
||||
order = ("Nutze den orchestrator-Skill (propose-only, Zwei-Kritiker-Gate). "
|
||||
"Auftrag aus dem Wissens-Vault (vom Commander im Auftragsbuch freigegeben):\n\n"
|
||||
+ content)
|
||||
order_file = Path(f"/tmp/mc2-skill-auftrag-{int(time.time())}.txt")
|
||||
try:
|
||||
order_file.write_text(order, encoding="utf-8")
|
||||
except OSError as exc:
|
||||
return {"ok": False, "error": f"Auftrag nicht schreibbar: {exc}"}
|
||||
unit = f"mc2-skill-auftrag-{int(time.time())}"
|
||||
r = subprocess.run(
|
||||
["systemd-run", "--user", "--collect", f"--unit={unit}",
|
||||
"/bin/bash", "-lc", f'hermes -z "$(cat {order_file})"'],
|
||||
capture_output=True, text=True, timeout=20)
|
||||
if r.returncode != 0:
|
||||
return {"ok": False, "error": f"Start fehlgeschlagen: {(r.stderr or r.stdout).strip()[:300]}"}
|
||||
try:
|
||||
from services import announce
|
||||
announce.add(f"Skill-Kandidat '{fname}' wurde beauftragt — die Werkstatt arbeitet, "
|
||||
"das Ergebnis erscheint als neuer Vorschlag im Auftragsbuch.",
|
||||
"[Auftragsbuch]", "auftragsbuch", "silent")
|
||||
except Exception:
|
||||
pass
|
||||
return {"ok": True, "unit": unit}
|
||||
|
||||
|
||||
def skill_reject(fname: str) -> dict:
|
||||
if not _available():
|
||||
return {"ok": False, "error": "Verwerfen geht nur auf der Box."}
|
||||
p = _kandidat_path(fname)
|
||||
if not p:
|
||||
return {"ok": False, "error": f"Kandidat nicht gefunden: {fname}"}
|
||||
verworfen = _kandidaten_dir() / "verworfen"
|
||||
verworfen.mkdir(parents=True, exist_ok=True)
|
||||
try:
|
||||
p.rename(verworfen / p.name)
|
||||
except OSError as exc:
|
||||
return {"ok": False, "error": f"Kandidat nicht verschiebbar: {exc}"}
|
||||
_vault_git(["add", "-A"])
|
||||
_vault_git(["commit", "-q", "-m", f"Skill-Kandidat verworfen: {p.name}"])
|
||||
return {"ok": True}
|
||||
@@ -1,7 +1,7 @@
|
||||
"""
|
||||
Voll-Zustands-Backup (mem0 + Hermes-Configs/Secrets + llama-swap config).
|
||||
Voll-Zustands-Backup (Hermes-Configs/Secrets + llama-swap config).
|
||||
Delegiert an deploy/backup.sh (eine Quelle der Wahrheit, identisch zum systemd-Timer);
|
||||
Restore läuft bewusst nur per CLI (deploy/restore.sh) — siehe docs/BACKUP.md.
|
||||
Restore läuft bewusst nur per CLI (deploy/restore.sh) — siehe docs/BETRIEB.md (Sicherung).
|
||||
"""
|
||||
|
||||
import subprocess
|
||||
@@ -27,11 +27,6 @@ def _latest() -> Path | None:
|
||||
return snaps[0] if snaps else None
|
||||
|
||||
|
||||
def snapshot_components(tarball: Path) -> list[str]:
|
||||
"""Öffentliche Sicht auf die Snapshot-Komponenten (Zeitmaschine-Detail in der UI)."""
|
||||
return _components(tarball)
|
||||
|
||||
|
||||
def _components(tarball: Path) -> list[str]:
|
||||
"""Top-Level-Einträge im Tarball (zur Anzeige im UI)."""
|
||||
try:
|
||||
@@ -70,9 +65,11 @@ def list_backups() -> list[dict]:
|
||||
return []
|
||||
out = []
|
||||
for p in sorted(BACKUP_DIR.glob("mc2-state-*.tar.gz"), reverse=True):
|
||||
st = p.stat()
|
||||
out.append({
|
||||
"snapshot": _ts(p),
|
||||
"file": p.name,
|
||||
"size_mb": round(p.stat().st_size / 1_000_000, 2),
|
||||
"size_mb": round(st.st_size / 1_000_000, 2),
|
||||
"mtime": st.st_mtime, # für die Sicherungs-Lampe im Cockpit (Alter der letzten)
|
||||
})
|
||||
return out
|
||||
|
||||
@@ -169,37 +169,3 @@ def setup_aware_ctx(params_b: float, quant: str, role: str | None = None) -> dic
|
||||
}
|
||||
|
||||
|
||||
def _snap_ctx(raw_ctx: float, cap: int | None = None) -> int:
|
||||
"""Größter 'schöner' Kontext ≤ raw_ctx (und ≤ Trainings-Kontext des Modells, falls bekannt)."""
|
||||
from services.fit import _NICE_CTX
|
||||
if cap:
|
||||
raw_ctx = min(raw_ctx, cap)
|
||||
best = _NICE_CTX[0]
|
||||
for c in _NICE_CTX:
|
||||
if c <= raw_ctx:
|
||||
best = c
|
||||
return best
|
||||
|
||||
|
||||
def setup_aware_ctx_for_model(model: dict) -> dict:
|
||||
"""Setup-bewusster Optimal-ctx für ein INSTALLIERTES Modell. Für den 'Auto'-Button an der
|
||||
Modellkarte. Nutzt die ECHTE KV-Größe des GGUF (gleiche Zahlensprache wie footprint_gb) —
|
||||
Fallback auf die params-Heuristik nur, wenn das GGUF nicht lesbar ist."""
|
||||
from services import gguf_meta
|
||||
quant = model.get("quant") or "Q4_K_M"
|
||||
path = model.get("gguf_path")
|
||||
meta = gguf_meta.arch_meta(path) if path else None
|
||||
if not meta:
|
||||
return setup_aware_ctx(params_of_model(model), quant, role=model.get("role"))
|
||||
|
||||
gtt = gtt_budget_gb()
|
||||
r = reserved_gb(model.get("role"))
|
||||
budget = max(gtt - r["reserved_gb"] - HEADROOM_GB, 0.0)
|
||||
bpp = QUANT_BYTES_PER_PARAM.get(quant.upper(), 0.55)
|
||||
size_gb = (model.get("size_bytes") or 0) / (1024 ** 3)
|
||||
weights = max(params_of_model(model) * bpp, size_gb)
|
||||
ck, cv = _cache_types(model.get("cmd") or "")
|
||||
per_tok = gguf_meta.kv_gb_per_token(meta, ck, cv)
|
||||
ctx = _snap_ctx((budget - weights) / per_tok, cap=meta.get("n_ctx_train")) if per_tok > 0 else 2048
|
||||
return {"ctx": ctx, "gtt_gb": gtt, "reserved_gb": round(r["reserved_gb"], 1),
|
||||
"budget_gb": round(budget, 1), "mode": r["mode"]}
|
||||
|
||||
@@ -1,165 +0,0 @@
|
||||
"""
|
||||
Connect: erzeugt saubere, getestete Konfig-Snippets für IDEs/Agenten auf dem
|
||||
LOKALEN PC (separate Maschine im LAN). Alle zeigen auf den **Gateway** der Box
|
||||
(reale Modelle coder/heavy/vision, Cockpit-Port :9001/v1) + den **Shared-Memory-MCP** (MC :9001).
|
||||
|
||||
Wichtig: Host ist die LAN-IP der Box (NICHT eine Proxy-Domain) — das war in v1
|
||||
die häufigste Fehlerquelle. Der Aufrufer übergibt den Host explizit.
|
||||
"""
|
||||
|
||||
import json
|
||||
|
||||
import httpx
|
||||
from config import HERMES_BUILTIN_UI_UPSTREAM, LLAMA_SWAP_URL, PORT, V1_UPSTREAM
|
||||
|
||||
DEFAULT_HOST = "192.168.178.151"
|
||||
|
||||
# IDEs/Agenten bekommen die ECHTEN Box-Modelle direkt (kein Lane-Routing): Coder (bauen), Planer (denken),
|
||||
# Augen (Bilder, Vision). Der User wechselt im Modellwähler — spiegelt das reale Hermes-Desktop-Setup 1:1.
|
||||
PRIMARY_MODEL = "coder"
|
||||
|
||||
|
||||
def _caps(tools: bool = True, images: bool = False) -> dict:
|
||||
"""Volle 7-Feld-Capabilities — braucht sie für die Modellwahl."""
|
||||
return {"tools": tools, "images": images, "parallel_tool_calls": False,
|
||||
"prompt_cache_key": False, "chat_completions": True,
|
||||
"interleaved_reasoning": False, "max_tokens_parameter": False}
|
||||
|
||||
|
||||
# Reale Box-Modelle für die IDE-Welt (getrennt von Lucy): bauen · denken · sehen.
|
||||
IDE_MODELS = [
|
||||
{"name": "coder", "display_name": "Box / Coder (bauen)", "max_tokens": 131072, "capabilities": _caps(True, False)},
|
||||
{"name": "heavy", "display_name": "Box / Planer (denken)", "max_tokens": 32768, "capabilities": _caps(True, False)},
|
||||
{"name": "vision", "display_name": "Box / Augen (Bilder)", "max_tokens": 32768, "capabilities": _caps(False, True)},
|
||||
]
|
||||
|
||||
|
||||
def _gw(host: str) -> str:
|
||||
# Client-Endpunkt bleibt :9001/v1 (MC2-Port) — seit UMBAU v3 P1 reicht MC2 dort
|
||||
# nur noch roh an den eigenständigen mc2-gateway (:9010) durch.
|
||||
return f"http://{host}:{PORT}/v1"
|
||||
|
||||
|
||||
def build_snippets(host: str = DEFAULT_HOST,
|
||||
mcp_script_path: str = r"F:\\Coding Stuff\\mission-control-2\\mcp\\mcp_memory.py",
|
||||
mcp_python: str = "python") -> dict:
|
||||
gw = _gw(host)
|
||||
mc_url = f"http://{host}:{PORT}"
|
||||
|
||||
# Kilo Code = aktiver Nachfolger der Roo/Cline-Linie (Roo im April 2026 eingestellt).
|
||||
# Gleiche Provider-Settings-Struktur; Multi-Agent kommt aus dem Tool (Orchestrator-Modus).
|
||||
kilo = json.dumps({
|
||||
"apiProvider": "openai",
|
||||
"openAiBaseUrl": gw,
|
||||
"openAiApiKey": "local",
|
||||
"openAiModelId": PRIMARY_MODEL,
|
||||
}, indent=2)
|
||||
|
||||
# Hermes Desktop = Remote-IDE, die auf dem Gateway läuft.
|
||||
# Anleitung: Browser aufweisen → Token aus Datei laden → loslegen.
|
||||
hermes_desktop = (
|
||||
f"# Hermes Desktop — Remote-IDE auf der Box\n"\
|
||||
f"#\n"\
|
||||
f"# 1. Browser öffnen: http://{host}:9001/hermes-ui\n"\
|
||||
f"# (MC2-Proxy auf dem Gateway)\n"\
|
||||
f"#\n"\
|
||||
f"# 2. Session-Token: auf der Box liegen in\n"\
|
||||
f"# ~/.hermes/desktop-gateway-token\n"\
|
||||
f"# (den Dateiname kopieren, den TOKEN-WERT NICHT hardcoden!)\n"\
|
||||
f"#\n"\
|
||||
f"# 3. Token im Browser-Login einfügen — fertig.\n"\
|
||||
f"#\n"\
|
||||
f"# Modelle im Hermes Desktop: {PRIMARY_MODEL} (bauen), heavy (denken), vision (Bilder)."
|
||||
)
|
||||
|
||||
# Claude Code spricht das Anthropic-Format; der Gateway ist OpenAI-kompatibel und
|
||||
# bietet KEIN /v1/messages (verifiziert). Daher braucht es einen kleinen Übersetzer
|
||||
# (Anthropic ⇄ OpenAI) als Aufsatz. Die env-Vars sind Claude Codes echte Schnittstelle.
|
||||
claude_code = (
|
||||
f"# Claude Code spricht das Anthropic-Format — der Gateway ist OpenAI-kompatibel ({gw})\n"
|
||||
f"# und hat kein /v1/messages. Dazwischen muss ein Übersetzer (Anthropic ⇄ OpenAI) laufen:\n"
|
||||
f"# • claude-code-router (leichtgewichtig, npm)\n"
|
||||
f"# • oder LiteLLM mit /v1/messages-Bridge\n"
|
||||
f"# Den Übersetzer auf den Gateway zeigen lassen: baseURL={gw}, model=coder, apiKey=local.\n"
|
||||
f"# Dann Claude Code auf den lokalen Übersetzer richten (Beispiel-Port 3456):\n"
|
||||
f"\n"
|
||||
f'export ANTHROPIC_BASE_URL="http://localhost:3456"\n'
|
||||
f'export ANTHROPIC_AUTH_TOKEN="local"\n'
|
||||
f'export ANTHROPIC_MODEL="coder"'
|
||||
)
|
||||
|
||||
memory_mcp = json.dumps({
|
||||
"mcpServers": {
|
||||
"mission-control-memory": {
|
||||
"command": mcp_python,
|
||||
"args": [mcp_script_path],
|
||||
"env": {"MC_URL": mc_url},
|
||||
}
|
||||
}
|
||||
}, indent=2)
|
||||
|
||||
return {
|
||||
"host": host,
|
||||
"gateway_url": gw,
|
||||
"mc_url": mc_url,
|
||||
# Leitung 1 — das MODELL. Bewusst NUR 3 Tools (Verdikt 09.07.2026): Hermes Desktop
|
||||
# (Remote-IDE im Browser), Kilo Code (VS-Code-Fallback mit Orchestrator-Modus),
|
||||
# Claude Code (starke Sessions).
|
||||
# Cursor/Continue/Roo/OpenCode entfernt — Roo eingestellt, Rest cloud-first, Terminal
|
||||
# oder von Kilo abgedeckt. Das Evolution-Radar (AUTONOMIE_PLAN E4) überwacht die Kategorie.
|
||||
"tools": {
|
||||
"hermes_desktop": {"label": "Hermes Desktop (empfohlen)", "lang": "bash", "snippet": hermes_desktop,
|
||||
"note": "Remote-IDE im Browser — Gateway-URL + Token aus Datei laden. Drei Modelle: "
|
||||
"Coder (bauen) · Planer (denken) · Augen (Bilder) — oben im Wähler umschalten."},
|
||||
"kilo": {"label": "Kilo Code", "lang": "json", "snippet": kilo,
|
||||
"note": "VS Code/JetBrains (schwergewichtiger). OpenAI-Provider → Gateway. "
|
||||
"API-Profile je Modus: Code→coder · Architect/Orchestrator→heavy · "
|
||||
"Ask/Debug→Lane 'chat' (virtuelles Modell, wählt selbst fast oder heavy)."},
|
||||
"claude_code": {"label": "Claude Code", "lang": "bash", "snippet": claude_code,
|
||||
"note": "Für die starken Sessions. Lokal-Betrieb bräuchte einen Anthropic⇄OpenAI-Übersetzer vor dem Gateway; Gedächtnis-Anbindung via Memory-MCP unten."},
|
||||
},
|
||||
# Leitung 2 — das GEDÄCHTNIS. Separater MCP-Server, gilt zusätzlich zu jedem Tool oben.
|
||||
"memory": {"label": "Shared Memory (MCP)", "lang": "json", "snippet": memory_mcp,
|
||||
"note": "Eigene Leitung: MCP-Block für jedes MCP-fähige Tool. mcp_memory.py muss lokal liegen."},
|
||||
}
|
||||
|
||||
|
||||
def check_health() -> dict:
|
||||
"""Live-Erreichbarkeit der drei Leitungen, aus Sicht der Box:
|
||||
Leitung 1 = der ECHTE Modell-Pfad, den Clients nutzen (mc2-gateway bei V1_UPSTREAM,
|
||||
sonst llama-swap direkt), Leitung 2 = Natives Hermes-Gedächtnis (hermes-gateway),
|
||||
Leitung 3 = Desktop-Gateway (Hermes-Builtin-UI)."""
|
||||
gateway = {"ok": False, "detail": "nicht erreichbar"}
|
||||
try:
|
||||
with httpx.Client(timeout=3.0) as c:
|
||||
r = c.get(f"{V1_UPSTREAM or LLAMA_SWAP_URL}/v1/models")
|
||||
if r.status_code == 200:
|
||||
n = len(r.json().get("data", []))
|
||||
gateway = {"ok": True, "detail": f"{n} Modelle verfügbar" if n else "bereit"}
|
||||
else:
|
||||
gateway = {"ok": False, "detail": f"HTTP {r.status_code}"}
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
memory = {"ok": False, "detail": "nicht erreichbar"}
|
||||
try:
|
||||
with httpx.Client(timeout=3.0) as c:
|
||||
# Hermes Gateway stellt das native Gedächtnis & Agent-Loop bereit
|
||||
r = c.get("http://127.0.0.1:8642/health")
|
||||
if r.status_code in (200, 404, 401):
|
||||
memory = {"ok": True, "detail": "Hermes-Nativ bereit"}
|
||||
else:
|
||||
memory = {"ok": False, "detail": f"HTTP {r.status_code}"}
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
desktop_gateway = {"ok": False, "detail": "nicht erreichbar"}
|
||||
try:
|
||||
with httpx.Client(timeout=3.0) as c:
|
||||
r = c.get(f"{HERMES_BUILTIN_UI_UPSTREAM}/api/status")
|
||||
desktop_gateway = ({"ok": True, "detail": "bereit"} if r.status_code == 200
|
||||
else {"ok": False, "detail": f"HTTP {r.status_code}"})
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return {"gateway": gateway, "memory": memory, "desktop_gateway": desktop_gateway}
|
||||
@@ -25,8 +25,6 @@ from services.sources import CATEGORIES, SKIP_TOKENS, TRUSTED_AUTHORS
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
_FIT_ORDER = {"perfect": 0, "marginal": 1, "too_tight": 2}
|
||||
|
||||
|
||||
def _categorize(repo_id: str) -> str:
|
||||
low = repo_id.lower()
|
||||
|
||||
@@ -99,8 +99,3 @@ def max_ctx_for(params_b: float, quant: str, sys_ram_gb: float) -> int:
|
||||
return max_ctx_in_budget(params_b, quant, max(sys_ram_gb - 4.0, 0) * 0.8)
|
||||
|
||||
|
||||
def recommend_ctx(params_b: float, quant: str, sys_ram_gb: float) -> dict:
|
||||
ctx = max_ctx_for(params_b, quant, sys_ram_gb)
|
||||
k = ctx // 1024
|
||||
return {"ctx": ctx, "k": k,
|
||||
"note": f"Bis ~{k}k Kontext passt komfortabel auf deine Hardware ({round(sys_ram_gb)} GB)."}
|
||||
|
||||
@@ -7,6 +7,7 @@ verstreute, still scheiternde String-Suche durch einen testbaren SSE-Zeilenparse
|
||||
|
||||
import json
|
||||
import logging
|
||||
import threading
|
||||
import time
|
||||
|
||||
from services.token_stats import increment_tokens
|
||||
@@ -37,12 +38,17 @@ def warn_truncation(model: str, max_tokens: int | None = None) -> None:
|
||||
return
|
||||
_trunc_last[model] = now
|
||||
log.warning("gateway: finish_reason=length bei %s — Antwort am Kontext-/Token-Limit abgerissen", model)
|
||||
# Im eigenen Thread abliefern: announce.add spricht im Gateway-Prozess per HTTP mit MC2 (bis 5 s).
|
||||
# Synchron hätte das den Event-Loop des Gateways und damit jeden LLM-Strom der Box angehalten.
|
||||
threading.Thread(target=_melde_abriss, args=(model,), daemon=True).start()
|
||||
|
||||
|
||||
def _melde_abriss(model: str) -> None:
|
||||
try:
|
||||
from services import announce
|
||||
announce.add(
|
||||
f"Eine Antwort von „{model}“ ist am Token-/Kontext-Limit abgerissen "
|
||||
f"(finish_reason=length). War das ein Nacht-Worker, ist seine Aufgabe zu groß "
|
||||
f"geschnitten — besser in Etappen teilen (eine Etappe = ein Worker-Lauf).",
|
||||
f"Eine Antwort von „{model}“ ist am Token- oder Kontext-Limit abgerissen "
|
||||
f"(finish_reason=length). Meist war die Aufgabe zu groß für einen Durchgang.",
|
||||
"[Kontext-Limit]", "gateway", "silent")
|
||||
except Exception:
|
||||
log.warning("gateway: Kontext-Limit-Warnung nicht zustellbar", exc_info=True)
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
"""Geheimnis-Ablage auf der Box (v3-Umbau P1).
|
||||
|
||||
WARUM ES DAS GIBT: Bis zum 28.08.2026 lagen das Box-Sudo-Passwort und der
|
||||
HuggingFace-Token im `localStorage` des Browsers und reisten bei jedem mutierenden
|
||||
Request mit (Header `X-Sudo-Password` **und** im JSON-Rumpf). Da der Dienst-Nutzer
|
||||
laut `/etc/sudoers` mit `NOPASSWD: ALL` läuft, wäre ein einziger XSS in der SPA
|
||||
gleichbedeutend mit Root auf der Box gewesen.
|
||||
|
||||
Das Sudo-Passwort ist ersatzlos entfallen — auf der Box gemessen: `sudo -n true`
|
||||
läuft durch, es wurde also nie gebraucht. Bleibt der HF-Token; der liegt jetzt hier:
|
||||
eine Datei neben den anderen `mc2-*.json` unter MODELS_DIR, Rechte 0600, und er wird
|
||||
**nie** an den Browser zurückgegeben. Die Oberfläche erfährt nur, OB einer gesetzt ist.
|
||||
|
||||
Absichtlich kein Verschlüsseln: Der Schlüssel müsste auf derselben Maschine liegen und
|
||||
wäre damit Theater. Der Gewinn ist, dass das Geheimnis den Browser gar nicht erst
|
||||
erreicht — nicht, dass die Datei unlesbar wäre.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
from config import MODELS_DIR
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
PFAD = Path(os.environ.get("MC_GEHEIMNISSE_PFAD", str(MODELS_DIR / "mc2-geheimnisse.json")))
|
||||
|
||||
# Was hier abgelegt werden darf. Neue Schlüssel bewusst eintragen — so kann ein
|
||||
# fehlgeleiteter Request keine beliebigen Felder in die Datei schreiben.
|
||||
ERLAUBT = frozenset({"hf_token"})
|
||||
|
||||
|
||||
def _lesen() -> dict[str, str]:
|
||||
"""Ganze Ablage. Fehlt die Datei (frische Box, Windows-Entwicklungsrechner ohne
|
||||
/srv/models), ist das kein Fehler, sondern schlicht 'nichts gesetzt'."""
|
||||
try:
|
||||
daten = json.loads(PFAD.read_text(encoding="utf-8"))
|
||||
return {k: v for k, v in daten.items() if k in ERLAUBT and isinstance(v, str)}
|
||||
except (OSError, ValueError):
|
||||
return {}
|
||||
|
||||
|
||||
def _schreiben(daten: dict[str, str]) -> bool:
|
||||
"""Atomar über eine Nachbardatei, damit ein Absturz mittendrin keine halbe Datei
|
||||
hinterlässt. Rechte 0600 werden VOR dem Umbenennen gesetzt — sonst gäbe es ein
|
||||
Zeitfenster, in dem das Geheimnis world-readable auf der Platte liegt."""
|
||||
try:
|
||||
PFAD.parent.mkdir(parents=True, exist_ok=True)
|
||||
tmp = PFAD.with_suffix(".json.tmp")
|
||||
tmp.write_text(json.dumps(daten, indent=2, ensure_ascii=False), encoding="utf-8")
|
||||
try:
|
||||
os.chmod(tmp, 0o600)
|
||||
except OSError:
|
||||
pass # Windows kennt keine Unix-Rechte — lokal harmlos, auf der Box greift es
|
||||
tmp.replace(PFAD)
|
||||
return True
|
||||
except OSError as exc:
|
||||
log.warning("Geheimnis-Ablage nicht schreibbar (%s): %s", PFAD, exc)
|
||||
return False
|
||||
|
||||
|
||||
def hf_token() -> str | None:
|
||||
"""Der HF-Token für Modell-Downloads. Reihenfolge: Prozess-Env schlägt Datei —
|
||||
so kann die systemd-Unit ihn setzen, ohne dass jemand die Oberfläche anfassen muss."""
|
||||
return os.environ.get("HF_TOKEN") or _lesen().get("hf_token") or None
|
||||
|
||||
|
||||
def setzen(schluessel: str, wert: str | None) -> bool:
|
||||
"""Setzt oder löscht (wert=None oder leer) ein Geheimnis."""
|
||||
if schluessel not in ERLAUBT:
|
||||
return False
|
||||
daten = _lesen()
|
||||
if wert:
|
||||
daten[schluessel] = wert
|
||||
else:
|
||||
daten.pop(schluessel, None)
|
||||
return _schreiben(daten)
|
||||
|
||||
|
||||
def status() -> dict[str, bool]:
|
||||
"""Was die Oberfläche erfahren darf: nur, OB etwas gesetzt ist — nie der Wert.
|
||||
`aus_env` sagt dem Nutzer, warum ein Löschen in der Oberfläche wirkungslos bliebe."""
|
||||
daten = _lesen()
|
||||
return {
|
||||
"hf_token_gesetzt": bool(daten.get("hf_token") or os.environ.get("HF_TOKEN")),
|
||||
"hf_token_aus_env": bool(os.environ.get("HF_TOKEN")),
|
||||
"schreibbar": _schreibbar(),
|
||||
}
|
||||
|
||||
|
||||
def _schreibbar() -> bool:
|
||||
"""Ehrlich melden, wenn die Ablage nicht beschreibbar ist (z. B. lokal auf Windows
|
||||
ohne /srv/models) — sonst speichert die Oberfläche scheinbar erfolgreich ins Leere."""
|
||||
try:
|
||||
PFAD.parent.mkdir(parents=True, exist_ok=True)
|
||||
return os.access(PFAD.parent, os.W_OK)
|
||||
except OSError:
|
||||
return False
|
||||
@@ -0,0 +1,21 @@
|
||||
"""Herkunftsprüfung für Knöpfe (24.09.2026, User-Entscheid: keine Anmeldung).
|
||||
|
||||
Knöpfe schicken POST/PUT/DELETE an /api. Ein Browser setzt dabei den Origin-Header — kommt er von
|
||||
einer fremden Webseite, wird die Anfrage abgelehnt. Das verhindert, dass eine fremde Seite im
|
||||
Browser eines Heimnetz-Geräts heimlich Updates, Neustarts oder Löschen auslöst.
|
||||
|
||||
Unverändert erlaubt: Anfragen ohne Origin (Skripte, curl, Lucy-Watchdog), Origin „null“/„file://“
|
||||
(Desktop-Lucy aus Electron) und alles unter /v1 (OpenChamber, Hermes).
|
||||
"""
|
||||
|
||||
ENTWICKLUNG = {"localhost:5173", "127.0.0.1:5173", "localhost:5180", "localhost:5181"}
|
||||
SCHREIBEND = {"POST", "PUT", "PATCH", "DELETE"}
|
||||
|
||||
|
||||
def erlaubt(methode: str, pfad: str, origin: str | None, host: str | None) -> bool:
|
||||
if methode.upper() not in SCHREIBEND or not pfad.startswith("/api/"):
|
||||
return True
|
||||
if not origin or origin in ("null", "file://"):
|
||||
return True
|
||||
wirt = origin.split("://", 1)[-1].rstrip("/")
|
||||
return wirt == (host or "") or wirt in ENTWICKLUNG
|
||||
+25
-15
@@ -17,10 +17,12 @@ def normalize_repo(s: str) -> str:
|
||||
return s.strip("/")
|
||||
|
||||
|
||||
def list_quants(repo: str) -> list[str]:
|
||||
"""Verfügbare Quant-Stufen eines Repos (aus den GGUF-Dateinamen, ohne mmproj)."""
|
||||
def list_quants(repo: str) -> list[dict]:
|
||||
"""Verfügbare Quant-Stufen eines Repos mit Downloadgröße (alle Teile + mmproj), ohne mmproj
|
||||
als eigene Stufe. Ein Aufruf der Hugging-Face-API für alle Stufen."""
|
||||
baum = _tree(repo)
|
||||
quants: set[str] = set()
|
||||
for e in _tree(repo):
|
||||
for e in baum:
|
||||
p = str(e.get("path", ""))
|
||||
if p.lower().endswith(".gguf") and "mmproj" not in p.lower():
|
||||
m = re.search(r"(I?Q\d[\w]*|F16|BF16|FP16|F32)", p, re.IGNORECASE)
|
||||
@@ -28,18 +30,18 @@ def list_quants(repo: str) -> list[str]:
|
||||
quants.add(m.group(1).upper())
|
||||
# gängige Reihenfolge zuerst
|
||||
order = {"Q4_K_M": 0, "Q4_K_S": 1, "Q5_K_M": 2, "Q6_K": 3, "Q8_0": 4, "Q3_K_M": 5, "Q2_K": 6}
|
||||
return sorted(quants, key=lambda q: (order.get(q, 99), q))
|
||||
return [{"quant": q, "total_bytes": auswahl(baum, q)["total_bytes"]}
|
||||
for q in sorted(quants, key=lambda q: (order.get(q, 99), q))]
|
||||
|
||||
|
||||
def search(q: str = "", limit: int = 24) -> list[dict]:
|
||||
"""Freie HF-Suche nach GGUF-Repos. Ohne q → Top-GGUF nach Downloads (Stöbern)."""
|
||||
url = (f"https://huggingface.co/api/models?filter=gguf"
|
||||
f"&sort=downloads&direction=-1&limit={limit}")
|
||||
params: dict[str, str | int] = {"filter": "gguf", "sort": "downloads", "direction": -1, "limit": limit}
|
||||
if q and q.strip():
|
||||
url += f"&search={q.strip()}"
|
||||
params["search"] = q.strip() # von httpx kodiert (vorher roh an die URL gehängt)
|
||||
try:
|
||||
with httpx.Client(timeout=12.0) as c:
|
||||
data = c.get(url).json()
|
||||
data = c.get("https://huggingface.co/api/models", params=params).json()
|
||||
except Exception:
|
||||
return []
|
||||
out = []
|
||||
@@ -69,26 +71,34 @@ def _size(entry: dict) -> int:
|
||||
|
||||
|
||||
def resolve_gguf(repo: str, quant: str = "Q4_K_M") -> dict:
|
||||
"""Beste GGUF-Auswahl eines Repos für einen Quant. Behandelt Split-GGUFs
|
||||
"""Beste GGUF-Auswahl eines Repos für einen Quant (siehe auswahl)."""
|
||||
return auswahl(_tree(repo), quant)
|
||||
|
||||
|
||||
def auswahl(tree: list[dict], quant: str = "Q4_K_M") -> dict:
|
||||
"""GGUF-Auswahl aus dem Dateibaum eines Repos für einen Quant. Behandelt Split-GGUFs
|
||||
(-00001-of-000NN) als Gruppe. Liefert die Datei-/Pattern-Infos für den Download.
|
||||
|
||||
Rückgabe: {files:[paths], first:path, total_bytes:int, mmproj:path|None, split:bool}
|
||||
"""
|
||||
tree = _tree(repo)
|
||||
ggufs = [e for e in tree if str(e.get("path", "")).lower().endswith(".gguf")]
|
||||
q = quant.lower()
|
||||
# mmproj separat (Vision-Projektor)
|
||||
mmproj = next((e["path"] for e in ggufs if "mmproj" in e["path"].lower()), None)
|
||||
model = [e for e in ggufs if "mmproj" not in e["path"].lower()]
|
||||
# bevorzugt den gewünschten Quant
|
||||
pref = [e for e in model if q in e["path"].lower()]
|
||||
chosen = pref or model
|
||||
# Nur der gewünschte Quant. Bis 24.09.2026 fiel die Auswahl sonst auf ALLE Modelldateien zurück —
|
||||
# bei aufgeteilten Repos lud der Download dann alle Teile aller Varianten (hunderte GB).
|
||||
chosen = [e for e in model if q in e["path"].lower()]
|
||||
if not chosen:
|
||||
return {"files": [], "first": None, "total_bytes": 0, "mmproj": mmproj, "split": False}
|
||||
# Split? Wenn die gewählten Dateien -of- enthalten → alle Teile dieser Gruppe.
|
||||
# Split? Wenn die gewählten Dateien -of- enthalten → alle Teile EINER Gruppe (gleicher Präfix).
|
||||
split = any("-of-" in e["path"].lower() for e in chosen)
|
||||
if split:
|
||||
parts = sorted([e for e in chosen if "-of-" in e["path"].lower()], key=lambda e: e["path"])
|
||||
def _gruppe(pfad: str) -> str:
|
||||
return re.sub(r"-\d{5}-of-\d{5}\.gguf$", "", pfad, flags=re.IGNORECASE)
|
||||
erste = min(e["path"] for e in chosen if "-of-" in e["path"].lower())
|
||||
parts = sorted([e for e in chosen if "-of-" in e["path"].lower() and _gruppe(e["path"]) == _gruppe(erste)],
|
||||
key=lambda e: e["path"])
|
||||
files = [e["path"] for e in parts]
|
||||
first = files[0]
|
||||
total = sum(_size(e) for e in parts)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+451
-114
@@ -1,118 +1,354 @@
|
||||
"""
|
||||
Mini-Job-System: Hintergrund-Prozesse mit Live-Log + Download-Fortschritt.
|
||||
Portiert aus Mission Control v1 (jobengine.py). In-Memory, ein Daemon-Thread je Job.
|
||||
Auftrags-System: Hintergrund-Aufträge (Updates, Modell-Downloads) mit Protokoll und Fortschritt.
|
||||
Ursprünglich aus Mission Control v1 portiert.
|
||||
|
||||
Seit Phase 2 (24.09.2026) überleben Aufträge einen Neustart von MC2:
|
||||
- Jeder Auftrag läuft als eigene systemd-Einheit „mc2-job-<id>“ (systemd-run), nicht mehr als
|
||||
Kindprozess von MC2. Ein Deploy oder Absturz von MC2 würgt ihn nicht mehr ab.
|
||||
- Akte (<id>.json), Protokoll (<id>.log) und Exit-Code (<id>.exit) liegen unter JOBS_DIR.
|
||||
MC2 liest die Akten beim Start wieder ein (wiederaufnehmen()) und beobachtet weiter.
|
||||
- Nacharbeiten („Rolle setzen, wenn der Download fertig ist“) sind benannt (@nacharbeit) und
|
||||
stehen mit ihren Daten in der Akte — sie laufen auch, wenn MC2 zwischendurch neu gestartet hat.
|
||||
- Geheimnisse (z. B. HF_TOKEN) gehen über eine Umgebungsdatei (nur für den Nutzer lesbar), die
|
||||
der Auftrag beim Start liest und löscht — nicht über die Befehlszeile oder die Einheit.
|
||||
- Ohne systemd (Entwicklungsrechner, Tests: MC_JOBS_ART=prozess) läuft der Auftrag als
|
||||
Kindprozess; dann überlebt er einen Neustart nicht.
|
||||
|
||||
Weiterhin: „Abbrechen“ beendet den ganzen Auftrag samt Kindern, jede Auftragsart hat ein
|
||||
Zeitlimit, start_job_exklusiv prüft und trägt unter EINER Sperre ein, beendete Aufträge
|
||||
verschwinden nach einem Tag bzw. ab 40 Stück.
|
||||
|
||||
Nur MC2 selbst beobachtet Aufträge (wiederaufnehmen() im Lebenszyklus der App); andere Prozesse
|
||||
starten keine.
|
||||
"""
|
||||
|
||||
import glob
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import shlex
|
||||
import shutil
|
||||
import signal
|
||||
import subprocess
|
||||
import threading
|
||||
import time
|
||||
import uuid
|
||||
from collections.abc import Callable
|
||||
from pathlib import Path
|
||||
|
||||
from kern.einstellungen import einstellungen
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
JOBS: dict[str, dict] = {}
|
||||
_PROCS: dict[str, subprocess.Popen] = {}
|
||||
_LOCK = threading.Lock()
|
||||
_LOG_CAP = 400
|
||||
_LOG_LESEN_MAX = 256_000 # so viel vom Protokollende wird für die Anzeige gelesen
|
||||
BEHALTEN_MAX = 40 # so viele beendete Aufträge bleiben sichtbar
|
||||
BEHALTEN_S = 24 * 3600 # beendete Aufträge verschwinden nach einem Tag
|
||||
STANDARD_ZEITLIMIT_S = 3 * 3600
|
||||
_ENDE = ("done", "failed", "canceled")
|
||||
_TAKT_S = 1.0 # wie oft ein Beobachter nach dem Exit-Code schaut
|
||||
_EINHEIT_PRUEFEN_S = 5.0 # wie oft er zusätzlich fragt, ob die Einheit noch lebt
|
||||
|
||||
# Nur im Speicher (nicht in der Akte): Fortschritt eines Downloads.
|
||||
_LAUFZEIT = {"progress", "done_bytes", "rate_bps", "eta_s"}
|
||||
# Nicht an die Oberfläche: Verwaltung des Auftrags.
|
||||
_INTERN = {"art", "nacharbeit", "nacharbeit_daten", "nacharbeit_erledigt", "fortschritt", "zeitlimit_s"}
|
||||
# Nicht in die Umgebung des Auftrags: gehört zur Einheit von MC2 oder ist in bash schreibgeschützt.
|
||||
_UMGEBUNG_OHNE = {"INVOCATION_ID", "JOURNAL_STREAM", "SYSTEMD_EXEC_PID", "MANAGERPID", "NOTIFY_SOCKET",
|
||||
"LISTEN_PID", "LISTEN_FDS", "LISTEN_FDNAMES", "WATCHDOG_PID", "WATCHDOG_USEC", "MAINPID",
|
||||
"_", "SHLVL", "PWD", "OLDPWD", "SHELLOPTS", "BASHOPTS", "BASH_VERSINFO", "EUID", "UID",
|
||||
"PPID", "GROUPS"}
|
||||
_NAME = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
|
||||
|
||||
# Die Hülle um den eigentlichen Befehl: Umgebung lesen (und die Datei löschen), Ausgabe ins
|
||||
# Protokoll, Exit-Code atomar ablegen. $1 = Pfadpräfix der Akte, danach der Befehl.
|
||||
_HUELLE = ('akte="$1"; shift; '
|
||||
'if [ -f "$akte.env" ]; then . "$akte.env"; rm -f "$akte.env"; fi; '
|
||||
'exec >>"$akte.log" 2>&1 </dev/null; '
|
||||
'"$@"; code=$?; echo "$code" > "$akte.exit.tmp" && mv -f "$akte.exit.tmp" "$akte.exit"')
|
||||
|
||||
_NACHARBEITEN: dict[str, Callable[..., None]] = {}
|
||||
_geladen = False
|
||||
|
||||
|
||||
def _append_log(job: dict, line: str) -> None:
|
||||
job["log"].append(line)
|
||||
if len(job["log"]) > _LOG_CAP:
|
||||
del job["log"][0]
|
||||
def nacharbeit(name: str) -> Callable[[Callable[..., None]], Callable[..., None]]:
|
||||
"""Eine Nacharbeit unter festem Namen anmelden. Sie bekommt die nacharbeit_daten des Auftrags
|
||||
als Schlüsselwort-Argumente und läuft nur, wenn der Auftrag erfolgreich war."""
|
||||
def anmelden(fn: Callable[..., None]) -> Callable[..., None]:
|
||||
_NACHARBEITEN[name] = fn
|
||||
return fn
|
||||
return anmelden
|
||||
|
||||
|
||||
def _pump_output(job: dict, stream) -> None:
|
||||
"""Liest byteweise; `\\r` (tqdm/hf-Fortschritt) überschreibt die letzte Zeile."""
|
||||
buf = b""
|
||||
overwrite = False
|
||||
pending_cr = False
|
||||
# --- Ablage ------------------------------------------------------------------------
|
||||
|
||||
def commit():
|
||||
line = buf.decode("utf-8", "replace")
|
||||
if overwrite and job["log"]:
|
||||
job["log"][-1] = line
|
||||
else:
|
||||
_append_log(job, line)
|
||||
|
||||
while True:
|
||||
ch = stream.read(1)
|
||||
if not ch:
|
||||
break
|
||||
if pending_cr:
|
||||
pending_cr = False
|
||||
if ch == b"\n":
|
||||
commit(); overwrite = False; buf = b""
|
||||
continue
|
||||
commit(); overwrite = True; buf = b""
|
||||
if ch == b"\r":
|
||||
pending_cr = True
|
||||
elif ch == b"\n":
|
||||
commit(); overwrite = False; buf = b""
|
||||
else:
|
||||
buf += ch
|
||||
if pending_cr:
|
||||
commit(); overwrite = True; buf = b""
|
||||
if buf:
|
||||
commit()
|
||||
def _jobs_dir() -> Path:
|
||||
return Path(os.environ.get("MC_JOBS_DIR", str(einstellungen().daten_dir / "mc2-jobs")))
|
||||
|
||||
|
||||
def _run_job(job_id: str, args: list[str], env: dict | None = None, sudo_password: str | None = None):
|
||||
job = JOBS[job_id]
|
||||
job["state"] = "running"
|
||||
def _pfad(job_id: str, endung: str) -> Path:
|
||||
return _jobs_dir() / f"{job_id}{endung}"
|
||||
|
||||
|
||||
def _speichern(job: dict) -> None:
|
||||
"""Akte ohne Laufzeitfelder atomar schreiben."""
|
||||
daten = {k: v for k, v in job.items() if k not in _LAUFZEIT and not k.startswith("_")}
|
||||
try:
|
||||
actual_args = list(args)
|
||||
if sudo_password is not None:
|
||||
for i, arg in enumerate(actual_args):
|
||||
if isinstance(arg, str):
|
||||
actual_args[i] = arg.replace("sudo -n", "sudo -S").replace("sudo ", "sudo -S ")
|
||||
_jobs_dir().mkdir(parents=True, exist_ok=True)
|
||||
tmp = _pfad(job["id"], ".json.tmp")
|
||||
tmp.write_text(json.dumps(daten, ensure_ascii=False), encoding="utf-8")
|
||||
tmp.replace(_pfad(job["id"], ".json"))
|
||||
except OSError:
|
||||
log.warning("jobengine: Akte %s nicht schreibbar", job.get("id"), exc_info=True)
|
||||
|
||||
proc = subprocess.Popen(
|
||||
actual_args, stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
|
||||
stdin=subprocess.PIPE if sudo_password is not None else None,
|
||||
bufsize=0,
|
||||
env={**os.environ, **(env or {})},
|
||||
)
|
||||
_PROCS[job_id] = proc
|
||||
|
||||
if sudo_password is not None and proc.stdin:
|
||||
proc.stdin.write((sudo_password + "\n").encode("utf-8"))
|
||||
proc.stdin.flush()
|
||||
proc.stdin.close()
|
||||
def _protokoll(job_id: str, zeile: str) -> None:
|
||||
"""Eine Zeile von MC2 ins Protokoll des Auftrags schreiben."""
|
||||
try:
|
||||
_jobs_dir().mkdir(parents=True, exist_ok=True)
|
||||
with _pfad(job_id, ".log").open("a", encoding="utf-8", newline="\n") as f:
|
||||
f.write(zeile.rstrip("\n") + "\n")
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
_pump_output(job, proc.stdout)
|
||||
proc.wait()
|
||||
job["returncode"] = proc.returncode
|
||||
job["state"] = "canceled" if job.get("canceled") else ("done" if proc.returncode == 0 else "failed")
|
||||
|
||||
# Check if failed due to sudo authorization failure
|
||||
if proc.returncode != 0 and job["log"]:
|
||||
log_str = "\n".join(job["log"])
|
||||
if "a password is required" in log_str or "password" in log_str.lower() or "sudo:" in log_str:
|
||||
job["sudo_failed"] = True
|
||||
|
||||
def zeilen_aus(text: str) -> list[str]:
|
||||
"""Protokolltext in Anzeigezeilen: `\\r` (Fortschrittsbalken von hf/tqdm/apt) überschreibt die Zeile."""
|
||||
zeilen = []
|
||||
for roh in text.split("\n"):
|
||||
if "\r" in roh:
|
||||
teile = [t for t in roh.split("\r") if t]
|
||||
roh = teile[-1] if teile else ""
|
||||
zeilen.append(roh)
|
||||
if zeilen and zeilen[-1] == "":
|
||||
zeilen.pop()
|
||||
return zeilen[-_LOG_CAP:]
|
||||
|
||||
|
||||
def _protokoll_lesen(job_id: str) -> list[str]:
|
||||
try:
|
||||
with _pfad(job_id, ".log").open("rb") as f:
|
||||
f.seek(0, os.SEEK_END)
|
||||
groesse = f.tell()
|
||||
f.seek(max(0, groesse - _LOG_LESEN_MAX))
|
||||
roh = f.read()
|
||||
except OSError:
|
||||
return []
|
||||
text = roh.decode("utf-8", "replace")
|
||||
if groesse > _LOG_LESEN_MAX:
|
||||
text = text.split("\n", 1)[-1] # angeschnittene erste Zeile verwerfen
|
||||
return zeilen_aus(text)
|
||||
|
||||
|
||||
def _exit_code(job_id: str) -> int | None:
|
||||
try:
|
||||
return int(_pfad(job_id, ".exit").read_text(encoding="utf-8").strip())
|
||||
except (OSError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def _exit_ablegen(job_id: str, code: int) -> None:
|
||||
try:
|
||||
tmp = _pfad(job_id, ".exit.tmp")
|
||||
tmp.write_text(str(code), encoding="utf-8")
|
||||
tmp.replace(_pfad(job_id, ".exit"))
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def _umgebungsdatei(job_id: str, env: dict | None) -> None:
|
||||
"""Umgebung des Auftrags (MC2-Umgebung + env) als nur für den Nutzer lesbare Datei."""
|
||||
werte = {**os.environ, **(env or {})}
|
||||
zeilen = [f"export {k}={shlex.quote(str(v))}" for k, v in werte.items()
|
||||
if _NAME.match(k) and k not in _UMGEBUNG_OHNE]
|
||||
fd = os.open(_pfad(job_id, ".env"), os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
|
||||
with os.fdopen(fd, "w", encoding="utf-8", newline="\n") as f:
|
||||
f.write("\n".join(zeilen) + "\n")
|
||||
|
||||
|
||||
# --- Ausführung ----------------------------------------------------------------------
|
||||
|
||||
def _art() -> str:
|
||||
"""„systemd“, wenn Aufträge als eigene Einheiten laufen können, sonst „prozess“."""
|
||||
gewuenscht = os.environ.get("MC_JOBS_ART", "").strip().lower()
|
||||
if gewuenscht in ("systemd", "prozess"):
|
||||
return gewuenscht
|
||||
if os.name != "posix" or not shutil.which("systemd-run"):
|
||||
return "prozess"
|
||||
if os.geteuid() != 0 and not os.environ.get("XDG_RUNTIME_DIR"):
|
||||
return "prozess"
|
||||
return "systemd"
|
||||
|
||||
|
||||
def _systemd(programm: str, *args: str) -> list[str]:
|
||||
"""systemctl/systemd-run für den richtigen Manager: als root der System-, sonst der Nutzer-Manager."""
|
||||
als_root = os.name == "posix" and os.geteuid() == 0
|
||||
return [programm, *(() if als_root else ("--user",)), *args]
|
||||
|
||||
|
||||
def _einheit(job_id: str) -> str:
|
||||
return f"mc2-job-{job_id}"
|
||||
|
||||
|
||||
def _einheit_lebt(job_id: str) -> bool:
|
||||
try:
|
||||
r = subprocess.run(_systemd("systemctl", "is-active", _einheit(job_id)),
|
||||
capture_output=True, text=True, timeout=10)
|
||||
except Exception:
|
||||
return True # Im Zweifel weiter beobachten statt einen laufenden Auftrag totzusagen.
|
||||
return r.stdout.strip() in ("active", "activating", "deactivating", "reloading")
|
||||
|
||||
|
||||
def _starte_systemd(job: dict, args: list[str], env: dict | None) -> None:
|
||||
job_id = job["id"]
|
||||
_umgebungsdatei(job_id, env)
|
||||
befehl = _systemd("systemd-run", "--unit", _einheit(job_id), "--collect", "--quiet",
|
||||
"--working-directory", os.getcwd(),
|
||||
"--property", f"RuntimeMaxSec={int(job['zeitlimit_s'])}",
|
||||
"--", "/bin/bash", "-c", _HUELLE, "mc2-job", str(_jobs_dir() / job_id), *args)
|
||||
r = subprocess.run(befehl, capture_output=True, text=True, timeout=30)
|
||||
if r.returncode != 0:
|
||||
_pfad(job_id, ".env").unlink(missing_ok=True)
|
||||
raise RuntimeError((r.stderr or r.stdout or "systemd-run scheiterte").strip()[:300])
|
||||
threading.Thread(target=_beobachten, args=(job_id,), daemon=True).start()
|
||||
|
||||
|
||||
def _starte_prozess(job: dict, args: list[str], env: dict | None) -> None:
|
||||
"""Rückfall ohne systemd: Kindprozess, Ausgabe ins Protokoll; ein Warte-Thread schließt ab."""
|
||||
job_id = job["id"]
|
||||
with _pfad(job_id, ".log").open("ab") as protokoll:
|
||||
proc = subprocess.Popen(list(args), stdout=protokoll, stderr=subprocess.STDOUT, stdin=subprocess.DEVNULL,
|
||||
env={**os.environ, **(env or {})}, start_new_session=(os.name == "posix"))
|
||||
_PROCS[job_id] = proc
|
||||
grenze = int(job["zeitlimit_s"])
|
||||
|
||||
def zu_lang() -> None:
|
||||
job["zeitlimit"] = True
|
||||
_protokoll(job_id, f"[mc] Zeitlimit ({grenze // 60} min) überschritten, Auftrag wird beendet.")
|
||||
_beende_gruppe(proc)
|
||||
|
||||
wecker = threading.Timer(grenze, zu_lang)
|
||||
wecker.daemon = True
|
||||
wecker.start()
|
||||
|
||||
def warten() -> None:
|
||||
code = proc.wait()
|
||||
wecker.cancel()
|
||||
_exit_ablegen(job_id, code)
|
||||
_abschliessen(job, code)
|
||||
|
||||
threading.Thread(target=warten, daemon=True).start()
|
||||
|
||||
|
||||
def _beende_gruppe(proc: subprocess.Popen) -> None:
|
||||
"""Den Auftragsprozess samt Kindern beenden (posix: ganze Prozessgruppe, sonst nur den Prozess)."""
|
||||
try:
|
||||
if os.name == "posix":
|
||||
os.killpg(os.getpgid(proc.pid), signal.SIGTERM)
|
||||
else:
|
||||
proc.terminate()
|
||||
except (ProcessLookupError, PermissionError, OSError):
|
||||
pass
|
||||
|
||||
|
||||
def _abschliessen(job: dict, code: int | None) -> None:
|
||||
"""Endzustand festhalten (genau einmal). Bei Erfolg läuft die Nacharbeit VOR dem Endzustand:
|
||||
Wer „done“ sieht, sieht auch schon ihre Wirkung (gesetzte Rolle, geleerte Zwischenspeicher)."""
|
||||
with _LOCK:
|
||||
if job["state"] in _ENDE or job.get("_schliesst"):
|
||||
return
|
||||
job["_schliesst"] = True
|
||||
felder: dict = {"returncode": code}
|
||||
if job.get("canceled"):
|
||||
felder["state"] = "canceled"
|
||||
elif job.get("zeitlimit") or (code is None and time.time() - job["started_at"] >= job["zeitlimit_s"] - 5):
|
||||
felder.update(state="failed", zeitlimit=True, error="Zeitlimit überschritten")
|
||||
elif code is None:
|
||||
felder.update(state="failed", error=job.get("error") or "Der Auftrag endete ohne Rückmeldung.")
|
||||
else:
|
||||
felder["state"] = "done" if code == 0 else "failed"
|
||||
if felder["state"] == "done":
|
||||
_nacharbeit_ausfuehren(job)
|
||||
with _LOCK:
|
||||
job.update(felder, finished_at=time.time())
|
||||
job.pop("_schliesst", None)
|
||||
_PROCS.pop(job["id"], None)
|
||||
_speichern(job)
|
||||
|
||||
|
||||
def _nacharbeit_ausfuehren(job: dict) -> None:
|
||||
name = job.get("nacharbeit")
|
||||
if not name or job.get("nacharbeit_erledigt"):
|
||||
return
|
||||
job["nacharbeit_erledigt"] = True
|
||||
_speichern(job)
|
||||
try:
|
||||
fn = _NACHARBEITEN.get(name)
|
||||
if fn is None:
|
||||
raise RuntimeError(f"Nacharbeit „{name}“ ist in dieser Instanz unbekannt")
|
||||
fn(**(job.get("nacharbeit_daten") or {}))
|
||||
except Exception as exc:
|
||||
_append_log(job, f"[mc] Fehler: {exc}")
|
||||
job["state"] = "failed"
|
||||
job["returncode"] = -1
|
||||
finally:
|
||||
_PROCS.pop(job_id, None)
|
||||
job["finished_at"] = time.time()
|
||||
cb = job.pop("_on_done", None)
|
||||
if cb and job["state"] == "done":
|
||||
try:
|
||||
cb()
|
||||
except Exception as exc:
|
||||
_append_log(job, f"[mc] Nachbearbeitung-Fehler: {exc}")
|
||||
_protokoll(job["id"], f"[mc] Nachbearbeitung-Fehler: {exc}")
|
||||
log.warning("jobengine: Nacharbeit %s von %s gescheitert", name, job["id"], exc_info=True)
|
||||
|
||||
|
||||
def _beobachten(job_id: str) -> None:
|
||||
"""Wartet auf den Exit-Code einer systemd-Einheit. Endet sie ohne einen (Abbruch, Zeitlimit,
|
||||
Absturz), schließt er den Auftrag trotzdem ab."""
|
||||
naechste_pruefung = time.time() + _EINHEIT_PRUEFEN_S
|
||||
while True:
|
||||
job = JOBS.get(job_id)
|
||||
if job is None or job["state"] in _ENDE:
|
||||
return
|
||||
if (code := _exit_code(job_id)) is not None:
|
||||
_abschliessen(job, code)
|
||||
return
|
||||
if time.time() >= naechste_pruefung:
|
||||
if not _einheit_lebt(job_id):
|
||||
time.sleep(0.5) # der Exit-Code kann gerade erst geschrieben werden
|
||||
_abschliessen(job, _exit_code(job_id))
|
||||
return
|
||||
naechste_pruefung = time.time() + _EINHEIT_PRUEFEN_S
|
||||
time.sleep(_TAKT_S)
|
||||
|
||||
|
||||
def _starten(job: dict, args: list[str], env: dict | None) -> None:
|
||||
job["state"] = "running"
|
||||
_speichern(job)
|
||||
try:
|
||||
if job["art"] == "systemd":
|
||||
_starte_systemd(job, args, env)
|
||||
else:
|
||||
_starte_prozess(job, args, env)
|
||||
except Exception as exc:
|
||||
_protokoll(job["id"], f"[mc] Fehler: {exc}")
|
||||
job["error"] = str(exc)
|
||||
_abschliessen(job, -1)
|
||||
|
||||
|
||||
# --- Fortschritt (Downloads) ------------------------------------------------------------
|
||||
|
||||
def attach_download_progress(job_id: str, local_dir: str, total_bytes: int) -> None:
|
||||
"""Fortschritt in % aus wachsenden *.incomplete-Dateien (hf schreibt sie)."""
|
||||
if not total_bytes or total_bytes <= 0:
|
||||
return
|
||||
job = JOBS.get(job_id)
|
||||
if job is not None:
|
||||
job["progress"] = 0
|
||||
job["total_bytes"] = total_bytes
|
||||
if job is None or not total_bytes or total_bytes <= 0:
|
||||
return
|
||||
job["fortschritt"] = {"ordner": local_dir, "gesamt": total_bytes}
|
||||
job["total_bytes"] = total_bytes
|
||||
job["progress"] = 0
|
||||
_speichern(job)
|
||||
_fortschritt_beobachten(job_id)
|
||||
|
||||
|
||||
def _fortschritt_beobachten(job_id: str) -> None:
|
||||
ziel = (JOBS.get(job_id) or {}).get("fortschritt") or {}
|
||||
local_dir, total_bytes = ziel.get("ordner"), ziel.get("gesamt")
|
||||
if not local_dir or not total_bytes:
|
||||
return
|
||||
|
||||
def _watch():
|
||||
pat = os.path.join(local_dir, ".cache", "huggingface", "download", "**", "*.incomplete")
|
||||
@@ -120,7 +356,7 @@ def attach_download_progress(job_id: str, local_dir: str, total_bytes: int) -> N
|
||||
rate = 0.0
|
||||
while True:
|
||||
j = JOBS.get(job_id)
|
||||
if not j or j["state"] in ("done", "failed", "canceled"):
|
||||
if not j or j["state"] in _ENDE:
|
||||
break
|
||||
try:
|
||||
inc = glob.glob(pat, recursive=True)
|
||||
@@ -147,49 +383,150 @@ def attach_download_progress(job_id: str, local_dir: str, total_bytes: int) -> N
|
||||
threading.Thread(target=_watch, daemon=True).start()
|
||||
|
||||
|
||||
def start_job(args: list[str], label: str, env: dict | None = None, on_done=None,
|
||||
sudo_password: str | None = None, group: str | None = None) -> str:
|
||||
# --- Verwaltung -------------------------------------------------------------------
|
||||
|
||||
def _laden() -> None:
|
||||
"""Akten von der Platte einlesen (einmal je Prozess; unter _LOCK aufrufen)."""
|
||||
global _geladen
|
||||
if _geladen:
|
||||
return
|
||||
_geladen = True
|
||||
for datei in sorted(_jobs_dir().glob("*.json")):
|
||||
try:
|
||||
akte = json.loads(datei.read_text(encoding="utf-8"))
|
||||
except (OSError, ValueError):
|
||||
continue
|
||||
if isinstance(akte, dict) and akte.get("id") and akte["id"] not in JOBS:
|
||||
JOBS[akte["id"]] = akte
|
||||
|
||||
|
||||
def _aufraeumen() -> None:
|
||||
"""Alte beendete Aufträge samt Dateien entfernen (unter _LOCK aufrufen)."""
|
||||
jetzt = time.time()
|
||||
fertig = sorted((j for j in JOBS.values() if j["state"] in _ENDE),
|
||||
key=lambda j: j.get("finished_at") or 0, reverse=True)
|
||||
for i, j in enumerate(fertig):
|
||||
if i >= BEHALTEN_MAX or jetzt - (j.get("finished_at") or jetzt) > BEHALTEN_S:
|
||||
JOBS.pop(j["id"], None)
|
||||
for endung in (".json", ".log", ".exit", ".env", ".exit.tmp", ".json.tmp"):
|
||||
_pfad(j["id"], endung).unlink(missing_ok=True)
|
||||
|
||||
|
||||
def _eintragen(args: list[str], label: str, group: str | None, zeitlimit_s: int | None,
|
||||
nacharbeit_name: str | None, nacharbeit_daten: dict | None) -> dict:
|
||||
"""Neue Akte anlegen (unter _LOCK aufrufen)."""
|
||||
if nacharbeit_name and nacharbeit_name not in _NACHARBEITEN:
|
||||
raise ValueError(f"Unbekannte Nacharbeit: {nacharbeit_name}")
|
||||
job_id = uuid.uuid4().hex[:12]
|
||||
# Mask password in log if present in args
|
||||
log_args = list(args)
|
||||
JOBS[job_id] = {
|
||||
"id": job_id, "label": label, "state": "queued", "group": group,
|
||||
"log": ["$ " + " ".join(shlex.quote(a) for a in log_args)],
|
||||
job = {
|
||||
"id": job_id, "label": label, "state": "queued", "group": group, "art": _art(),
|
||||
"returncode": None, "started_at": time.time(), "finished_at": None,
|
||||
"zeitlimit_s": int(zeitlimit_s or STANDARD_ZEITLIMIT_S),
|
||||
"nacharbeit": nacharbeit_name, "nacharbeit_daten": nacharbeit_daten or {},
|
||||
}
|
||||
if on_done:
|
||||
JOBS[job_id]["_on_done"] = on_done
|
||||
threading.Thread(target=_run_job, args=(job_id, args, env, sudo_password), daemon=True).start()
|
||||
return job_id
|
||||
JOBS[job_id] = job
|
||||
_protokoll(job_id, "$ " + " ".join(shlex.quote(a) for a in args))
|
||||
_speichern(job)
|
||||
return job
|
||||
|
||||
|
||||
def active_in_group(group: str) -> dict | None:
|
||||
"""Erster laufender/wartender Job einer Gruppe (z.B. 'maintenance'), sonst None.
|
||||
Basis für den Wartungs-Riegel: nur EIN System-Update gleichzeitig."""
|
||||
for j in JOBS.values():
|
||||
def _aktiv_in(group: str) -> dict | None:
|
||||
for j in list(JOBS.values()):
|
||||
if j.get("group") == group and j.get("state") in ("running", "queued"):
|
||||
return j
|
||||
return None
|
||||
|
||||
|
||||
def start_job(args: list[str], label: str, env: dict | None = None, group: str | None = None,
|
||||
zeitlimit_s: int | None = None, nacharbeit: str | None = None,
|
||||
nacharbeit_daten: dict | None = None) -> str:
|
||||
with _LOCK:
|
||||
_laden()
|
||||
_aufraeumen()
|
||||
job = _eintragen(list(args), label, group, zeitlimit_s, nacharbeit, nacharbeit_daten)
|
||||
_starten(job, list(args), env)
|
||||
return job["id"]
|
||||
|
||||
|
||||
def start_job_exklusiv(group: str, args: list[str], label: str, env: dict | None = None,
|
||||
zeitlimit_s: int | None = None, nacharbeit: str | None = None,
|
||||
nacharbeit_daten: dict | None = None) -> tuple[str | None, dict | None]:
|
||||
"""Wie start_job, aber nur, wenn in der Gruppe nichts läuft — Prüfen und Eintragen unter einer
|
||||
Sperre. Rückgabe: (neue Auftrags-ID, None) oder (None, der schon laufende Auftrag)."""
|
||||
with _LOCK:
|
||||
_laden()
|
||||
if (laeuft := _aktiv_in(group)) is not None:
|
||||
return None, laeuft
|
||||
_aufraeumen()
|
||||
job = _eintragen(list(args), label, group, zeitlimit_s, nacharbeit, nacharbeit_daten)
|
||||
_starten(job, list(args), env)
|
||||
return job["id"], None
|
||||
|
||||
|
||||
def active_in_group(group: str) -> dict | None:
|
||||
"""Erster laufender/wartender Auftrag einer Gruppe (z. B. 'maintenance'), sonst None.
|
||||
Basis für den Wartungs-Riegel: nur EIN System-Update gleichzeitig."""
|
||||
with _LOCK:
|
||||
_laden()
|
||||
return _aktiv_in(group)
|
||||
|
||||
|
||||
def wiederaufnehmen() -> int:
|
||||
"""Beim Start von MC2: Akten einlesen und laufende Aufträge weiter beobachten.
|
||||
Rückgabe: Zahl der wieder aufgenommenen Aufträge."""
|
||||
with _LOCK:
|
||||
_laden()
|
||||
offen = [j for j in JOBS.values() if j["state"] in ("queued", "running")]
|
||||
nacharbeit_offen = [j for j in JOBS.values() if j["state"] == "done" and j.get("nacharbeit")
|
||||
and not j.get("nacharbeit_erledigt")]
|
||||
for job in offen:
|
||||
if job.get("art") == "systemd":
|
||||
threading.Thread(target=_beobachten, args=(job["id"],), daemon=True).start()
|
||||
_fortschritt_beobachten(job["id"])
|
||||
elif (code := _exit_code(job["id"])) is not None:
|
||||
_abschliessen(job, code)
|
||||
else:
|
||||
# Kindprozess des vorigen MC2: sein Warte-Thread ist mit ihm gegangen.
|
||||
_protokoll(job["id"], "[mc] MC2 wurde neu gestartet; der Auftrag lief als Kindprozess mit.")
|
||||
job["error"] = "MC2 wurde während des Auftrags neu gestartet."
|
||||
_abschliessen(job, None)
|
||||
for job in nacharbeit_offen:
|
||||
_nacharbeit_ausfuehren(job)
|
||||
if offen:
|
||||
log.info("jobengine: %d laufende Aufträge wieder aufgenommen", len(offen))
|
||||
return len(offen)
|
||||
|
||||
|
||||
def cancel_job(job_id: str) -> bool:
|
||||
job = JOBS.get(job_id)
|
||||
if not job or job["state"] in ("done", "failed", "canceled"):
|
||||
return False
|
||||
job["canceled"] = True
|
||||
_append_log(job, "[mc] Abbruch angefordert…")
|
||||
proc = _PROCS.get(job_id)
|
||||
if proc is not None:
|
||||
with _LOCK:
|
||||
_laden()
|
||||
job = JOBS.get(job_id)
|
||||
if not job or job["state"] in _ENDE:
|
||||
return False
|
||||
job["canceled"] = True
|
||||
_speichern(job)
|
||||
_protokoll(job_id, "[mc] Abbruch angefordert…")
|
||||
if job["state"] == "queued":
|
||||
_abschliessen(job, None)
|
||||
elif job.get("art") == "systemd":
|
||||
try:
|
||||
proc.terminate()
|
||||
subprocess.run(_systemd("systemctl", "stop", "--no-block", _einheit(job_id)),
|
||||
capture_output=True, timeout=15)
|
||||
except Exception:
|
||||
pass
|
||||
log.warning("jobengine: Abbruch von %s gescheitert", job_id, exc_info=True)
|
||||
elif (proc := _PROCS.get(job_id)) is not None:
|
||||
_beende_gruppe(proc)
|
||||
else:
|
||||
job["state"] = "canceled"
|
||||
job["finished_at"] = time.time()
|
||||
_abschliessen(job, None)
|
||||
return True
|
||||
|
||||
|
||||
def public_jobs() -> list[dict]:
|
||||
"""Jobs ohne interne Felder (_on_done) für die API."""
|
||||
return [{k: v for k, v in j.items() if not k.startswith("_")} for j in JOBS.values()]
|
||||
def public_jobs(mit_log: bool = True) -> list[dict]:
|
||||
"""Aufträge ohne interne Felder für die API; das Protokoll kommt aus der Datei (Ende)."""
|
||||
with _LOCK:
|
||||
_laden()
|
||||
_aufraeumen()
|
||||
jobs = [{k: v for k, v in j.items() if k not in _INTERN and not k.startswith("_")} for j in JOBS.values()]
|
||||
for j in jobs:
|
||||
j["log"] = _protokoll_lesen(j["id"]) if mit_log else []
|
||||
return sorted(jobs, key=lambda j: j.get("started_at") or 0)
|
||||
|
||||
@@ -6,9 +6,13 @@ NEU in 2.0: `groups` für Ko-Residenz (schnell + schwer gleichzeitig geladen,
|
||||
`swap:false`) → Multi-Model-Delegation ohne Nachlade-Latenz.
|
||||
"""
|
||||
|
||||
import functools
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import threading
|
||||
from contextlib import contextmanager
|
||||
from pathlib import Path
|
||||
|
||||
import httpx
|
||||
from config import (
|
||||
@@ -17,12 +21,18 @@ from config import (
|
||||
DEFAULT_TTL,
|
||||
DRAFTS_DIR,
|
||||
LLAMA_SWAP_URL,
|
||||
MODELS_DIR,
|
||||
SPEC_DRAFT_MODEL_PATH,
|
||||
SPEC_DRAFT_N_MAX,
|
||||
SPEC_TYPE,
|
||||
)
|
||||
from ruamel.yaml.scalarstring import LiteralScalarString
|
||||
|
||||
try:
|
||||
import fcntl
|
||||
except ImportError: # Windows (Entwicklung): nur die Prozess-Sperre
|
||||
fcntl = None
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
# Kanonische Serving-Rollen — EINE Quelle der Wahrheit (identisch zu sources.ROLE_IDS,
|
||||
@@ -53,6 +63,44 @@ def _gguf_total_size(path: str) -> int | None:
|
||||
return None
|
||||
|
||||
|
||||
# --- Sperre ------------------------------------------------------------------
|
||||
# Die llama-swap-Config hat mehrere Schreiber: die Oberfläche, das Radar, das Aufräumen und die
|
||||
# Hirn-Umstellung. Ohne Sperre gingen gleichzeitige Änderungen verloren (lesen, ändern, schreiben
|
||||
# überlappten). Seit 24.09.2026 läuft jedes Lesen-Ändern-Schreiben unter dieser Sperre: im
|
||||
# Prozess per RLock, zwischen Prozessen per flock auf einer Datei neben der Config.
|
||||
_SPERRE = threading.RLock()
|
||||
_SPERR_TIEFE = threading.local()
|
||||
|
||||
|
||||
@contextmanager
|
||||
def config_sperre():
|
||||
with _SPERRE:
|
||||
tiefe = getattr(_SPERR_TIEFE, "n", 0)
|
||||
_SPERR_TIEFE.n = tiefe + 1
|
||||
datei = None
|
||||
try:
|
||||
if tiefe == 0 and fcntl is not None:
|
||||
try:
|
||||
datei = open(CONFIG_PATH.with_name(".mc2-config.lock"), "a+")
|
||||
fcntl.flock(datei, fcntl.LOCK_EX)
|
||||
except OSError:
|
||||
datei = None # ohne Sperrdatei (z. B. fehlende Rechte) bleibt die Prozess-Sperre
|
||||
yield
|
||||
finally:
|
||||
_SPERR_TIEFE.n = tiefe
|
||||
if datei is not None:
|
||||
fcntl.flock(datei, fcntl.LOCK_UN)
|
||||
datei.close()
|
||||
|
||||
|
||||
def _mit_sperre(fn):
|
||||
@functools.wraps(fn)
|
||||
def huelle(*args, **kwargs):
|
||||
with config_sperre():
|
||||
return fn(*args, **kwargs)
|
||||
return huelle
|
||||
|
||||
|
||||
# --- Lesen -------------------------------------------------------------------
|
||||
def read_config() -> dict:
|
||||
if not CONFIG_PATH.exists():
|
||||
@@ -236,7 +284,7 @@ def write_config(cfg: dict) -> None:
|
||||
) from exc
|
||||
|
||||
|
||||
|
||||
@_mit_sperre
|
||||
def register_model(model_path: str, role: str | None = None, ctx: int = 8192,
|
||||
ttl: int | None = None, mmproj_path: str | None = None,
|
||||
jinja: bool = False, set_alias: bool = True) -> str:
|
||||
@@ -261,7 +309,7 @@ def register_model(model_path: str, role: str | None = None, ctx: int = 8192,
|
||||
if "--cache-reuse" not in cmd and "--mmproj" not in cmd:
|
||||
cmd += " --cache-reuse 256 -cram 16384"
|
||||
# IDE-Coding profitiert von Nebenläufigkeit; sonst Default 1 Slot = voller Kontext/Anfrage
|
||||
# (--parallel teilt den Kontext HART auf die Slots auf, s. docs/OPTIMIZATION_PLAN.md §9.4 V6).
|
||||
# (--parallel teilt den Kontext HART auf die Slots auf, s. docs/archiv/2026-06-30-optimierungsplan.md §9.4 V6).
|
||||
if role_lower == "coder" and "--parallel" not in cmd:
|
||||
cmd += " --parallel 2"
|
||||
# Vocab-kompatiblen Draft automatisch anhängen — klassisch (DRAFTS_DIR) ODER MTP-Kopf neben
|
||||
@@ -368,70 +416,8 @@ def spec_draft_flags(target_path: str) -> str:
|
||||
return _spec_flags_for_draft(d) if d else ""
|
||||
|
||||
|
||||
def drafts_for(target_path: str) -> dict:
|
||||
"""Für die UI: alle Drafts + ihre Kompatibilität zum Ziel-Modell. Schließt MTP-Köpfe
|
||||
NEBEN dem Zielmodell ein (DRAFTS_DIR kennt sie nicht). `mtp:true` markiert MTP-Drafts.
|
||||
compatible=None heißt 'nicht prüfbar' (Ziel- oder Draft-GGUF fehlt)."""
|
||||
from services import gguf_meta
|
||||
exists = bool(target_path and os.path.exists(target_path))
|
||||
drafts = list_drafts()
|
||||
seen = {d["path"] for d in drafts}
|
||||
for p in _sibling_mtp_drafters(target_path):
|
||||
if p not in seen:
|
||||
drafts.append({"path": p, "filename": os.path.basename(p),
|
||||
"size_bytes": os.path.getsize(p) if os.path.exists(p) else None,
|
||||
"vocab": gguf_meta.fingerprint(p)})
|
||||
for d in drafts:
|
||||
d["compatible"] = gguf_meta.compatible(target_path, d["path"]) if exists else None
|
||||
d["mtp"] = _is_mtp_draft(d["path"])
|
||||
return {
|
||||
"target_path": target_path,
|
||||
"target_exists": exists,
|
||||
"target_vocab": gguf_meta.fingerprint(target_path) if exists else None,
|
||||
"drafts": drafts,
|
||||
}
|
||||
|
||||
|
||||
def set_spec_draft(model_id: str, draft_path: str | None) -> dict:
|
||||
"""Setzt (oder entfernt mit draft_path=None) den Spec-Draft eines Modells.
|
||||
Validiert die Vocab-Kompatibilität — ein inkompatibler/unprüfbarer Draft wird
|
||||
abgelehnt (idiotensicher). Returns {ok, reason}."""
|
||||
cfg = read_config()
|
||||
spec = (cfg.get("models") or {}).get(model_id)
|
||||
if not isinstance(spec, dict):
|
||||
return {"ok": False, "reason": "Modell nicht gefunden"}
|
||||
cmd = str(spec.get("cmd", ""))
|
||||
# vorhandene Spec-Flags entfernen (idempotent) — klassisch UND MTP.
|
||||
cmd = re.sub(r"\s+--(?:spec-draft-model|model-draft)\s+\S+", "", cmd)
|
||||
cmd = re.sub(r"\s+-md\s+\S+", "", cmd)
|
||||
cmd = re.sub(r"\s+--spec-type\s+\S+", "", cmd)
|
||||
cmd = re.sub(r"\s+--spec-draft-n-(?:max|min)\s+\S+", "", cmd)
|
||||
|
||||
if draft_path:
|
||||
# relative Angabe (nur Dateiname) gegen DRAFTS_DIR auflösen
|
||||
if not os.path.isabs(draft_path) and "/" not in draft_path:
|
||||
draft_path = str(DRAFTS_DIR / draft_path)
|
||||
if not os.path.exists(draft_path):
|
||||
return {"ok": False, "reason": "Draft-Datei nicht gefunden"}
|
||||
from services import gguf_meta
|
||||
target = ""
|
||||
if (mt := _PATH_RE.search(cmd)):
|
||||
target = mt.group(1).replace("'", "").replace('"', "")
|
||||
comp = gguf_meta.compatible(target, draft_path) if os.path.exists(target) else None
|
||||
if comp is not True:
|
||||
reason = ("Draft ist NICHT vocab-kompatibel zum Modell — Speculative Decoding "
|
||||
"würde beim Laden scheitern."
|
||||
if comp is False else
|
||||
"Kompatibilität nicht prüfbar (Modell-GGUF fehlt) — Draft nicht gesetzt.")
|
||||
return {"ok": False, "reason": reason}
|
||||
cmd = cmd.rstrip() + _spec_flags_for_draft(draft_path)
|
||||
|
||||
spec["cmd"] = LiteralScalarString(cmd.rstrip() + "\n")
|
||||
write_config(cfg)
|
||||
return {"ok": True, "reason": ""}
|
||||
|
||||
|
||||
# --- Groups (Ko-Residenz) ----------------------------------------------------
|
||||
@_mit_sperre
|
||||
def set_group(group: str, members: list[str], swap: bool = False, persist: bool = False) -> None:
|
||||
"""llama-swap-`groups`-Eintrag setzen. swap=False → alle Mitglieder dürfen
|
||||
GLEICHZEITIG laufen (Ko-Residenz, keine Nachlade-Latenz). persist=True →
|
||||
@@ -444,6 +430,10 @@ def set_group(group: str, members: list[str], swap: bool = False, persist: bool
|
||||
cfg = read_config()
|
||||
groups = cfg.setdefault("groups", {})
|
||||
groups[group] = {"swap": swap, "persist": persist, "persistent": persist,
|
||||
# Eine immer-warme Gruppe darf nichts verdrängen. llama-swap macht Gruppen sonst
|
||||
# `exclusive`, und JEDE Anfrage ans Hirn entlud den Coder samt Prompt-Cache
|
||||
# (live gefunden 24.09.2026: Lucy-Anfrage → OpenChamber-Coder weg).
|
||||
**({"exclusive": False} if persist else {}),
|
||||
"members": list(members)}
|
||||
# Härtung: eine persistente (immer-warme) Gruppe verlangt ttl:0 je Mitglied.
|
||||
# `persistent` schützt NUR gegen Verdrängung durch andere Modelle, NICHT gegen
|
||||
@@ -463,6 +453,7 @@ def list_groups() -> dict:
|
||||
return read_config().get("groups") or {}
|
||||
|
||||
|
||||
@_mit_sperre
|
||||
def set_role(model_id: str, role: str | None) -> bool:
|
||||
"""Rolle (llama-swap-Alias) eines bestehenden Modells setzen/ändern. So tauscht man
|
||||
z.B. das `fast`-Hirn: Rolle `fast` auf ein anderes Modell legen (Alias wandert)."""
|
||||
@@ -474,22 +465,23 @@ def set_role(model_id: str, role: str | None) -> bool:
|
||||
return True
|
||||
|
||||
|
||||
def set_ctx(model_id: str, ctx: int) -> bool:
|
||||
"""Kontextlänge (-c) eines bestehenden Modells ändern."""
|
||||
@_mit_sperre
|
||||
def add_role(model_id: str, role: str) -> bool:
|
||||
"""Wie set_role, aber die übrigen Aliase des Ziel-Modells bleiben — für Modelle mit zwei Rollen
|
||||
(Coder: coder UND heavy). set_role behielte nur die geschützten Aliase und würfe coder wieder weg."""
|
||||
cfg = read_config()
|
||||
spec = (cfg.get("models") or {}).get(model_id)
|
||||
if not spec:
|
||||
models = cfg.get("models") or {}
|
||||
rolle = (role or "").strip().lower()
|
||||
if model_id not in models or not rolle or not isinstance(models[model_id], dict):
|
||||
return False
|
||||
cmd = str(spec.get("cmd", ""))
|
||||
if _CTX_RE.search(cmd):
|
||||
cmd = re.sub(r"-(?:c|-ctx-size)\s+\d+", f"-c {ctx}", cmd)
|
||||
else:
|
||||
cmd = cmd.rstrip() + f" -c {ctx}"
|
||||
spec["cmd"] = LiteralScalarString(cmd if cmd.endswith("\n") else cmd + "\n")
|
||||
bisher = [a for a in (models[model_id].get("aliases") or []) if str(a).lower() != rolle]
|
||||
set_role_alias(cfg, model_id, rolle) # nimmt die Rolle allen anderen Modellen weg
|
||||
models[model_id]["aliases"] = bisher + [rolle]
|
||||
write_config(cfg)
|
||||
return True
|
||||
|
||||
|
||||
@_mit_sperre
|
||||
def set_ttl(model_id: str, ttl: int) -> bool:
|
||||
"""Idle-TTL (Sekunden) eines bestehenden Modells setzen. ttl=0 → nie automatisch
|
||||
entladen (für das Agent-Hirn, das dauerhaft warm bleiben muss)."""
|
||||
@@ -502,6 +494,15 @@ def set_ttl(model_id: str, ttl: int) -> bool:
|
||||
return True
|
||||
|
||||
|
||||
@_mit_sperre
|
||||
def im_modellordner(pfad: str) -> bool:
|
||||
"""Liegt der Pfad (aufgelöst) unter MODELS_DIR? Grenze für Löschen und Eintragen (24.09.2026)."""
|
||||
try:
|
||||
return Path(pfad).resolve().is_relative_to(MODELS_DIR.resolve())
|
||||
except (OSError, ValueError):
|
||||
return False
|
||||
|
||||
|
||||
def delete_model(model_id: str) -> bool:
|
||||
"""Entfernt einen Modell-Eintrag aus der config.yaml, löscht die zugehörigen
|
||||
GGUF-Dateien (auch Splits) vom Datenträger und bereinigt leere Ordner.
|
||||
@@ -513,8 +514,17 @@ def delete_model(model_id: str) -> bool:
|
||||
|
||||
model_spec = models[model_id] or {}
|
||||
cmd = str(model_spec.get("cmd", "")).strip()
|
||||
if (m := _PATH_RE.search(cmd)):
|
||||
# Seit 24.09.2026 teilen sich die Bild-Zwillinge ihre Gewichte (und ggf. Projektoren) mit Hirn und Coder.
|
||||
# Dateien, die ein anderer Eintrag noch nennt, bleiben liegen — sonst risse das Löschen eines Zwillings
|
||||
# den Coder mit. Dann wird nur der Eintrag entfernt.
|
||||
andere = " ".join(str((s or {}).get("cmd", "")) for mid, s in models.items() if mid != model_id)
|
||||
if (m := _PATH_RE.search(cmd)) and m.group(1).replace("'", "").replace('"', "") in andere:
|
||||
m = None
|
||||
if m:
|
||||
path = m.group(1).replace("'", "").replace('"', "")
|
||||
if path and not im_modellordner(path):
|
||||
log.warning("delete_model: %s liegt außerhalb von %s — nur der Eintrag wird entfernt", path, MODELS_DIR)
|
||||
path = ""
|
||||
if path:
|
||||
# 1. Haupt-GGUF-Datei löschen
|
||||
if os.path.exists(path):
|
||||
@@ -542,7 +552,7 @@ def delete_model(model_id: str) -> bool:
|
||||
mmproj_match = re.search(r'--mmproj\s+[\'"]?([^\s\'"]+)[\'"]?', cmd)
|
||||
if mmproj_match:
|
||||
m_path = mmproj_match.group(1)
|
||||
if os.path.exists(m_path):
|
||||
if os.path.exists(m_path) and m_path not in andere and im_modellordner(m_path):
|
||||
try:
|
||||
os.remove(m_path)
|
||||
except Exception:
|
||||
@@ -550,7 +560,7 @@ def delete_model(model_id: str) -> bool:
|
||||
|
||||
# 3. Eltern-Ordner löschen, falls er leer ist und nicht der Modelle-Wurzelordner selbst ist
|
||||
try:
|
||||
if not os.listdir(dirname) and os.path.basename(dirname) != "models":
|
||||
if not os.listdir(dirname) and Path(dirname).resolve() != MODELS_DIR.resolve():
|
||||
os.rmdir(dirname)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
+733
-802
File diff suppressed because it is too large
Load Diff
@@ -1,128 +0,0 @@
|
||||
"""24-h-Metrik-Verlauf für die Cockpit-Zeitachse (User-Wunsch 16.07.: „WANN war Last?").
|
||||
|
||||
Ein leichter Sammler (Lifespan-Task in app.py) legt alle SAMPLE_S Sekunden einen
|
||||
kompakten Punkt in einen Ringpuffer: CPU/RAM/GPU/Disk in Prozent + die Token-
|
||||
GESAMTZÄHLER (das Frontend rechnet Raten aus den Deltas). Der Puffer hält exakt
|
||||
24 h — Älteres fällt automatisch raus (deque maxlen), nichts wächst unbegrenzt.
|
||||
Periodisch wird auf Platte persistiert (übersteht MC2-Restarts/Deploys); beim
|
||||
Laden fliegt alles raus, was älter als 24 h ist.
|
||||
|
||||
Bewusst NICHT im Steward: die Historie ist reine UI-Ware, und ein paar Sekunden
|
||||
Lücke pro Deploy sind egal.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
from collections import deque
|
||||
|
||||
from config import MODELS_DIR
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
SAMPLE_S = 10 # Abtast-Takt
|
||||
RETENTION_S = 24 * 3600 # 24 h — danach löschen und neu bauen (Ringpuffer)
|
||||
MAXLEN = RETENTION_S // SAMPLE_S # 8640 Punkte
|
||||
FLUSH_S = 300 # höchstens alle 5 min auf Platte
|
||||
MAX_RETURN_POINTS = 300 # Endpoint downsampled auf ~diese Punktzahl
|
||||
|
||||
HISTORY_PATH = MODELS_DIR / "mc2-metrics-history.json"
|
||||
|
||||
# Punkt = [t, cpu, ram, gpu, disk, tp, tc] (t = Epoch-Sekunden; tp/tc = Token-Totale)
|
||||
_points: deque = deque(maxlen=MAXLEN)
|
||||
_loaded = False
|
||||
_last_flush = 0.0
|
||||
|
||||
|
||||
def _load() -> None:
|
||||
global _loaded
|
||||
if _loaded:
|
||||
return
|
||||
_loaded = True
|
||||
try:
|
||||
raw = json.loads(HISTORY_PATH.read_text(encoding="utf-8"))
|
||||
cutoff = time.time() - RETENTION_S
|
||||
for p in raw if isinstance(raw, list) else []:
|
||||
if isinstance(p, list) and len(p) == 7 and isinstance(p[0], (int, float)) and p[0] >= cutoff:
|
||||
_points.append(p)
|
||||
if _points:
|
||||
log.info("metrics_history: %d Punkte aus %s geladen", len(_points), HISTORY_PATH)
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
except Exception:
|
||||
log.warning("metrics_history: %s nicht lesbar — starte leer", HISTORY_PATH, exc_info=True)
|
||||
|
||||
|
||||
def _flush() -> None:
|
||||
global _last_flush
|
||||
_last_flush = time.time()
|
||||
try:
|
||||
tmp = HISTORY_PATH.with_suffix(".tmp")
|
||||
tmp.write_text(json.dumps(list(_points), separators=(",", ":")), encoding="utf-8")
|
||||
tmp.replace(HISTORY_PATH)
|
||||
except OSError:
|
||||
log.warning("metrics_history: %s nicht schreibbar", HISTORY_PATH, exc_info=True)
|
||||
|
||||
|
||||
def _sample() -> None:
|
||||
import psutil
|
||||
|
||||
from services.system import _gpu_sysfs
|
||||
from services.token_stats import get_stats
|
||||
|
||||
vm = psutil.virtual_memory()
|
||||
disk = None
|
||||
try:
|
||||
du = psutil.disk_usage(str(MODELS_DIR) if MODELS_DIR.exists() else os.getcwd())
|
||||
disk = du.percent
|
||||
except Exception:
|
||||
pass
|
||||
gpu = None
|
||||
try:
|
||||
g = _gpu_sysfs()
|
||||
gpu = g.get("busy_percent") if g else None
|
||||
except Exception:
|
||||
pass
|
||||
tp = tc = None
|
||||
try:
|
||||
ts = get_stats()
|
||||
tp, tc = ts.get("prompt_tokens"), ts.get("completion_tokens")
|
||||
except Exception:
|
||||
pass
|
||||
# interval=None: nicht-blockierende CPU-Messung seit dem letzten Aufruf (10 s her — ideal).
|
||||
_points.append([int(time.time()), psutil.cpu_percent(interval=None), vm.percent, gpu, disk, tp, tc])
|
||||
|
||||
|
||||
async def sampler_loop() -> None:
|
||||
"""Dauer-Sammler fürs App-Lifespan. Fehler eines Ticks reißen die Schleife nie."""
|
||||
_load()
|
||||
while True:
|
||||
try:
|
||||
await asyncio.to_thread(_sample)
|
||||
except Exception:
|
||||
log.debug("metrics_history: Sample fehlgeschlagen", exc_info=True)
|
||||
if time.time() - _last_flush >= FLUSH_S:
|
||||
try:
|
||||
await asyncio.to_thread(_flush)
|
||||
except Exception:
|
||||
pass
|
||||
await asyncio.sleep(SAMPLE_S)
|
||||
|
||||
|
||||
def history(minutes: int = 60) -> dict:
|
||||
"""Punkte der letzten N Minuten, auf ≤ MAX_RETURN_POINTS ausgedünnt (Stride)."""
|
||||
_load()
|
||||
minutes = max(1, min(int(minutes), RETENTION_S // 60))
|
||||
cutoff = time.time() - minutes * 60
|
||||
pts = [p for p in _points if p[0] >= cutoff]
|
||||
stride = max(1, len(pts) // MAX_RETURN_POINTS)
|
||||
pts = pts[::stride]
|
||||
return {
|
||||
"sample_s": SAMPLE_S * stride,
|
||||
"points": [
|
||||
{"t": p[0], "cpu": p[1], "ram": p[2], "gpu": p[3], "disk": p[4], "tp": p[5], "tc": p[6]}
|
||||
for p in pts
|
||||
],
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
"""
|
||||
Wer nutzt die Modelle? (Box-Wart, Umbau 09/2026)
|
||||
|
||||
llama-swap protokolliert jede Anfrage mit Absender-IP im Journal. Daraus zählen wir je
|
||||
Absender und Tag die Chat-Anfragen, je Stunde der letzten 24 h, und wann welches Modell
|
||||
zuletzt geladen wurde ("Health check passed").
|
||||
|
||||
Warum das Journal und nicht /api/metrics/activity: Letzteres hält nur die letzten rund 25
|
||||
Anfragen. NerdQuiz schickt nachts in einer Stunde fast 600 — das wäre längst überschrieben,
|
||||
bevor jemand nachsieht (gemessen am 23.09.2026).
|
||||
|
||||
Die Zuordnung IP → Name kommt aus MC_NUTZER_NAMEN ("ip=Name;ip=Name"); Loopback ist alles,
|
||||
was über MC2 und den Gateway kommt (Lucy, OpenChamber, MC2 selbst).
|
||||
"""
|
||||
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import threading
|
||||
import time
|
||||
from collections import Counter, defaultdict
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from kern.zeit import LOCAL_TZ
|
||||
|
||||
CACHE_S = 600
|
||||
TAGE = 7
|
||||
|
||||
_STANDARD_NAMEN = {
|
||||
"127.0.0.1": "Lucy und OpenChamber über MC2",
|
||||
"::1": "Lucy und OpenChamber über MC2",
|
||||
"192.168.178.28": "NerdQuiz auf Arcane",
|
||||
"192.168.178.22": "NerdQuiz-Tests vom PC",
|
||||
}
|
||||
|
||||
_ANFRAGE = re.compile(r'\] Request (\S+) "POST (/v1/[a-z/_]+)')
|
||||
_GELADEN = re.compile(r"<([^>]+)> Health check passed")
|
||||
|
||||
_lock = threading.Lock()
|
||||
_cache: dict = {"ts": 0.0, "daten": None}
|
||||
|
||||
|
||||
def _namen() -> dict[str, str]:
|
||||
namen = dict(_STANDARD_NAMEN)
|
||||
for paar in os.environ.get("MC_NUTZER_NAMEN", "").split(";"):
|
||||
if "=" in paar:
|
||||
ip, name = paar.split("=", 1)
|
||||
namen[ip.strip()] = name.strip()
|
||||
return namen
|
||||
|
||||
|
||||
def _journal(seit: str) -> list[str]:
|
||||
"""Nur die Zeilen, die uns interessieren — llama-swap loggt sonst ~6.000 Status-Abfragen
|
||||
am Tag. Ohne systemd (Windows-Dev) leer."""
|
||||
cmd = (f"journalctl -u llama-swap --since '{seit}' -o short-iso --no-pager 2>/dev/null"
|
||||
" | grep -E 'POST /v1/|Health check passed'")
|
||||
try:
|
||||
out = subprocess.run(["bash", "-c", cmd], capture_output=True, text=True, timeout=60)
|
||||
except Exception:
|
||||
return []
|
||||
return out.stdout.splitlines()
|
||||
|
||||
|
||||
def werte_aus(zeilen: list[str], jetzt: datetime, namen: dict[str, str]) -> dict:
|
||||
"""Reine Auswertung (testbar): Journal-Zeilen → Nutzung je Absender, Stunde, Modell."""
|
||||
je_absender: Counter = Counter()
|
||||
je_tag: dict[str, Counter] = defaultdict(Counter)
|
||||
stunden: dict[str, Counter] = defaultdict(Counter) # "HH" → Absender → Anzahl (letzte 24 h)
|
||||
geladen: dict[str, str] = {}
|
||||
grenze_24h = jetzt - timedelta(hours=24)
|
||||
for z in zeilen:
|
||||
try:
|
||||
ts = datetime.fromisoformat(z.split(" ", 1)[0])
|
||||
except ValueError:
|
||||
continue
|
||||
if ts.tzinfo is None:
|
||||
ts = ts.replace(tzinfo=LOCAL_TZ)
|
||||
if (m := _GELADEN.search(z)):
|
||||
geladen[m.group(1)] = ts.isoformat()
|
||||
continue
|
||||
m = _ANFRAGE.search(z)
|
||||
if not m or not m.group(2).startswith("/v1/chat/completions"):
|
||||
continue
|
||||
wer = namen.get(m.group(1), m.group(1))
|
||||
je_absender[wer] += 1
|
||||
je_tag[ts.date().isoformat()][wer] += 1
|
||||
if ts >= grenze_24h:
|
||||
stunden[f"{ts.astimezone(LOCAL_TZ).hour:02d}"][wer] += 1
|
||||
return {
|
||||
"tage": TAGE,
|
||||
"absender": [{"name": n, "anfragen": c} for n, c in je_absender.most_common()],
|
||||
"je_tag": {tag: dict(c) for tag, c in sorted(je_tag.items())},
|
||||
"letzte_24h": [{"stunde": f"{h:02d}", "je_absender": dict(stunden.get(f"{h:02d}", {}))}
|
||||
for h in range(24)],
|
||||
"zuletzt_geladen": geladen,
|
||||
}
|
||||
|
||||
|
||||
def nutzung() -> dict:
|
||||
"""Gecachte Auswertung der letzten TAGE Tage (10 min)."""
|
||||
with _lock:
|
||||
if _cache["daten"] is not None and time.time() - _cache["ts"] < CACHE_S:
|
||||
return _cache["daten"]
|
||||
jetzt = datetime.now(LOCAL_TZ)
|
||||
seit = (jetzt - timedelta(days=TAGE)).strftime("%Y-%m-%d %H:%M:%S")
|
||||
daten = werte_aus(_journal(seit), jetzt, _namen())
|
||||
with _lock:
|
||||
_cache.update(ts=time.time(), daten=daten)
|
||||
return daten
|
||||
File diff suppressed because it is too large
Load Diff
@@ -21,16 +21,15 @@ import threading
|
||||
import time
|
||||
from datetime import datetime, timedelta
|
||||
from pathlib import Path
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from config import MODELS_DIR
|
||||
from kern.zeit import LOCAL_TZ
|
||||
|
||||
from services import announce
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
STORE_PATH = Path(os.environ.get("MC_REMINDERS_STORE", str(MODELS_DIR / "mc2-reminders.json")))
|
||||
LOCAL_TZ = ZoneInfo(os.environ.get("MC_LOCAL_TZ", "Europe/Berlin"))
|
||||
INTERVAL = int(os.environ.get("MC_REMINDERS_INTERVAL", "20")) # Wecker-Tick (Sekunden)
|
||||
MAX_ITEMS = int(os.environ.get("MC_REMINDERS_MAX", "100"))
|
||||
REPEATS = ("", "daily", "weekdays", "weekly")
|
||||
|
||||
@@ -1,143 +0,0 @@
|
||||
"""
|
||||
Rollen-Empfehlung: welches INSTALLIERTE Modell passt am besten auf eine Serving-Rolle?
|
||||
Capability-getrieben (Vision/Coder/Tools/MoE aus services.caps) + setup-bewusster Fit
|
||||
(services.budget). Speist den 'Empfohlen'-Hinweis + Auto-Pick im Rollen-Zuweisungs-Modal.
|
||||
|
||||
EINE Quelle der Wahrheit mit der ctx-/Fit-Logik: nutzt budget.setup_aware_ctx_for_model
|
||||
und fit.evaluate_fit — dieselbe Mathematik wie Install-Automatik und Auto-ctx-Button.
|
||||
"""
|
||||
|
||||
import psutil
|
||||
|
||||
from services import budget, catalog, llamaswap
|
||||
from services.fit import evaluate_fit
|
||||
|
||||
|
||||
def _ram_gb() -> float:
|
||||
return psutil.virtual_memory().total / (1024 ** 3)
|
||||
|
||||
|
||||
def _capability_suit(role: str, caps: dict, name: str) -> float:
|
||||
"""0..1 — Capability-Eignung (HARTE Gates). 0 = grundsätzlich falsch für die Rolle.
|
||||
Größe/Tempo bewertet getrennt _pref(), damit z.B. 'fast' nicht das größte Modell zieht."""
|
||||
role = (role or "").lower()
|
||||
low = (name or "").lower()
|
||||
vision = bool(caps.get("vision"))
|
||||
coder = bool(caps.get("coder"))
|
||||
tools = caps.get("tools") != "no"
|
||||
|
||||
if role == "vision":
|
||||
if not vision:
|
||||
return 0.0 # harte Anforderung
|
||||
return 1.0 if ("vl" in low or "llava" in low or "pixtral" in low) else 0.7 # dediziert > omni
|
||||
if role == "coder":
|
||||
return 1.0 if coder else 0.4 # Coder-Modell Pflicht für Empfehlung
|
||||
if role == "hermes":
|
||||
# Agent-Hirn: Hermes-Familie am robustesten; sonst natives Tool-Calling Pflicht.
|
||||
if "hermes" in low:
|
||||
return 1.0
|
||||
return 0.6 if tools else 0.1
|
||||
if role == "fast":
|
||||
# Alltags-Hirn braucht zuverlässige Tools; Größe/Tempo macht _pref.
|
||||
return 1.0 if tools else 0.6
|
||||
if role == "heavy":
|
||||
return 1.0
|
||||
if role == "scout":
|
||||
return 0.9 if vision else 0.7
|
||||
return 0.5
|
||||
|
||||
|
||||
def _pref(role: str, params: float, tps: float) -> float:
|
||||
"""0..1 — rollengerechte GRÖSSEN-/TEMPO-Präferenz. 'fast' belohnt Tempo & Kleinheit,
|
||||
'heavy' Größe (Wissen), 'hermes' moderate Größe (muss warm + ko-resident bleiben)."""
|
||||
role = (role or "").lower()
|
||||
if role == "fast":
|
||||
speed = min(tps / 25.0, 1.0)
|
||||
size_ok = 1.0 if params <= 50 else 50.0 / params
|
||||
return speed * size_ok
|
||||
if role == "heavy":
|
||||
return min(params / 120.0, 1.0)
|
||||
if role == "hermes":
|
||||
return 1.0 if params <= 24 else max(0.15, 24.0 / params) # 7–24B ideal als Hirn
|
||||
if role == "vision":
|
||||
return 1.0 if params <= 12 else 0.7 # klein/günstig bevorzugt
|
||||
if role == "coder":
|
||||
return 0.5 + 0.5 * min(params / 80.0, 1.0)
|
||||
if role == "scout":
|
||||
return 1.0 if params <= 40 else 0.5
|
||||
return 0.5
|
||||
|
||||
|
||||
def _catalog_role_match(role: str, name: str) -> bool:
|
||||
"""Ist dieses Modell im kuratierten Katalog (Cookbook) genau für DIESE Rolle gelistet?
|
||||
Dann ist es der prinzipien-konforme Pick → starker Bonus."""
|
||||
meta = catalog.meta_for_name(name)
|
||||
return bool(meta and (meta.get("role") or "").lower() == (role or "").lower())
|
||||
|
||||
|
||||
def _reason(role: str, caps: dict, name: str, fit: dict, fits: bool,
|
||||
incomplete: bool, cat_match: bool) -> str:
|
||||
if incomplete:
|
||||
return "Download unvollständig"
|
||||
if role == "vision" and not caps.get("vision"):
|
||||
return "keine Vision-Fähigkeit"
|
||||
if role == "coder" and not caps.get("coder"):
|
||||
return "kein Coder-Modell"
|
||||
if role == "hermes" and "hermes" not in (name or "").lower() and caps.get("tools") == "no":
|
||||
return "kein natives Tool-Calling"
|
||||
if not fits:
|
||||
return "passt nicht ins Budget (OOM)"
|
||||
bits = []
|
||||
if cat_match:
|
||||
bits.append("Katalog-Pick ✓")
|
||||
if role == "vision":
|
||||
bits.append("Vision ✓")
|
||||
if role == "coder" and caps.get("coder"):
|
||||
bits.append("Coder ✓")
|
||||
if role == "hermes":
|
||||
bits.append("Hermes" if "hermes" in (name or "").lower()
|
||||
else ("Tools ✓" if caps.get("tools") != "no" else "ohne Tools"))
|
||||
if caps.get("moe"):
|
||||
bits.append("MoE")
|
||||
bits.append(f"{fit['text']}, ~{fit['tps']:.0f} t/s")
|
||||
return " · ".join(bits)
|
||||
|
||||
|
||||
def recommend_for_role(role: str) -> dict:
|
||||
"""Rankt alle installierten Modelle für eine Rolle. Empfohlen = bester geeigneter,
|
||||
passender Eintrag. Liefert pro Modell Fit/Eignung/Begründung fürs UI."""
|
||||
role = (role or "").strip().lower()
|
||||
ram = _ram_gb()
|
||||
out = []
|
||||
for m in llamaswap.list_models():
|
||||
caps = m.get("capabilities") or {}
|
||||
params = budget.params_of_model(m)
|
||||
quant = m.get("quant") or "Q4_K_M"
|
||||
ctx = budget.setup_aware_ctx_for_model(m)["ctx"]
|
||||
fit = evaluate_fit(params, quant, ctx, ram, name=m["name"])
|
||||
incomplete = bool(m.get("incomplete"))
|
||||
fits = (fit["level"] != "too_tight") and not incomplete
|
||||
tps = fit["tps"] or 0
|
||||
suit = _capability_suit(role, caps, m["name"])
|
||||
cat_match = _catalog_role_match(role, m["name"])
|
||||
suitable = suit >= 0.5 and fits
|
||||
|
||||
fit_term = {"perfect": 1.0, "marginal": 0.3}.get(fit["level"], -2.0)
|
||||
# Eignung dominiert (×2), rollengerechte Größe/Tempo (_pref), Katalog-Anker, dann Fit.
|
||||
score = (2.0 * suit) + _pref(role, params, tps) + (0.6 if cat_match else 0.0) + fit_term
|
||||
if not fits:
|
||||
score -= 5.0
|
||||
|
||||
out.append({
|
||||
"name": m["name"], "current_role": m.get("role"),
|
||||
"params_b": round(params, 1), "quant": quant,
|
||||
"fit": fit, "suitable": suitable, "incomplete": incomplete,
|
||||
"score": round(score, 3),
|
||||
"reason": _reason(role, caps, m["name"], fit, fits, incomplete, cat_match),
|
||||
})
|
||||
|
||||
out.sort(key=lambda x: -x["score"])
|
||||
rec = next((o["name"] for o in out if o["suitable"]), None)
|
||||
for o in out:
|
||||
o["recommended"] = (o["name"] == rec)
|
||||
return {"role": role, "recommended": rec, "models": out}
|
||||
@@ -19,8 +19,6 @@ from services.routing_policy import load_policy
|
||||
# (routing_policy.py) und kommen pro Request via load_policy() (hot-reload). Die Env-Vars
|
||||
# sind dort die Defaults. Die Regex-Keyword-Listen unten bleiben bewusst im Code.
|
||||
|
||||
# Virtuelle Lanes, die im Gateway als „Modelle" sichtbar sind.
|
||||
LANES = ["coding", "chat"]
|
||||
LANE_ALIASES = {"auto": "chat"} # Rückwärtskompatibel: model:auto == chat
|
||||
|
||||
_HEAVY_KW = re.compile(
|
||||
@@ -36,25 +34,46 @@ _CODING_HEAVY_KW = re.compile(
|
||||
r"entwirf\s+(eine\s+)?architektur|plane?\s+(die\s+)?architektur)\b",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
# Code-Indikatoren — verhindert, dass echte Code-Anfragen als „trivial" auf fast abrutschen.
|
||||
# Bewusst breit (inkl. natürlichsprachiger Coding-Begriffe DE+EN): in der coding-Lane soll im Zweifel
|
||||
# `coder` gewinnen; nur echte Nicht-Code-Kürze ("hallo", "wie spät") rutscht auf fast.
|
||||
_CODE_HINT = re.compile(
|
||||
r"```|\bdef \b|\bclass \b|\bimport \b|\bfunction\b|=>|;\s*$|"
|
||||
r"\.(py|ts|tsx|js|jsx|go|rs|java|cpp|c|rb|php|sql)\b|/src/|traceback|stack\s*trace|"
|
||||
r"\b(funktion|function|bug|fix|fehler|error|exception|implementier\w*|schreib\w*|"
|
||||
r"code\w*|coden|test\w*|klasse|method\w*|methode|refactor\w*|kompil\w*|compile|"
|
||||
r"build|deploy|debug|script|skript|api|endpoint|query|regex|json|yaml|"
|
||||
r"npm|pip|git|docker|terminal|shell|command)\b",
|
||||
re.IGNORECASE | re.MULTILINE,
|
||||
)
|
||||
|
||||
|
||||
# Bild-Weiche (Faden 11): Aliase, die selbst Bilder koennen — die werden NIE umgeroutet.
|
||||
VISION_CAPABLE = {"vision", "scout"}
|
||||
# Seit 24.09.2026 sind vision und coder-bild die Bild-Zwillinge von Hirn und Coder (gleiche Gewichte
|
||||
# plus Bild-Projektor, ohne Draft — llama.cpp kann Draft und Bild nicht zusammen, HTTP 500).
|
||||
VISION_CAPABLE = {"vision", "coder-bild", "scout"}
|
||||
IMAGE_PART_TYPES = {"image_url", "input_image", "image"}
|
||||
|
||||
|
||||
def ist_coder_alias(alias: str) -> bool:
|
||||
"""Coder-Ziele: coder, heavy (derselbe Coder) und rohe Coder-IDs."""
|
||||
a = (alias or "").lower()
|
||||
return "coder" in a or a == "heavy"
|
||||
|
||||
|
||||
def bilder_aufteilen(body: dict) -> tuple[list[tuple[dict, int]], list[tuple[dict, int]]]:
|
||||
"""Bild-Parts in (frisch, alt) teilen. Frisch = nach der letzten Antwort des Modells (der Schritt,
|
||||
um den es gerade geht: neue Nutzer-Nachricht oder Werkzeug-Ergebnis). Alt = davor — das Modell hat
|
||||
sie schon gesehen und seine Schlüsse im Verlauf. Jeder Eintrag ist (Nachricht, Index im content)."""
|
||||
msgs = [m for m in body.get("messages") or [] if isinstance(m, dict)]
|
||||
letzte_antwort = max((i for i, m in enumerate(msgs) if m.get("role") == "assistant"), default=-1)
|
||||
frisch: list[tuple[dict, int]] = []
|
||||
alt: list[tuple[dict, int]] = []
|
||||
for i, m in enumerate(msgs):
|
||||
if not isinstance(m.get("content"), list):
|
||||
continue
|
||||
for j, part in enumerate(m["content"]):
|
||||
if isinstance(part, dict) and part.get("type") in IMAGE_PART_TYPES:
|
||||
(frisch if i > letzte_antwort else alt).append((m, j))
|
||||
return frisch, alt
|
||||
|
||||
|
||||
def bild_ziel(alias: str, vision_alias: str, coder_vision_alias: str | None) -> str:
|
||||
"""Bild-Zwilling für eine Anfrage mit frischem Bild: Coder-Ziele an den Coder-Zwilling (falls es ihn
|
||||
gibt), alles andere an den Hirn-Zwilling (vision)."""
|
||||
if coder_vision_alias and ist_coder_alias(alias):
|
||||
return coder_vision_alias
|
||||
return vision_alias
|
||||
|
||||
|
||||
def has_image(body: dict) -> bool:
|
||||
"""True, wenn irgendeine Nachricht einen Bild-Part enthaelt (OpenAI-multimodaler
|
||||
content: eine Liste mit einem {"type": "image_url"|"input_image"|"image", ...}-Teil)."""
|
||||
@@ -117,6 +136,3 @@ def choose_for_lane(lane: str, body: dict) -> tuple[str, str]:
|
||||
return _route_chat(text, n) # chat + alles Unbekannte
|
||||
|
||||
|
||||
def choose_model(body: dict) -> tuple[str, str]:
|
||||
"""Rückwärtskompatibel: altes `model:auto` == chat-Lane."""
|
||||
return choose_for_lane("chat", body)
|
||||
|
||||
+117
-131
@@ -1,131 +1,117 @@
|
||||
"""
|
||||
UI-editierbare Routing-Policy für die Gateway-Lanes (coding/chat).
|
||||
|
||||
Persistiert als JSON unter MC_ROUTING_POLICY_PATH (Default MODELS_DIR/mc2-routing.json —
|
||||
gleiche Konvention wie mc2-discover.json). **Hot-reload:** load_policy() liest die Datei nur
|
||||
bei Änderung neu (mtime-Cache) → UI-Edits greifen ohne Dienst-Neustart. Die Env-Vars (bisher
|
||||
einzige Stellschraube in router_logic.py) bleiben als Defaults/Fallback erhalten.
|
||||
|
||||
Bewusst NICHT editierbar (v1): die Regex-Keyword-Listen (heavy/coding-heavy/code-hint) — die
|
||||
bleiben in router_logic.py im Code.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import threading
|
||||
from pathlib import Path
|
||||
|
||||
from config import MODELS_DIR
|
||||
|
||||
POLICY_PATH = Path(os.environ.get("MC_ROUTING_POLICY_PATH", str(MODELS_DIR / "mc2-routing.json")))
|
||||
|
||||
|
||||
def _env_bool(name: str, default: str) -> bool:
|
||||
return os.environ.get(name, default) not in ("0", "false", "")
|
||||
|
||||
|
||||
# Defaults aus den Env-Vars — Quelle der Wahrheit, solange keine Policy-Datei existiert.
|
||||
DEFAULTS: dict = {
|
||||
"fast": os.environ.get("MC_ROUTE_FAST", "fast"),
|
||||
"heavy": os.environ.get("MC_ROUTE_HEAVY", "heavy"),
|
||||
"coder": os.environ.get("MC_ROUTE_CODER", "coder"),
|
||||
"coder_lite": os.environ.get("MC_ROUTE_CODER_LITE", ""),
|
||||
# Bild-Weiche (Faden 11): Requests mit Bild-Anhang werden automatisch hierhin geroutet
|
||||
# (die MTP-Hirn-Config kann keine Bilder). "" schaltet die Weiche ab (Passthrough).
|
||||
"vision": os.environ.get("MC_ROUTE_VISION", "vision"),
|
||||
"heavy_chars": int(os.environ.get("MC_GATEWAY_HEAVY_CHARS", "8000")),
|
||||
"coding_escalate_chars": int(os.environ.get("MC_CODING_ESCALATE_CHARS", "120000")),
|
||||
"fast_no_think": _env_bool("MC_FAST_NO_THINK", "1"),
|
||||
}
|
||||
|
||||
# Feld-Spezifikation für die UI (Typ + Grenzen + Label). Treibt Editor & Validierung.
|
||||
FIELDS: list[dict] = [
|
||||
{"key": "fast", "label": "fast-Alias (chat: Standard)", "type": "str"},
|
||||
{"key": "heavy", "label": "heavy-Alias (chat: lang/komplex)", "type": "str"},
|
||||
{"key": "coder", "label": "coder-Alias (coding: stark / Eskalation)", "type": "str"},
|
||||
{"key": "coder_lite", "label": "coder-lite-Alias (coding: schneller Default; leer = aus)", "type": "str"},
|
||||
{"key": "vision", "label": "vision-Alias (Bild-Weiche: Requests mit Bild; leer = aus)", "type": "str"},
|
||||
{"key": "heavy_chars", "label": "chat → heavy ab N Zeichen", "type": "int", "min": 500, "max": 1_000_000},
|
||||
{"key": "coding_escalate_chars", "label": "coding → starker Coder ab N Zeichen", "type": "int", "min": 1000, "max": 4_000_000},
|
||||
{"key": "fast_no_think", "label": "fast-Spur: Thinking aus (flotte Antworten)", "type": "bool"},
|
||||
]
|
||||
|
||||
_LOCK = threading.Lock()
|
||||
_CACHE: dict = {"mtime": None, "policy": None}
|
||||
|
||||
|
||||
def _read_file() -> dict:
|
||||
try:
|
||||
with open(POLICY_PATH, "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
return data if isinstance(data, dict) else {}
|
||||
except (FileNotFoundError, json.JSONDecodeError, OSError):
|
||||
return {}
|
||||
|
||||
|
||||
def _coerce(patch: dict) -> dict:
|
||||
"""Nur bekannte Keys, typ-/bereichsvalidiert. Wirft ValueError bei ungültigen Werten."""
|
||||
spec = {f["key"]: f for f in FIELDS}
|
||||
out: dict = {}
|
||||
for k, v in (patch or {}).items():
|
||||
f = spec.get(k)
|
||||
if not f:
|
||||
continue # unbekannte Keys still verwerfen
|
||||
if f["type"] == "int":
|
||||
iv = int(v)
|
||||
lo, hi = f.get("min", 1), f.get("max", 10**9)
|
||||
if not (lo <= iv <= hi):
|
||||
raise ValueError(f"{k}={iv} außerhalb [{lo}, {hi}]")
|
||||
out[k] = iv
|
||||
elif f["type"] == "bool":
|
||||
out[k] = bool(v)
|
||||
else: # str
|
||||
sv = str(v).strip()
|
||||
if k not in ("coder_lite", "vision") and not sv:
|
||||
raise ValueError(f"{k} darf nicht leer sein")
|
||||
out[k] = sv
|
||||
return out
|
||||
|
||||
|
||||
def _coerce_safe(patch: dict) -> dict:
|
||||
"""Wie _coerce, aber schluckt Fehler — kaputte Datei darf den Betrieb nicht stoppen."""
|
||||
try:
|
||||
return _coerce(patch)
|
||||
except (ValueError, TypeError):
|
||||
return {}
|
||||
|
||||
|
||||
def load_policy() -> dict:
|
||||
"""Aktuelle Policy (Datei über DEFAULTS gemerged). Hot-reload via mtime-Cache, pro Request billig."""
|
||||
try:
|
||||
mtime = POLICY_PATH.stat().st_mtime
|
||||
except OSError:
|
||||
mtime = None
|
||||
with _LOCK:
|
||||
if _CACHE["policy"] is None or _CACHE["mtime"] != mtime:
|
||||
merged = {**DEFAULTS}
|
||||
if mtime is not None:
|
||||
merged.update(_coerce_safe(_read_file()))
|
||||
_CACHE["mtime"] = mtime
|
||||
_CACHE["policy"] = merged
|
||||
return dict(_CACHE["policy"])
|
||||
|
||||
|
||||
def save_policy(patch: dict) -> dict:
|
||||
"""Validiert + persistiert atomar. Gibt die neue, vollständige Policy zurück."""
|
||||
clean = _coerce(patch) # wirft bei ungültigem Input
|
||||
with _LOCK:
|
||||
current = {**DEFAULTS, **_coerce_safe(_read_file()), **clean}
|
||||
POLICY_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||||
tmp = POLICY_PATH.with_suffix(".json.tmp")
|
||||
with open(tmp, "w", encoding="utf-8") as f:
|
||||
json.dump(current, f, ensure_ascii=False, indent=2)
|
||||
os.replace(tmp, POLICY_PATH)
|
||||
_CACHE["mtime"] = None # nächster load_policy() lädt frisch
|
||||
_CACHE["policy"] = None
|
||||
return current
|
||||
|
||||
|
||||
def policy_meta() -> dict:
|
||||
"""Für den UI-Editor: aktuelle Werte + Defaults (für „Zurücksetzen“) + Feld-Spezifikation."""
|
||||
return {"policy": load_policy(), "defaults": dict(DEFAULTS), "fields": FIELDS}
|
||||
"""
|
||||
UI-editierbare Routing-Policy für die Gateway-Lanes (coding/chat).
|
||||
|
||||
Persistiert als JSON unter MC_ROUTING_POLICY_PATH (Default MODELS_DIR/mc2-routing.json —
|
||||
gleiche Konvention wie mc2-discover.json). **Hot-reload:** load_policy() liest die Datei nur
|
||||
bei Änderung neu (mtime-Cache) → UI-Edits greifen ohne Dienst-Neustart. Die Env-Vars (bisher
|
||||
einzige Stellschraube in router_logic.py) bleiben als Defaults/Fallback erhalten.
|
||||
|
||||
Bewusst NICHT editierbar (v1): die Regex-Keyword-Listen (heavy/coding-heavy/code-hint) — die
|
||||
bleiben in router_logic.py im Code.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import threading
|
||||
from pathlib import Path
|
||||
|
||||
from config import MODELS_DIR
|
||||
|
||||
POLICY_PATH = Path(os.environ.get("MC_ROUTING_POLICY_PATH", str(MODELS_DIR / "mc2-routing.json")))
|
||||
|
||||
|
||||
def _env_bool(name: str, default: str) -> bool:
|
||||
return os.environ.get(name, default) not in ("0", "false", "")
|
||||
|
||||
|
||||
# Defaults aus den Env-Vars — Quelle der Wahrheit, solange keine Policy-Datei existiert.
|
||||
DEFAULTS: dict = {
|
||||
"fast": os.environ.get("MC_ROUTE_FAST", "fast"),
|
||||
"heavy": os.environ.get("MC_ROUTE_HEAVY", "heavy"),
|
||||
"coder": os.environ.get("MC_ROUTE_CODER", "coder"),
|
||||
"coder_lite": os.environ.get("MC_ROUTE_CODER_LITE", ""),
|
||||
# Bild-Weiche (Faden 11): Requests mit Bild-Anhang werden automatisch hierhin geroutet
|
||||
# (die MTP-Hirn-Config kann keine Bilder). "" schaltet die Weiche ab (Passthrough).
|
||||
"vision": os.environ.get("MC_ROUTE_VISION", "vision"),
|
||||
# Seit 24.09.2026: Bild-Zwilling des Coders — Coder-Anfragen mit neuem Bild gehen hierhin statt an vision.
|
||||
# "" = auch Coder-Bilder gehen an vision.
|
||||
"coder_vision": os.environ.get("MC_ROUTE_CODER_VISION", "coder-bild"),
|
||||
"heavy_chars": int(os.environ.get("MC_GATEWAY_HEAVY_CHARS", "8000")),
|
||||
"coding_escalate_chars": int(os.environ.get("MC_CODING_ESCALATE_CHARS", "120000")),
|
||||
"fast_no_think": _env_bool("MC_FAST_NO_THINK", "1"),
|
||||
}
|
||||
|
||||
# Feld-Spezifikation für die UI (Typ + Grenzen + Label). Treibt Editor & Validierung.
|
||||
FIELDS: list[dict] = [
|
||||
{"key": "fast", "label": "fast-Alias (chat: Standard)", "type": "str"},
|
||||
{"key": "heavy", "label": "heavy-Alias (chat: lang/komplex)", "type": "str"},
|
||||
{"key": "coder", "label": "coder-Alias (coding: stark / Eskalation)", "type": "str"},
|
||||
{"key": "coder_lite", "label": "coder-lite-Alias (coding: schneller Default; leer = aus)", "type": "str"},
|
||||
{"key": "vision", "label": "vision-Alias (Bild-Weiche: Requests mit Bild; leer = aus)", "type": "str"},
|
||||
{"key": "coder_vision", "label": "Bild-Zwilling des Coders (leer = Coder-Bilder an vision)", "type": "str"},
|
||||
{"key": "heavy_chars", "label": "chat → heavy ab N Zeichen", "type": "int", "min": 500, "max": 1_000_000},
|
||||
{"key": "coding_escalate_chars", "label": "coding → starker Coder ab N Zeichen", "type": "int", "min": 1000, "max": 4_000_000},
|
||||
{"key": "fast_no_think", "label": "fast-Spur: Thinking aus (flotte Antworten)", "type": "bool"},
|
||||
]
|
||||
|
||||
_LOCK = threading.Lock()
|
||||
_CACHE: dict = {"mtime": None, "policy": None}
|
||||
|
||||
|
||||
def _read_file() -> dict:
|
||||
try:
|
||||
with open(POLICY_PATH, "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
return data if isinstance(data, dict) else {}
|
||||
except (FileNotFoundError, json.JSONDecodeError, OSError):
|
||||
return {}
|
||||
|
||||
|
||||
def _coerce(patch: dict) -> dict:
|
||||
"""Nur bekannte Keys, typ-/bereichsvalidiert. Wirft ValueError bei ungültigen Werten."""
|
||||
spec = {f["key"]: f for f in FIELDS}
|
||||
out: dict = {}
|
||||
for k, v in (patch or {}).items():
|
||||
f = spec.get(k)
|
||||
if not f:
|
||||
continue # unbekannte Keys still verwerfen
|
||||
if f["type"] == "int":
|
||||
iv = int(v)
|
||||
lo, hi = f.get("min", 1), f.get("max", 10**9)
|
||||
if not (lo <= iv <= hi):
|
||||
raise ValueError(f"{k}={iv} außerhalb [{lo}, {hi}]")
|
||||
out[k] = iv
|
||||
elif f["type"] == "bool":
|
||||
out[k] = bool(v)
|
||||
else: # str
|
||||
sv = str(v).strip()
|
||||
if k not in ("coder_lite", "vision", "coder_vision") and not sv:
|
||||
raise ValueError(f"{k} darf nicht leer sein")
|
||||
out[k] = sv
|
||||
return out
|
||||
|
||||
|
||||
def _coerce_safe(patch: dict) -> dict:
|
||||
"""Wie _coerce, aber schluckt Fehler — kaputte Datei darf den Betrieb nicht stoppen."""
|
||||
try:
|
||||
return _coerce(patch)
|
||||
except (ValueError, TypeError):
|
||||
return {}
|
||||
|
||||
|
||||
def load_policy() -> dict:
|
||||
"""Aktuelle Policy (Datei über DEFAULTS gemerged). Hot-reload via mtime-Cache, pro Request billig."""
|
||||
try:
|
||||
mtime = POLICY_PATH.stat().st_mtime
|
||||
except OSError:
|
||||
mtime = None
|
||||
with _LOCK:
|
||||
if _CACHE["policy"] is None or _CACHE["mtime"] != mtime:
|
||||
merged = {**DEFAULTS}
|
||||
if mtime is not None:
|
||||
merged.update(_coerce_safe(_read_file()))
|
||||
_CACHE["mtime"] = mtime
|
||||
_CACHE["policy"] = merged
|
||||
return dict(_CACHE["policy"])
|
||||
|
||||
|
||||
|
||||
@@ -1,152 +0,0 @@
|
||||
"""
|
||||
Health-Wächter der Box (Lucy-Proaktivität, Faden A3).
|
||||
|
||||
Prüft periodisch die Kern-Dienste (Engine, Agent-Hirn, Hermes-Gateway, Mem0,
|
||||
Voice-Sidecar, Platte) und meldet ZUSTANDSWECHSEL in den Melde-Briefkasten
|
||||
(services/announce.py → Lucy spricht es) und via notify.sh (Telegram).
|
||||
|
||||
Flankenerkennung statt Dauerfeuer: Alarm erst nach FAIL_AFTER Fehl-Ticks in
|
||||
Folge (überlebt Neustarts/Update-Fenster), Entwarnung beim ersten grünen Tick
|
||||
nach einem Alarm. Hält ein Problem an, wird frühestens nach REMIND_S erinnert.
|
||||
|
||||
Braucht KEIN sudo, keine neuen Dienste — läuft als asyncio-Task im MC2-Backend
|
||||
(wie der Re-Warm-Wächter). Abschaltbar via MC_SENTRY_ENABLED=0.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
|
||||
import httpx
|
||||
import psutil
|
||||
from config import HERMES_API_URL, MODELS_DIR, VOICE_SERVICE_URL
|
||||
|
||||
from services import announce, llamaswap
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
ENABLED = os.environ.get("MC_SENTRY_ENABLED", "1") != "0"
|
||||
INTERVAL = int(os.environ.get("MC_SENTRY_INTERVAL", "120")) # Sekunden zwischen Ticks
|
||||
START_DELAY = int(os.environ.get("MC_SENTRY_START_DELAY", "90")) # Dienste nach Boot setzen lassen
|
||||
FAIL_AFTER = int(os.environ.get("MC_SENTRY_FAIL_AFTER", "3")) # Fehl-Ticks bis Alarm (3×120s = 6 min)
|
||||
REMIND_S = int(os.environ.get("MC_SENTRY_REMIND_S", "21600")) # Erinnerung bei Dauerproblem: 6 h
|
||||
DISK_ALARM_PCT = float(os.environ.get("MC_SENTRY_DISK_PCT", "90"))
|
||||
|
||||
|
||||
def _reach(url: str, path: str = "/health") -> bool:
|
||||
try:
|
||||
with httpx.Client(timeout=5.0) as c:
|
||||
return c.get(f"{url}{path}").status_code < 500
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def _check_engine() -> bool:
|
||||
return llamaswap.engine_reachable()
|
||||
|
||||
|
||||
def _check_brain() -> bool:
|
||||
"""Hirn tot? Verdrängung durch ein ANDERES laufendes Modell (IDE-Last) ist NORMAL —
|
||||
Alarm nur, wenn gar nichts läuft und das Hirn trotz Re-Warm-Wächter kalt bleibt."""
|
||||
st = llamaswap.brain_status()
|
||||
if st.get("ready"):
|
||||
return True
|
||||
return bool(llamaswap.get_running_models()) # anderes Modell aktiv → Verdrängung, kein Defekt
|
||||
|
||||
|
||||
def _check_disk() -> bool:
|
||||
try:
|
||||
return psutil.disk_usage(str(MODELS_DIR) if MODELS_DIR.exists() else os.getcwd()).percent < DISK_ALARM_PCT
|
||||
except Exception:
|
||||
return True # kein Messwert ≠ Alarm
|
||||
|
||||
|
||||
# name → (Checker, Alarm-Text, Entwarnungs-Text) — Texte sind Lucy-sprechbar (kurz, Alltagssprache).
|
||||
CHECKS: dict[str, tuple] = {
|
||||
"engine": (_check_engine,
|
||||
"Die Modell-Engine antwortet nicht mehr. Ohne sie laufen keine KI-Modelle.",
|
||||
"Die Modell-Engine ist wieder da."),
|
||||
"brain": (_check_brain,
|
||||
"Mein Gehirn lädt nicht — ich kann gerade nicht richtig denken. Ein Neustart der Engine könnte helfen.",
|
||||
"Mein Gehirn ist wieder geladen. Alles klar bei mir."),
|
||||
"hermes": (lambda: _reach(HERMES_API_URL, "/v1/models"),
|
||||
"Der Agent-Dienst ist ausgefallen — Telegram und meine Tools gehen gerade nicht.",
|
||||
"Der Agent-Dienst läuft wieder."),
|
||||
"voice": (lambda: _reach(VOICE_SERVICE_URL),
|
||||
"Der Hör-Dienst auf der Box ist ausgefallen — Spracheingabe könnte haken.",
|
||||
"Der Hör-Dienst läuft wieder."),
|
||||
"disk": (_check_disk,
|
||||
f"Die Platte der Box ist zu über {DISK_ALARM_PCT:.0f} Prozent voll. Es wird eng für Modelle und Backups.",
|
||||
"Die Platte hat wieder genug Luft."),
|
||||
}
|
||||
|
||||
|
||||
# Steward-Modus (UMBAU v3 P2): Läuft der Wächter als EIGENER Prozess (mc2-steward),
|
||||
# beobachtet er zusätzlich das Steuerpult selbst und den mc2-gateway — genau die zwei
|
||||
# Ausfälle, die der alte In-Process-Wächter prinzipbedingt nie melden konnte (er starb mit).
|
||||
if os.environ.get("MC_SENTRY_WATCH_MC2", "") == "1":
|
||||
_MC2_URL = os.environ.get("MC_SENTRY_MC2_URL", "http://127.0.0.1:9001")
|
||||
_GW_URL = os.environ.get("MC_SENTRY_GATEWAY_URL", "http://127.0.0.1:9010")
|
||||
CHECKS["mc2"] = (lambda: _reach(_MC2_URL, "/api/health"),
|
||||
"Das Steuerpult ist ausgefallen — Dashboard, Briefkasten und Auftragsbuch gehen gerade nicht.",
|
||||
"Das Steuerpult ist wieder da.")
|
||||
CHECKS["gateway"] = (lambda: _reach(_GW_URL, "/gw/health"),
|
||||
"Der Modell-Gateway ist ausgefallen — meine Denk-Anfragen und die der Worker hängen gerade.",
|
||||
"Der Modell-Gateway läuft wieder.")
|
||||
|
||||
|
||||
class _Watch:
|
||||
__slots__ = ("alert_ts", "alerted", "fails")
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.fails = 0 # Fehl-Ticks in Folge
|
||||
self.alerted = False # Alarm ist raus, Entwarnung steht aus
|
||||
self.alert_ts = 0.0 # Zeitpunkt des letzten Alarms (für REMIND_S)
|
||||
|
||||
|
||||
_watches: dict[str, _Watch] = {name: _Watch() for name in CHECKS}
|
||||
|
||||
|
||||
def _notify_telegram(subject: str, text: str) -> None:
|
||||
"""Telegram-Direktweg (gemeinsamer Helper in announce.py); Briefkasten-Eintrag
|
||||
legt der Wächter selbst ab."""
|
||||
announce.notify_telegram(subject, text + " (Diese Meldung kam auch an Lucy.)")
|
||||
|
||||
|
||||
def _tick() -> None:
|
||||
now = time.time()
|
||||
for name, (check, fail_msg, ok_msg) in CHECKS.items():
|
||||
w = _watches[name]
|
||||
try:
|
||||
ok = bool(check())
|
||||
except Exception:
|
||||
ok = False
|
||||
if ok:
|
||||
w.fails = 0
|
||||
if w.alerted:
|
||||
w.alerted = False
|
||||
announce.add(ok_msg, subject="[Box wieder ok]", source="sentry")
|
||||
_notify_telegram("[Box wieder ok]", ok_msg)
|
||||
continue
|
||||
w.fails += 1
|
||||
due = (not w.alerted and w.fails >= FAIL_AFTER) or (w.alerted and now - w.alert_ts >= REMIND_S)
|
||||
if due:
|
||||
prefix = "" if not w.alerted else "Immer noch: "
|
||||
w.alerted = True
|
||||
w.alert_ts = now
|
||||
announce.add(prefix + fail_msg, subject="[Box-Problem]", source="sentry")
|
||||
_notify_telegram("[Box-Problem]", prefix + fail_msg)
|
||||
log.warning("sentry: %s ALARM (%s Fehl-Ticks)", name, w.fails)
|
||||
|
||||
|
||||
async def sentry_loop() -> None:
|
||||
"""Endlos-Schleife (Hintergrund-Task im MC2-Lifespan)."""
|
||||
await asyncio.sleep(START_DELAY)
|
||||
log.info("sentry: Health-Wächter aktiv (Intervall %ss, Alarm nach %s Fehl-Ticks)", INTERVAL, FAIL_AFTER)
|
||||
while True:
|
||||
try:
|
||||
await asyncio.to_thread(_tick)
|
||||
except Exception:
|
||||
log.debug("sentry: Tick fehlgeschlagen", exc_info=True)
|
||||
await asyncio.sleep(INTERVAL)
|
||||
+75
-27
@@ -8,8 +8,10 @@ der Box, daher sysfs). Verschachtelte Struktur wie v1 (cpu.percent, ram.used Byt
|
||||
|
||||
import glob
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import threading
|
||||
import time
|
||||
|
||||
import psutil
|
||||
from config import MODELS_DIR
|
||||
@@ -126,33 +128,25 @@ def find_hermes_agent_git() -> dict | None:
|
||||
|
||||
|
||||
def get_engine_version() -> dict:
|
||||
engine_path = os.environ.get("MC_ENGINE_PATH", "/opt/llamacpp")
|
||||
git_info = get_git_info(engine_path)
|
||||
if git_info:
|
||||
return {**git_info, "type": "git"}
|
||||
|
||||
candidates = [
|
||||
os.path.join(engine_path, "llama-server"),
|
||||
os.path.join(engine_path, "bin", "llama-server"),
|
||||
"/usr/local/bin/llama-server",
|
||||
"/usr/bin/llama-server",
|
||||
"llama-server"
|
||||
]
|
||||
|
||||
for binary in candidates:
|
||||
if binary != "llama-server" and not os.path.exists(binary):
|
||||
continue
|
||||
try:
|
||||
res = subprocess.run([binary, "--version"], capture_output=True, text=True, timeout=2)
|
||||
output = (res.stdout or "").strip() or (res.stderr or "").strip()
|
||||
if output:
|
||||
lines = output.splitlines()
|
||||
ver = lines[0] if lines else "unknown"
|
||||
return {"version_text": ver, "type": "binary"}
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return {"type": "unknown"}
|
||||
"""Build-Nummer der laufenden Engine (offizieller Vulkan-Build unter /opt/llamacpp-vulkan).
|
||||
|
||||
Bis 09/2026 stand hier /opt/llamacpp als Standard — ein verwaister Ordner aus der ROCm-Zeit
|
||||
mit eigenem git-Stand. MC2 zeigte deshalb „1 (1ec44d1)“, während Build 11057 lief. Die
|
||||
Binary braucht ihre .so-Dateien aus dem eigenen Ordner, daher LD_LIBRARY_PATH."""
|
||||
engine_path = os.environ.get("MC_ENGINE_PATH", "/opt/llamacpp-vulkan")
|
||||
binary = os.path.join(engine_path, "llama-server")
|
||||
if not os.path.exists(binary):
|
||||
return {"type": "unknown"}
|
||||
try:
|
||||
env = dict(os.environ, LD_LIBRARY_PATH=engine_path)
|
||||
res = subprocess.run([binary, "--version"], capture_output=True, text=True, timeout=20, env=env)
|
||||
text = (res.stdout or "") + (res.stderr or "")
|
||||
except Exception:
|
||||
return {"type": "unknown"}
|
||||
if (m := re.search(r"\bbuild\s+(\d{3,})\b", text)):
|
||||
return {"type": "binary", "build": int(m.group(1)), "version_text": f"b{m.group(1)}"}
|
||||
zeile = next((z for z in text.splitlines() if z.startswith("version")), "")
|
||||
return {"type": "binary", "version_text": zeile or "unbekannt"}
|
||||
|
||||
|
||||
_VERSION_CACHE = {"ts": 0.0, "data": {}}
|
||||
@@ -197,5 +191,59 @@ def system_status() -> dict:
|
||||
"gpu": _gpu_sysfs(),
|
||||
"temp": _temps(),
|
||||
"disk": disk,
|
||||
# Betriebszeit in Sekunden (v3-Umbau P3). Gehoert in die neue Statusleiste, weil
|
||||
# sich die Box woechentlich selbst neu startet, wenn das OS es verlangt — dann ist
|
||||
# "laeuft seit 20 Minuten" die Antwort auf eine ganze Klasse von Fragen.
|
||||
"uptime_s": _uptime_s(),
|
||||
"versions": check_versions_cached(),
|
||||
}
|
||||
|
||||
|
||||
def metrik_punkt() -> dict:
|
||||
"""Leichter Messpunkt fuer den Ereignisstrom (v3-Umbau P4) — EINMAL pro Sekunde.
|
||||
|
||||
Bewusst NICHT `system_status()`: das ruft `psutil.cpu_percent(interval=0.1)` und
|
||||
blockiert damit den Event-Loop 100 ms je Aufruf (bei 1-s-Takt also 10 % der Zeit),
|
||||
und es haengt den Versions-Check dran, den niemand sekuendlich braucht.
|
||||
|
||||
`interval=None` misst gegen den VORIGEN Aufruf statt zu warten — genau richtig fuer
|
||||
einen festen Takt. Der allererste Wert ist 0.0; das faellt bei 1 s nicht auf.
|
||||
|
||||
Token stehen hier als GESAMTZAEHLER, nicht als Rate: Der Klient rechnet die Rate aus
|
||||
zwei Punkten selbst. So bleibt der Server zustandslos und ein verpasster Punkt
|
||||
verfaelscht nichts."""
|
||||
vm = psutil.virtual_memory()
|
||||
temp = _temps() or {}
|
||||
gpu = _gpu_sysfs() or {}
|
||||
try:
|
||||
from services.token_stats import get_stats
|
||||
tok = get_stats()
|
||||
except Exception:
|
||||
tok = {}
|
||||
try:
|
||||
du = psutil.disk_usage(str(MODELS_DIR) if MODELS_DIR.exists() else os.getcwd())
|
||||
disk = du.percent
|
||||
except Exception:
|
||||
disk = None
|
||||
return {
|
||||
"cpu": psutil.cpu_percent(interval=None),
|
||||
"ram": vm.percent,
|
||||
"ram_used": vm.used,
|
||||
"ram_total": vm.total,
|
||||
"gpu": gpu.get("busy_percent"),
|
||||
"disk": disk,
|
||||
"temp_cpu": temp.get("cpu"),
|
||||
"temp_gpu": temp.get("gpu"),
|
||||
"uptime_s": _uptime_s(),
|
||||
"tok_p": tok.get("prompt_tokens", 0),
|
||||
"tok_c": tok.get("completion_tokens", 0),
|
||||
}
|
||||
|
||||
|
||||
def _uptime_s() -> int | None:
|
||||
"""Sekunden seit dem Systemstart. None statt einer Ausrede, wenn psutil hier nichts
|
||||
liefert — eine erfundene Zahl waere schlimmer als eine fehlende."""
|
||||
try:
|
||||
return int(time.time() - psutil.boot_time())
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
@@ -0,0 +1,236 @@
|
||||
"""
|
||||
Update-Verlauf und festgehaltene Bausteine (Box-Wart, Umbau 09/2026).
|
||||
|
||||
Was ein Update-Lauf wirklich gebracht hat, steht nicht im Hermes-Cron-Status — der sagt nur
|
||||
„Skript lief". Die Wahrheit ist das, was deploy/autoupdate.sh am Ende meldet: eine Zeile je
|
||||
Baustein („255 → 256 eingespielt", „zurückgerollt + GEPINNT" …). notify.sh schreibt jede
|
||||
Meldung mit Zeitstempel in ~/mc2-notify.log; der Verlauf wird von dort gelesen, nur lesend.
|
||||
|
||||
Das Pin-Register (/srv/models/mc2-pins.json) führt autoupdate.sh: Besteht ein Update den Check
|
||||
nicht, wird zurückgerollt und der Baustein festgehalten — künftige Sonntage überspringen ihn.
|
||||
Vom 06. bis 17.09.2026 hat das niemand gesehen, die Box bekam elf Tage keine Updates. Darum
|
||||
zeigt der Wächter jeden festgehaltenen Baustein als Hinweis, und hier lässt er sich freigeben.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import threading
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
from kern.zeit import LOCAL_TZ
|
||||
|
||||
NOTIFY_LOG = Path(os.environ.get("MC_NOTIFY_LOG", str(Path.home() / "mc2-notify.log")))
|
||||
PINS_FILE = Path(os.environ.get("MC_PINS_FILE", "/srv/models/mc2-pins.json"))
|
||||
LESE_MAX = 3_000_000 # Bytes vom Ende des Protokolls — reicht für Monate
|
||||
LAUF_LUECKE_S = 45 * 60 # Meldungen mit größerem Abstand gehören zu verschiedenen Läufen
|
||||
|
||||
NAMEN = {"swap": "llama-swap", "engine": "Motor (llama.cpp)", "hermes": "Hermes"}
|
||||
FREIGEGEBEN_TEXT = "Freigegeben. Der nächste Sonntags-Lauf versucht das Update erneut."
|
||||
|
||||
# So heißen die Bausteine in den Meldungen von autoupdate.sh → kurzer Name fürs Cockpit.
|
||||
_BAUSTEIN_IM_TEXT = (
|
||||
(re.compile(r"Router \(llama-swap\)"), "llama-swap"),
|
||||
(re.compile(r"Engine \(llama\.cpp\)"), "Motor"),
|
||||
(re.compile(r"Hermes(-Agent)?\b"), "Hermes"),
|
||||
)
|
||||
|
||||
# Kopfzeile eines Protokolleintrags (notify.sh): Zeitstempel, Zustellweg, Nachricht.
|
||||
_EINTRAG = re.compile(
|
||||
r"^(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}) "
|
||||
r"(?:OK telegram(?: direkt)?|QUEUED für Morgen-Digest|FALLBACK \(.*?\)): ?(.*)$")
|
||||
|
||||
_ZUSAMMENFASSUNG = re.compile(r"Wochenpflege der Box ist durch")
|
||||
_UPDATE_MELDUNG = re.compile(
|
||||
r"^(KRITISCH: )?(Router \(llama-swap\)|Engine \(llama\.cpp\)|Hermes-Agent|Hermes)[ -](aktualisiert|Update)"
|
||||
r"|^Auto-Update abgebrochen|^Sonntags-Update")
|
||||
_NEUSTART = re.compile(r"^Die Box startet jetzt neu|^Neustart (NICHT|verschoben|FEHLGESCHLAGEN)")
|
||||
# Die Morgenmeldung (07:00) wiederholt die Nachtmeldungen gesammelt — sie ist kein eigener Lauf.
|
||||
_MORGENMELDUNG = re.compile(r"^Guten Morgen, Commander\. Heute Nacht")
|
||||
|
||||
# Einordnung einer Ergebniszeile. Reihenfolge zählt: „zurückgerollt + GEPINNT“ ist ein Rückbau,
|
||||
# „Check fehlgeschlagen“ ist offen und kein Stack-Schaden.
|
||||
_EINORDNUNG = (
|
||||
("fehler", re.compile(r"kritisch", re.IGNORECASE)),
|
||||
("zurueckgerollt", re.compile(r"zurückgerollt", re.IGNORECASE)),
|
||||
("festgehalten", re.compile(r"gepinnt", re.IGNORECASE)),
|
||||
("offen", re.compile(r"wartet|unklar|check fehlgeschlagen|job-start|nicht ausgefuehrt|verschoben",
|
||||
re.IGNORECASE)),
|
||||
("fehler", re.compile(r"fehlgeschlagen|abgebrochen|ausgefallen|mit fehler", re.IGNORECASE)),
|
||||
("neustart", re.compile(r"startet jetzt neu", re.IGNORECASE)),
|
||||
("eingespielt", re.compile(r"eingespielt|aktualisiert|repariert", re.IGNORECASE)),
|
||||
("aktuell", re.compile(r"aktuell", re.IGNORECASE)),
|
||||
)
|
||||
STUFE_DER_ZEILE = {"fehler": "rot", "zurueckgerollt": "gelb", "festgehalten": "gelb", "offen": "gelb",
|
||||
"neustart": "info", "eingespielt": "gruen", "aktuell": "grau"}
|
||||
_RANG = {"rot": 4, "gelb": 3, "gruen": 2, "info": 1, "grau": 0}
|
||||
|
||||
_cache_lock = threading.Lock()
|
||||
_cache: dict = {"schluessel": None, "laeufe": []}
|
||||
|
||||
|
||||
# --- Protokoll lesen ----------------------------------------------------------------
|
||||
|
||||
def eintraege(text: str) -> list[tuple[datetime, str]]:
|
||||
"""Protokoll → (Zeitpunkt, Nachricht). Zeilen ohne Zeitstempel gehören zur Nachricht davor
|
||||
(mehrzeilige Meldungen wie die Wochen-Zusammenfassung oder die Nachrichten-Übersicht)."""
|
||||
ergebnis: list[tuple[datetime, str]] = []
|
||||
for zeile in text.splitlines():
|
||||
if m := _EINTRAG.match(zeile):
|
||||
try:
|
||||
zeit = datetime.strptime(m.group(1), "%Y-%m-%d %H:%M:%S").replace(tzinfo=LOCAL_TZ)
|
||||
except ValueError:
|
||||
continue
|
||||
ergebnis.append((zeit, m.group(2)))
|
||||
elif ergebnis:
|
||||
zeit, bisher = ergebnis[-1]
|
||||
ergebnis[-1] = (zeit, f"{bisher}\n{zeile}")
|
||||
return ergebnis
|
||||
|
||||
|
||||
def _einordnen(text: str) -> str:
|
||||
return next((art for art, muster in _EINORDNUNG if muster.search(text)), "offen")
|
||||
|
||||
|
||||
def _baustein_aus(text: str) -> str:
|
||||
return next((name for muster, name in _BAUSTEIN_IM_TEXT if muster.search(text)), "Update-Lauf")
|
||||
|
||||
|
||||
def _lesbar(text: str) -> str:
|
||||
"""Meldungstext fürs Cockpit: Fachwörter der Skripte in Alltagssprache."""
|
||||
text = re.sub(r"\s*\+\s*GEPINNT", " und festgehalten", text)
|
||||
text = re.sub(r"gepinnt, übersprungen", "festgehalten, übersprungen", text, flags=re.IGNORECASE)
|
||||
text = text.replace("GEPINNT", "festgehalten").replace("FEHLGESCHLAGEN", "fehlgeschlagen")
|
||||
text = re.sub(r"\b1 Commits\b", "1 Änderung", text)
|
||||
text = re.sub(r"\b(\d+) Commits\b", r"\1 Änderungen", text)
|
||||
# Nur der erste Satz: der Rest der Meldungen ist Begleittext für Telegram.
|
||||
return re.split(r"(?<=[.!])\s", text.strip(), maxsplit=1)[0][:240]
|
||||
|
||||
|
||||
def _zeile(baustein: str, text: str) -> dict:
|
||||
ergebnis = _einordnen(text)
|
||||
return {"baustein": baustein, "ergebnis": ergebnis, "stufe": STUFE_DER_ZEILE[ergebnis], "text": _lesbar(text)}
|
||||
|
||||
|
||||
def _lauf(gruppe: list[tuple[datetime, str]]) -> dict:
|
||||
zusammenfassung = next((t for _, t in reversed(gruppe) if _ZUSAMMENFASSUNG.search(t)), None)
|
||||
zeilen: list[dict] = []
|
||||
if zusammenfassung:
|
||||
for z in zusammenfassung.splitlines()[1:]:
|
||||
if m := re.match(r"^\s*•\s*(.+?):\s*(.+)$", z):
|
||||
zeilen.append(_zeile(_baustein_aus(m.group(1)), m.group(2)))
|
||||
for _, text in gruppe:
|
||||
erste = text.splitlines()[0] if text else ""
|
||||
if _NEUSTART.search(erste):
|
||||
zeilen.append(_zeile("Neustart", erste))
|
||||
elif erste.startswith(("Sonntags-Update", "Auto-Update abgebrochen")):
|
||||
zeilen.append(_zeile("Update-Lauf", erste))
|
||||
elif not zusammenfassung and _UPDATE_MELDUNG.search(erste):
|
||||
# Ohne Abschlussmeldung (Lauf abgebrochen) sind die Einzelmeldungen alles, was es gibt.
|
||||
zeilen.append(_zeile(_baustein_aus(erste), re.sub(r"^KRITISCH:\s*", "Kritisch: ", erste)))
|
||||
|
||||
stufe = max((z["stufe"] for z in zeilen), key=lambda s: _RANG[s], default="grau")
|
||||
arten = {z["ergebnis"] for z in zeilen}
|
||||
if stufe == "rot":
|
||||
titel = "Fehler"
|
||||
elif stufe == "gelb":
|
||||
titel = ("Zurückgerollt" if "zurueckgerollt" in arten
|
||||
else "Festgehalten" if "festgehalten" in arten else "Unvollständig")
|
||||
elif stufe == "gruen":
|
||||
titel = "Eingespielt"
|
||||
elif stufe == "info":
|
||||
titel = "Neu gestartet"
|
||||
else:
|
||||
titel = "Alles aktuell"
|
||||
start, ende = gruppe[0][0], gruppe[-1][0]
|
||||
sonntag = start.weekday() == 6 and 4 <= start.hour < 6
|
||||
return {"start": start.isoformat(), "ende": ende.isoformat(),
|
||||
"anlass": "Updates am Sonntag" if sonntag else "Update von Hand",
|
||||
"stufe": stufe, "titel": titel, "zeilen": zeilen}
|
||||
|
||||
|
||||
def laeufe_aus(text: str) -> list[dict]:
|
||||
"""Alle Update-Läufe im Protokolltext, neueste zuerst."""
|
||||
relevant = [(z, t) for z, t in eintraege(text)
|
||||
if not _MORGENMELDUNG.match(t)
|
||||
and (_ZUSAMMENFASSUNG.search(t) or _UPDATE_MELDUNG.search(t.splitlines()[0] if t else "")
|
||||
or _NEUSTART.search(t.splitlines()[0] if t else ""))]
|
||||
gruppen: list[list[tuple[datetime, str]]] = []
|
||||
for eintrag in relevant:
|
||||
if gruppen and (eintrag[0] - gruppen[-1][-1][0]).total_seconds() <= LAUF_LUECKE_S:
|
||||
gruppen[-1].append(eintrag)
|
||||
else:
|
||||
gruppen.append([eintrag])
|
||||
# Ein Neustart allein ist kein Update-Lauf (z. B. von Hand ausgelöst).
|
||||
laeufe = [_lauf(g) for g in gruppen if any(not _NEUSTART.search(t.splitlines()[0]) for _, t in g)]
|
||||
return list(reversed(laeufe))
|
||||
|
||||
|
||||
def _protokoll_ende() -> str:
|
||||
try:
|
||||
with NOTIFY_LOG.open("rb") as f:
|
||||
f.seek(0, os.SEEK_END)
|
||||
groesse = f.tell()
|
||||
f.seek(max(0, groesse - LESE_MAX))
|
||||
roh = f.read()
|
||||
except OSError:
|
||||
return ""
|
||||
text = roh.decode("utf-8", errors="replace")
|
||||
if groesse > LESE_MAX:
|
||||
text = text.split("\n", 1)[-1] # angeschnittene erste Zeile verwerfen
|
||||
return text
|
||||
|
||||
|
||||
def laeufe(anzahl: int = 8) -> list[dict]:
|
||||
"""Die letzten Update-Läufe (neueste zuerst). Neu gelesen nur, wenn das Protokoll wuchs."""
|
||||
try:
|
||||
st = NOTIFY_LOG.stat()
|
||||
schluessel = (st.st_mtime_ns, st.st_size)
|
||||
except OSError:
|
||||
return []
|
||||
with _cache_lock:
|
||||
if _cache["schluessel"] != schluessel:
|
||||
_cache["laeufe"] = laeufe_aus(_protokoll_ende())
|
||||
_cache["schluessel"] = schluessel
|
||||
return _cache["laeufe"][:anzahl]
|
||||
|
||||
|
||||
# --- Festgehaltene Bausteine (Pin-Register von autoupdate.sh) -----------------------
|
||||
|
||||
def _pins_lesen() -> dict:
|
||||
try:
|
||||
daten = json.loads(PINS_FILE.read_text(encoding="utf-8"))
|
||||
except (OSError, ValueError):
|
||||
return {}
|
||||
return daten if isinstance(daten, dict) else {}
|
||||
|
||||
|
||||
def festgehalten() -> list[dict]:
|
||||
"""Bausteine, die der Sonntags-Lauf nach einem gescheiterten Update überspringt."""
|
||||
ergebnis = []
|
||||
for baustein, eintrag in _pins_lesen().items():
|
||||
if not (isinstance(eintrag, dict) and eintrag.get("pinned")):
|
||||
continue
|
||||
datum = str(eintrag.get("datum") or "")
|
||||
m = re.fullmatch(r"\d{4}-(\d{2})-(\d{2})", datum)
|
||||
seit = f"{m.group(2)}.{m.group(1)}." if m else (datum or "?")
|
||||
ergebnis.append({"baustein": baustein, "name": NAMEN.get(baustein, baustein),
|
||||
"version": str(eintrag.get("version") or "?"), "seit": seit,
|
||||
"grund": _lesbar(str(eintrag.get("grund") or ""))})
|
||||
return ergebnis
|
||||
|
||||
|
||||
def freigeben(baustein: str) -> bool:
|
||||
"""Pin lösen (atomar schreiben). False, wenn der Baustein gar nicht festgehalten war."""
|
||||
daten = _pins_lesen()
|
||||
if baustein not in daten:
|
||||
return False
|
||||
daten.pop(baustein)
|
||||
tmp = PINS_FILE.with_suffix(".tmp")
|
||||
try:
|
||||
tmp.write_text(json.dumps(daten, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
os.replace(tmp, PINS_FILE)
|
||||
except OSError:
|
||||
return False
|
||||
return True
|
||||
@@ -1,187 +0,0 @@
|
||||
"""
|
||||
Per-Stage-Latenz-Metriken + Per-Turn-Trace für die Voice/Lucy-Pipeline.
|
||||
|
||||
Zwei Sichten auf dieselben Messungen:
|
||||
|
||||
1. **Rollende Stats** (`record_stage`/`Timer`/`get_metrics`) — avg/p50/p95/last je Stufe über die
|
||||
letzten N Messungen. Gut für Trends („Wie schnell ist STT im Schnitt?").
|
||||
2. **Per-Turn-Trace** (`TurnTrace`/`get_trace`) — jeder einzelne Chat-Turn als eigener Datensatz mit
|
||||
seinem Stufen-Breakdown. Nur so sieht man den EINEN langsamen Turn (der 30-s-Hänger), den ein
|
||||
Durchschnitt verschluckt. Das war der eigentliche Anlass (Telegram-/Voice-Hänger diagnostizieren).
|
||||
|
||||
**Was MC2 messen kann — und was nicht:** MC2 proxyt den Chat nur an Hermes (:8642). Die Stufen STT,
|
||||
Vision, Hirn-TTFT (Zeit bis zum ersten Inhalts-Token) und Generierung sind hier direkt messbar. Der
|
||||
**Mem0-Retrieve** läuft zwar in Hermes, ruft aber MC2s `/api/memory` per HTTP zurück → messbar und als
|
||||
Unter-Detail INNERHALB der Hirn-Zeit ausgewiesen (kein Doppelzählen). Die **Tool-Runden** dagegen laufen
|
||||
im Hermes-LLM-Loop ohne Callback an MC2 → für MC2 unsichtbar, sie stecken im „Generierung"-Bucket.
|
||||
|
||||
STT (davor) und Mem0-Retrieve (währenddessen) sind separate HTTP-Requests ohne Turn-ID. Auf einem
|
||||
EIN-Nutzer-Gerät genügt eine schlanke Best-Effort-Korrelation: die zuletzt gemessene STT-Dauer bzw. der
|
||||
letzte Retrieve werden global „geparkt" und vom nächsten Chat-Turn eingesammelt (mit Frist-/Reihenfolge-
|
||||
Check). Kein Turn-ID-Durchreichen durch den Lucy-Client nötig.
|
||||
|
||||
In-Memory + thread-safe (keine Datei-I/O — Latenz-Telemetrie ist transient, Restart = Reset).
|
||||
"""
|
||||
|
||||
import threading
|
||||
import time
|
||||
import uuid
|
||||
from collections import deque
|
||||
|
||||
_LOCK = threading.Lock()
|
||||
_MAX = 200
|
||||
_STAGES: dict[str, deque] = {}
|
||||
_TURNS: deque = deque(maxlen=60) # letzte N vollständige Chat-Turns (Per-Turn-Trace)
|
||||
|
||||
# Bekannte Stufen (für stabile UI-Reihenfolge); unbekannte werden trotzdem erfasst.
|
||||
STAGES = ("stt", "vision", "memory_retrieve", "chat_ttfb", "chat_first_content", "tts")
|
||||
|
||||
# Best-effort-Korrelation (Ein-Nutzer-Gerät): zuletzt gemessene STT-Dauer / Mem0-Retrieve, je
|
||||
# (ms, perf_counter-Zeitstempel). Der nächste passende Chat-Turn sammelt sie ein und leert sie.
|
||||
_PARKED: dict[str, tuple[float, float] | None] = {"stt": None, "retrieve": None}
|
||||
|
||||
|
||||
def record_stage(stage: str, ms: float) -> None:
|
||||
"""Eine gemessene Stage-Dauer (ms) verbuchen. No-op bei negativen Werten."""
|
||||
if ms is None or ms < 0:
|
||||
return
|
||||
with _LOCK:
|
||||
dq = _STAGES.get(stage)
|
||||
if dq is None:
|
||||
dq = _STAGES[stage] = deque(maxlen=_MAX)
|
||||
dq.append(float(ms))
|
||||
|
||||
|
||||
def park(kind: str, ms: float) -> None:
|
||||
"""Eine Messung, die NICHT im Chat-Request selbst passiert (STT davor, Mem0-Retrieve als
|
||||
Rückruf während), global parken, damit der nächste Chat-Turn sie einsammeln kann."""
|
||||
if ms is None or ms < 0 or kind not in _PARKED:
|
||||
return
|
||||
with _LOCK:
|
||||
_PARKED[kind] = (float(ms), time.perf_counter())
|
||||
|
||||
|
||||
def _take_stt(max_age: float = 20.0) -> float | None:
|
||||
"""Geparkte STT-Dauer einsammeln, wenn frisch (STT liegt VOR dem Turn-Start)."""
|
||||
with _LOCK:
|
||||
v = _PARKED.get("stt")
|
||||
if v and (time.perf_counter() - v[1]) <= max_age:
|
||||
_PARKED["stt"] = None
|
||||
return round(v[0], 1)
|
||||
return None
|
||||
|
||||
|
||||
def _take_retrieve(since_perf: float, max_age: float = 90.0) -> float | None:
|
||||
"""Geparkten Mem0-Retrieve einsammeln, wenn er NACH dem Turn-Start kam (Rückruf während des
|
||||
Turns) und frisch ist."""
|
||||
with _LOCK:
|
||||
v = _PARKED.get("retrieve")
|
||||
if v and v[1] >= since_perf and (time.perf_counter() - v[1]) <= max_age:
|
||||
_PARKED["retrieve"] = None
|
||||
return round(v[0], 1)
|
||||
return None
|
||||
|
||||
|
||||
class Timer:
|
||||
"""Context-Manager: misst die verstrichene Zeit und verbucht sie auf `stage`.
|
||||
Funktioniert um `await`-Aufrufe herum (enter → await → exit)."""
|
||||
|
||||
def __init__(self, stage: str) -> None:
|
||||
self.stage = stage
|
||||
self._t0 = 0.0
|
||||
|
||||
def __enter__(self) -> "Timer":
|
||||
self._t0 = time.perf_counter()
|
||||
return self
|
||||
|
||||
def __exit__(self, *exc) -> None:
|
||||
record_stage(self.stage, (time.perf_counter() - self._t0) * 1000.0)
|
||||
|
||||
|
||||
class TurnTrace:
|
||||
"""Ein Per-Turn-Trace für den Voice/Lucy-Chatpfad. In `voice.py` über die Dauer eines Chat-Turns
|
||||
gehalten; `commit()` schreibt den Datensatz in den Ringpuffer UND speist die rollenden Stats.
|
||||
|
||||
Balken-Stufen (zeitlich DISJUNKT, ergeben zusammen den Turn): stt · vision · hirn · gen.
|
||||
Unter-Detail: mem0 (Teil VON hirn, wird separat ausgewiesen, aber NICHT zum Balken addiert)."""
|
||||
|
||||
def __init__(self, session_id: str = "", kind: str = "voice") -> None:
|
||||
self.id = uuid.uuid4().hex[:8]
|
||||
self.ts = time.time()
|
||||
self.perf0 = time.perf_counter()
|
||||
self._brain0 = self.perf0 # Referenz für die Hirn-Zeit (nach Vision neu gesetzt)
|
||||
self.session_id = (session_id or "")[:24]
|
||||
self.kind = kind
|
||||
self.vision_ms: float | None = None # Bildschirm-Beschreibung (falls Bilder)
|
||||
self.hirn_ms: float | None = None # Zeit bis zum ersten Inhalts-Token (Agent + Mem0 + TTFT)
|
||||
self.had_images = False
|
||||
self.error: str | None = None
|
||||
|
||||
def note_vision(self, ms: float) -> None:
|
||||
self.vision_ms = round(ms, 1)
|
||||
record_stage("vision", ms)
|
||||
|
||||
def mark_brain_start(self) -> None:
|
||||
"""Startpunkt der Hirn-Zeit — direkt VOR dem Hermes-Request, damit die Vision-Zeit NICHT
|
||||
in die Hirn-Zeit gezählt wird (sonst Doppelzählung mit dem Vision-Segment)."""
|
||||
self._brain0 = time.perf_counter()
|
||||
|
||||
def note_ttfb(self) -> None:
|
||||
record_stage("chat_ttfb", (time.perf_counter() - self._brain0) * 1000.0) # SSE-Start (~5 ms), nur rollend
|
||||
|
||||
def note_first_content(self) -> None:
|
||||
"""Erster Inhalts-Delta = echte Hirn-Latenz (Agent-Overhead + Mem0 + LLM-TTFT)."""
|
||||
ms = (time.perf_counter() - self._brain0) * 1000.0
|
||||
self.hirn_ms = round(ms, 1)
|
||||
record_stage("chat_first_content", ms)
|
||||
|
||||
def commit(self) -> dict:
|
||||
total = (time.perf_counter() - self.perf0) * 1000.0
|
||||
stt = _take_stt() # rollend bereits in /voice/stt erfasst
|
||||
mem0 = _take_retrieve(self.perf0) # rollend bereits in /api/memory erfasst
|
||||
# Generierung = alles nach dem ersten Inhalts-Token bis Stream-Ende.
|
||||
gen = round(total - self.hirn_ms, 1) if self.hirn_ms is not None else None
|
||||
rec = {
|
||||
"id": self.id,
|
||||
"ts": round(self.ts, 3),
|
||||
"session": self.session_id,
|
||||
"kind": self.kind,
|
||||
"had_images": self.had_images,
|
||||
"stt_ms": stt,
|
||||
"vision_ms": self.vision_ms,
|
||||
"hirn_ms": self.hirn_ms,
|
||||
"gen_ms": gen if (gen is None or gen >= 0) else 0.0,
|
||||
"mem0_ms": mem0,
|
||||
"total_ms": round(total, 1),
|
||||
"error": self.error,
|
||||
}
|
||||
with _LOCK:
|
||||
_TURNS.append(rec)
|
||||
return rec
|
||||
|
||||
|
||||
def _summary(vals: list[float]) -> dict:
|
||||
if not vals:
|
||||
return {"count": 0}
|
||||
s = sorted(vals)
|
||||
n = len(s)
|
||||
return {
|
||||
"count": n,
|
||||
"avg_ms": round(sum(s) / n, 1),
|
||||
"p50_ms": round(s[n // 2], 1),
|
||||
"p95_ms": round(s[min(n - 1, int(n * 0.95))], 1),
|
||||
"last_ms": round(vals[-1], 1),
|
||||
}
|
||||
|
||||
|
||||
def get_metrics() -> dict:
|
||||
"""Rollende Zusammenfassung je Stufe."""
|
||||
with _LOCK:
|
||||
return {stage: _summary(list(dq)) for stage, dq in _STAGES.items()}
|
||||
|
||||
|
||||
def get_trace(limit: int = 20) -> list[dict]:
|
||||
"""Die letzten `limit` Chat-Turns (neueste zuerst) mit Stufen-Breakdown."""
|
||||
limit = max(1, min(int(limit or 20), _TURNS.maxlen or 60))
|
||||
with _LOCK:
|
||||
return list(_TURNS)[-limit:][::-1]
|
||||
@@ -0,0 +1,666 @@
|
||||
"""
|
||||
Wächter der Box (Box-Wart, Umbau 09/2026) — löst den alten Health-Wächter (sentry.py) ab.
|
||||
|
||||
Der alte Wächter kannte nur sieben Dienste per HTTP. Dass projekte-sync seit dem 07.09.
|
||||
stündlich scheiterte und der Nachrichten-Job jeden Morgen Werkzeugfehler warf, hat er nie
|
||||
gesehen. Dieser Wächter schaut deshalb breiter hin:
|
||||
|
||||
• Dienste systemd-Zustand der Kern-Dienste (System + User)
|
||||
• Timer-Läufe Einmal-Dienste hinter Timern (projekte-sync, Sicherung)
|
||||
• Hermes-Jobs Status der Cron-Jobs + Werkzeugfehler im letzten Lauf (errors.log)
|
||||
• Kern Engine, Hirn, Hermes, Hör-Dienst, MC2, Gateway antworten per HTTP
|
||||
• Platte Füllstand des Modell-Laufwerks
|
||||
• Updates Bausteine, die der Sonntags-Lauf nach einem Fehlschlag festhält
|
||||
|
||||
Jeder Befund wird zum Hinweis mit Stufe (rot = jetzt, gelb = bei Gelegenheit), Beginn und
|
||||
Knöpfen. Einfaches behebt er selbst (User-Entscheid 23.09.): Ein ABGESTÜRZTER Dienst
|
||||
(ActiveState=failed) wird neu gestartet, höchstens AUTO_MAX_PRO_STUNDE-mal pro Stunde. Ein
|
||||
bewusst gestoppter Dienst (inactive) bleibt aus und wird nur gemeldet. Rote Hinweise gehen
|
||||
zusätzlich an Telegram und in Lucys Briefkasten.
|
||||
|
||||
Läuft im mc2-steward (eigener Prozess, übersteht MC2-Neustarts) und ist dort der EINZIGE
|
||||
Schreiber von STORE_PATH. MC2 liest den Stand nur (lese_stand()) und führt Knopf-Aktionen
|
||||
selbst aus (fuehre_aktion_aus()); der nächste Takt sieht das Ergebnis.
|
||||
|
||||
Während eines Updates (autoupdate.sh, update-engine.sh, update-swap.sh, hermes update)
|
||||
hält er still: keine neuen Dienst-Hinweise, keine Selbstreparatur — Neustarts gehören dort
|
||||
zum Ablauf.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import sqlite3
|
||||
import subprocess
|
||||
import threading
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
import httpx
|
||||
import psutil
|
||||
from config import HERMES_API_KEY, HERMES_API_URL, HERMES_HOME, VOICE_SERVICE_URL
|
||||
from kern import partner
|
||||
from kern.einstellungen import einstellungen
|
||||
|
||||
from services import announce, llamaswap, maintenance, update_verlauf
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
ROLLE = einstellungen().rolle
|
||||
DATEN_DIR = einstellungen().daten_dir
|
||||
|
||||
# MC_SENTRY_ENABLED bleibt als Rückfall-Schalter gültig: Die bestehenden Units setzen ihn.
|
||||
ENABLED = os.environ.get("MC_WAECHTER_ENABLED", os.environ.get("MC_SENTRY_ENABLED", "1")) != "0"
|
||||
INTERVAL = int(os.environ.get("MC_WAECHTER_INTERVAL", "60")) # Sekunden zwischen Takten
|
||||
START_DELAY = int(os.environ.get("MC_WAECHTER_START_DELAY", "60")) # Dienste nach Boot setzen lassen
|
||||
FAIL_AFTER = int(os.environ.get("MC_WAECHTER_FAIL_AFTER", "2")) # Takte bis zum Hinweis
|
||||
REMIND_S = int(os.environ.get("MC_WAECHTER_REMIND_S", "21600")) # Telegram-Erinnerung: 6 h
|
||||
AUTO_MAX_PRO_STUNDE = int(os.environ.get("MC_WAECHTER_AUTO_MAX", "2"))
|
||||
DISK_ROT_PCT = float(os.environ.get("MC_WAECHTER_DISK_ROT", "90"))
|
||||
DISK_GELB_PCT = float(os.environ.get("MC_WAECHTER_DISK_GELB", "80"))
|
||||
STORE_PATH = Path(os.environ.get("MC_WAECHTER_STORE", str(DATEN_DIR / "mc2-waechter.json")))
|
||||
# „Ausblenden bis zum nächsten Lauf“ (24.09.2026): Hinweis-ID → Start des Laufs, der ausgeblendet ist.
|
||||
# Schreibt nur MC2 (Knopf), der Steward liest. Ein neuer Lauf macht den Eintrag wirkungslos.
|
||||
QUITTIERT_PATH = Path(os.environ.get("MC_WAECHTER_QUITTIERT", str(DATEN_DIR / "mc2-quittiert.json")))
|
||||
# Die andere Instanz darf einen Neustart lang schweigen (Sonntags-Update der Box, Container-Update
|
||||
# auf dem Proxmox-PC), bevor daraus ein roter Hinweis wird.
|
||||
PARTNER_TAKTE = int(os.environ.get("MC_WAECHTER_PARTNER_TAKTE", "5"))
|
||||
VERLAUF_MAX = 200
|
||||
# Trockenlauf (Probelauf neben dem echten Betrieb): prüft und führt Hinweise, meldet aber
|
||||
# nichts an Telegram/Lucy und repariert nichts selbst — das macht weiter der echte Wächter.
|
||||
TROCKEN = os.environ.get("MC_WAECHTER_TROCKEN", "") == "1"
|
||||
|
||||
# Im Steward-Modus beobachtet er auch MC2 selbst und den Gateway (wie der alte Wächter).
|
||||
WATCH_MC2 = os.environ.get("MC_SENTRY_WATCH_MC2", os.environ.get("MC_WAECHTER_WATCH_MC2", "")) == "1"
|
||||
MC2_URL = os.environ.get("MC_SENTRY_MC2_URL", "http://127.0.0.1:9001")
|
||||
GATEWAY_URL = os.environ.get("MC_SENTRY_GATEWAY_URL", "http://127.0.0.1:9010")
|
||||
|
||||
# Dienst → (System-Dienst?, wichtig?, Anzeigename). Wichtig = rot, sonst gelb.
|
||||
DIENSTE: dict[str, tuple[bool, bool, str]] = {
|
||||
"llama-swap": (True, True, "Motor (llama-swap)"),
|
||||
"hermes-gateway": (False, True, "Hermes"),
|
||||
"mc2-gateway": (False, True, "Modell-Gateway"),
|
||||
"mission-control-2": (False, True, "MC2"),
|
||||
"voice-service": (False, False, "Spracherkennung"),
|
||||
"lucy-stimme": (False, False, "Lucys Stimme"),
|
||||
"hermes-builtin-ui": (False, False, "Hermes-Dashboard"),
|
||||
}
|
||||
|
||||
# Einmal-Dienste hinter Timern: Name → (Anzeigename, wichtig?)
|
||||
TIMER_DIENSTE: dict[str, tuple[str, bool]] = {
|
||||
"projekte-sync": ("Projekte-Abgleich", False),
|
||||
"mc2-backup": ("Sicherung", True),
|
||||
"mc2-radar": ("Modell-Radar", False),
|
||||
# Rot: scheitert sie, erfährst du nichts von dem, was nachts passiert ist.
|
||||
"mc2-morgenmeldung": ("Morgenmeldung", True),
|
||||
# Seit 24.09.2026 ein eigener Timer statt Hermes-Cron (Hermes startet sich beim Update selbst neu).
|
||||
"mc2-autoupdate": ("Updates am Sonntag", True),
|
||||
}
|
||||
|
||||
HERMES_JOBS_PATH = HERMES_HOME / "cron" / "jobs.json"
|
||||
HERMES_EXEC_DB = HERMES_HOME / "cron" / "executions.db"
|
||||
HERMES_ERRORS_LOG = HERMES_HOME / "logs" / "errors.log"
|
||||
|
||||
# Prozesse, an denen ein laufendes Update zu erkennen ist.
|
||||
_UPDATE_PROZESSE = re.compile(r"autoupdate\.sh|update-engine\.sh|update-swap\.sh|hermes(\s+\S+)*\s+update\b")
|
||||
|
||||
|
||||
@dataclass
|
||||
class Befund:
|
||||
"""Ein aktuell festgestelltes Problem. Wird nach FAIL_AFTER Takten zum Hinweis."""
|
||||
id: str
|
||||
stufe: str # "rot" | "gelb"
|
||||
titel: str
|
||||
text: str
|
||||
quelle: str
|
||||
aktionen: list[dict] = field(default_factory=list)
|
||||
auto: str | None = None # Name der Selbstreparatur, falls erlaubt
|
||||
sofort: bool = False # ohne FAIL_AFTER-Wartezeit (dauerhafte Befunde)
|
||||
nach_takten: int = 0 # eigene Wartezeit statt FAIL_AFTER (0 = Standard)
|
||||
|
||||
|
||||
def _aktion(aid: str, label: str, **extra: str) -> dict:
|
||||
return {"id": aid, "label": label, **extra}
|
||||
|
||||
|
||||
# --- Rohdaten -------------------------------------------------------------------
|
||||
|
||||
def _systemctl_show(name: str, system: bool) -> dict[str, str]:
|
||||
"""Zustand einer Unit. Auf Windows (Dev) oder ohne systemd: leeres Dict (harmlos)."""
|
||||
cmd = ["systemctl"] if system else ["systemctl", "--user"]
|
||||
cmd += ["show", name, "-p",
|
||||
"LoadState,ActiveState,SubState,Result,ExecMainStatus,InactiveExitTimestamp,UnitFileState"]
|
||||
try:
|
||||
out = subprocess.run(cmd, capture_output=True, text=True, timeout=10).stdout
|
||||
except Exception:
|
||||
return {}
|
||||
return dict(line.split("=", 1) for line in out.splitlines() if "=" in line)
|
||||
|
||||
|
||||
def _journal_fehlerzeile(name: str, system: bool = False) -> str:
|
||||
"""Die aussagekräftigste Fehlerzeile aus den letzten Journal-Zeilen einer Unit."""
|
||||
cmd = ["journalctl"] + ([] if system else ["--user"]) + ["-u", name, "-n", "40", "-o", "cat", "--no-pager"]
|
||||
try:
|
||||
zeilen = subprocess.run(cmd, capture_output=True, text=True, timeout=10).stdout.splitlines()
|
||||
except Exception:
|
||||
return ""
|
||||
return waehle_fehlerzeile(zeilen)
|
||||
|
||||
|
||||
def waehle_fehlerzeile(zeilen: list[str]) -> str:
|
||||
"""Die Zeile mit der eigentlichen Ursache. Zuerst die Meldungen des Skripts selbst
|
||||
(projekte-sync markiert Fehler mit „!“), erst dann allgemeine Fehlerwörter — und nie die
|
||||
systemd-Rahmenzeilen („Failed to start …“, „Main process exited …“): Die sagen nur,
|
||||
DASS es scheiterte, nicht warum (so stand es im ersten Probelauf am 23.09.)."""
|
||||
rahmen = re.compile(r"Failed to start|Main process exited|Failed with result|Consumed .* CPU time")
|
||||
stufen = (
|
||||
re.compile(r"(^(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}\s+)?\s*!|fehlgeschlagen|ABBRUCH)", re.IGNORECASE),
|
||||
re.compile(r"(error|failed|fatal|traceback)", re.IGNORECASE),
|
||||
)
|
||||
for muster in stufen:
|
||||
for zeile in reversed(zeilen):
|
||||
if muster.search(zeile) and not rahmen.search(zeile):
|
||||
# Zeitstempel "2026-09-23 21:02:46 " des Skript-Logs abschneiden
|
||||
return re.sub(r"^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}\s+", "", zeile).strip()[:300]
|
||||
return ""
|
||||
|
||||
|
||||
def _reach(url: str, path: str, headers: dict[str, str] | None = None) -> bool:
|
||||
"""Antwortet der Dienst? < 500 genügt — ein 401 beweist, dass jemand zuhört."""
|
||||
try:
|
||||
with httpx.Client(timeout=5.0) as c:
|
||||
return c.get(f"{url}{path}", headers=headers or {}).status_code < 500
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def _update_laeuft() -> bool:
|
||||
"""Läuft gerade ein Update? Dann gehören Neustarts zum Ablauf und sind kein Befund."""
|
||||
try:
|
||||
for p in psutil.process_iter(["cmdline"]):
|
||||
cmd = " ".join(p.info.get("cmdline") or [])
|
||||
if cmd and _UPDATE_PROZESSE.search(cmd):
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
return False
|
||||
|
||||
|
||||
# --- Prüfungen ------------------------------------------------------------------
|
||||
|
||||
def dienst_zustand(name: str, system: bool = False) -> dict[str, str]:
|
||||
"""systemd-Zustand einer Unit für andere Module (Dienste-Liste der Oberfläche)."""
|
||||
return _systemctl_show(name, system)
|
||||
|
||||
|
||||
def schlaeft(zustand: dict[str, str]) -> bool:
|
||||
"""Abgeschaltet und nicht mehr für den Autostart eingetragen = bewusst schlafen gelegt
|
||||
(24.09.2026: Konsole, Spracherkennung). Das ist kein Fehler; geweckt wird per Knopf."""
|
||||
return zustand.get("ActiveState") == "inactive" and zustand.get("UnitFileState") == "disabled"
|
||||
|
||||
|
||||
def pruefe_dienste() -> list[Befund]:
|
||||
befunde: list[Befund] = []
|
||||
for name, (system, wichtig, anzeige) in DIENSTE.items():
|
||||
z = _systemctl_show(name, system)
|
||||
if not z or z.get("LoadState") in ("not-found", ""):
|
||||
continue # nicht installiert oder kein systemd (Dev)
|
||||
zustand = z.get("ActiveState", "")
|
||||
if zustand in ("active", "activating", "reloading", "deactivating"):
|
||||
continue
|
||||
if schlaeft(z):
|
||||
continue # bewusst schlafen gelegt (disable --now), z. B. die Spracherkennung bis zur App
|
||||
stufe = "rot" if wichtig else "gelb"
|
||||
aktionen = [_aktion("neustart", "Neu starten", dienst=name), _aktion("protokoll", "Protokoll", dienst=name)]
|
||||
if zustand == "failed":
|
||||
befunde.append(Befund(
|
||||
id=f"dienst:{name}", stufe=stufe, titel=f"{anzeige} ist abgestürzt",
|
||||
text=_journal_fehlerzeile(name, system) or f"Die Unit {name} steht auf „failed“.",
|
||||
quelle=name, aktionen=aktionen, auto="neustart"))
|
||||
else: # inactive: vermutlich bewusst gestoppt → nur melden, nicht eigenmächtig starten
|
||||
befunde.append(Befund(
|
||||
id=f"dienst:{name}", stufe=stufe, titel=f"{anzeige} ist gestoppt",
|
||||
text=f"Die Unit {name} läuft nicht. Sie wurde vermutlich angehalten.",
|
||||
quelle=name, aktionen=[_aktion("neustart", "Starten", dienst=name), aktionen[1]]))
|
||||
return befunde
|
||||
|
||||
|
||||
def pruefe_timer_dienste() -> list[Befund]:
|
||||
befunde: list[Befund] = []
|
||||
for name, (anzeige, wichtig) in TIMER_DIENSTE.items():
|
||||
z = _systemctl_show(name, False)
|
||||
if not z or z.get("LoadState") in ("not-found", ""):
|
||||
continue
|
||||
if z.get("ActiveState") != "failed" and z.get("Result", "success") == "success":
|
||||
continue
|
||||
grund = _journal_fehlerzeile(name) or f"Letzter Lauf endete mit Code {z.get('ExecMainStatus', '?')}."
|
||||
befunde.append(Befund(
|
||||
id=f"timer:{name}", stufe="rot" if wichtig else "gelb",
|
||||
titel=f"{anzeige} scheitert beim letzten Lauf", text=grund, quelle=name,
|
||||
aktionen=[_aktion("protokoll", "Protokoll", dienst=name),
|
||||
_aktion("neustart", "Jetzt erneut laufen lassen", dienst=name)],
|
||||
sofort=True))
|
||||
return befunde
|
||||
|
||||
|
||||
def _hermes_jobs() -> list[dict]:
|
||||
try:
|
||||
daten = json.loads(HERMES_JOBS_PATH.read_text(encoding="utf-8"))
|
||||
except (OSError, ValueError):
|
||||
return []
|
||||
jobs = daten.get("jobs", daten) if isinstance(daten, dict) else daten
|
||||
if isinstance(jobs, dict):
|
||||
jobs = list(jobs.values())
|
||||
return [j for j in jobs if isinstance(j, dict)]
|
||||
|
||||
|
||||
def _letzter_lauf(job_id: str) -> dict | None:
|
||||
"""Letzter Lauf eines Jobs aus executions.db (nur lesend geöffnet)."""
|
||||
if not HERMES_EXEC_DB.exists():
|
||||
return None
|
||||
try:
|
||||
con = sqlite3.connect(f"file:{HERMES_EXEC_DB}?mode=ro", uri=True, timeout=2)
|
||||
try:
|
||||
row = con.execute(
|
||||
"select status, started_at, finished_at, error from executions "
|
||||
"where job_id=? order by started_at desc limit 1", (job_id,)).fetchone()
|
||||
finally:
|
||||
con.close()
|
||||
except sqlite3.Error:
|
||||
return None
|
||||
if not row:
|
||||
return None
|
||||
return {"status": row[0], "started_at": row[1], "finished_at": row[2], "error": row[3]}
|
||||
|
||||
|
||||
def _gelesene_seite(zeilen: list[str], i: int) -> bool:
|
||||
"""Hermes hängt an jedes web_extract-Ergebnis ein leeres "error"-Feld und hält ein Ergebnis für
|
||||
gescheitert, sobald '"error"' in seinen ersten 500 Zeichen steht — bei kurzen Seiten also auch
|
||||
Erfolge (agent/display.py, 24.09. gesehen). Ein mehrzeiliges Ergebnis mit "results" ist eine
|
||||
gelesene Seite, kein Werkzeugfehler; echte Fehler kommen als {"success": false, "error": …}."""
|
||||
return (zeilen[i].rstrip().endswith("{") and i + 1 < len(zeilen)
|
||||
and zeilen[i + 1].lstrip().startswith('"results"'))
|
||||
|
||||
|
||||
def werkzeugfehler_im_lauf(zeilen: list[str], job_id: str, start_iso: str) -> tuple[int, str]:
|
||||
"""Zählt 'Tool X returned error'-Zeilen eines Laufs in errors.log. Hermes markiert jede
|
||||
Zeile mit [cron_<job>_<JJJJMMTT>_<HHMMSS>]; der Tag des Laufstarts reicht zur Zuordnung."""
|
||||
try:
|
||||
tag = datetime.fromisoformat(start_iso).strftime("%Y%m%d")
|
||||
except (TypeError, ValueError):
|
||||
return 0, ""
|
||||
marke = f"[cron_{job_id}_{tag}_"
|
||||
treffer = [z for i, z in enumerate(zeilen)
|
||||
if marke in z and "returned error" in z and not _gelesene_seite(zeilen, i)]
|
||||
if not treffer:
|
||||
return 0, ""
|
||||
m = re.search(r"Tool (\S+) returned error[^:]*:\s*(.*)$", treffer[0])
|
||||
beispiel = ""
|
||||
if m:
|
||||
beispiel = f"{m.group(1)}: {m.group(2)}"
|
||||
if (fm := re.search(r'"error":\s*"([^"]+)', m.group(2))):
|
||||
beispiel = f"{m.group(1)}: {fm.group(1)}"
|
||||
return len(treffer), beispiel[:220]
|
||||
|
||||
|
||||
def _errors_log_ende(max_bytes: int = 400_000) -> list[str]:
|
||||
try:
|
||||
with HERMES_ERRORS_LOG.open("rb") as f:
|
||||
f.seek(0, os.SEEK_END)
|
||||
f.seek(max(0, f.tell() - max_bytes))
|
||||
return f.read().decode("utf-8", "replace").splitlines()
|
||||
except OSError:
|
||||
return []
|
||||
|
||||
|
||||
def _quittiert() -> dict[str, str]:
|
||||
try:
|
||||
daten = json.loads(QUITTIERT_PATH.read_text(encoding="utf-8"))
|
||||
return daten if isinstance(daten, dict) else {}
|
||||
except (OSError, ValueError):
|
||||
return {}
|
||||
|
||||
|
||||
def quittieren(hinweis_id: str, lauf: str) -> None:
|
||||
"""Hinweis bis zum nächsten Lauf ausblenden (atomar schreiben, alte Einträge begrenzen)."""
|
||||
daten = _quittiert()
|
||||
daten[hinweis_id] = lauf
|
||||
daten = dict(list(daten.items())[-50:])
|
||||
tmp = QUITTIERT_PATH.with_suffix(".json.tmp")
|
||||
tmp.write_text(json.dumps(daten, ensure_ascii=False, indent=1), encoding="utf-8")
|
||||
os.replace(tmp, QUITTIERT_PATH)
|
||||
|
||||
|
||||
def pruefe_hermes_jobs() -> list[Befund]:
|
||||
befunde: list[Befund] = []
|
||||
jobs = _hermes_jobs()
|
||||
if not jobs:
|
||||
return befunde
|
||||
log_zeilen = _errors_log_ende()
|
||||
jetzt = time.time()
|
||||
for job in jobs:
|
||||
if not job.get("enabled", True):
|
||||
continue
|
||||
jid, name = str(job.get("id", "")), str(job.get("name", "Job"))
|
||||
wichtig = "update" in name.lower()
|
||||
status = job.get("last_status")
|
||||
if status not in (None, "ok", "success") or int(job.get("failure_streak") or 0) > 0:
|
||||
befunde.append(Befund(
|
||||
id=f"job:{jid}", stufe="rot" if wichtig else "gelb",
|
||||
titel=f"Job „{name}“ ist fehlgeschlagen",
|
||||
text=str(job.get("last_error") or "Der letzte Lauf endete mit einem Fehler.")[:300],
|
||||
quelle=f"hermes-cron:{jid}",
|
||||
aktionen=[_aktion("job-wiederholen", "Erneut ausführen", job=jid),
|
||||
_aktion("protokoll", "Protokoll", job=jid)],
|
||||
sofort=True))
|
||||
continue
|
||||
if job.get("no_agent"):
|
||||
continue # Skript-Jobs haben keine Werkzeuge
|
||||
lauf = _letzter_lauf(jid)
|
||||
if not lauf or not lauf.get("started_at"):
|
||||
continue
|
||||
try:
|
||||
alter = jetzt - datetime.fromisoformat(lauf["started_at"]).timestamp()
|
||||
except ValueError:
|
||||
continue
|
||||
if alter > 26 * 3600:
|
||||
continue # nur der aktuelle Lauf zählt
|
||||
anzahl, beispiel = werkzeugfehler_im_lauf(log_zeilen, jid, lauf["started_at"])
|
||||
befund_id = f"job:{jid}:werkzeug"
|
||||
if anzahl and _quittiert().get(befund_id) != lauf["started_at"]:
|
||||
befunde.append(Befund(
|
||||
id=befund_id, stufe="gelb",
|
||||
titel=f"Job „{name}“: {anzahl} Werkzeugfehler im letzten Lauf",
|
||||
text=beispiel or "Einzelne Werkzeuge meldeten Fehler, der Lauf selbst kam durch.",
|
||||
quelle=f"hermes-cron:{jid}",
|
||||
aktionen=[_aktion("protokoll", "Protokoll", job=jid),
|
||||
_aktion("ausblenden", "Ausblenden bis zum nächsten Lauf", lauf=lauf["started_at"])],
|
||||
sofort=True))
|
||||
return befunde
|
||||
|
||||
|
||||
def _hermes_kopf() -> dict[str, str]:
|
||||
return {"Authorization": f"Bearer {HERMES_API_KEY}"} if HERMES_API_KEY else {}
|
||||
|
||||
|
||||
def pruefe_kern() -> list[Befund]:
|
||||
"""HTTP-Proben: läuft der Dienst UND antwortet er? (Der Dienst-Check sieht nur systemd.)"""
|
||||
proben: list[tuple[str, str, str, bool]] = [] # (id, Titel, Text, ok)
|
||||
engine_ok = llamaswap.engine_reachable()
|
||||
proben.append(("kern:engine", "Der Motor antwortet nicht",
|
||||
"llama-swap reagiert nicht. Ohne ihn laufen keine Modelle.", engine_ok))
|
||||
if engine_ok:
|
||||
st = llamaswap.brain_status()
|
||||
hirn_ok = bool(st.get("ready")) or bool(llamaswap.get_running_models())
|
||||
proben.append(("kern:hirn", "Lucys Hirn ist nicht geladen",
|
||||
"Das Hirn-Modell lädt nicht. Der Re-Warm-Wächter versucht es weiter.", hirn_ok))
|
||||
proben.append(("kern:hermes", "Hermes antwortet nicht",
|
||||
"Der Agent-Dienst reagiert nicht. Telegram und Lucys Werkzeuge gehen gerade nicht.",
|
||||
_reach(HERMES_API_URL, "/v1/models", _hermes_kopf())))
|
||||
# Die Spracherkennung schläft seit 24.09.2026 (bis zur Android-App) — dann ist Schweigen kein Fehler.
|
||||
if not schlaeft(_systemctl_show("voice-service", False)):
|
||||
proben.append(("kern:hoeren", "Die Spracherkennung antwortet nicht",
|
||||
"Spracheingabe über Lucy-Desktop kann hängen.", _reach(VOICE_SERVICE_URL, "/health")))
|
||||
if WATCH_MC2:
|
||||
proben.append(("kern:mc2", "MC2 antwortet nicht",
|
||||
"Die Oberfläche und Lucys Sprach-Schnittstellen hängen.", _reach(MC2_URL, "/api/health")))
|
||||
proben.append(("kern:gateway", "Der Modell-Gateway antwortet nicht",
|
||||
"Anfragen von Lucy und OpenChamber an die Modelle hängen.", _reach(GATEWAY_URL, "/gw/health")))
|
||||
return [Befund(id=i, stufe="rot", titel=t, text=x, quelle=i.split(":", 1)[1])
|
||||
for (i, t, x, ok) in proben if not ok]
|
||||
|
||||
|
||||
def pruefe_platte() -> list[Befund]:
|
||||
try:
|
||||
pct = psutil.disk_usage(str(DATEN_DIR) if DATEN_DIR.exists() else os.getcwd()).percent
|
||||
except Exception:
|
||||
return []
|
||||
if pct >= DISK_ROT_PCT:
|
||||
stufe = "rot"
|
||||
elif pct >= DISK_GELB_PCT:
|
||||
stufe = "gelb"
|
||||
else:
|
||||
return []
|
||||
return [Befund(id="platte", stufe=stufe, titel=f"Die Platte ist zu {pct:.0f} % voll",
|
||||
text="Es wird eng für Modelle und Sicherungen. Alte Modelldateien löschen hilft am meisten.",
|
||||
quelle="platte", sofort=True)]
|
||||
|
||||
|
||||
def pruefe_festgehalten() -> list[Befund]:
|
||||
"""Festgehaltene Bausteine (Pin-Register von autoupdate.sh). Vom 06. bis 17.09.2026 hielt
|
||||
die Box Motor und Hermes fest, ohne dass es jemand sah — elf Tage ohne Updates."""
|
||||
return [Befund(id=f"pin:{p['baustein']}", stufe="gelb",
|
||||
titel=f"{p['name']} bekommt keine Updates mehr",
|
||||
text=(f"Seit {p['seit']} auf {p['version']} festgehalten: {p['grund']}. "
|
||||
"Der Sonntags-Lauf überspringt ihn, bis du ihn freigibst."),
|
||||
quelle="updates", sofort=True,
|
||||
aktionen=[_aktion("freigeben", "Freigeben", baustein=p["baustein"])])
|
||||
for p in update_verlauf.festgehalten()]
|
||||
|
||||
|
||||
def pruefe_partner() -> list[Befund]:
|
||||
"""Die andere Instanz (User-Entscheid 24.09.2026): Box und Homelab prüfen sich gegenseitig —
|
||||
fällt eine aus, meldet die andere es."""
|
||||
stand = partner.status(frisch=True)
|
||||
if not stand.get("eingerichtet") or stand.get("erreichbar"):
|
||||
return []
|
||||
grund = stand.get("fehler") or "nicht erreichbar"
|
||||
if ROLLE == "homelab":
|
||||
text = f"Die KI-Box ({stand['url']}) ist {grund}. Lucy, die Modelle und der Box-Wart sind so lange weg."
|
||||
else:
|
||||
text = (f"Der Homelab-Teil auf dem Proxmox-PC ({stand['url']}) ist {grund}. "
|
||||
"Updates im Homelab lassen sich so lange nicht anstoßen.")
|
||||
return [Befund(id="partner", stufe="rot", titel=f"{stand['name']} antwortet nicht", text=text,
|
||||
quelle="partner", nach_takten=PARTNER_TAKTE)]
|
||||
|
||||
|
||||
# Was jede Rolle prüft. Die KI-Box kennt Dienste, Timer, Hermes und ihren Kern; der Homelab-Teil
|
||||
# bekommt seine Prüfungen mit den Homelab-Bausteinen.
|
||||
PRUEFUNGEN = {
|
||||
"box": (pruefe_dienste, pruefe_timer_dienste, pruefe_hermes_jobs, pruefe_kern, pruefe_platte,
|
||||
pruefe_festgehalten, pruefe_partner),
|
||||
"homelab": (pruefe_platte, pruefe_partner),
|
||||
}[ROLLE]
|
||||
BETREFF_PROBLEM, BETREFF_OK = {"box": ("[Box-Problem]", "[Box wieder ok]"),
|
||||
"homelab": ("[Homelab-Problem]", "[Homelab wieder ok]")}[ROLLE]
|
||||
|
||||
|
||||
# --- Zustand, Takt, Selbstreparatur -----------------------------------------------
|
||||
|
||||
_lock = threading.Lock()
|
||||
_stand: dict = {"hinweise": {}, "kandidaten": {}, "verlauf": [], "auto": {}, "stand": 0.0}
|
||||
|
||||
|
||||
def _lade() -> None:
|
||||
global _stand
|
||||
try:
|
||||
daten = json.loads(STORE_PATH.read_text(encoding="utf-8"))
|
||||
if isinstance(daten.get("hinweise"), dict):
|
||||
_stand = {**_stand, **daten}
|
||||
except (OSError, ValueError):
|
||||
pass
|
||||
|
||||
|
||||
def _speichere() -> None:
|
||||
try:
|
||||
tmp = STORE_PATH.with_suffix(".tmp")
|
||||
tmp.write_text(json.dumps(_stand, ensure_ascii=False), encoding="utf-8")
|
||||
tmp.replace(STORE_PATH)
|
||||
except OSError:
|
||||
log.warning("waechter: Stand %s nicht schreibbar", STORE_PATH, exc_info=True)
|
||||
|
||||
|
||||
def _verlauf(art: str, hinweis_id: str, text: str) -> None:
|
||||
_stand["verlauf"].append({"ts": time.time(), "art": art, "id": hinweis_id, "text": text})
|
||||
del _stand["verlauf"][:-VERLAUF_MAX]
|
||||
|
||||
|
||||
def _telegram(betreff: str, text: str) -> None:
|
||||
if TROCKEN:
|
||||
log.info("waechter (trocken): würde melden %s %s", betreff, text)
|
||||
return
|
||||
announce.add(text, subject=betreff, source="waechter")
|
||||
announce.notify_telegram(betreff, text + " (Diese Meldung kam auch an Lucy.)")
|
||||
|
||||
|
||||
def _auto_erlaubt(schluessel: str, jetzt: float) -> bool:
|
||||
versuche = [t for t in _stand["auto"].get(schluessel, []) if jetzt - t < 3600]
|
||||
_stand["auto"][schluessel] = versuche
|
||||
return len(versuche) < AUTO_MAX_PRO_STUNDE
|
||||
|
||||
|
||||
def _selbst_beheben(b: Befund, jetzt: float) -> None:
|
||||
if b.auto != "neustart" or not _auto_erlaubt(b.id, jetzt):
|
||||
return
|
||||
if TROCKEN:
|
||||
_verlauf("auto", b.id, f"{b.titel}: würde automatisch neu starten (Trockenlauf)")
|
||||
_stand["auto"][b.id].append(jetzt)
|
||||
return
|
||||
ergebnis = maintenance.restart_service(b.quelle)
|
||||
_stand["auto"][b.id].append(jetzt)
|
||||
ok = bool(ergebnis.get("ok", ergebnis.get("returncode", 1) == 0))
|
||||
_verlauf("auto", b.id, f"{b.titel}: automatisch neu gestartet" + ("" if ok else " (ohne Erfolg)"))
|
||||
log.warning("waechter: %s → Neustart von %s (%s)", b.id, b.quelle, "ok" if ok else "fehlgeschlagen")
|
||||
|
||||
|
||||
def takt() -> None:
|
||||
"""Ein Prüfdurchlauf: Befunde sammeln, Hinweise führen, Einfaches selbst beheben."""
|
||||
jetzt = time.time()
|
||||
update = _update_laeuft()
|
||||
befunde: list[Befund] = []
|
||||
for pruefung in PRUEFUNGEN:
|
||||
if update and pruefung in (pruefe_dienste, pruefe_kern):
|
||||
continue # während eines Updates sind Neustarts normal
|
||||
try:
|
||||
befunde += pruefung()
|
||||
except Exception as exc:
|
||||
# Bis 24.09.2026 stand das nur im Debug-Log: Die Prüfung war dann still aus, und das
|
||||
# Cockpit blieb grün (z. B. wenn Hermes das Format seiner Job-Liste ändert).
|
||||
log.warning("waechter: %s fehlgeschlagen", pruefung.__name__, exc_info=True)
|
||||
befunde.append(Befund(
|
||||
id=f"pruefung:{pruefung.__name__}", stufe="gelb",
|
||||
titel="Eine Prüfung des Wächters lief nicht",
|
||||
text=f"{pruefung.__name__}: {exc.__class__.__name__}: {exc}"[:240],
|
||||
quelle="mc2-steward"))
|
||||
|
||||
with _lock:
|
||||
hinweise: dict = _stand["hinweise"]
|
||||
kandidaten: dict = _stand["kandidaten"]
|
||||
aktuell = {b.id for b in befunde}
|
||||
|
||||
for b in befunde:
|
||||
k = kandidaten.setdefault(b.id, {"takte": 0, "erstmals": jetzt})
|
||||
k["takte"] += 1
|
||||
if not update and b.auto:
|
||||
_selbst_beheben(b, jetzt)
|
||||
if not (b.sofort or k["takte"] >= (b.nach_takten or FAIL_AFTER)):
|
||||
continue
|
||||
h = hinweise.get(b.id)
|
||||
neu = h is None
|
||||
h = h or {"id": b.id, "seit": k["erstmals"], "gemeldet": 0.0}
|
||||
h.update(stufe=b.stufe, titel=b.titel, text=b.text, quelle=b.quelle,
|
||||
aktionen=b.aktionen, zuletzt=jetzt, takte=k["takte"])
|
||||
hinweise[b.id] = h
|
||||
if neu:
|
||||
_verlauf("neu", b.id, b.titel)
|
||||
if b.stufe == "rot" and (neu or jetzt - h.get("gemeldet", 0.0) >= REMIND_S):
|
||||
vorsatz = "" if neu else "Immer noch: "
|
||||
_telegram(BETREFF_PROBLEM, f"{vorsatz}{b.titel}. {b.text}")
|
||||
h["gemeldet"] = jetzt
|
||||
|
||||
# Nicht mehr festgestellt → erledigt. Während eines Updates bleiben Dienst- und
|
||||
# Kern-Hinweise stehen (sie wurden in diesem Takt gar nicht geprüft).
|
||||
for hid in list(hinweise):
|
||||
if hid in aktuell:
|
||||
continue
|
||||
if update and hid.startswith(("dienst:", "kern:")):
|
||||
continue
|
||||
h = hinweise.pop(hid)
|
||||
_verlauf("erledigt", hid, h.get("titel", hid))
|
||||
if h.get("stufe") == "rot" and h.get("gemeldet"):
|
||||
_telegram(BETREFF_OK, f"Erledigt: {h.get('titel', hid)}.")
|
||||
for kid in list(kandidaten):
|
||||
if kid not in aktuell and not (update and kid.startswith(("dienst:", "kern:"))):
|
||||
kandidaten.pop(kid)
|
||||
|
||||
_stand["stand"] = jetzt
|
||||
_stand["update_laeuft"] = update
|
||||
_speichere()
|
||||
|
||||
|
||||
async def waechter_loop() -> None:
|
||||
"""Endlos-Schleife im mc2-steward."""
|
||||
_lade()
|
||||
await asyncio.sleep(START_DELAY)
|
||||
log.info("waechter: aktiv (Takt %ss, Hinweis nach %s Takten, Selbstreparatur max. %s/h)",
|
||||
INTERVAL, FAIL_AFTER, AUTO_MAX_PRO_STUNDE)
|
||||
while True:
|
||||
try:
|
||||
await asyncio.to_thread(takt)
|
||||
except Exception:
|
||||
log.warning("waechter: Takt fehlgeschlagen", exc_info=True)
|
||||
await asyncio.sleep(INTERVAL)
|
||||
|
||||
|
||||
# --- Lesen und Knöpfe (MC2-Prozess) ------------------------------------------------
|
||||
|
||||
def lese_stand() -> dict:
|
||||
"""Stand für die Oberfläche: Hinweise (rot zuerst, dann nach Beginn) + Verlauf."""
|
||||
try:
|
||||
daten = json.loads(STORE_PATH.read_text(encoding="utf-8"))
|
||||
except (OSError, ValueError):
|
||||
daten = {}
|
||||
hinweise = sorted((daten.get("hinweise") or {}).values(),
|
||||
key=lambda h: (h.get("stufe") != "rot", h.get("seit", 0)))
|
||||
for h in hinweise:
|
||||
h.pop("gemeldet", None)
|
||||
return {
|
||||
"hinweise": hinweise,
|
||||
"verlauf": list(reversed((daten.get("verlauf") or [])[-50:])),
|
||||
"stand": daten.get("stand"),
|
||||
"update_laeuft": bool(daten.get("update_laeuft")),
|
||||
"aktiv": bool(daten),
|
||||
}
|
||||
|
||||
|
||||
def _job_protokoll(job_id: str) -> dict:
|
||||
marke = f"[cron_{job_id}_"
|
||||
zeilen = [z for z in _errors_log_ende() if marke in z][-60:]
|
||||
return {"ok": True, "text": "\n".join(zeilen) or "Keine Fehlerzeilen zu diesem Job gefunden."}
|
||||
|
||||
|
||||
def fuehre_aktion_aus(hinweis_id: str, aktion_id: str) -> dict:
|
||||
"""Knopf eines Hinweises ausführen. Nur Aktionen, die der Hinweis selbst anbietet."""
|
||||
stand = lese_stand()
|
||||
hinweis = next((h for h in stand["hinweise"] if h.get("id") == hinweis_id), None)
|
||||
if hinweis is None:
|
||||
return {"ok": False, "detail": "Diesen Hinweis gibt es nicht mehr."}
|
||||
aktion = next((a for a in hinweis.get("aktionen", []) if a.get("id") == aktion_id), None)
|
||||
if aktion is None:
|
||||
return {"ok": False, "detail": "Diese Aktion gehört nicht zu dem Hinweis."}
|
||||
if aktion_id == "neustart":
|
||||
return maintenance.restart_service(aktion["dienst"])
|
||||
if aktion_id == "protokoll":
|
||||
if aktion.get("job"):
|
||||
return _job_protokoll(aktion["job"])
|
||||
return maintenance.logs(aktion["dienst"], lines=120)
|
||||
if aktion_id == "freigeben":
|
||||
if not update_verlauf.freigeben(aktion["baustein"]):
|
||||
return {"ok": False, "detail": "Der Baustein war schon freigegeben."}
|
||||
return {"ok": True, "text": update_verlauf.FREIGEGEBEN_TEXT}
|
||||
if aktion_id == "ausblenden":
|
||||
quittieren(hinweis_id, str(aktion.get("lauf", "")))
|
||||
return {"ok": True, "text": "Ausgeblendet. Der Hinweis verschwindet in spätestens einer Minute "
|
||||
"und kommt nur wieder, wenn der nächste Lauf erneut Fehler hat."}
|
||||
if aktion_id == "job-wiederholen":
|
||||
try:
|
||||
r = subprocess.run(["hermes", "cron", "run", aktion["job"]],
|
||||
capture_output=True, text=True, timeout=30)
|
||||
return {"ok": r.returncode == 0,
|
||||
"text": (r.stdout or r.stderr).strip()[:500] or "Job läuft beim nächsten Takt."}
|
||||
except Exception as e:
|
||||
return {"ok": False, "detail": f"hermes cron run ging nicht: {e}"}
|
||||
return {"ok": False, "detail": f"Unbekannte Aktion {aktion_id}."}
|
||||
@@ -0,0 +1,99 @@
|
||||
"""
|
||||
Flugplan der Box (Box-Wart, Umbau 09/2026): was lief heute, was kommt als Nächstes.
|
||||
|
||||
Quellen: die Hermes-Cron-Jobs (jobs.json: nächster/letzter Lauf, Status), die systemd-
|
||||
Timer (Sicherung) und die Modell-Nutzung (NerdQuiz-Nachtlauf, erkannt an den nächtlichen
|
||||
Anfragen). Alles nur lesend.
|
||||
"""
|
||||
|
||||
import json
|
||||
import subprocess
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from kern.zeit import LOCAL_TZ
|
||||
|
||||
from services import modell_nutzung, waechter
|
||||
|
||||
TIMER = {"mc2-backup.timer": "Sicherung", "mc2-radar.timer": "Modell-Radar",
|
||||
"mc2-morgenmeldung.timer": "Morgenmeldung", "mc2-autoupdate.timer": "Updates am Sonntag"}
|
||||
|
||||
|
||||
def _parse_iso(wert) -> datetime | None:
|
||||
"""ISO-Zeit; ohne Offset gilt MC_LOCAL_TZ (Projektregel, nie raten)."""
|
||||
if not wert:
|
||||
return None
|
||||
try:
|
||||
dt = datetime.fromisoformat(str(wert).replace("Z", "+00:00"))
|
||||
except ValueError:
|
||||
return None
|
||||
return dt if dt.tzinfo else dt.replace(tzinfo=LOCAL_TZ)
|
||||
|
||||
|
||||
def _timer() -> list[dict]:
|
||||
"""systemd-Timer als JSON (systemd ≥ 256). Ohne systemd: leer."""
|
||||
try:
|
||||
out = subprocess.run(["systemctl", "--user", "list-timers", "--all", "--no-pager", "--output=json"],
|
||||
capture_output=True, text=True, timeout=10).stdout
|
||||
daten = json.loads(out or "[]")
|
||||
except Exception:
|
||||
return []
|
||||
ergebnis = []
|
||||
for t in daten if isinstance(daten, list) else []:
|
||||
name = TIMER.get(t.get("unit", ""))
|
||||
if not name:
|
||||
continue
|
||||
def _us(v):
|
||||
try:
|
||||
return datetime.fromtimestamp(int(v) / 1_000_000, LOCAL_TZ) if v else None
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
ergebnis.append({"name": name, "naechster": _us(t.get("next")), "letzter": _us(t.get("last"))})
|
||||
return ergebnis
|
||||
|
||||
|
||||
def _nerdquiz_nacht(nutzung: dict) -> dict | None:
|
||||
"""Der NerdQuiz-Nachtlauf, erkannt an Anfragen zwischen 01 und 06 Uhr in den letzten 24 h."""
|
||||
stunden = [s for s in nutzung.get("letzte_24h", []) if "01" <= s["stunde"] <= "06"]
|
||||
anfragen = sum(v for s in stunden for n, v in s["je_absender"].items() if "NerdQuiz" in n)
|
||||
if not anfragen:
|
||||
return None
|
||||
erste = next((s["stunde"] for s in stunden
|
||||
if any("NerdQuiz" in n for n in s["je_absender"])), "03")
|
||||
heute = datetime.now(LOCAL_TZ).replace(hour=int(erste), minute=0, second=0, microsecond=0)
|
||||
return {"zeit": heute, "titel": "NerdQuiz-Nachtlauf", "text": f"{anfragen} Anfragen ans Hirn",
|
||||
"status": "erledigt"}
|
||||
|
||||
|
||||
def flugplan() -> dict:
|
||||
"""Heute Gelaufenes und die nächsten geplanten Läufe, je zeitlich sortiert."""
|
||||
jetzt = datetime.now(LOCAL_TZ)
|
||||
heute_start = jetzt.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
gelaufen: list[dict] = []
|
||||
geplant: list[dict] = []
|
||||
|
||||
for job in waechter._hermes_jobs():
|
||||
if not job.get("enabled", True):
|
||||
continue
|
||||
name = str(job.get("name", "Job"))
|
||||
letzter, naechster = _parse_iso(job.get("last_run_at")), _parse_iso(job.get("next_run_at"))
|
||||
if letzter and letzter >= heute_start:
|
||||
ok = job.get("last_status") in (None, "ok", "success")
|
||||
gelaufen.append({"zeit": letzter, "titel": name, "text": "",
|
||||
"status": "erledigt" if ok else "fehler"})
|
||||
if naechster and naechster > jetzt:
|
||||
geplant.append({"zeit": naechster, "titel": name, "text": "", "status": "geplant"})
|
||||
|
||||
for t in _timer():
|
||||
if t["letzter"] and t["letzter"] >= heute_start:
|
||||
gelaufen.append({"zeit": t["letzter"], "titel": t["name"], "text": "", "status": "erledigt"})
|
||||
if t["naechster"] and t["naechster"] > jetzt:
|
||||
geplant.append({"zeit": t["naechster"], "titel": t["name"], "text": "", "status": "geplant"})
|
||||
|
||||
if (nq := _nerdquiz_nacht(modell_nutzung.nutzung())):
|
||||
gelaufen.append(nq)
|
||||
|
||||
def _aus(eintraege: list[dict]) -> list[dict]:
|
||||
return [{**e, "zeit": e["zeit"].isoformat()} for e in sorted(eintraege, key=lambda e: e["zeit"])]
|
||||
|
||||
grenze = jetzt + timedelta(days=8)
|
||||
return {"gelaufen": _aus(gelaufen), "geplant": _aus([g for g in geplant if g["zeit"] <= grenze])}
|
||||
+15
-15
@@ -1,24 +1,27 @@
|
||||
"""
|
||||
MC2-Steward — die Wächter-Loops als EIGENER Prozess (UMBAU v3, P2).
|
||||
|
||||
Bisher hingen Re-Warm-Wächter, Health-Wächter (sentry) und Mem0-Auto-Dedupe am
|
||||
Bisher hingen Re-Warm-Wächter und Health-Wächter am
|
||||
Lebenszyklus des Steuerpult-Webservers (app.py-Lifespan): jeder MC2-Neustart riss
|
||||
den Wächtern Timing und Flanken-Gedächtnis weg — und ein TOTES Steuerpult konnte
|
||||
sich prinzipbedingt nicht selbst melden. Hier laufen DIESELBEN Loops (unveränderte
|
||||
Module) als eigener Mini-Dienst (mc2-steward.service, Restart=always):
|
||||
sich prinzipbedingt nicht selbst melden. Hier laufen die Loops als eigener
|
||||
Mini-Dienst (mc2-steward.service, Restart=always):
|
||||
|
||||
• warmer.rewarm_loop — Warm-Set nachladen (+ Config-Watch ersetzt den
|
||||
In-Process-Nudge aus llamaswap.write_config)
|
||||
• sentry.sentry_loop — Health-Flanken → Briefkasten (HTTP an MC2) + Telegram;
|
||||
• waechter.waechter_loop — Box-Wart-Wächter (seit 09/2026, löst sentry ab): Dienste,
|
||||
Timer, Hermes-Jobs, Kern, Platte → Hinweise + Selbstreparatur;
|
||||
beobachtet im Steward-Modus AUCH MC2 selbst + mc2-gateway
|
||||
• memory.auto_dedupe_loop — Gedächtnis-Dubletten (HTTP an den Mem0-Sidecar)
|
||||
|
||||
Das dritte Loop (Gedächtnis-Dubletten gegen den Sidecar auf :8765) ist mit dessen Ablösung
|
||||
am 07.08.2026 entfallen — Hermes führt sein Gedächtnis selbst (docs/wissen/VERDIKTE.md).
|
||||
|
||||
BEWUSST NICHT hier: reminders_loop — der teilt sich Datei UND CRUD-Pfade mit dem
|
||||
/api/reminders-Router (Zwei-Schreiber-Risiko auf mc2-reminders.json); er bleibt im
|
||||
Steuerpult. Der Briefkasten-Store gehört weiter EXKLUSIV dem MC2-Prozess — dieser
|
||||
Prozess liefert Meldungen per HTTP ab (announce.py, MC_ANNOUNCE_HTTP).
|
||||
|
||||
Die Loop-Schalter (MC_REWARM_ENABLED / MC_SENTRY_ENABLED / MC_MEM_DEDUPE_ENABLED)
|
||||
Die Loop-Schalter (MC_REWARM_ENABLED / MC_SENTRY_ENABLED)
|
||||
stehen in der MC2-Unit auf 0 und hier auf Default 1 — reiner Konfig-Split, kein
|
||||
Verhaltens-Code im Steuerpult angefasst. Zeilen dort entfernen = Rollback.
|
||||
"""
|
||||
@@ -28,8 +31,8 @@ import logging
|
||||
import os
|
||||
|
||||
from config import CONFIG_PATH
|
||||
from services import memory as memory_svc
|
||||
from services import sentry, warmer
|
||||
from kern.einstellungen import einstellungen
|
||||
from services import waechter, warmer
|
||||
|
||||
logging.basicConfig(
|
||||
level=os.environ.get("MC_LOG_LEVEL", "INFO").upper(),
|
||||
@@ -59,17 +62,14 @@ async def config_watch() -> None:
|
||||
|
||||
async def main() -> None:
|
||||
tasks: list[asyncio.Task] = []
|
||||
if warmer.ENABLED:
|
||||
# Warm-Set und llama-swap gibt es nur auf der KI-Box; der Homelab-Teil hat nur den Wächter.
|
||||
if einstellungen().rolle == "box" and warmer.ENABLED:
|
||||
tasks.append(asyncio.create_task(warmer.rewarm_loop()))
|
||||
tasks.append(asyncio.create_task(config_watch()))
|
||||
log.info("Re-Warm-Wächter aktiv (Intervall %ss, Config-Watch %ss)",
|
||||
warmer.INTERVAL, CONFIG_WATCH_S)
|
||||
if sentry.ENABLED:
|
||||
tasks.append(asyncio.create_task(sentry.sentry_loop()))
|
||||
if memory_svc.AUTO_DEDUPE_ENABLED:
|
||||
tasks.append(asyncio.create_task(memory_svc.auto_dedupe_loop()))
|
||||
log.info("Mem0-Auto-Dedupe aktiv (alle %ss, Schwelle %s)",
|
||||
memory_svc.AUTO_DEDUPE_INTERVAL, memory_svc.AUTO_DEDUPE_THRESHOLD)
|
||||
if waechter.ENABLED:
|
||||
tasks.append(asyncio.create_task(waechter.waechter_loop()))
|
||||
if not tasks:
|
||||
log.warning("steward: alle Loops per Env deaktiviert — nichts zu tun, Ende.")
|
||||
return
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
"""Backend-Module importieren sich gegenseitig ohne Paketpräfix (from config import …) —
|
||||
für die Tests liegt deshalb backend/ auf dem Suchpfad, genau wie beim Start der Dienste."""
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
@@ -0,0 +1,67 @@
|
||||
"""Aufräumen der Modell-Platte: nur Ungenutztes, nur auf Knopfdruck, geteilte Dateien bleiben."""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from services import aufraeumen, llamaswap
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def platte(tmp_path, monkeypatch):
|
||||
"""Nachbau der Box: Hirn + Bild-Zwilling teilen Gewichte, der Projektor liegt im Original-Ordner."""
|
||||
for ordner, datei, groesse in (("Hirn-GGUF", "hirn.gguf", 3000), ("Original-GGUF", "mmproj.gguf", 500),
|
||||
("Coder-GGUF", "coder.gguf", 2000), ("Alt-GGUF", "alt.gguf", 4000),
|
||||
("Muse-GGUF", "muse.gguf", 1500), ("Reserve-GGUF", "reserve.gguf", 2500),
|
||||
("radar", "log.txt", 10), (".cache", "x", 10)):
|
||||
(tmp_path / ordner).mkdir()
|
||||
(tmp_path / ordner / datei).write_bytes(b"x" * groesse)
|
||||
m = str(tmp_path)
|
||||
cfg = {"models": {
|
||||
"hirn": {"cmd": f"llama-server -m {m}/Hirn-GGUF/hirn.gguf", "aliases": ["hermes", "fast"]},
|
||||
"hirn-bild": {"cmd": f"llama-server -m {m}/Hirn-GGUF/hirn.gguf --mmproj {m}/Original-GGUF/mmproj.gguf",
|
||||
"aliases": ["vision"]},
|
||||
"coder": {"cmd": f"llama-server -m {m}/Coder-GGUF/coder.gguf", "aliases": ["coder"]},
|
||||
"alt": {"cmd": f"llama-server -m {m}/Alt-GGUF/alt.gguf"},
|
||||
"muse": {"cmd": f"llama-server -m {m}/Muse-GGUF/muse.gguf", "aliases": ["debugger"]},
|
||||
}, "groups": {"brains": {"persistent": True, "members": ["hirn"]}}}
|
||||
monkeypatch.setattr(aufraeumen, "MODELS_DIR", tmp_path)
|
||||
monkeypatch.setattr(aufraeumen, "AUF_DER_BOX", True)
|
||||
monkeypatch.setattr(llamaswap, "read_config", lambda: cfg)
|
||||
monkeypatch.setattr(llamaswap, "write_config", lambda c: None)
|
||||
return tmp_path, cfg
|
||||
|
||||
|
||||
def test_kandidaten_sind_rollenlose_eintraege_und_verwaiste_ordner(platte):
|
||||
namen = {(k["art"], k["name"]) for k in aufraeumen.kandidaten()}
|
||||
assert namen == {("eintrag", "alt"), ("eintrag", "muse"), ("ordner", "Reserve-GGUF")}
|
||||
# Der Original-Ordner ist KEIN Kandidat: der Bild-Zwilling nutzt seinen Projektor.
|
||||
muse = next(k for k in aufraeumen.kandidaten() if k["name"] == "muse")
|
||||
assert "debugger" in muse["hinweis"]
|
||||
|
||||
|
||||
def test_eintrag_loeschen_nimmt_ihn_aus_der_config_und_seinen_ordner_mit(platte):
|
||||
wurzel, cfg = platte
|
||||
ergebnis = aufraeumen.loeschen("eintrag", "alt")
|
||||
assert ergebnis["ok"] and "alt gelöscht" in ergebnis["text"]
|
||||
assert "alt" not in cfg["models"] and not (wurzel / "Alt-GGUF").exists()
|
||||
assert (wurzel / "Hirn-GGUF" / "hirn.gguf").exists() and (wurzel / "Coder-GGUF").exists()
|
||||
|
||||
|
||||
def test_nur_aktuelle_kandidaten_und_nie_systemordner(platte):
|
||||
wurzel, _ = platte
|
||||
assert not aufraeumen.loeschen("ordner", "Hirn-GGUF")["ok"] # wird genutzt
|
||||
assert not aufraeumen.loeschen("ordner", "radar")["ok"] # Systemordner
|
||||
assert not aufraeumen.loeschen("ordner", "../etwas")["ok"]
|
||||
assert (wurzel / "Hirn-GGUF").exists() and (wurzel / "radar").exists()
|
||||
assert aufraeumen.loeschen("ordner", "Reserve-GGUF")["ok"] and not (wurzel / "Reserve-GGUF").exists()
|
||||
|
||||
|
||||
def test_delete_model_laesst_geteilte_gewichte_liegen(platte):
|
||||
"""Das Löschen des Bild-Zwillings darf die Gewichte des Hirns nicht mitnehmen (24.09.)."""
|
||||
wurzel, cfg = platte
|
||||
assert llamaswap.delete_model("hirn-bild") is True
|
||||
assert "hirn-bild" not in cfg["models"]
|
||||
assert (wurzel / "Hirn-GGUF" / "hirn.gguf").exists()
|
||||
# Im Zweifel liegen lassen: der Ordner taucht danach als verwaist im Aufräumen auf.
|
||||
assert Path(wurzel / "Original-GGUF" / "mmproj.gguf").exists()
|
||||
assert ("ordner", "Original-GGUF") in {(k["art"], k["name"]) for k in aufraeumen.kandidaten()}
|
||||
@@ -0,0 +1,100 @@
|
||||
"""Bild-Weiche v3 (24.09.2026): frische Bilder an den Bild-Zwilling, ältere als Beschreibung."""
|
||||
|
||||
import asyncio
|
||||
import copy
|
||||
from typing import ClassVar
|
||||
|
||||
from routers import gateway_proxy
|
||||
from services.router_logic import bild_ziel, bilder_aufteilen, ist_coder_alias
|
||||
|
||||
BILD_A = {"type": "image_url", "image_url": {"url": "data:image/png;base64,AAAA"}}
|
||||
BILD_B = {"type": "image_url", "image_url": {"url": "data:image/png;base64,BBBB"}}
|
||||
|
||||
|
||||
def verlauf() -> dict:
|
||||
"""Agenten-Ablauf: Bild A im ersten Schritt, danach Werkzeug-Aufruf, jetzt Bild B als Ergebnis."""
|
||||
return {"model": "coder", "messages": [
|
||||
{"role": "system", "content": "Du bist ein Coding-Agent."},
|
||||
{"role": "user", "content": [BILD_A, {"type": "text", "text": "Was zeigt der Fehler?"}]},
|
||||
{"role": "assistant", "content": "", "tool_calls": [
|
||||
{"id": "c1", "type": "function", "function": {"name": "screenshot", "arguments": "{}"}}]},
|
||||
{"role": "tool", "tool_call_id": "c1", "content": "Bildschirmfoto folgt."},
|
||||
{"role": "user", "content": [BILD_B, {"type": "text", "text": "Bildschirmfoto aus screenshot."}]},
|
||||
]}
|
||||
|
||||
|
||||
def test_frisch_ist_was_nach_der_letzten_antwort_kommt():
|
||||
frisch, alt = bilder_aufteilen(verlauf())
|
||||
assert [m["content"][i] for m, i in frisch] == [BILD_B]
|
||||
assert [m["content"][i] for m, i in alt] == [BILD_A]
|
||||
|
||||
|
||||
def test_ohne_antwort_sind_alle_bilder_frisch():
|
||||
frisch, alt = bilder_aufteilen({"messages": [{"role": "user", "content": [BILD_A, BILD_B]}]})
|
||||
assert len(frisch) == 2 and alt == []
|
||||
|
||||
|
||||
def test_coder_ziele_gehen_an_den_coder_zwilling_alles_andere_an_vision():
|
||||
assert ist_coder_alias("coder") and ist_coder_alias("heavy") and not ist_coder_alias("fast")
|
||||
assert bild_ziel("coder", "vision", "coder-bild") == "coder-bild"
|
||||
assert bild_ziel("heavy", "vision", "coder-bild") == "coder-bild"
|
||||
assert bild_ziel("fast", "vision", "coder-bild") == "vision"
|
||||
assert bild_ziel("coder", "vision", None) == "vision" # ohne Coder-Zwilling: Hirn-Zwilling
|
||||
|
||||
|
||||
class _FalscherClient:
|
||||
"""Ersetzt httpx.AsyncClient: zählt Beschreibungs-Anfragen und antwortet mit fester Beschreibung."""
|
||||
anfragen: ClassVar[list] = []
|
||||
|
||||
def __init__(self, *a, **kw):
|
||||
pass
|
||||
|
||||
async def __aenter__(self):
|
||||
return self
|
||||
|
||||
async def __aexit__(self, *a):
|
||||
return False
|
||||
|
||||
async def post(self, url, json):
|
||||
_FalscherClient.anfragen.append(json)
|
||||
|
||||
class Antwort:
|
||||
status_code = 200
|
||||
text = ""
|
||||
|
||||
@staticmethod
|
||||
def json():
|
||||
return {"choices": [{"message": {"content": "Fehlermeldung: PORT 4711 belegt"}}]}
|
||||
return Antwort()
|
||||
|
||||
|
||||
def test_aeltere_bilder_werden_einmal_beschrieben_und_gemerkt(monkeypatch):
|
||||
monkeypatch.setattr(gateway_proxy.httpx, "AsyncClient", _FalscherClient)
|
||||
monkeypatch.setattr(gateway_proxy, "_BESCHREIBUNGEN", gateway_proxy.OrderedDict())
|
||||
_FalscherClient.anfragen = []
|
||||
|
||||
body = verlauf()
|
||||
_, alt = bilder_aufteilen(body)
|
||||
assert asyncio.run(gateway_proxy._alte_bilder_beschreiben(alt, "vision")) is True
|
||||
ersetzt = body["messages"][1]["content"][0]
|
||||
assert ersetzt["type"] == "text" and "PORT 4711" in ersetzt["text"]
|
||||
assert body["messages"][4]["content"][0] == BILD_B # das frische Bild bleibt ein Bild
|
||||
anfrage = _FalscherClient.anfragen[0]
|
||||
assert anfrage["model"] == "vision" and anfrage["chat_template_kwargs"] == {"enable_thinking": False}
|
||||
|
||||
# Nächster Schritt schickt denselben Verlauf erneut: aus dem Gedächtnis, keine zweite Anfrage.
|
||||
nochmal = verlauf()
|
||||
_, alt = bilder_aufteilen(nochmal)
|
||||
assert asyncio.run(gateway_proxy._alte_bilder_beschreiben(alt, "vision")) is True
|
||||
assert len(_FalscherClient.anfragen) == 1
|
||||
|
||||
|
||||
def test_zu_viele_neue_bilder_bleiben_drin(monkeypatch):
|
||||
monkeypatch.setattr(gateway_proxy, "_BESCHREIBUNGEN", gateway_proxy.OrderedDict())
|
||||
monkeypatch.setattr(gateway_proxy, "_BILD_MAX", 1)
|
||||
body = {"messages": [{"role": "user", "content": [BILD_A, BILD_B]}, {"role": "assistant", "content": "ok"},
|
||||
{"role": "user", "content": "und jetzt?"}]}
|
||||
vorher = copy.deepcopy(body)
|
||||
_, alt = bilder_aufteilen(body)
|
||||
assert asyncio.run(gateway_proxy._alte_bilder_beschreiben(alt, "vision")) is False
|
||||
assert body == vorher
|
||||
@@ -0,0 +1,23 @@
|
||||
"""Herkunftsprüfung für Knöpfe (24.09.2026)."""
|
||||
|
||||
from services.herkunft import erlaubt
|
||||
|
||||
BOX = "192.168.178.151:9001"
|
||||
|
||||
|
||||
def test_eigene_seite_und_skripte_duerfen():
|
||||
assert erlaubt("POST", "/api/maintenance/os-update", "http://192.168.178.151:9001", BOX)
|
||||
assert erlaubt("POST", "/api/alarm", None, BOX) # Lucy-Watchdog, curl
|
||||
assert erlaubt("POST", "/api/voice/chat", "null", BOX) # Desktop-Lucy (Electron)
|
||||
assert erlaubt("POST", "/api/voice/stt", "file://", BOX)
|
||||
assert erlaubt("POST", "/api/system/restart", "http://localhost:5173", BOX) # Vite-Entwicklung
|
||||
|
||||
|
||||
def test_fremde_webseite_darf_keine_knoepfe_druecken():
|
||||
assert not erlaubt("POST", "/api/maintenance/update-all", "https://boese.example", BOX)
|
||||
assert not erlaubt("DELETE", "/api/models/x", "http://192.168.178.99:8080", BOX)
|
||||
|
||||
|
||||
def test_lesen_und_v1_bleiben_offen():
|
||||
assert erlaubt("GET", "/api/start", "https://boese.example", BOX)
|
||||
assert erlaubt("POST", "/v1/chat/completions", "http://localhost:3000", BOX)
|
||||
@@ -0,0 +1,67 @@
|
||||
"""Hermes-Plugin mc2-web-lesen (deploy/hermes-plugins): liest Seiten lokal für web_extract.
|
||||
|
||||
Das Plugin läuft in Hermes' eigener Umgebung. Hier wird die Hermes-Schnittstelle durch eine
|
||||
Attrappe ersetzt und das Netz durch httpx.MockTransport — ohne Hermes, ohne Internet.
|
||||
"""
|
||||
|
||||
import abc
|
||||
import asyncio
|
||||
import importlib.util
|
||||
import sys
|
||||
import types
|
||||
from pathlib import Path
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
PLUGIN = Path(__file__).resolve().parents[2] / "deploy" / "hermes-plugins" / "mc2-web-lesen" / "__init__.py"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def plugin(monkeypatch):
|
||||
class WebSearchProvider(abc.ABC): # Attrappe der Hermes-Schnittstelle
|
||||
@property
|
||||
@abc.abstractmethod
|
||||
def name(self) -> str: ...
|
||||
|
||||
agent = types.ModuleType("agent")
|
||||
schnittstelle = types.ModuleType("agent.web_search_provider")
|
||||
schnittstelle.WebSearchProvider = WebSearchProvider
|
||||
monkeypatch.setitem(sys.modules, "agent", agent)
|
||||
monkeypatch.setitem(sys.modules, "agent.web_search_provider", schnittstelle)
|
||||
spec = importlib.util.spec_from_file_location("mc2_web_lesen", PLUGIN)
|
||||
modul = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(modul)
|
||||
return modul
|
||||
|
||||
|
||||
SEITE = ("<html><head><title>Box-News</title><script>var x = 1;</script></head>"
|
||||
"<body><article><h1>Neues Modell</h1><p>Die Box testet nachts selbst.</p></article></body></html>")
|
||||
|
||||
|
||||
def _lies(plugin, antwort: httpx.Response, url: str = "https://example.org/a") -> dict:
|
||||
async def lauf():
|
||||
async with httpx.AsyncClient(transport=httpx.MockTransport(lambda _: antwort)) as client:
|
||||
return await plugin.lies(client, url)
|
||||
return asyncio.run(lauf())
|
||||
|
||||
|
||||
def test_liest_titel_und_haupttext(plugin):
|
||||
ergebnis = _lies(plugin, httpx.Response(200, headers={"content-type": "text/html; charset=utf-8"}, text=SEITE))
|
||||
assert ergebnis["url"] == "https://example.org/a" and ergebnis["title"] == "Box-News"
|
||||
assert "testet nachts selbst" in ergebnis["content"]
|
||||
assert "var x" not in ergebnis["content"] and "error" not in ergebnis
|
||||
|
||||
|
||||
def test_fehler_werden_je_seite_gemeldet_statt_geworfen(plugin):
|
||||
assert _lies(plugin, httpx.Response(404))["error"] == "HTTP 404"
|
||||
pdf = _lies(plugin, httpx.Response(200, headers={"content-type": "application/pdf"}, content=b"%PDF"))
|
||||
assert "Kein Text-Inhalt (application/pdf)" in pdf["error"]
|
||||
|
||||
|
||||
def test_anbieter_liest_nur_und_meldet_sich_an(plugin):
|
||||
angemeldet = []
|
||||
plugin.register(types.SimpleNamespace(register_web_search_provider=angemeldet.append))
|
||||
anbieter = angemeldet[0]
|
||||
assert anbieter.name == "mc2-lesen" and anbieter.is_available()
|
||||
assert anbieter.supports_extract() and not anbieter.supports_search()
|
||||
@@ -0,0 +1,33 @@
|
||||
"""Tests für die Pfad-Umschreibung des Hermes-Dashboard-Proxys (Anmeldung seit Hermes v0.21)."""
|
||||
|
||||
from routers import hermes_ui
|
||||
|
||||
|
||||
def test_umleitung_auf_login_bekommt_praefix():
|
||||
assert hermes_ui.praefixiere_ort("/login?next=%2F") == "/hermes-ui/login?next=%2F"
|
||||
assert hermes_ui.praefixiere_ort("/") == "/hermes-ui/"
|
||||
|
||||
|
||||
def test_fremde_oder_schon_praefixierte_ziele_bleiben():
|
||||
assert hermes_ui.praefixiere_ort("/hermes-ui/sessions") == "/hermes-ui/sessions"
|
||||
assert hermes_ui.praefixiere_ort("https://example.org/x") == "https://example.org/x"
|
||||
assert hermes_ui.praefixiere_ort("//evil.example/x") == "//evil.example/x"
|
||||
|
||||
|
||||
def test_anmeldeseite_schickt_an_praefixierten_pfad():
|
||||
html = (b'<html><head><title>Login</title></head><body>'
|
||||
b'<form action="/auth/password-login" method="post"></form>'
|
||||
b'<script>fetch("/auth/password-login",{method:"POST"});location.href="/login?x=1"</script>'
|
||||
b'</body></html>')
|
||||
neu = hermes_ui.praefixiere_html(html)
|
||||
assert b'action="/hermes-ui/auth/password-login"' in neu
|
||||
assert b'fetch("/hermes-ui/auth/password-login"' in neu
|
||||
assert b'"/hermes-ui/login?x=1"' in neu
|
||||
assert b'window.__HERMES_BASE_PATH__="/hermes-ui"' in neu
|
||||
|
||||
|
||||
def test_andere_absolute_pfade_bleiben_unberuehrt():
|
||||
html = b'<head></head><a href="/assets/x.js">x</a><a href="/authors">y</a>'
|
||||
neu = hermes_ui.praefixiere_html(html)
|
||||
assert b'href="/assets/x.js"' in neu
|
||||
assert b'href="/authors"' in neu
|
||||
@@ -0,0 +1,196 @@
|
||||
"""Auftrags-System (Phase 2, 24.09.2026): Akten auf der Platte, Wiederaufnahme nach einem Neustart,
|
||||
benannte Nacharbeiten, Zeitlimit, Abbruch. Die Tests laufen als Kindprozess („prozess“); den
|
||||
systemd-Weg prüfen sie am gebauten Befehl."""
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
|
||||
import pytest
|
||||
from services import jobengine
|
||||
|
||||
PY = sys.executable
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def motor(tmp_path, monkeypatch):
|
||||
ordner = tmp_path / "jobs"
|
||||
monkeypatch.setenv("MC_JOBS_DIR", str(ordner))
|
||||
monkeypatch.setenv("MC_JOBS_ART", "prozess")
|
||||
monkeypatch.setattr(jobengine, "JOBS", {})
|
||||
monkeypatch.setattr(jobengine, "_PROCS", {})
|
||||
monkeypatch.setattr(jobengine, "_geladen", False)
|
||||
monkeypatch.setattr(jobengine, "_NACHARBEITEN", dict(jobengine._NACHARBEITEN))
|
||||
return ordner
|
||||
|
||||
|
||||
def _warte(job_id: str, sekunden: float = 30) -> dict:
|
||||
ende = time.time() + sekunden
|
||||
while time.time() < ende:
|
||||
job = jobengine.JOBS[job_id]
|
||||
if job["state"] in ("done", "failed", "canceled"):
|
||||
return job
|
||||
time.sleep(0.05)
|
||||
raise AssertionError(f"Auftrag {job_id} wurde nicht fertig: {jobengine.JOBS[job_id]}")
|
||||
|
||||
|
||||
def _oeffentlich(job_id: str) -> dict:
|
||||
return next(j for j in jobengine.public_jobs() if j["id"] == job_id)
|
||||
|
||||
|
||||
def test_erfolg_mit_protokoll_und_akte(motor):
|
||||
code = "import sys; print('eins'); sys.stdout.write('10%\\r50%\\r100%\\n'); print('zwei')"
|
||||
job_id = jobengine.start_job([PY, "-c", code], "Probe")
|
||||
job = _warte(job_id)
|
||||
assert (job["state"], job["returncode"]) == ("done", 0)
|
||||
log = _oeffentlich(job_id)["log"]
|
||||
assert log[0].startswith("$ ") and log[1:] == ["eins", "100%", "zwei"]
|
||||
akte = json.loads((motor / f"{job_id}.json").read_text(encoding="utf-8"))
|
||||
assert akte["state"] == "done" and akte["label"] == "Probe"
|
||||
assert (motor / f"{job_id}.exit").read_text(encoding="utf-8").strip() == "0"
|
||||
|
||||
|
||||
def test_fehlschlag_mit_code(motor):
|
||||
job = _warte(jobengine.start_job([PY, "-c", "raise SystemExit(3)"], "Kaputt"))
|
||||
assert (job["state"], job["returncode"]) == ("failed", 3)
|
||||
|
||||
|
||||
def test_zeitlimit_beendet_den_auftrag(motor):
|
||||
job = _warte(jobengine.start_job([PY, "-c", "import time; time.sleep(30)"], "Hängt", zeitlimit_s=1))
|
||||
assert job["state"] == "failed" and job["error"] == "Zeitlimit überschritten"
|
||||
assert any("Zeitlimit" in z for z in _oeffentlich(job["id"])["log"])
|
||||
|
||||
|
||||
def test_abbrechen(motor):
|
||||
job_id = jobengine.start_job([PY, "-c", "import time; time.sleep(30)"], "Lang")
|
||||
time.sleep(0.3)
|
||||
assert jobengine.cancel_job(job_id) is True
|
||||
assert _warte(job_id)["state"] == "canceled"
|
||||
assert jobengine.cancel_job(job_id) is False
|
||||
|
||||
|
||||
def test_nacharbeit_nur_bei_erfolg_und_mit_daten(motor):
|
||||
aufrufe: list[dict] = []
|
||||
jobengine.nacharbeit("probe:merken")(lambda **daten: aufrufe.append(daten))
|
||||
_warte(jobengine.start_job([PY, "-c", "pass"], "Gut", nacharbeit="probe:merken",
|
||||
nacharbeit_daten={"model_id": "m", "role": "coder"}))
|
||||
_warte(jobengine.start_job([PY, "-c", "raise SystemExit(1)"], "Schlecht", nacharbeit="probe:merken",
|
||||
nacharbeit_daten={"x": 1}))
|
||||
assert aufrufe == [{"model_id": "m", "role": "coder"}]
|
||||
with pytest.raises(ValueError, match="Unbekannte Nacharbeit"):
|
||||
jobengine.start_job([PY, "-c", "pass"], "X", nacharbeit="gibt:es-nicht")
|
||||
|
||||
|
||||
def test_exklusiv_je_gruppe(motor):
|
||||
erster, _ = jobengine.start_job_exklusiv("maintenance", [PY, "-c", "import time; time.sleep(30)"], "Update A")
|
||||
zweiter, laeuft = jobengine.start_job_exklusiv("maintenance", [PY, "-c", "pass"], "Update B")
|
||||
assert erster and zweiter is None and laeuft["label"] == "Update A"
|
||||
assert jobengine.active_in_group("maintenance")["id"] == erster
|
||||
jobengine.cancel_job(erster)
|
||||
_warte(erster)
|
||||
assert jobengine.active_in_group("maintenance") is None
|
||||
|
||||
|
||||
def _akte(ordner, job_id: str, **felder) -> None:
|
||||
ordner.mkdir(parents=True, exist_ok=True)
|
||||
akte = {"id": job_id, "label": "Alt", "state": "running", "group": "download", "art": "systemd",
|
||||
"returncode": None, "started_at": time.time() - 60, "finished_at": None, "zeitlimit_s": 3600,
|
||||
"nacharbeit": None, "nacharbeit_daten": {}, **felder}
|
||||
(ordner / f"{job_id}.json").write_text(json.dumps(akte), encoding="utf-8")
|
||||
|
||||
|
||||
def test_wiederaufnahme_fertiger_einheit_mit_nacharbeit(motor):
|
||||
"""MC2 startet neu, während ein Download lief; er ist inzwischen fertig — die Rolle wird gesetzt."""
|
||||
aufrufe: list[dict] = []
|
||||
jobengine.nacharbeit("probe:rolle")(lambda **daten: aufrufe.append(daten))
|
||||
_akte(motor, "abc123", nacharbeit="probe:rolle", nacharbeit_daten={"role": "coder"})
|
||||
(motor / "abc123.exit").write_text("0", encoding="utf-8")
|
||||
assert jobengine.wiederaufnehmen() == 1
|
||||
assert _warte("abc123")["state"] == "done"
|
||||
assert aufrufe == [{"role": "coder"}]
|
||||
# Ein zweiter Start von MC2 wiederholt die Nacharbeit nicht.
|
||||
jobengine._geladen = False
|
||||
jobengine.JOBS.clear()
|
||||
assert jobengine.wiederaufnehmen() == 0
|
||||
assert aufrufe == [{"role": "coder"}]
|
||||
|
||||
|
||||
def test_wiederaufnahme_verschwundene_einheit(motor, monkeypatch):
|
||||
monkeypatch.setattr(jobengine, "_einheit_lebt", lambda job_id: False)
|
||||
monkeypatch.setattr(jobengine, "_EINHEIT_PRUEFEN_S", 0.0)
|
||||
_akte(motor, "weg456")
|
||||
jobengine.wiederaufnehmen()
|
||||
job = _warte("weg456")
|
||||
assert job["state"] == "failed" and job["error"] == "Der Auftrag endete ohne Rückmeldung."
|
||||
|
||||
|
||||
def test_wiederaufnahme_laufender_einheit_bleibt_laufend(motor, monkeypatch):
|
||||
monkeypatch.setattr(jobengine, "_einheit_lebt", lambda job_id: True)
|
||||
monkeypatch.setattr(jobengine, "_EINHEIT_PRUEFEN_S", 0.0)
|
||||
_akte(motor, "lebt789", group="maintenance")
|
||||
jobengine.wiederaufnehmen()
|
||||
time.sleep(0.5)
|
||||
assert jobengine.JOBS["lebt789"]["state"] == "running"
|
||||
assert jobengine.active_in_group("maintenance")["id"] == "lebt789"
|
||||
(motor / "lebt789.exit").write_text("0", encoding="utf-8")
|
||||
assert _warte("lebt789")["state"] == "done"
|
||||
|
||||
|
||||
def test_wiederaufnahme_kindprozess_ist_verloren(motor):
|
||||
_akte(motor, "kind01", art="prozess")
|
||||
jobengine.wiederaufnehmen()
|
||||
job = jobengine.JOBS["kind01"]
|
||||
assert job["state"] == "failed" and "neu gestartet" in job["error"]
|
||||
|
||||
|
||||
def test_aufraeumen_loescht_alte_akten(motor, monkeypatch):
|
||||
monkeypatch.setattr(jobengine, "BEHALTEN_MAX", 1)
|
||||
ids = [_warte(jobengine.start_job([PY, "-c", "pass"], f"Nr {i}"))["id"] for i in range(3)]
|
||||
sichtbar = [j["id"] for j in jobengine.public_jobs()]
|
||||
assert sichtbar == [ids[-1]]
|
||||
assert not (motor / f"{ids[0]}.json").exists() and not (motor / f"{ids[0]}.log").exists()
|
||||
|
||||
|
||||
def test_systemd_befehl_ohne_geheimnis_in_der_befehlszeile(motor, monkeypatch):
|
||||
monkeypatch.setenv("MC_JOBS_ART", "systemd")
|
||||
monkeypatch.setenv("INVOCATION_ID", "von-mc2")
|
||||
befehle: list[list[str]] = []
|
||||
|
||||
class Ergebnis:
|
||||
returncode = 0
|
||||
stdout = stderr = ""
|
||||
|
||||
monkeypatch.setattr(jobengine.subprocess, "run", lambda befehl, **kw: befehle.append(befehl) or Ergebnis())
|
||||
monkeypatch.setattr(jobengine, "_beobachten", lambda job_id: None)
|
||||
job_id = jobengine.start_job(["hf", "download", "org/modell"], "Download", env={"HF_TOKEN": "geheim-123"},
|
||||
zeitlimit_s=600)
|
||||
befehl = befehle[0]
|
||||
assert befehl[0] == "systemd-run" and f"mc2-job-{job_id}" in befehl and "--collect" in befehl
|
||||
assert "RuntimeMaxSec=600" in befehl
|
||||
assert befehl[-3:] == ["hf", "download", "org/modell"]
|
||||
assert not any("geheim-123" in teil for teil in befehl)
|
||||
umgebung = (motor / f"{job_id}.env").read_text(encoding="utf-8")
|
||||
assert "export HF_TOKEN=geheim-123" in umgebung and "INVOCATION_ID" not in umgebung
|
||||
if os.name == "posix":
|
||||
assert (motor / f"{job_id}.env").stat().st_mode & 0o077 == 0
|
||||
|
||||
|
||||
def test_systemd_start_scheitert_sauber(motor, monkeypatch):
|
||||
monkeypatch.setenv("MC_JOBS_ART", "systemd")
|
||||
|
||||
class Ergebnis:
|
||||
returncode = 1
|
||||
stdout = ""
|
||||
stderr = "Failed to connect to bus"
|
||||
|
||||
monkeypatch.setattr(jobengine.subprocess, "run", lambda befehl, **kw: Ergebnis())
|
||||
job_id = jobengine.start_job(["true"], "Probe")
|
||||
job = jobengine.JOBS[job_id]
|
||||
assert job["state"] == "failed" and "Failed to connect to bus" in job["error"]
|
||||
assert not (motor / f"{job_id}.env").exists()
|
||||
|
||||
|
||||
def test_zeilen_aus_ueberschreibt_mit_wagenruecklauf():
|
||||
assert jobengine.zeilen_aus("a\n 1%\r 50%\r100%\nb\r\n") == ["a", "100%", "b"]
|
||||
assert jobengine.zeilen_aus("läuft\r 7%") == [" 7%"]
|
||||
@@ -0,0 +1,45 @@
|
||||
"""Tests für die Auswertung des llama-swap-Journals — Zeilenformat wie auf der Box (23.09.2026)."""
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from services import modell_nutzung
|
||||
|
||||
ZEILEN = [
|
||||
('2026-09-23T03:12:44+02:00 box llama-swap[1]: [INFO] Request 192.168.178.28 '
|
||||
'"POST /v1/chat/completions HTTP/1.1" 200 10254 "Go-http-client/1.1" 1.2s'),
|
||||
('2026-09-23T03:12:50+02:00 box llama-swap[1]: [INFO] Request 192.168.178.28 '
|
||||
'"POST /v1/chat/completions HTTP/1.1" 200 9000 "Go-http-client/1.1" 1.1s'),
|
||||
('2026-09-23T07:01:00+02:00 box llama-swap[1]: [INFO] Request 127.0.0.1 '
|
||||
'"POST /v1/chat/completions HTTP/1.1" 200 800 "python-httpx/0.28.1" 3s'),
|
||||
('2026-09-23T07:02:00+02:00 box llama-swap[1]: [INFO] Request 127.0.0.1 '
|
||||
'"POST /v1/embeddings HTTP/1.1" 200 800 "python-httpx/0.28.1" 20ms'),
|
||||
"2026-09-17T19:59:00+02:00 box llama-swap[1]: [INFO] <Qwen3.8-27B> Health check passed on http://localhost:5805/health",
|
||||
('2026-09-10T03:00:00+02:00 box llama-swap[1]: [INFO] Request 192.168.178.28 '
|
||||
'"POST /v1/chat/completions HTTP/1.1" 200 1 "Go-http-client/1.1" 1s'),
|
||||
]
|
||||
|
||||
NAMEN = {"127.0.0.1": "Lucy und OpenChamber über MC2", "192.168.178.28": "NerdQuiz auf Arcane"}
|
||||
|
||||
|
||||
def test_zaehlt_nur_chat_anfragen_je_absender():
|
||||
d = modell_nutzung.werte_aus(ZEILEN, datetime.fromisoformat("2026-09-23T20:45:00+02:00"), NAMEN)
|
||||
assert d["absender"] == [
|
||||
{"name": "NerdQuiz auf Arcane", "anfragen": 3},
|
||||
{"name": "Lucy und OpenChamber über MC2", "anfragen": 1},
|
||||
]
|
||||
|
||||
|
||||
def test_letzte_24h_je_stunde_und_zuletzt_geladen():
|
||||
d = modell_nutzung.werte_aus(ZEILEN, datetime.fromisoformat("2026-09-23T20:45:00+02:00"), NAMEN)
|
||||
stunden = {s["stunde"]: s["je_absender"] for s in d["letzte_24h"]}
|
||||
assert stunden["03"] == {"NerdQuiz auf Arcane": 2} # der Lauf vom 10.09. liegt außerhalb
|
||||
assert stunden["07"] == {"Lucy und OpenChamber über MC2": 1}
|
||||
assert len(d["letzte_24h"]) == 24
|
||||
assert d["zuletzt_geladen"]["Qwen3.8-27B"].startswith("2026-09-17T19:59")
|
||||
|
||||
|
||||
def test_unbekannte_ip_bleibt_als_ip_stehen():
|
||||
zeile = ('2026-09-23T10:00:00+02:00 box llama-swap[1]: [INFO] Request 10.0.0.9 '
|
||||
'"POST /v1/chat/completions HTTP/1.1" 200 1 "curl/8" 1s')
|
||||
d = modell_nutzung.werte_aus([zeile], datetime.fromisoformat("2026-09-23T20:45:00+02:00"), NAMEN)
|
||||
assert d["absender"] == [{"name": "10.0.0.9", "anfragen": 1}]
|
||||
@@ -0,0 +1,210 @@
|
||||
"""Nachtruhe und Morgenmeldung (deploy/notify.sh, deploy/morgenmeldung.sh, 24.09.2026).
|
||||
|
||||
Bis zum 24.09. parkte notify.sh nachts alles in einer Warteschlange, die niemand mehr auslieferte.
|
||||
Die Tests fahren die echten Skripte mit einem Ersatz für `hermes send`, der nur mitschreibt."""
|
||||
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
DEPLOY = Path(__file__).resolve().parents[2] / "deploy"
|
||||
BASH = shutil.which("bash")
|
||||
|
||||
# Unter Windows kann „bash“ die WSL-Hülle sein, die Windows-Pfade nicht versteht — dann überspringen.
|
||||
pytestmark = pytest.mark.skipif(
|
||||
BASH is None or (sys.platform == "win32" and "system32" in BASH.lower()),
|
||||
reason="braucht eine bash (Git-Bash oder Linux)",
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def umgebung(tmp_path):
|
||||
protokoll = tmp_path / "gesendet.txt"
|
||||
stub = tmp_path / "hermes-stub"
|
||||
stub.write_text(
|
||||
"#!/usr/bin/env bash\n"
|
||||
'printf "%s\\n" "$*" >> "$STUB_PROTOKOLL"\n'
|
||||
'exit "${STUB_EXIT:-0}"\n',
|
||||
encoding="utf-8", newline="\n")
|
||||
stub.chmod(0o755)
|
||||
env = {
|
||||
**os.environ,
|
||||
"MC_NOTIFY_LOG": (tmp_path / "notify.log").as_posix(),
|
||||
"MC_NIGHT_QUEUE": (tmp_path / "night-queue.txt").as_posix(),
|
||||
"MC_NOTIFY_HERMES": stub.as_posix(),
|
||||
"MC_NOTIFY_NO_ANNOUNCE": "1",
|
||||
"MC_NOTIFY_OHNE_WALL": "1",
|
||||
# Auf der Box läge unter ~/.hermes/.env das echte Bot-Token — der Zweitweg darf in Tests
|
||||
# nie dorthin greifen, sonst schickt der Deploy-Testlauf echte Telegram-Nachrichten.
|
||||
"MC_TELEGRAM_ENV": (tmp_path / "keine.env").as_posix(),
|
||||
"MC_TELEGRAM_API": "http://127.0.0.1:9",
|
||||
"STUB_PROTOKOLL": protokoll.as_posix(),
|
||||
"MC_MORGENMELDUNG_PAUSE": "0",
|
||||
}
|
||||
return tmp_path, env, protokoll
|
||||
|
||||
|
||||
def _notify(env, *args, stunde="03"):
|
||||
return subprocess.run([BASH, (DEPLOY / "notify.sh").as_posix(), *args],
|
||||
env={**env, "MC_NOTIFY_STUNDE": stunde}, capture_output=True, text=True, timeout=60)
|
||||
|
||||
|
||||
def _gesendet(protokoll: Path) -> str:
|
||||
return protokoll.read_text(encoding="utf-8") if protokoll.exists() else ""
|
||||
|
||||
|
||||
def test_nachts_wird_gesammelt(umgebung):
|
||||
tmp, env, protokoll = umgebung
|
||||
assert _notify(env, "-s", "[Box-Update]", "Engine aktualisiert").returncode == 0
|
||||
assert _gesendet(protokoll) == ""
|
||||
assert (tmp / "night-queue.txt").read_text(encoding="utf-8") == "- [Box-Update]: Engine aktualisiert\n"
|
||||
assert "QUEUED für Morgen-Digest: Engine aktualisiert" in (tmp / "notify.log").read_text(encoding="utf-8")
|
||||
|
||||
|
||||
@pytest.mark.parametrize("args", [
|
||||
("-d", "-s", "[Box-Update]", "Wichtig"),
|
||||
("-s", "[Alarm]", "Lucy antwortet nicht"),
|
||||
("-s", "[Box-Update]", "KRITISCH: Update UND Rollback fehlgeschlagen"),
|
||||
])
|
||||
def test_dringendes_geht_nachts_sofort_raus(umgebung, args):
|
||||
tmp, env, protokoll = umgebung
|
||||
assert _notify(env, *args).returncode == 0
|
||||
assert "send --to telegram" in _gesendet(protokoll)
|
||||
assert not (tmp / "night-queue.txt").exists()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("stunde", ["07", "08", "09", "23"])
|
||||
def test_tagsueber_sofort(umgebung, stunde):
|
||||
tmp, env, protokoll = umgebung
|
||||
assert _notify(env, "-s", "[Test]", "Hallo", stunde=stunde).returncode == 0
|
||||
assert "Hallo" in _gesendet(protokoll)
|
||||
assert not (tmp / "night-queue.txt").exists()
|
||||
|
||||
|
||||
def _morgenmeldung(env, **extra):
|
||||
return subprocess.run([BASH, (DEPLOY / "morgenmeldung.sh").as_posix()], env={**env, **extra},
|
||||
capture_output=True, text=True, timeout=60)
|
||||
|
||||
|
||||
def test_morgenmeldung_schickt_alles_als_eine_nachricht(umgebung):
|
||||
tmp, env, protokoll = umgebung
|
||||
_notify(env, "-s", "[Box-Update]", "Router aktualisiert")
|
||||
_notify(env, "-s", "[Modell-Radar]", "Kandidat bestanden")
|
||||
assert _morgenmeldung(env).returncode == 0
|
||||
gesendet = _gesendet(protokoll)
|
||||
assert gesendet.count("send --to telegram") == 1
|
||||
assert "[Morgenmeldung]" in gesendet and "2 Meldungen" in gesendet
|
||||
assert "Router aktualisiert" in gesendet and "Kandidat bestanden" in gesendet
|
||||
assert not (tmp / "night-queue.txt").exists()
|
||||
assert (tmp / "night-queue.txt.zuletzt-gesendet").exists()
|
||||
|
||||
|
||||
def test_morgenmeldung_ohne_nachtmeldungen_schweigt(umgebung):
|
||||
_, env, protokoll = umgebung
|
||||
assert _morgenmeldung(env).returncode == 0
|
||||
assert _gesendet(protokoll) == ""
|
||||
|
||||
|
||||
def test_morgenmeldung_verliert_nichts_wenn_telegram_klemmt(umgebung):
|
||||
tmp, env, protokoll = umgebung
|
||||
_notify(env, "-s", "[Box-Update]", "Erste Nacht")
|
||||
fehl = _morgenmeldung(env, STUB_EXIT="1", MC_MORGENMELDUNG_VERSUCHE="2")
|
||||
assert fehl.returncode == 1
|
||||
assert (tmp / "night-queue.txt.senden").exists()
|
||||
|
||||
_notify(env, "-s", "[Box-Update]", "Zweite Nacht")
|
||||
protokoll.unlink()
|
||||
assert _morgenmeldung(env).returncode == 0
|
||||
gesendet = _gesendet(protokoll)
|
||||
assert "2 Meldungen" in gesendet
|
||||
assert gesendet.index("Erste Nacht") < gesendet.index("Zweite Nacht")
|
||||
assert not (tmp / "night-queue.txt.senden").exists()
|
||||
|
||||
|
||||
# ── Zweitweg direkt an die Bot-API (seit 24.09.2026) ──────────────────────────────────────────────
|
||||
|
||||
@pytest.fixture
|
||||
def bot_api():
|
||||
"""Ein Ersatz für api.telegram.org, der mitschreibt, was ankommt."""
|
||||
import threading
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
from urllib.parse import parse_qs
|
||||
|
||||
empfangen: list[dict] = []
|
||||
antwort = {"code": 200}
|
||||
|
||||
class Handler(BaseHTTPRequestHandler):
|
||||
def do_POST(self):
|
||||
laenge = int(self.headers.get("Content-Length", 0))
|
||||
felder = parse_qs(self.rfile.read(laenge).decode("utf-8"))
|
||||
empfangen.append({"pfad": self.path, **{k: v[0] for k, v in felder.items()}})
|
||||
self.send_response(antwort["code"])
|
||||
self.end_headers()
|
||||
self.wfile.write(b'{"ok": true}')
|
||||
|
||||
def log_message(self, *args):
|
||||
pass
|
||||
|
||||
server = ThreadingHTTPServer(("127.0.0.1", 0), Handler)
|
||||
threading.Thread(target=server.serve_forever, daemon=True).start()
|
||||
yield f"http://127.0.0.1:{server.server_port}", empfangen, antwort
|
||||
server.shutdown()
|
||||
|
||||
|
||||
def _env_datei(tmp: Path, inhalt: str) -> str:
|
||||
datei = tmp / "hermes.env"
|
||||
datei.write_text(inhalt, encoding="utf-8", newline="\n")
|
||||
return datei.as_posix()
|
||||
|
||||
|
||||
def test_zweitweg_springt_ein_wenn_hermes_scheitert(umgebung, bot_api):
|
||||
tmp, env, _ = umgebung
|
||||
url, empfangen, _ = bot_api
|
||||
env = {**env, "STUB_EXIT": "1", "MC_TELEGRAM_API": url, "MC_TELEGRAM_ENV": _env_datei(
|
||||
tmp, 'OPENAI_API_KEY=egal\nexport TELEGRAM_BOT_TOKEN="123:abc"\nTELEGRAM_HOME_CHANNEL=4711\n'
|
||||
"TELEGRAM_HOME_CHANNEL_THREAD_ID=\n")}
|
||||
lauf = _notify(env, "-s", "[Alarm]", "Hermes steht", stunde="12")
|
||||
assert lauf.returncode == 0
|
||||
assert empfangen == [{"pfad": "/bot123:abc/sendMessage", "chat_id": "4711",
|
||||
"text": "[Alarm]\nHermes steht"}]
|
||||
assert "OK telegram direkt: Hermes steht" in (tmp / "notify.log").read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def test_zweitweg_nimmt_den_thread_mit(umgebung, bot_api):
|
||||
tmp, env, _ = umgebung
|
||||
url, empfangen, _ = bot_api
|
||||
env = {**env, "STUB_EXIT": "1", "MC_TELEGRAM_API": url, "MC_TELEGRAM_ENV": _env_datei(
|
||||
tmp, "TELEGRAM_BOT_TOKEN='9:x'\r\nTELEGRAM_HOME_CHANNEL=-100\r\nTELEGRAM_HOME_CHANNEL_THREAD_ID=7\r\n")}
|
||||
assert _notify(env, "Ohne Betreff", stunde="12").returncode == 0
|
||||
assert empfangen == [{"pfad": "/bot9:x/sendMessage", "chat_id": "-100", "text": "Ohne Betreff",
|
||||
"message_thread_id": "7"}]
|
||||
|
||||
|
||||
def test_hermes_zuerst_zweitweg_bleibt_still(umgebung, bot_api):
|
||||
tmp, env, protokoll = umgebung
|
||||
url, empfangen, _ = bot_api
|
||||
env = {**env, "MC_TELEGRAM_API": url,
|
||||
"MC_TELEGRAM_ENV": _env_datei(tmp, "TELEGRAM_BOT_TOKEN=1:a\nTELEGRAM_HOME_CHANNEL=1\n")}
|
||||
assert _notify(env, "Normal", stunde="12").returncode == 0
|
||||
assert "Normal" in _gesendet(protokoll)
|
||||
assert empfangen == []
|
||||
|
||||
|
||||
@pytest.mark.parametrize("inhalt, code", [
|
||||
("", 200), # keine Zugangsdaten
|
||||
("TELEGRAM_BOT_TOKEN=1:a\n", 200), # Chat fehlt
|
||||
("TELEGRAM_BOT_TOKEN=1:a\nTELEGRAM_HOME_CHANNEL=1\n", 500), # Telegram lehnt ab
|
||||
])
|
||||
def test_letzter_ausweg_wenn_auch_der_zweitweg_scheitert(umgebung, bot_api, inhalt, code):
|
||||
tmp, env, _ = umgebung
|
||||
url, _, antwort = bot_api
|
||||
antwort["code"] = code
|
||||
env = {**env, "STUB_EXIT": "1", "MC_NOTIFY_STRICT": "1", "MC_TELEGRAM_API": url,
|
||||
"MC_TELEGRAM_ENV": _env_datei(tmp, inhalt)}
|
||||
lauf = _notify(env, "-s", "[Test]", "Geht nirgends hin", stunde="12")
|
||||
assert lauf.returncode == 1
|
||||
assert "FALLBACK (telegram fehlgeschlagen" in (tmp / "notify.log").read_text(encoding="utf-8")
|
||||
@@ -0,0 +1,210 @@
|
||||
"""Zwei Instanzen, eine Oberfläche (24.09.2026): Partner-Lebenszeichen, Durchreiche, Wächter, Rollen."""
|
||||
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
from kern import einstellungen as einstellungen_mod
|
||||
from kern import partner
|
||||
from routers import partner as partner_router
|
||||
from services import waechter
|
||||
|
||||
BACKEND = Path(__file__).resolve().parent.parent
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def partner_server():
|
||||
"""Eine Partner-Instanz zum Anfassen: /api/health antwortet, alles andere wird mitgeschrieben."""
|
||||
empfangen: list[dict] = []
|
||||
|
||||
class Handler(BaseHTTPRequestHandler):
|
||||
def _antworte(self, code: int, daten: dict) -> None:
|
||||
roh = json.dumps(daten).encode()
|
||||
self.send_response(code)
|
||||
self.send_header("Content-Type", "application/json")
|
||||
self.send_header("Content-Length", str(len(roh)))
|
||||
self.end_headers()
|
||||
self.wfile.write(roh)
|
||||
|
||||
def _merke(self) -> None:
|
||||
laenge = int(self.headers.get("Content-Length") or 0)
|
||||
empfangen.append({"methode": self.command, "pfad": self.path,
|
||||
"koerper": self.rfile.read(laenge).decode() if laenge else "",
|
||||
"von": self.headers.get("X-MC-Von")})
|
||||
|
||||
def do_GET(self):
|
||||
if self.path == "/api/health":
|
||||
self._antworte(200, {"status": "ok", "version": "t", "rolle": "homelab", "instanz": "Homelab"})
|
||||
return
|
||||
self._merke()
|
||||
self._antworte(200, {"pfad": self.path})
|
||||
|
||||
def do_POST(self):
|
||||
self._merke()
|
||||
self._antworte(202, {"angenommen": True})
|
||||
|
||||
def log_message(self, *args):
|
||||
pass
|
||||
|
||||
server = ThreadingHTTPServer(("127.0.0.1", 0), Handler)
|
||||
threading.Thread(target=server.serve_forever, daemon=True).start()
|
||||
yield f"http://127.0.0.1:{server.server_port}", empfangen
|
||||
server.shutdown()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mit_partner(monkeypatch):
|
||||
"""Setzt MC_PARTNER_URL und leert die Caches davor und danach."""
|
||||
def setzen(url: str) -> None:
|
||||
monkeypatch.setenv("MC_PARTNER_URL", url)
|
||||
einstellungen_mod.einstellungen.cache_clear()
|
||||
partner._cache.update(ts=0.0, daten=None)
|
||||
|
||||
yield setzen
|
||||
monkeypatch.delenv("MC_PARTNER_URL", raising=False)
|
||||
einstellungen_mod.einstellungen.cache_clear()
|
||||
partner._cache.update(ts=0.0, daten=None)
|
||||
|
||||
|
||||
def test_ohne_partner_nichts_eingerichtet(mit_partner):
|
||||
mit_partner("")
|
||||
assert partner.status() == {"eingerichtet": False}
|
||||
|
||||
|
||||
def test_partner_antwortet(mit_partner, partner_server):
|
||||
url, _ = partner_server
|
||||
mit_partner(url)
|
||||
stand = partner.status()
|
||||
assert stand["erreichbar"] is True and stand["rolle"] == "homelab" and stand["name"] == "Homelab"
|
||||
|
||||
|
||||
def test_partner_weg(mit_partner):
|
||||
mit_partner("http://127.0.0.1:9")
|
||||
stand = partner.status()
|
||||
assert stand["erreichbar"] is False and stand["fehler"] == "nicht erreichbar"
|
||||
|
||||
|
||||
def _client() -> TestClient:
|
||||
app = FastAPI()
|
||||
app.include_router(partner_router.router)
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
def test_durchreiche_get_und_post(mit_partner, partner_server):
|
||||
url, empfangen = partner_server
|
||||
mit_partner(url)
|
||||
c = _client()
|
||||
r = c.get("/api/partner/homelab/ziele", params={"frisch": "1"})
|
||||
assert r.status_code == 200 and r.json() == {"pfad": "/api/homelab/ziele?frisch=1"}
|
||||
r = c.post("/api/partner/homelab/update", json={"ziel": "ct:101"})
|
||||
assert r.status_code == 202
|
||||
letzte = empfangen[-1]
|
||||
assert (letzte["methode"], letzte["pfad"], letzte["von"]) == ("POST", "/api/homelab/update", "Box-Wart")
|
||||
assert json.loads(letzte["koerper"]) == {"ziel": "ct:101"}
|
||||
|
||||
|
||||
def test_instanz_ohne_netz(mit_partner):
|
||||
mit_partner("http://127.0.0.1:9")
|
||||
assert _client().get("/api/instanz").json() == {
|
||||
"rolle": "box", "instanz": "Box-Wart", "partner": {"eingerichtet": True, "name": "Homelab"}}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("pfad", ["partner/health", "partner", "stream"])
|
||||
def test_durchreiche_sperrt_schleifen_und_strom(mit_partner, partner_server, pfad):
|
||||
url, empfangen = partner_server
|
||||
mit_partner(url)
|
||||
assert _client().get(f"/api/partner/{pfad}").status_code == 404
|
||||
assert empfangen == []
|
||||
|
||||
|
||||
def test_durchreiche_ohne_partner_und_bei_ausfall(mit_partner):
|
||||
mit_partner("")
|
||||
assert _client().get("/api/partner/health").status_code == 404
|
||||
mit_partner("http://127.0.0.1:9")
|
||||
r = _client().get("/api/partner/health")
|
||||
assert r.status_code == 502 and "nicht erreichbar" in r.json()["detail"]
|
||||
|
||||
|
||||
def test_waechter_meldet_den_partner_erst_nach_geduld(monkeypatch, tmp_path):
|
||||
"""Ein Neustart der anderen Instanz (Sonntags-Update) ist kein Alarm; erst nach PARTNER_TAKTE."""
|
||||
monkeypatch.setattr(waechter, "STORE_PATH", tmp_path / "waechter.json")
|
||||
monkeypatch.setattr(waechter, "_stand", {"hinweise": {}, "kandidaten": {}, "verlauf": [], "auto": {}, "stand": 0.0})
|
||||
monkeypatch.setattr(waechter, "_update_laeuft", lambda: False)
|
||||
monkeypatch.setattr(waechter, "PRUEFUNGEN", (waechter.pruefe_partner,))
|
||||
monkeypatch.setattr(waechter, "PARTNER_TAKTE", 3)
|
||||
meldungen: list[str] = []
|
||||
monkeypatch.setattr(waechter, "_telegram", lambda betreff, text: meldungen.append(text))
|
||||
erreichbar = [False]
|
||||
monkeypatch.setattr(partner, "status", lambda frisch=False: {
|
||||
"eingerichtet": True, "name": "Homelab", "url": "http://pve:9001",
|
||||
"erreichbar": erreichbar[0], "fehler": None if erreichbar[0] else "nicht erreichbar"})
|
||||
|
||||
waechter.takt()
|
||||
waechter.takt()
|
||||
assert waechter._stand["hinweise"] == {} and meldungen == []
|
||||
waechter.takt()
|
||||
assert waechter._stand["hinweise"]["partner"]["titel"] == "Homelab antwortet nicht"
|
||||
assert len(meldungen) == 1 and "Proxmox-PC (http://pve:9001) ist nicht erreichbar" in meldungen[0]
|
||||
|
||||
erreichbar[0] = True
|
||||
waechter.takt()
|
||||
assert waechter._stand["hinweise"] == {}
|
||||
assert meldungen[-1] == "Erledigt: Homelab antwortet nicht."
|
||||
|
||||
|
||||
def test_ohne_partner_kein_befund(monkeypatch):
|
||||
monkeypatch.setattr(partner, "status", lambda frisch=False: {"eingerichtet": False})
|
||||
assert waechter.pruefe_partner() == []
|
||||
|
||||
|
||||
def _starte(rolle: str, tmp_path: Path, code: str) -> dict:
|
||||
"""Die App frisch in einem eigenen Interpreter laden — die Rolle wird beim Import festgelegt."""
|
||||
env = {**os.environ, "MC_ROLLE": rolle, "MC_MODELS_DIR": str(tmp_path), "MC_DATEN_DIR": str(tmp_path),
|
||||
"MC_PARTNER_URL": ""}
|
||||
lauf = subprocess.run([sys.executable, "-c", code], cwd=BACKEND, env=env, capture_output=True, text=True,
|
||||
timeout=120)
|
||||
assert lauf.returncode == 0, lauf.stderr[-2000:]
|
||||
return json.loads(lauf.stdout.strip().splitlines()[-1])
|
||||
|
||||
|
||||
_PROBE = """
|
||||
import json, sys
|
||||
from fastapi.testclient import TestClient
|
||||
import app
|
||||
pfade = sorted(app.app.openapi()["paths"])
|
||||
gesund = TestClient(app.app).get("/api/health").json()
|
||||
box_module = [m for m in ("services.llamaswap", "services.maintenance", "routers.boxwart") if m in sys.modules]
|
||||
print(json.dumps({"pfade": pfade, "gesund": gesund, "box_module": box_module}))
|
||||
"""
|
||||
|
||||
|
||||
def test_homelab_rolle_laedt_nichts_von_der_box(tmp_path):
|
||||
ergebnis = _starte("homelab", tmp_path, _PROBE)
|
||||
assert ergebnis["gesund"] == {"status": "ok", "version": ergebnis["gesund"]["version"],
|
||||
"rolle": "homelab", "instanz": "Homelab"}
|
||||
assert "/api/start" not in ergebnis["pfade"] and "/api/partner" in ergebnis["pfade"]
|
||||
assert ergebnis["box_module"] == []
|
||||
|
||||
|
||||
def test_box_rolle_bleibt_wie_sie_war(tmp_path):
|
||||
ergebnis = _starte("box", tmp_path, _PROBE.replace('.get("/api/health").json()', '.get("/api/partner").json()'))
|
||||
assert {"/api/start", "/api/health", "/api/partner"} <= set(ergebnis["pfade"])
|
||||
assert ergebnis["gesund"] == {"eingerichtet": False}
|
||||
|
||||
|
||||
def test_unbekannte_rolle_bricht_ab(monkeypatch):
|
||||
monkeypatch.setenv("MC_ROLLE", "keller")
|
||||
einstellungen_mod.einstellungen.cache_clear()
|
||||
try:
|
||||
with pytest.raises(ValueError, match="MC_ROLLE"):
|
||||
einstellungen_mod.einstellungen()
|
||||
finally:
|
||||
monkeypatch.delenv("MC_ROLLE")
|
||||
einstellungen_mod.einstellungen.cache_clear()
|
||||
@@ -0,0 +1,777 @@
|
||||
"""Tests für das Modell-Radar: reine Logik (Zeitfenster, Wochengrenze, Filter, Speicher-Rechnung,
|
||||
Zustandsübergänge) und die Urteile und Programmieraufgaben des Prüfstands — ohne Netz und ohne Box.
|
||||
Zahlen wie auf der Box am 23.09.2026 (Ornith 21,7 GB + mmproj, Flash-Next UD-IQ3_XXS 82 GB)."""
|
||||
|
||||
import json
|
||||
import os
|
||||
import struct
|
||||
import time
|
||||
import zlib
|
||||
from datetime import date, datetime
|
||||
|
||||
import pytest
|
||||
from services import radar
|
||||
|
||||
BERLIN = radar.LOCAL_TZ
|
||||
|
||||
|
||||
def _zeit(text: str) -> datetime:
|
||||
return datetime.fromisoformat(text).replace(tzinfo=BERLIN)
|
||||
|
||||
|
||||
def _kandidat(kid: str, rolle: str = "hirn", status: str = "neu", quelle: str = "watchlist", rang: int = 0,
|
||||
**extra) -> dict:
|
||||
return {"id": kid, "name": kid, "rolle": rolle, "repo": f"org/{kid}-GGUF", "quant": "Q4_K_M", "quelle": quelle,
|
||||
"rang": rang, "datei": f"{kid}-Q4_K_M.gguf", "dateien": [[f"{kid}-Q4_K_M.gguf", 100]],
|
||||
"mmproj": ["mmproj-F16.gguf", 10], "draft": None, "flags": [], "groesse_gb": 21.7, "passt": True,
|
||||
"eng": False, "ctx": 131072, "status": status, "begruendung": "", **extra}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def zustand(monkeypatch, tmp_path):
|
||||
"""Radar-Zustand und Radar-Ordner in einem Temp-Ordner statt unter /srv/models."""
|
||||
monkeypatch.setattr(radar, "STORE_PATH", tmp_path / "mc2-radar.json")
|
||||
monkeypatch.setattr(radar, "RADAR_DIR", tmp_path / "radar")
|
||||
(tmp_path / "radar").mkdir()
|
||||
return tmp_path
|
||||
|
||||
|
||||
# --- Zeitfenster und Wochengrenze ------------------------------------------------------------
|
||||
|
||||
def test_zeitfenster_nur_von_0030_bis_0230():
|
||||
assert not radar.im_fenster(_zeit("2026-09-29T00:29:59"))
|
||||
assert radar.im_fenster(_zeit("2026-09-29T00:30:00"))
|
||||
assert radar.im_fenster(_zeit("2026-09-29T02:29:59"))
|
||||
assert not radar.im_fenster(_zeit("2026-09-29T02:30:00"))
|
||||
assert not radar.im_fenster(_zeit("2026-09-29T14:00:00"))
|
||||
assert radar.fenster_ende(_zeit("2026-09-29T00:45:00")) == _zeit("2026-09-29T02:30:00")
|
||||
|
||||
|
||||
def test_fensterende_am_umstellungstag_nie_nach_0300():
|
||||
# 28.03.2027: 02:00 springt auf 03:00, 02:30 gibt es nicht. Lieber früher enden als in den NerdQuiz-Lauf.
|
||||
ende = radar.fenster_ende(_zeit("2027-03-28T00:45:00"))
|
||||
assert ende.timestamp() <= datetime(2027, 3, 28, 3, 0, tzinfo=BERLIN).timestamp()
|
||||
|
||||
|
||||
def test_naechster_start_ist_die_naechste_nacht():
|
||||
assert radar.naechster_start(_zeit("2026-09-29T00:10:00")) == _zeit("2026-09-29T00:30:00")
|
||||
assert radar.naechster_start(_zeit("2026-09-29T01:00:00")) == _zeit("2026-09-30T00:30:00")
|
||||
assert radar.naechster_start(_zeit("2026-09-29T14:00:00")) == _zeit("2026-09-30T00:30:00")
|
||||
|
||||
|
||||
def test_wochengrenze_ein_neuer_kandidat_pro_woche():
|
||||
stand = radar._leer()
|
||||
stand["kandidaten"] = {"a": _kandidat("a", status="bestanden",
|
||||
erster_start=_zeit("2026-09-29T00:31:00").timestamp()),
|
||||
"b": _kandidat("b")}
|
||||
assert radar.wochen_frei_ab(stand, date(2026, 10, 3)) == date(2026, 10, 6)
|
||||
assert radar.wochen_frei_ab(stand, date(2026, 10, 6)) is None
|
||||
|
||||
|
||||
def test_plan_testet_nur_im_fenster_mit_freier_woche_und_setzt_angefangene_fort():
|
||||
stand = radar._leer()
|
||||
stand["kandidaten"] = {"a": _kandidat("a", status="bestanden",
|
||||
erster_start=_zeit("2026-09-29T00:31:00").timestamp()),
|
||||
"b": _kandidat("b", rang=1)}
|
||||
radar._ordne(stand)
|
||||
assert stand["kandidaten"]["b"]["status"] == "wartet"
|
||||
|
||||
plan = radar.plane_test(stand, _zeit("2026-10-03T00:31:00"))
|
||||
assert plan["kandidat"] is None and "06.10." in plan["grund"]
|
||||
assert radar.plane_test(stand, _zeit("2026-10-06T00:31:00"))["kandidat"] == "b"
|
||||
assert "außerhalb" in radar.plane_test(stand, _zeit("2026-10-06T14:00:00"))["grund"]
|
||||
assert "Minuten" in radar.plane_test(stand, _zeit("2026-10-06T02:15:00"))["grund"]
|
||||
|
||||
# angefangen (z. B. Download unterbrochen): darf in der nächsten Nacht weiter, die Woche gilt ab dem 1. Start
|
||||
stand["kandidaten"]["b"]["erster_start"] = _zeit("2026-10-06T00:31:00").timestamp()
|
||||
assert radar.plane_test(stand, _zeit("2026-10-07T00:31:00"))["kandidat"] == "b"
|
||||
|
||||
|
||||
def test_laufender_test_blockiert_zweiten_lauf_und_zeigt_sich(zustand):
|
||||
stand = radar._leer()
|
||||
stand["kandidaten"]["b"] = _kandidat("b", status="wartet", erster_start=time.time())
|
||||
stand["lauf"] = {"pid": os.getpid(), "kandidat": "b", "schritt": "test", "seit": time.time(),
|
||||
"frist": time.time() + 3600}
|
||||
assert radar.plane_test(stand, _zeit("2026-10-06T00:40:00"))["grund"] == "es läuft schon ein Test"
|
||||
radar._speichere(stand)
|
||||
assert radar.uebersicht()["kandidaten"][0]["begruendung"].startswith("Läuft gerade seit")
|
||||
stand["lauf"]["pid"] = 999_999_999 # Prozess tot → Lauf gilt als beendet
|
||||
assert radar._aktiver_lauf(stand) is None
|
||||
|
||||
|
||||
# --- Namen, Filter, Dateien, Speicher -----------------------------------------------------------
|
||||
|
||||
def test_namen_und_staemme():
|
||||
assert radar.anzeigename("bartowski/Qwen_Qwen3.6-35B-A3B-GGUF") == "Qwen3.6-35B-A3B"
|
||||
assert radar.anzeigename("unsloth/Qwen3.8-Flash-Next-GGUF") == "Qwen3.8-Flash-Next"
|
||||
assert radar.stamm("Qwen3.8-Flash-Next-UD-IQ3_XXS-00001-of-00003.gguf") == "qwen3.8-flash-next"
|
||||
assert radar.stamm("Qwen3.6-35B-A3B") == radar.stamm("unsloth/Qwen3.6-35B-A3B-GGUF")
|
||||
assert radar.generation("Qwen3.8-27B") == ("qwen", 3.8)
|
||||
assert radar.kandidat_id("hirn", "ornith-ai/Ornith-1.5-35B-A3B-GGUF", "Q4_K_M") == \
|
||||
"hirn-ornith-ai-ornith-1-5-35b-a3b-gguf-q4-k-m"
|
||||
|
||||
|
||||
def test_entdeckung_filtert_alte_kleine_bekannte_und_dichte_hirne():
|
||||
heute = {"hirn": "Qwen3.6-35B-A3B", "coder": "Qwen3.8-27B"}
|
||||
bekannt = {"qwen3.6-35b-a3b", "qwen3.8-27b"}
|
||||
|
||||
def fund(repo, params, aktiv=None, kuratiert=False):
|
||||
return {"repo": repo, "params_b": params, "caps": {"active_b": aktiv}, "curated": kuratiert}
|
||||
|
||||
def grund(m, rolle):
|
||||
return radar.entdeckung_ungeeignet(m, rolle, bekannt, heute)
|
||||
|
||||
assert "Katalog" in grund(fund("unsloth/Qwen3-Coder-30B-A3B-Instruct-GGUF", 30, 3, kuratiert=True), "coder")
|
||||
assert "auf der Box" in grund(fund("bartowski/Qwen_Qwen3.6-35B-A3B-GGUF", 35, 3), "hirn")
|
||||
assert "zu klein" in grund(fund("lmstudio-community/Qwen2.5-Coder-14B-Instruct-GGUF", 14), "coder")
|
||||
assert "dichte" in grund(fund("bartowski/Hermes-3-Llama-3.1-70B-GGUF", 70), "hirn")
|
||||
assert "ältere Generation" in grund(fund("bartowski/Qwen_Qwen3.5-35B-A3B-GGUF", 35, 3), "hirn")
|
||||
assert grund(fund("unsloth/Qwen-AgentWorld-35B-A3B-GGUF", 35, 3), "hirn") is None
|
||||
|
||||
|
||||
BAUM_FLASH = [
|
||||
{"type": "directory", "path": "UD-IQ3_XXS"},
|
||||
{"path": "UD-IQ3_XXS/Qwen3.8-Flash-Next-UD-IQ3_XXS-00001-of-00003.gguf", "size": 10_000_000},
|
||||
{"path": "UD-IQ3_XXS/Qwen3.8-Flash-Next-UD-IQ3_XXS-00002-of-00003.gguf", "size": 49_570_000_000},
|
||||
{"path": "UD-IQ3_XXS/Qwen3.8-Flash-Next-UD-IQ3_XXS-00003-of-00003.gguf", "lfs": {"size": 32_380_000_000}},
|
||||
{"path": "UD-IQ4_XS/Qwen3.8-Flash-Next-UD-IQ4_XS-00001-of-00003.gguf", "size": 10_000_000},
|
||||
{"path": "MTP/mtp-Qwen3.8-Flash-Next-Q8_0.gguf", "size": 4_140_000_000},
|
||||
{"path": "mmproj-BF16.gguf", "size": 910_000_000},
|
||||
{"path": "mmproj-F16.gguf", "size": 900_000_000},
|
||||
]
|
||||
KOPF_FLASH = {"general.architecture": "qwen4exp", "qwen4exp.block_count": 48, "qwen4exp.attention.head_count": 24,
|
||||
"qwen4exp.attention.head_count_kv": 2, "qwen4exp.attention.key_length": 256,
|
||||
"qwen4exp.attention.value_length": 256, "qwen4exp.full_attention_interval": 4}
|
||||
|
||||
|
||||
def test_dateiauswahl_nimmt_alle_teile_des_quants_und_das_f16_mmproj():
|
||||
wahl = radar.waehle_dateien(BAUM_FLASH, "UD-IQ3_XXS")
|
||||
assert [p.rsplit("-", 3)[-3] for p, _ in wahl["dateien"]] == ["00001", "00002", "00003"]
|
||||
assert all(p.startswith("UD-IQ3_XXS/") for p, _ in wahl["dateien"])
|
||||
assert wahl["mmproj"] == ["mmproj-F16.gguf", 900_000_000]
|
||||
assert radar.waehle_dateien(BAUM_FLASH, "Q4_K_M") is None
|
||||
assert radar.waehle_dateien(BAUM_FLASH, "Q8_0") is None # der MTP-Kopf ist kein Modell
|
||||
ornith = [{"path": "Ornith-1.5-35B-Q4_K_M.gguf", "size": 21_710_000_000},
|
||||
{"path": "Ornith-1.5-35B-Q8_0.gguf", "size": 37_800_000_000},
|
||||
{"path": "mmproj-Ornith-1.5-35B-BF16.gguf", "size": 900_000_000}]
|
||||
wahl = radar.waehle_dateien(ornith, "Q4_K_M")
|
||||
assert wahl["dateien"] == [["Ornith-1.5-35B-Q4_K_M.gguf", 21_710_000_000]]
|
||||
assert wahl["mmproj"][0] == "mmproj-Ornith-1.5-35B-BF16.gguf"
|
||||
|
||||
|
||||
def _gguf_kopf_bytes(werte: dict) -> bytes:
|
||||
"""Minimaler GGUF-Kopf wie von llama.cpp: Architektur-Schlüssel, danach der Tokenizer (abgeschnitten)."""
|
||||
def s(text: str) -> bytes:
|
||||
return struct.pack("<Q", len(text.encode())) + text.encode()
|
||||
|
||||
teile = [b"GGUF", struct.pack("<I", 3), struct.pack("<Q", 0), struct.pack("<Q", len(werte) + 1)]
|
||||
for k, v in werte.items():
|
||||
if isinstance(v, str):
|
||||
teile += [s(k), struct.pack("<I", 8), s(v)]
|
||||
elif isinstance(v, list):
|
||||
teile += [s(k), struct.pack("<I", 9), struct.pack("<I", 5), struct.pack("<Q", len(v)),
|
||||
struct.pack(f"<{len(v)}i", *v)]
|
||||
else:
|
||||
teile += [s(k), struct.pack("<I", 4), struct.pack("<I", v)]
|
||||
teile += [s("tokenizer.ggml.tokens"), struct.pack("<I", 9)]
|
||||
return b"".join(teile)
|
||||
|
||||
|
||||
def test_kv_cache_aus_dem_gguf_kopf_kennt_hybrid_modelle():
|
||||
daten = _gguf_kopf_bytes({"general.architecture": "qwen35moe", "qwen35moe.block_count": 41,
|
||||
"qwen35moe.attention.head_count": 16, "qwen35moe.attention.head_count_kv": 2,
|
||||
"qwen35moe.rope.dimension_sections": [11, 11, 10, 0],
|
||||
"qwen35moe.attention.key_length": 256, "qwen35moe.attention.value_length": 256,
|
||||
"qwen35moe.full_attention_interval": 4})
|
||||
kopf = radar.gguf_kopf_aus_bytes(daten)
|
||||
assert kopf["general.architecture"] == "qwen35moe" and kopf["qwen35moe.block_count"] == 41
|
||||
# 11 von 41 Schichten mit KV-Cache × 2 KV-Köpfe × (256 + 256) × 1,0625 Byte (q8_0)
|
||||
assert radar.kv_je_token_gb(kopf) == pytest.approx(11 * 2 * 512 * 1.0625 / 1e9)
|
||||
assert radar.kv_je_token_gb(kopf) * 131072 == pytest.approx(1.57, abs=0.01)
|
||||
ohne_hybrid = {k: v for k, v in kopf.items() if not k.endswith("full_attention_interval")}
|
||||
assert radar.kv_je_token_gb(ohne_hybrid) == pytest.approx(41 * 2 * 512 * 1.0625 / 1e9)
|
||||
assert radar.gguf_kopf_aus_bytes(b"kein gguf") is None
|
||||
assert radar.gguf_kopf_aus_bytes(daten[:20]) is None
|
||||
|
||||
|
||||
def test_speicher_passung_nach_der_115_gb_regel():
|
||||
budget = radar.SPEICHER_GRENZE_GB - radar.WARMSET_GB
|
||||
p = radar.speicher_passung("hirn", 22.6, 11968 / 1e9) # Ornith: bequem, voller Kontext
|
||||
assert p["passt"] and not p["eng"] and p["ctx"] == 131072
|
||||
p = radar.speicher_passung("coder", 82.9, 13056 / 1e9, eng_markiert=True) # Flash-Next: knapp
|
||||
assert p["passt"] and p["eng"] and p["bedarf_gb"] <= budget
|
||||
p = radar.speicher_passung("coder", 82.9, 52224 / 1e9) # großer KV-Cache → halber Kontext
|
||||
assert p["passt"] and p["eng"] and p["ctx"] == 65536
|
||||
p = radar.speicher_passung("coder", 94.0, 13056 / 1e9) # UD-IQ4_XS passt nicht
|
||||
assert not p["passt"] and p["text"].startswith("Passt nicht")
|
||||
p = radar.speicher_passung("hirn", 85.0, 1e-6) # Test ja, im Betrieb neben dem Coder knapp
|
||||
assert p["passt"] and p["eng"] and "Im Betrieb" in p["text"]
|
||||
assert radar.platte_nach_download(265, 1900, 82) == pytest.approx(18.26, abs=0.01)
|
||||
|
||||
|
||||
def test_bewertung_ohne_netz(monkeypatch):
|
||||
monkeypatch.setattr(radar, "_hf_baum", lambda repo: BAUM_FLASH)
|
||||
monkeypatch.setattr(radar, "_gguf_kopf", lambda repo, datei: KOPF_FLASH)
|
||||
monkeypatch.setattr(radar, "engine_kennt", lambda arch: True)
|
||||
fund = {"name": "Qwen3.8-Flash-Next", "rolle": "coder", "repo": "unsloth/Qwen3.8-Flash-Next-GGUF",
|
||||
"quant": "UD-IQ3_XXS", "eng": True, "quelle": "watchlist", "rang": 1}
|
||||
k = radar._bewerte(fund)
|
||||
assert k["tauglich"] and k["passt"] and k["eng"] and k["ctx"] == 131072
|
||||
assert k["datei"] == "Qwen3.8-Flash-Next-UD-IQ3_XXS-00001-of-00003.gguf"
|
||||
assert k["groesse_gb"] == pytest.approx(82.9, abs=0.05)
|
||||
assert k["begruendung"].startswith("Aus der Merkliste. Passt knapp")
|
||||
|
||||
monkeypatch.setattr(radar, "engine_kennt", lambda arch: False)
|
||||
k = radar._bewerte(fund)
|
||||
assert not k["tauglich"] and "qwen4exp" in k["begruendung"]
|
||||
|
||||
# Kopf nicht lesbar: die grobe Schätzung sagt „passt nicht“ — das darf nicht zum Verwerfen führen
|
||||
monkeypatch.setattr(radar, "_gguf_kopf", lambda repo, datei: None)
|
||||
k = radar._bewerte(fund)
|
||||
assert k["netzfehler"] and not k["tauglich"] and k["begruendung"].startswith("Speicherbedarf unklar")
|
||||
|
||||
|
||||
def test_merkliste_im_repo():
|
||||
funde, ok = radar._merkliste()
|
||||
assert ok
|
||||
nach_name = {f["name"]: f for f in funde}
|
||||
assert nach_name["Ornith-1.5-35B-A3B"]["rolle"] == "hirn"
|
||||
assert nach_name["Qwen3.8-Flash-Next"]["quant"] == "UD-IQ3_XXS" and nach_name["Qwen3.8-Flash-Next"]["eng"] is True
|
||||
k2 = radar._bewerte(nach_name["K2-Horizon-MoVA-36B-A4B"]) # übersprungen, ganz ohne Netz
|
||||
assert not k2["tauglich"] and k2["begruendung"].startswith("Übersprungen") and "llama.cpp" in k2["begruendung"]
|
||||
|
||||
|
||||
# --- Zustandsübergänge ---------------------------------------------------------------------
|
||||
|
||||
def test_suche_uebernimmt_merkliste_und_laesst_geschichte_in_ruhe():
|
||||
stand = radar._leer()
|
||||
stand["kandidaten"] = {"alt": _kandidat("alt", status="durchgefallen", quelle="discover"),
|
||||
"weg": _kandidat("weg", quelle="discover"),
|
||||
"nein": _kandidat("nein", status="verworfen", verworfen_von="nutzer")}
|
||||
bewertet = [
|
||||
{**_kandidat("ornith", rang=0), "tauglich": True, "begruendung": "Aus der Merkliste. Passt."},
|
||||
{**_kandidat("k2", rang=2), "tauglich": False, "passt": False,
|
||||
"begruendung": "Übersprungen: läuft noch nicht im offiziellen llama.cpp."},
|
||||
{**_kandidat("alt", quelle="discover"), "tauglich": True},
|
||||
{**_kandidat("nein"), "tauglich": True},
|
||||
{**_kandidat("klein", quelle="discover"), "tauglich": False, "begruendung": "Kein GGUF mit Q4_K_M im Repo."},
|
||||
]
|
||||
ergebnis = radar._suche_anwenden(stand, bewertet, {"watchlist": True, "discover": True}, 1000.0)
|
||||
k = stand["kandidaten"]
|
||||
assert k["ornith"]["status"] == "wartet" # Kopf der Warteschlange
|
||||
assert k["k2"]["status"] == "verworfen" and k["k2"]["verworfen_von"] == "radar"
|
||||
assert k["alt"]["status"] == "durchgefallen" # Geschichte bleibt
|
||||
assert k["nein"]["status"] == "verworfen" and k["nein"]["verworfen_von"] == "nutzer"
|
||||
assert "weg" not in k and "klein" not in k
|
||||
assert "klein" in stand["abgelehnt"]
|
||||
assert ergebnis["neu"] == ["ornith"] and stand["letzte_suche"] == 1000.0
|
||||
|
||||
stand["kandidaten"]["weg"] = _kandidat("weg", quelle="discover")
|
||||
radar._suche_anwenden(stand, [], {"watchlist": True, "discover": False}, 2000.0) # Entdeckung offline
|
||||
assert "weg" in stand["kandidaten"]
|
||||
|
||||
|
||||
def test_bestanden_bleibt_liegen_durchgefallen_wird_geloescht(zustand):
|
||||
stand = radar._leer()
|
||||
for kid in ("gut", "schlecht"):
|
||||
ordner = radar.RADAR_DIR / kid
|
||||
ordner.mkdir()
|
||||
(ordner / "m.gguf").write_bytes(b"x")
|
||||
stand["kandidaten"][kid] = _kandidat(kid, status="wartet", ordner=str(ordner), erster_start=1.0)
|
||||
test = radar._abschluss(stand, "gut", "bestanden", "Bestanden: versteht Bilder (heute nicht).",
|
||||
werte={"tiefe": {"tg_tps": 95.0}}, vergleich={"Bilder": {"urteil": "besser"}})
|
||||
radar._abschluss(stand, "schlecht", "durchgefallen", "Durchgefallen: Werkzeuge 3/6 (heute 6/6).")
|
||||
assert stand["kandidaten"]["gut"]["status"] == "bestanden" and (radar.RADAR_DIR / "gut").exists()
|
||||
assert "wartet auf deine Entscheidung" in stand["kandidaten"]["gut"]["begruendung"]
|
||||
assert stand["kandidaten"]["schlecht"]["status"] == "durchgefallen" and not (radar.RADAR_DIR / "schlecht").exists()
|
||||
assert [t["ergebnis"] for t in stand["tests"]] == ["bestanden", "durchgefallen"]
|
||||
assert set(test) == set(radar.TEST_FELDER)
|
||||
|
||||
|
||||
def test_abbrueche_zaehlen_nur_mit_schuld_des_kandidaten(zustand):
|
||||
ordner = radar.RADAR_DIR / "zaeh"
|
||||
ordner.mkdir()
|
||||
stand = radar._leer()
|
||||
stand["kandidaten"]["zaeh"] = _kandidat("zaeh", status="wartet", ordner=str(ordner), erster_start=1.0)
|
||||
radar._abschluss(stand, "zaeh", "abgebrochen", "Port 5899 ist belegt.", zaehlt=False)
|
||||
assert stand["kandidaten"]["zaeh"]["status"] == "wartet" and not stand["kandidaten"]["zaeh"].get("versuche")
|
||||
for _ in range(radar.VERSUCHE_MAX):
|
||||
radar._abschluss(stand, "zaeh", "abgebrochen", "Das Nachtfenster endete um 02:30, bevor der Test fertig war.")
|
||||
k = stand["kandidaten"]["zaeh"]
|
||||
assert k["status"] == "durchgefallen" and k["begruendung"].startswith(f"{radar.VERSUCHE_MAX}-mal abgebrochen")
|
||||
assert not ordner.exists()
|
||||
assert {t["ergebnis"] for t in stand["tests"]} == {"abgebrochen"}
|
||||
|
||||
|
||||
def test_ohne_vergleichswerte_bleibt_der_kandidat_getestet(zustand):
|
||||
stand = radar._leer()
|
||||
stand["kandidaten"]["g"] = _kandidat("g", status="wartet", erster_start=1.0)
|
||||
assert radar._abschluss(stand, "g", None, "", werte={"tiefe": {"tg_tps": 80.0}}) is None
|
||||
assert stand["kandidaten"]["g"]["status"] == "getestet" and stand["kandidaten"]["g"]["messung"]
|
||||
assert stand["tests"] == []
|
||||
|
||||
|
||||
def test_verwerfen_und_uebernehmen_pruefen_den_status(zustand):
|
||||
ordner = radar.RADAR_DIR / "b"
|
||||
ordner.mkdir()
|
||||
stand = radar._leer()
|
||||
stand["kandidaten"] = {"b": _kandidat("b", status="bestanden", ordner=str(ordner)),
|
||||
"u": _kandidat("u", status="uebernommen"), "n": _kandidat("n")}
|
||||
radar._speichere(stand)
|
||||
assert radar.uebernehmen("n")["status"] == 409 # nur bestandene
|
||||
assert radar.uebernehmen("gibt-es-nicht")["status"] == 404
|
||||
assert radar.verwerfen("u")["status"] == 409 # schon übernommen
|
||||
antwort = radar.verwerfen("b")
|
||||
assert antwort["ok"] and antwort["kandidat"]["status"] == "verworfen"
|
||||
assert not ordner.exists()
|
||||
assert radar._lade()["kandidaten"]["b"]["verworfen_von"] == "nutzer"
|
||||
|
||||
|
||||
def test_uebersicht_hat_die_felder_der_oberflaeche(zustand):
|
||||
stand = radar._leer()
|
||||
stand["kandidaten"] = {"a": _kandidat("a", status="wartet", erster_start=_zeit("2026-09-29T00:31:00").timestamp()),
|
||||
"b": _kandidat("b", rang=1)}
|
||||
radar._abschluss(stand, "a", "bestanden", "Bestanden: Prefill bei 13k 18 % schneller.",
|
||||
zeit=_zeit("2026-09-29T01:40:00").timestamp())
|
||||
radar._speichere(stand)
|
||||
u = radar.uebersicht(_zeit("2026-10-01T12:00:00"))
|
||||
assert set(u) == {"naechster_test", "letzter_test", "kandidaten", "tests"}
|
||||
assert set(u["kandidaten"][0]) == set(radar.KANDIDAT_FELDER)
|
||||
assert [k["status"] for k in u["kandidaten"]] == ["wartet", "bestanden"]
|
||||
assert u["naechster_test"] == "2026-10-06T00:30:00+02:00" # die Woche ab dem 29.09. ist belegt
|
||||
assert u["letzter_test"].startswith("2026-09-29T01:40")
|
||||
assert set(u["tests"][0]) == set(radar.TEST_FELDER)
|
||||
|
||||
|
||||
def _uebernahme_vorbereiten(zustand, monkeypatch, rolle: str, alt: dict) -> tuple[list, dict]:
|
||||
"""Bestandenen Kandidaten „o“ anlegen und llama-swap/Hirn-Wechsel durch Protokoll-Attrappen ersetzen."""
|
||||
modelle = zustand / "models"
|
||||
modelle.mkdir()
|
||||
monkeypatch.setattr(radar, "MODELS_DIR", modelle)
|
||||
ordner = radar.RADAR_DIR / "o"
|
||||
ordner.mkdir()
|
||||
(ordner / "o-Q4_K_M.gguf").write_bytes(b"x")
|
||||
(ordner / "mmproj-F16.gguf").write_bytes(b"x")
|
||||
stand = radar._leer()
|
||||
stand["kandidaten"]["o"] = _kandidat("o", rolle=rolle, status="bestanden", ordner=str(ordner))
|
||||
radar._speichere(stand)
|
||||
aufrufe = []
|
||||
cfg = {"models": {"alter-zwilling": {"cmd": "llama-server -m /alt.gguf --mmproj /alt-mm.gguf",
|
||||
"aliases": ["vision" if rolle == "hirn" else "coder-bild"]}},
|
||||
"groups": {"bild": {"swap": True, "exclusive": False, "members": ["alter-zwilling"]}}}
|
||||
|
||||
def eintragen(pfad, **kw):
|
||||
aufrufe.append(("eintragen", pfad, kw["role"], kw["set_alias"]))
|
||||
cfg["models"]["o"] = {"cmd": "vorlage"}
|
||||
return "o"
|
||||
|
||||
from services import agent
|
||||
monkeypatch.setattr(radar, "_heutige_modelle", lambda: {rolle: alt})
|
||||
monkeypatch.setattr(radar.llamaswap, "register_model", eintragen)
|
||||
monkeypatch.setattr(radar.llamaswap, "read_config", lambda: cfg)
|
||||
monkeypatch.setattr(radar.llamaswap, "write_config", lambda c: aufrufe.append(("schreiben",)))
|
||||
monkeypatch.setattr(radar.llamaswap, "set_role", lambda mid, r: aufrufe.append(("rolle", mid, r)) or True)
|
||||
monkeypatch.setattr(radar.llamaswap, "add_role", lambda mid, r: aufrufe.append(("dazu", mid, r)) or True)
|
||||
monkeypatch.setattr(radar, "_steward_neu_starten", lambda: aufrufe.append(("steward",)) or None)
|
||||
monkeypatch.setattr(radar.llamaswap, "set_ttl", lambda mid, ttl: aufrufe.append(("ttl", mid, ttl)) or True)
|
||||
monkeypatch.setattr(agent, "set_agent_brain", lambda mid: aufrufe.append(("hirn", mid)) or {"ok": True})
|
||||
monkeypatch.setattr(radar, "STEWARD_WARMSET", zustand / "warmset.conf")
|
||||
return aufrufe, cfg
|
||||
|
||||
|
||||
def test_uebernehmen_hirn_nutzt_den_hirn_wechsel_und_nimmt_fast_mit(zustand, monkeypatch):
|
||||
aufrufe, cfg = _uebernahme_vorbereiten(zustand, monkeypatch, "hirn",
|
||||
{"name": "Qwen3.6-35B-A3B", "aliases": ["hermes", "fast"], "ttl": 0})
|
||||
(zustand / "warmset.conf").write_text('[Service]\nEnvironment="MC_WARMSET=Qwen3-Embedding-0.6B Qwen3.6-35B-A3B"\n',
|
||||
encoding="utf-8")
|
||||
antwort = radar.uebernehmen("o")
|
||||
assert antwort["ok"] and antwort["modell_id"] == "o"
|
||||
assert [a[0] for a in aufrufe] == ["eintragen", "schreiben", "hirn", "dazu", "steward"]
|
||||
assert aufrufe[0][2:] == ("hermes", False) and aufrufe[3] == ("dazu", "o", "fast")
|
||||
befehl = str(cfg["models"]["o"]["cmd"])
|
||||
assert "--parallel 2" in befehl and "-ctk q8_0" in befehl and "--mmproj" not in befehl
|
||||
# Wie im Betrieb seit 24.09.: Bilder sieht der neue Bild-Zwilling (Projektor, ohne Draft), der alte fliegt raus.
|
||||
zwilling = cfg["models"]["o-Bild"]
|
||||
assert zwilling["aliases"] == ["vision"] and "--mmproj" in str(zwilling["cmd"]) and "--spec" not in str(zwilling["cmd"])
|
||||
assert "alter-zwilling" not in cfg["models"] and cfg["groups"]["bild"]["members"] == ["o-Bild"]
|
||||
assert (zustand / "models" / "o-GGUF" / "o-Q4_K_M.gguf").exists() and not (radar.RADAR_DIR / "o").exists()
|
||||
k = radar._lade()["kandidaten"]["o"]
|
||||
assert k["status"] == "uebernommen" and "vorher Qwen3.6-35B-A3B" in k["begruendung"]
|
||||
# Der Steward hält danach das neue Hirn warm, nicht mehr das alte — ohne Handgriff.
|
||||
assert 'MC_WARMSET=Qwen3-Embedding-0.6B o"' in (zustand / "warmset.conf").read_text(encoding="utf-8")
|
||||
assert antwort["text"] is None and "Noch zu tun" not in k["begruendung"]
|
||||
|
||||
|
||||
def test_uebernehmen_coder_nimmt_heavy_mit(zustand, monkeypatch):
|
||||
aufrufe, cfg = _uebernahme_vorbereiten(zustand, monkeypatch, "coder",
|
||||
{"name": "Qwen3.8-27B", "aliases": ["coder", "heavy"], "ttl": 5400})
|
||||
antwort = radar.uebernehmen("o")
|
||||
assert antwort["ok"] and antwort["text"] is None
|
||||
assert aufrufe[2:] == [("rolle", "o", "coder"), ("dazu", "o", "heavy"), ("ttl", "o", 5400)]
|
||||
assert "--parallel 1" in str(cfg["models"]["o"]["cmd"])
|
||||
|
||||
|
||||
def test_betriebsbefehl_uebernimmt_die_getesteten_flags():
|
||||
pfade = {"gguf": "/srv/models/F-GGUF/f.gguf", "mmproj": "/srv/models/F-GGUF/mm.gguf", "draft": None}
|
||||
k = _kandidat("f", rolle="coder", ctx=65536, draft={"typ": "draft-mtp", "n_max": 2, "datei": None}, flags=["-ub", "512"])
|
||||
befehl = radar.betriebs_befehl("coder", pfade, k)
|
||||
assert "-c 65536" in befehl and "--parallel 1" in befehl and "-ctk q8_0 -ctv q8_0" in befehl
|
||||
assert "--mmproj" not in befehl and "--load-mode none" in befehl # Bilder sieht der Zwilling
|
||||
assert befehl.endswith("--spec-type draft-mtp --spec-draft-n-max 2 -ub 512")
|
||||
zwilling = radar.zwilling_befehl("coder", pfade, k)
|
||||
assert "--mmproj /srv/models/F-GGUF/mm.gguf" in zwilling and "--spec" not in zwilling and "-c 131072" in zwilling
|
||||
assert zwilling.endswith("-ub 512")
|
||||
assert "--parallel 2" in radar.betriebs_befehl("hirn", {"gguf": "x.gguf", "mmproj": None, "draft": None},
|
||||
_kandidat("h"))
|
||||
|
||||
|
||||
def test_betriebsbefehl_nimmt_den_im_test_gewaehlten_draft():
|
||||
"""Gewählt wurde der Draft des heutigen Modells: fester Pfad, nicht der Kandidaten-Ordner."""
|
||||
pfade = {"gguf": "/srv/models/O-GGUF/o.gguf", "mmproj": "/srv/models/O-GGUF/mm.gguf", "draft": None}
|
||||
k = _kandidat("o", draft_betrieb={"typ": "draft-dflash", "ort": "fremd", "pfad": "/srv/models/D/d.gguf",
|
||||
"n_max": None})
|
||||
assert radar.betriebs_befehl("hirn", pfade, k).endswith("--spec-type draft-dflash --spec-draft-model /srv/models/D/d.gguf")
|
||||
k["draft_betrieb"] = None # im Test war „ohne Draft“ am schnellsten
|
||||
assert "--spec" not in radar.betriebs_befehl("hirn", pfade, k)
|
||||
|
||||
|
||||
def _gguf_datei(pfad, arch: str) -> None:
|
||||
"""Winzige GGUF-Datei mit general.architecture (genug für _lokale_architektur)."""
|
||||
wert = arch.encode()
|
||||
schluessel = b"general.architecture"
|
||||
daten = (b"GGUF" + struct.pack("<I", 3) + struct.pack("<Q", 0) + struct.pack("<Q", 1)
|
||||
+ struct.pack("<Q", len(schluessel)) + schluessel + struct.pack("<I", 8)
|
||||
+ struct.pack("<Q", len(wert)) + wert)
|
||||
pfad.write_bytes(daten)
|
||||
|
||||
|
||||
def test_draft_varianten_probieren_den_draft_des_heutigen_modells_bei_gleicher_architektur(tmp_path, monkeypatch):
|
||||
hirn_gguf, draft = tmp_path / "hirn.gguf", tmp_path / "dflash.gguf"
|
||||
_gguf_datei(hirn_gguf, "qwen35moe")
|
||||
draft.write_bytes(b"x" * 1000)
|
||||
heute = {"name": "Hirn", "gguf_path": str(hirn_gguf),
|
||||
"cmd": f"llama-server -m {hirn_gguf} --spec-type draft-dflash --spec-draft-model {draft}"}
|
||||
monkeypatch.setattr(radar, "_heutige_modelle", lambda: {"hirn": heute})
|
||||
pfade = {"gguf": "/x/k.gguf", "mmproj": "/x/mm.gguf", "draft": None}
|
||||
|
||||
gleich = radar._draft_varianten(_kandidat("k", arch="qwen35moe", bedarf_gb=24.2, mtp=True), pfade)
|
||||
assert [v["name"] for v in gleich] == ["ohne Draft", "eingebauter MTP-Kopf", "Draft des heutigen Modells"]
|
||||
assert gleich[2]["flags"] == ["--spec-type", "draft-dflash", "--spec-draft-model", str(draft)]
|
||||
assert gleich[2]["draft"]["ort"] == "fremd"
|
||||
|
||||
fremd = radar._draft_varianten(_kandidat("k", arch="qwen3next", bedarf_gb=24.2), pfade)
|
||||
assert [v["name"] for v in fremd] == ["ohne Draft"] # andere Architektur: nicht probieren
|
||||
zu_gross = radar._draft_varianten(_kandidat("k", arch="qwen35moe", bedarf_gb=88.0), pfade)
|
||||
assert [v["name"] for v in zu_gross] == ["ohne Draft"] # Draft passt nicht mehr in den Speicher
|
||||
merkliste = radar._draft_varianten(_kandidat("k", arch="qwen35moe", draft={"typ": "draft-simple"}),
|
||||
{**pfade, "draft": "/x/d.gguf"})
|
||||
assert [v["name"] for v in merkliste] == ["Draft aus der Merkliste"]
|
||||
|
||||
|
||||
def test_waehle_draft_nimmt_die_schnellste_variante_die_startet(ps, monkeypatch):
|
||||
tempi = {"": 67.4, "draft-mtp": 81.0, "draft-dflash": 98.2}
|
||||
|
||||
def mit_server(gguf, arbeit, *, flags=(), **kw):
|
||||
typ = flags[1] if flags else ""
|
||||
if typ == "draft-mtp":
|
||||
raise ps.ServerFehler("vocab mismatch")
|
||||
return {"tiefe": {"tg_tps": tempi[typ], "acc": 0.8 if typ else None}}, None
|
||||
|
||||
monkeypatch.setattr(ps, "mit_server", mit_server)
|
||||
varianten = [{"name": "ohne Draft", "flags": []},
|
||||
{"name": "eingebauter MTP-Kopf", "flags": ["--spec-type", "draft-mtp"]},
|
||||
{"name": "Draft des heutigen Modells", "flags": ["--spec-type", "draft-dflash"]}]
|
||||
wahl = ps.waehle_draft("/x/k.gguf", varianten, protokoll=lambda *_: None)
|
||||
assert wahl["name"] == "Draft des heutigen Modells"
|
||||
assert wahl["messungen"]["ohne Draft"] == 67.4 and "geht nicht" in wahl["messungen"]["eingebauter MTP-Kopf"]
|
||||
assert "Gemessen mit Draft des heutigen Modells" in radar._draft_text({**wahl, "draft": {"typ": "draft-dflash"}})
|
||||
|
||||
|
||||
def test_mit_draft_laeuft_die_bild_probe_auf_einem_zwilling_ohne_draft(ps, monkeypatch):
|
||||
"""Draft und Bild gehen in llama.cpp nicht zusammen (HTTP 500) — der Prüfstand trennt sie wie der Betrieb."""
|
||||
starts = []
|
||||
|
||||
def mit_server(gguf, arbeit, *, mmproj=None, flags=(), **kw):
|
||||
starts.append((mmproj, list(flags)))
|
||||
return ({"bild": None, "tiefe": {}} if len(starts) == 1 else {"direkt": 1.0, "agentisch": True}), \
|
||||
type("S", (), {"befehl": ["llama-server"], "ladezeit": 4})()
|
||||
|
||||
monkeypatch.setattr(ps, "mit_server", mit_server)
|
||||
lauf = ps.pruefe_kandidat("hirn", "/x/k.gguf", mmproj="/x/mm.gguf",
|
||||
flags=["--spec-type", "draft-dflash", "--spec-draft-model", "/d.gguf", "-ub", "512"])
|
||||
assert starts[0] == (None, ["--spec-type", "draft-dflash", "--spec-draft-model", "/d.gguf", "-ub", "512"])
|
||||
assert starts[1] == ("/x/mm.gguf", ["-ub", "512"])
|
||||
assert lauf["werte"]["bild"] == {"direkt": 1.0, "agentisch": True}
|
||||
|
||||
|
||||
# --- Prüfstand: Urteil, Programmieraufgaben, Bild ----------------------------------------------------
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def ps():
|
||||
return radar._pruefstand()
|
||||
|
||||
|
||||
BILD_GUT = {"direkt": 1.0, "gefunden": ["Wort ZEBRA", "Zahl 4711"], "agentisch": True}
|
||||
|
||||
|
||||
def _werte(rolle: str, decode: float = 100.0, prefill: float = 13.0, werkzeuge: int = 6, code: int = 8,
|
||||
bild: dict | None = None, deutsch: bool = True) -> dict:
|
||||
werte = {"rolle": rolle, "tiefe": {"tg_tps": decode, "pp_s": prefill}, "werkzeuge": {"ok": werkzeuge, "von": 6},
|
||||
"bild": bild}
|
||||
if rolle == "hirn":
|
||||
werte.update(deutsch={"ok": deutsch}, json={"ok": True})
|
||||
else:
|
||||
werte["code"] = {"ok": code, "von": 10}
|
||||
return werte
|
||||
|
||||
|
||||
def test_urteil_hirn_mit_bildern_besteht_ohne_verschlechterung(ps):
|
||||
u = ps.urteil("hirn", _werte("hirn", decode=95.0, bild=BILD_GUT), _werte("hirn", decode=100.8))
|
||||
assert u["ergebnis"] == "bestanden"
|
||||
assert u["zusammenfassung"] == "Bestanden: versteht Bilder (heute nicht)."
|
||||
assert u["vergleich"]["Decode bei 13k"]["urteil"] == "gleich" # −6 % liegt in der Toleranz
|
||||
assert list(u["vergleich"]) == ["Decode bei 13k", "Prefill bei 13k", "Werkzeug-Aufrufe", "Deutsch", "JSON",
|
||||
"Bilder"]
|
||||
|
||||
|
||||
def test_urteil_hirn_faellt_bei_schwaeche_oder_ohne_vorteil_durch(ps):
|
||||
basis = _werte("hirn", decode=100.8)
|
||||
u = ps.urteil("hirn", _werte("hirn", werkzeuge=5, bild=BILD_GUT), basis)
|
||||
assert u["ergebnis"] == "durchgefallen" and "Werkzeuge 5/6 (heute 6/6)" in u["zusammenfassung"]
|
||||
u = ps.urteil("hirn", _werte("hirn", decode=70.0, bild=BILD_GUT), basis)
|
||||
assert u["ergebnis"] == "durchgefallen" and "Decode bei 13k 70 statt 100,8 t/s" in u["zusammenfassung"]
|
||||
u = ps.urteil("hirn", _werte("hirn", bild=BILD_GUT), _werte("hirn", bild=BILD_GUT))
|
||||
assert u["zusammenfassung"] == "Durchgefallen: kein Vorteil gegenüber dem heutigen Modell."
|
||||
u = ps.urteil("hirn", _werte("hirn", decode=130.0), basis) # schneller, aber blind
|
||||
assert u["ergebnis"] == "durchgefallen" and "versteht keine Bilder" in u["zusammenfassung"]
|
||||
|
||||
|
||||
def test_urteil_coder_zaehlt_programmieraufgaben_und_bilder(ps):
|
||||
basis = _werte("coder", decode=30.4, prefill=41.2, code=8, bild=BILD_GUT)
|
||||
u = ps.urteil("coder", _werte("coder", decode=45.0, prefill=12.0, code=9, bild=BILD_GUT), basis)
|
||||
assert u["ergebnis"] == "bestanden" and "Programmieraufgaben 9/10 statt 8/10" in u["zusammenfassung"]
|
||||
u = ps.urteil("coder", _werte("coder", decode=45.0, prefill=12.0, code=7, bild=BILD_GUT), basis)
|
||||
assert u["ergebnis"] == "durchgefallen" and "Programmieraufgaben 7/10 (heute 8/10)" in u["zusammenfassung"]
|
||||
u = ps.urteil("coder", _werte("coder", code=9), basis) # der heutige Coder kann Bilder
|
||||
assert u["ergebnis"] == "durchgefallen" and "versteht keine Bilder" in u["zusammenfassung"]
|
||||
|
||||
|
||||
REFERENZ = {
|
||||
"palindrom": "def ist_palindrom(text):\n z = [c.lower() for c in text if c.isalnum()]\n return z == z[::-1]\n",
|
||||
"roemisch": (
|
||||
"def roemisch(zahl):\n"
|
||||
" if not isinstance(zahl, int) or not 1 <= zahl <= 3999:\n"
|
||||
" raise ValueError(zahl)\n"
|
||||
" out = ''\n"
|
||||
" for wert, zeichen in ((1000, 'M'), (900, 'CM'), (500, 'D'), (400, 'CD'), (100, 'C'), (90, 'XC'),\n"
|
||||
" (50, 'L'), (40, 'XL'), (10, 'X'), (9, 'IX'), (5, 'V'), (4, 'IV'), (1, 'I')):\n"
|
||||
" while zahl >= wert:\n"
|
||||
" out += zeichen\n"
|
||||
" zahl -= wert\n"
|
||||
" return out\n"),
|
||||
"intervalle": (
|
||||
"def zusammenfassen(intervalle):\n"
|
||||
" out = []\n"
|
||||
" for a, b in sorted(intervalle):\n"
|
||||
" if out and a <= out[-1][1]:\n"
|
||||
" out[-1][1] = max(out[-1][1], b)\n"
|
||||
" else:\n"
|
||||
" out.append([a, b])\n"
|
||||
" return out\n"),
|
||||
"flach": (
|
||||
"def flach(liste):\n"
|
||||
" out = []\n"
|
||||
" for x in liste:\n"
|
||||
" out.extend(flach(x) if isinstance(x, list) else [x])\n"
|
||||
" return out\n"),
|
||||
"iban": (
|
||||
"def iban_gueltig(iban):\n"
|
||||
" s = iban.replace(' ', '').upper()\n"
|
||||
" if not 15 <= len(s) <= 34 or not s.isalnum() or not s.isascii():\n"
|
||||
" return False\n"
|
||||
" return int(''.join(str(int(c, 36)) for c in s[4:] + s[:4])) % 97 == 1\n"),
|
||||
"lru": (
|
||||
"from collections import OrderedDict\n"
|
||||
"class LRUCache:\n"
|
||||
" def __init__(self, kapazitaet):\n"
|
||||
" self.k, self.d = kapazitaet, OrderedDict()\n"
|
||||
" def get(self, schluessel):\n"
|
||||
" if schluessel not in self.d:\n"
|
||||
" return None\n"
|
||||
" self.d.move_to_end(schluessel)\n"
|
||||
" return self.d[schluessel]\n"
|
||||
" def put(self, schluessel, wert):\n"
|
||||
" self.d[schluessel] = wert\n"
|
||||
" self.d.move_to_end(schluessel)\n"
|
||||
" if len(self.d) > self.k:\n"
|
||||
" self.d.popitem(last=False)\n"),
|
||||
"klammern": (
|
||||
"def klammern_ok(text):\n"
|
||||
" paare, stapel = {')': '(', ']': '[', '}': '{'}, []\n"
|
||||
" for c in text:\n"
|
||||
" if c in '([{':\n"
|
||||
" stapel.append(c)\n"
|
||||
" elif c in paare and (not stapel or stapel.pop() != paare[c]):\n"
|
||||
" return False\n"
|
||||
" return not stapel\n"),
|
||||
"wege": (
|
||||
"import heapq\n"
|
||||
"def kuerzeste_wege(graph, start):\n"
|
||||
" dist, heap = {start: 0}, [(0, start)]\n"
|
||||
" while heap:\n"
|
||||
" d, u = heapq.heappop(heap)\n"
|
||||
" if d > dist[u]:\n"
|
||||
" continue\n"
|
||||
" for v, w in graph.get(u, {}).items():\n"
|
||||
" if v not in dist or d + w < dist[v]:\n"
|
||||
" dist[v] = d + w\n"
|
||||
" heapq.heappush(heap, (d + w, v))\n"
|
||||
" return dist\n"),
|
||||
"median": (
|
||||
"def median(zahlen):\n"
|
||||
" if not zahlen:\n"
|
||||
" raise ValueError('leer')\n"
|
||||
" s, n = sorted(zahlen), len(zahlen)\n"
|
||||
" return s[n // 2] if n % 2 else (s[n // 2 - 1] + s[n // 2]) / 2\n"),
|
||||
"csv": (
|
||||
"def spaltensumme(csv_text, spalte):\n"
|
||||
" zeilen = [z for z in csv_text.splitlines() if z.strip()]\n"
|
||||
" kopf = zeilen[0].split(';')\n"
|
||||
" if spalte not in kopf:\n"
|
||||
" raise KeyError(spalte)\n"
|
||||
" i, summe = kopf.index(spalte), 0.0\n"
|
||||
" for z in zeilen[1:]:\n"
|
||||
" zellen = z.split(';')\n"
|
||||
" wert = zellen[i].strip() if i < len(zellen) else ''\n"
|
||||
" if wert:\n"
|
||||
" summe += float(wert.replace('.', '').replace(',', '.'))\n"
|
||||
" return summe\n"),
|
||||
}
|
||||
|
||||
|
||||
def test_referenzloesungen_bestehen_alle_programmieraufgaben(ps):
|
||||
assert {a["id"] for a in ps.AUFGABEN_CODE} == set(REFERENZ)
|
||||
for aufgabe in ps.AUFGABEN_CODE:
|
||||
ok, grund = ps.fuehre_aufgabe_aus(REFERENZ[aufgabe["id"]], aufgabe["tests"])
|
||||
assert ok, f"{aufgabe['id']}: {grund}"
|
||||
|
||||
|
||||
def test_falsche_verbotene_und_endlose_loesungen_fallen_durch(ps):
|
||||
tests = next(a["tests"] for a in ps.AUFGABEN_CODE if a["id"] == "median")
|
||||
ok, _ = ps.fuehre_aufgabe_aus("def median(z):\n z.sort()\n return z[len(z) // 2]\n", tests)
|
||||
assert not ok
|
||||
ok, grund = ps.fuehre_aufgabe_aus("import os\ndef median(z):\n return os.getpid()\n", tests)
|
||||
assert not ok and grund.startswith("unerlaubter Aufruf")
|
||||
ok, grund = ps.fuehre_aufgabe_aus("def median(z):\n while True:\n pass\n", tests, zeitlimit=2)
|
||||
assert not ok and "Zeitlimit" in grund
|
||||
|
||||
|
||||
def test_code_aus_antwort_nimmt_den_loesungsblock(ps):
|
||||
antwort = "<think>kurz nachdenken</think>Hier:\n```python\ndef f():\n return 1\n```\nAufruf:\n```python\nf()\n```"
|
||||
assert ps.code_aus_antwort(antwort) == "def f():\n return 1\n"
|
||||
assert ps.code_aus_antwort("def g(): return 2") == "def g(): return 2"
|
||||
|
||||
|
||||
def test_bild_ist_ein_gueltiges_png_mit_rotem_kreis(ps):
|
||||
png = ps.bild_png()
|
||||
assert png.startswith(b"\x89PNG\r\n\x1a\n")
|
||||
breite, hoehe = struct.unpack(">II", png[16:24])
|
||||
assert (breite, hoehe) == (448, 300)
|
||||
laenge = struct.unpack(">I", png[33:37])[0]
|
||||
roh = zlib.decompress(png[41:41 + laenge])
|
||||
zeile = 1 + breite * 3
|
||||
assert roh[95 * zeile + 1 + 80 * 3:95 * zeile + 1 + 81 * 3] == b"\xdc\x14\x14" # Mitte des Kreises
|
||||
beschreibung = "Ein roter Kreis, ein blaues Quadrat, ein grünes Dreieck und darunter der Text ZEBRA 4711."
|
||||
assert ps.bild_merkmale(beschreibung) == list(ps.BILD_MERKMALE)
|
||||
assert ps.bild_data_url().startswith("data:image/png;base64,")
|
||||
|
||||
|
||||
def test_werkzeug_treffer_liest_json_argumente(ps):
|
||||
aufruf = [{"function": {"name": "send_telegram", "arguments": json.dumps({"text": "Prüfstand läuft."})}}]
|
||||
assert ps.trifft(aufruf, [("send_telegram", r"pr(ü|ue)fstand")])
|
||||
assert not ps.trifft(aufruf, [("terminal", r".*")])
|
||||
assert len(ps.WERKZEUGE_HIRN) >= 90 # Lucy hat ~100 Werkzeuge
|
||||
assert ps.tiefen_prompt("ab12").startswith("Messlauf ab12.")
|
||||
|
||||
|
||||
def test_add_role_behaelt_die_uebrigen_aliase(monkeypatch):
|
||||
"""add_role ohne Attrappe: coder bleibt, heavy wandert vom alten Coder herüber."""
|
||||
from services import llamaswap
|
||||
cfg = {"models": {"alt": {"cmd": "x", "aliases": ["coder", "heavy"]}, "neu": {"cmd": "y", "aliases": ["coder"]}}}
|
||||
monkeypatch.setattr(llamaswap, "read_config", lambda: cfg)
|
||||
monkeypatch.setattr(llamaswap, "write_config", lambda c: None)
|
||||
assert llamaswap.add_role("neu", "heavy") is True
|
||||
assert cfg["models"]["neu"]["aliases"] == ["coder", "heavy"]
|
||||
assert cfg["models"]["alt"]["aliases"] == ["coder"]
|
||||
assert llamaswap.add_role("gibt-es-nicht", "heavy") is False
|
||||
|
||||
|
||||
def test_baseline_misst_neu_wenn_sich_die_proben_geaendert_haben(ps, tmp_path, monkeypatch):
|
||||
"""Eine Baseline aus einem älteren Prüfstand ist nicht vergleichbar (23.09.: Ablenker geändert)."""
|
||||
cache = tmp_path / "baseline.json"
|
||||
cache.write_text(json.dumps({"hirn": {"kennung": "k", "version": ps.PRUEFSTAND_VERSION - 1,
|
||||
"zeit": time.time(), "werte": {"alt": True}}}), encoding="utf-8")
|
||||
gemessen = []
|
||||
monkeypatch.setattr(ps, "pruefe", lambda *a, **kw: gemessen.append(1) or {"neu": True})
|
||||
assert ps.baseline("hirn", str(cache), kennung="k", protokoll=lambda *_: None)["werte"] == {"neu": True}
|
||||
assert gemessen == [1]
|
||||
# Gleiche Version und Kennung: aus dem Cache, keine neue Messung.
|
||||
assert ps.baseline("hirn", str(cache), kennung="k", protokoll=lambda *_: None)["werte"] == {"neu": True}
|
||||
assert gemessen == [1]
|
||||
|
||||
|
||||
def test_kein_ablenker_klingt_nach_plattenplatz(ps):
|
||||
namen = {w["function"]["name"] for w in ps.WERKZEUGE_HIRN}
|
||||
assert "system_speicher" not in namen and "system_arbeitsspeicher" in namen
|
||||
|
||||
|
||||
def test_baseline_misst_bilder_ueber_den_zwilling(monkeypatch, ps):
|
||||
"""Seit 24.09. sieht das heutige Hirn über seinen Bild-Zwilling (vision) — die Baseline muss das wissen."""
|
||||
hirn = {"name": "Qwen3.6-35B-A3B", "aliases": ["hermes", "fast"], "gguf_path": "/m/hirn.gguf",
|
||||
"cmd": "llama-server -m /m/hirn.gguf --spec-type draft-dflash"}
|
||||
zwilling = {"name": "Qwen3.6-35B-A3B-Bild", "aliases": ["vision"], "gguf_path": "/m/hirn.gguf",
|
||||
"cmd": "llama-server -m /m/hirn.gguf --mmproj /m/mmproj.gguf"}
|
||||
fremd = {"name": "Qwen3-VL", "aliases": [], "gguf_path": "/m/vl.gguf", "cmd": "llama-server -m /m/vl.gguf --mmproj x"}
|
||||
monkeypatch.setattr(radar.llamaswap, "list_models", lambda: [hirn, zwilling, fremd])
|
||||
aufrufe = []
|
||||
monkeypatch.setattr(ps, "baseline", lambda rolle, pfad, **kw: aufrufe.append(kw) or {"werte": {}})
|
||||
radar._baseline("hirn", time.time() + 60)
|
||||
assert aufrufe[0]["bild"] is True and aufrufe[0]["bild_modell"] == "vision"
|
||||
|
||||
# Ohne Zwilling (Projektor auf anderen Gewichten zählt nicht): keine Bild-Probe.
|
||||
monkeypatch.setattr(radar.llamaswap, "list_models", lambda: [hirn, fremd])
|
||||
radar._baseline("hirn", time.time() + 60)
|
||||
assert aufrufe[1]["bild"] is False and aufrufe[1]["bild_modell"] is None
|
||||
assert aufrufe[0]["kennung"] != aufrufe[1]["kennung"] # neuer Zwilling → neue Baseline
|
||||
|
||||
|
||||
def test_immer_warme_gruppe_verdraengt_nichts(monkeypatch):
|
||||
"""24.09.: ohne exclusive: false entlud jede Anfrage ans Hirn den Coder — set_group muss das setzen."""
|
||||
from services import llamaswap
|
||||
cfg = {"models": {"h": {"cmd": "x", "ttl": 300}}, "groups": {}}
|
||||
monkeypatch.setattr(llamaswap, "read_config", lambda: cfg)
|
||||
monkeypatch.setattr(llamaswap, "write_config", lambda c: None)
|
||||
llamaswap.set_group("brains", ["h"], swap=False, persist=True)
|
||||
assert cfg["groups"]["brains"]["exclusive"] is False and cfg["groups"]["brains"]["persistent"] is True
|
||||
assert cfg["models"]["h"]["ttl"] == 0
|
||||
llamaswap.set_group("andere", ["h"], swap=True, persist=False)
|
||||
assert "exclusive" not in cfg["groups"]["andere"]
|
||||
|
||||
|
||||
def test_hf_download_nimmt_nur_den_gewuenschten_quant(monkeypatch):
|
||||
"""24.09.2026: Fehlt der Quant, lädt der Download NICHTS (vorher: alle Teile aller Varianten)."""
|
||||
from services import hf
|
||||
|
||||
baum = [
|
||||
{"path": "Q8_0/Modell-Q8_0-00001-of-00002.gguf", "size": 50},
|
||||
{"path": "Q8_0/Modell-Q8_0-00002-of-00002.gguf", "size": 50},
|
||||
{"path": "Q4_K_M/Modell-Q4_K_M-00001-of-00002.gguf", "size": 30},
|
||||
{"path": "Q4_K_M/Modell-Q4_K_M-00002-of-00002.gguf", "size": 30},
|
||||
{"path": "mmproj-F16.gguf", "size": 5},
|
||||
]
|
||||
monkeypatch.setattr(hf, "_tree", lambda repo: baum)
|
||||
leer = hf.resolve_gguf("x/y", "IQ3_XXS")
|
||||
assert leer["first"] is None and leer["files"] == []
|
||||
q4 = hf.resolve_gguf("x/y", "Q4_K_M")
|
||||
assert q4["files"] == ["Q4_K_M/Modell-Q4_K_M-00001-of-00002.gguf", "Q4_K_M/Modell-Q4_K_M-00002-of-00002.gguf"]
|
||||
assert q4["total_bytes"] == 65 and q4["mmproj"] == "mmproj-F16.gguf"
|
||||
@@ -0,0 +1,156 @@
|
||||
"""Update-Verlauf aus dem Meldeprotokoll und festgehaltene Bausteine (Box-Wart 09/2026)."""
|
||||
|
||||
import json
|
||||
|
||||
from services import update_verlauf
|
||||
|
||||
# Echte Formate aus ~/mc2-notify.log der Box (gekürzt), inklusive fremder Meldungen dazwischen.
|
||||
PROTOKOLL = """\
|
||||
2026-08-14 18:53:58 OK telegram: Mein Gehirn lädt nicht — ich kann gerade nicht richtig denken.
|
||||
2026-08-23 04:31:15 QUEUED für Morgen-Digest: KRITISCH: Engine (llama.cpp)-Update auf 10588 UND Rollback fehlgeschlagen — Stack möglicherweise kaputt.
|
||||
2026-08-23 07:04:17 OK telegram: 📰 DAILY NEWS 23.08.2026
|
||||
|
||||
• **Eine Nachricht** (Quelle, 21. August 2026)
|
||||
Text der Nachricht.
|
||||
2026-09-04 14:07:27 OK telegram: Router (llama-swap) aktualisiert: 252 → 253. Stack-Check grün, alles läuft.
|
||||
2026-09-04 14:09:30 OK telegram: Commander, die Wochenpflege der Box ist durch — kurz für dich:
|
||||
• Router (llama-swap): 252 → 253 eingespielt, Stack-Check grün.
|
||||
• Engine (llama.cpp): 10733 → 10797 eingespielt, Stack-Check grün.
|
||||
• Hermes-Agent: 1 Commits eingespielt, Gehirn-Check grün.
|
||||
2026-09-04 14:26:29 OK telegram: Die Box startet jetzt neu — Kernel 7.0.0-31 ist eingespielt.
|
||||
2026-09-06 04:31:16 QUEUED für Morgen-Digest: Engine (llama.cpp) aktualisiert: 10797 → 10819. Stack-Check grün, alles läuft.
|
||||
2026-09-06 04:55:53 QUEUED für Morgen-Digest: Hermes-Update fehlgeschlagen (Gehirn-Check rot). Automatisch zurückgerollt auf 63279301b — läuft wieder. Hermes ist jetzt GEPINNT.
|
||||
2026-09-06 04:55:53 QUEUED für Morgen-Digest: Commander, die Wochenpflege der Box ist durch — kurz für dich:
|
||||
• Router (llama-swap): aktuell (253).
|
||||
• Engine (llama.cpp): 10797 → 10819 eingespielt, Stack-Check grün.
|
||||
• Hermes-Agent: Update FEHLGESCHLAGEN → zurückgerollt + GEPINNT auf 63279301b.
|
||||
2026-09-10 12:00:00 OK telegram: Die Box startet jetzt neu — von Hand.
|
||||
2026-09-13 04:31:07 QUEUED für Morgen-Digest: Commander, die Wochenpflege der Box ist durch — kurz für dich:
|
||||
• Router (llama-swap): 253 → 255 eingespielt, Stack-Check grün.
|
||||
• Engine (llama.cpp): 10936 FEHLGESCHLAGEN → zurückgerollt auf 10819 + GEPINNT.
|
||||
• Hermes-Agent: gepinnt, übersprungen.
|
||||
2026-09-13 04:31:07 QUEUED für Morgen-Digest: Die Box startet jetzt neu — das OS verlangt es nach dem Update (libc6 ). Sie ist ein paar Minuten weg.
|
||||
2026-09-20 04:30:39 QUEUED für Morgen-Digest: Router (llama-swap) aktualisiert: 255 → 256. Stack-Check grün, alles läuft.
|
||||
2026-09-20 04:35:33 QUEUED für Morgen-Digest: Commander, die Wochenpflege der Box ist durch — kurz für dich:
|
||||
• Router (llama-swap): 255 → 256 eingespielt, Stack-Check grün.
|
||||
• Engine (llama.cpp): 11026 → 11057 eingespielt, Stack-Check grün.
|
||||
• Hermes-Agent: 1861 Commits eingespielt, Gehirn-Check grün.
|
||||
2026-09-27 04:31:00 QUEUED für Morgen-Digest: Commander, die Wochenpflege der Box ist durch — kurz für dich:
|
||||
• Router (llama-swap): aktuell (256).
|
||||
• Engine (llama.cpp): aktuell (11057).
|
||||
• Hermes-Agent: aktuell.
|
||||
"""
|
||||
|
||||
|
||||
def test_laeufe_neueste_zuerst_und_ohne_fremde_meldungen():
|
||||
laeufe = update_verlauf.laeufe_aus(PROTOKOLL)
|
||||
# 27.09., 20.09., 13.09., 06.09., 04.09., 23.08. — der Neustart vom 10.09. allein ist kein Lauf.
|
||||
assert [lauf["start"][:10] for lauf in laeufe] == [
|
||||
"2026-09-27", "2026-09-20", "2026-09-13", "2026-09-06", "2026-09-04", "2026-08-23"]
|
||||
|
||||
|
||||
def test_gruener_sonntag_mit_einer_zeile_je_baustein():
|
||||
lauf = update_verlauf.laeufe_aus(PROTOKOLL)[1]
|
||||
assert lauf["anlass"] == "Updates am Sonntag"
|
||||
assert (lauf["stufe"], lauf["titel"]) == ("gruen", "Eingespielt")
|
||||
assert [(z["baustein"], z["ergebnis"]) for z in lauf["zeilen"]] == [
|
||||
("llama-swap", "eingespielt"), ("Motor", "eingespielt"), ("Hermes", "eingespielt")]
|
||||
assert lauf["zeilen"][2]["text"] == "1861 Änderungen eingespielt, Gehirn-Check grün."
|
||||
|
||||
|
||||
def test_rueckbau_und_festhalten_werden_gelb_und_lesbar():
|
||||
lauf = update_verlauf.laeufe_aus(PROTOKOLL)[2] # 13.09.
|
||||
assert (lauf["stufe"], lauf["titel"]) == ("gelb", "Zurückgerollt")
|
||||
zeilen = {z["baustein"]: z for z in lauf["zeilen"]}
|
||||
assert zeilen["Motor"]["ergebnis"] == "zurueckgerollt"
|
||||
assert zeilen["Motor"]["text"] == "10936 fehlgeschlagen → zurückgerollt auf 10819 und festgehalten."
|
||||
assert zeilen["Hermes"]["ergebnis"] == "festgehalten"
|
||||
assert zeilen["Neustart"]["ergebnis"] == "neustart"
|
||||
assert zeilen["Neustart"]["text"] == "Die Box startet jetzt neu — das OS verlangt es nach dem Update (libc6 )."
|
||||
|
||||
|
||||
def test_abgebrochener_lauf_ohne_zusammenfassung_ist_rot():
|
||||
lauf = update_verlauf.laeufe_aus(PROTOKOLL)[-1] # 23.08.
|
||||
assert (lauf["stufe"], lauf["titel"]) == ("rot", "Fehler")
|
||||
assert lauf["zeilen"][0]["baustein"] == "Motor"
|
||||
assert lauf["zeilen"][0]["text"].startswith("Kritisch: Engine (llama.cpp)-Update auf 10588")
|
||||
|
||||
|
||||
def test_lauf_von_hand_mit_neustart_danach():
|
||||
lauf = update_verlauf.laeufe_aus(PROTOKOLL)[4] # 04.09. nachmittags
|
||||
assert lauf["anlass"] == "Update von Hand"
|
||||
assert lauf["titel"] == "Eingespielt"
|
||||
assert lauf["zeilen"][-1]["baustein"] == "Neustart"
|
||||
assert lauf["zeilen"][2]["text"] == "1 Änderung eingespielt, Gehirn-Check grün."
|
||||
|
||||
|
||||
def test_morgenmeldung_ist_kein_eigener_lauf():
|
||||
"""Die Morgenmeldung um 07:00 wiederholt die Nachtmeldungen samt Wochen-Zusammenfassung — sie darf
|
||||
keinen zweiten Lauf „Update von Hand“ erzeugen."""
|
||||
morgen = (
|
||||
"2026-09-20 07:00:02 OK telegram: Guten Morgen, Commander. Heute Nacht gab es 2 Meldungen:\n"
|
||||
"- [Box-Update]: Router (llama-swap) aktualisiert: 255 → 256. Stack-Check grün, alles läuft.\n"
|
||||
"- [Box-Update]: Commander, die Wochenpflege der Box ist durch — kurz für dich:\n"
|
||||
"• Router (llama-swap): 255 → 256 eingespielt, Stack-Check grün.\n"
|
||||
)
|
||||
ohne = update_verlauf.laeufe_aus(PROTOKOLL)
|
||||
mit = update_verlauf.laeufe_aus(PROTOKOLL.replace("2026-09-27 04:31:00", morgen + "2026-09-27 04:31:00"))
|
||||
assert [lauf["start"] for lauf in mit] == [lauf["start"] for lauf in ohne]
|
||||
|
||||
|
||||
def test_alles_aktuell_ist_grau():
|
||||
lauf = update_verlauf.laeufe_aus(PROTOKOLL)[0]
|
||||
assert (lauf["stufe"], lauf["titel"]) == ("grau", "Alles aktuell")
|
||||
|
||||
|
||||
def test_laeufe_liest_die_datei_und_merkt_sich_den_stand(tmp_path, monkeypatch):
|
||||
datei = tmp_path / "mc2-notify.log"
|
||||
datei.write_text(PROTOKOLL, encoding="utf-8")
|
||||
monkeypatch.setattr(update_verlauf, "NOTIFY_LOG", datei)
|
||||
monkeypatch.setattr(update_verlauf, "_cache", {"schluessel": None, "laeufe": []})
|
||||
assert len(update_verlauf.laeufe(3)) == 3
|
||||
monkeypatch.setattr(update_verlauf, "NOTIFY_LOG", tmp_path / "gibt-es-nicht.log")
|
||||
assert update_verlauf.laeufe() == []
|
||||
|
||||
|
||||
def test_festgehalten_und_freigeben(tmp_path, monkeypatch):
|
||||
pins = tmp_path / "mc2-pins.json"
|
||||
pins.write_text(json.dumps({
|
||||
"engine": {"pinned": True, "version": "10819", "datum": "2026-09-13",
|
||||
"grund": "Update auf 10936 zerschoss den Stack-Check; Rollback grün"},
|
||||
"hermes": {"pinned": True, "version": "63279301b", "datum": "2026-09-06", "grund": "x"},
|
||||
"swap": {"pinned": False},
|
||||
}), encoding="utf-8")
|
||||
monkeypatch.setattr(update_verlauf, "PINS_FILE", pins)
|
||||
|
||||
fest = update_verlauf.festgehalten()
|
||||
assert [(p["baustein"], p["seit"]) for p in fest] == [("engine", "13.09."), ("hermes", "06.09.")]
|
||||
assert fest[0]["name"] == "Motor (llama.cpp)"
|
||||
|
||||
assert update_verlauf.freigeben("engine") is True
|
||||
assert update_verlauf.freigeben("engine") is False
|
||||
rest = json.loads(pins.read_text(encoding="utf-8"))
|
||||
assert set(rest) == {"hermes", "swap"}
|
||||
|
||||
|
||||
def test_waechter_meldet_festgehaltene_bausteine(tmp_path, monkeypatch):
|
||||
from services import waechter
|
||||
|
||||
pins = tmp_path / "mc2-pins.json"
|
||||
pins.write_text(json.dumps({"engine": {"pinned": True, "version": "10819", "datum": "2026-09-13",
|
||||
"grund": "Update zerschoss den Stack-Check"}}), encoding="utf-8")
|
||||
monkeypatch.setattr(update_verlauf, "PINS_FILE", pins)
|
||||
befunde = waechter.pruefe_festgehalten()
|
||||
assert len(befunde) == 1
|
||||
b = befunde[0]
|
||||
assert (b.id, b.stufe, b.sofort) == ("pin:engine", "gelb", True)
|
||||
assert "Seit 13.09. auf 10819 festgehalten" in b.text
|
||||
assert b.aktionen[0]["id"] == "freigeben" and b.aktionen[0]["baustein"] == "engine"
|
||||
|
||||
|
||||
def test_zweitweg_zaehlt_wie_telegram():
|
||||
"""notify.sh schreibt „OK telegram direkt“, wenn Hermes stand und die Bot-API direkt ansprang."""
|
||||
text = ("2026-09-27 04:40:00 OK telegram direkt: Router (llama-swap) aktualisiert: 256 → 257. "
|
||||
"Stack-Check grün, alles läuft.\n")
|
||||
laeufe = update_verlauf.laeufe_aus(text)
|
||||
assert [(z["baustein"], z["ergebnis"]) for z in laeufe[0]["zeilen"]] == [("llama-swap", "eingespielt")]
|
||||
@@ -0,0 +1,179 @@
|
||||
"""Tests für die reinen Auswertungen des Wächters — mit echten Zeilen von der Box (23.09.2026)."""
|
||||
|
||||
from services import waechter
|
||||
|
||||
PROJEKTE_SYNC_LOG = [
|
||||
"2026-09-23 21:02:44 === Sync-Lauf (14 Repos gemeldet) ===",
|
||||
"2026-09-23 21:02:45 = NerdQuiz-next: aktuell (a1b2c3d)",
|
||||
"2026-09-23 21:02:46 ! TTT2-Toolbox_ClaudeEdition: Pull fehlgeschlagen — fatal: no such ref was fetched",
|
||||
"2026-09-23 21:02:46 === Ende (Fehler: 1) ===",
|
||||
]
|
||||
|
||||
ERRORS_LOG = [
|
||||
('2026-09-23 07:02:41,584 WARNING [cron_195e479e8e30_20260923_070017] agent.tool_executor: '
|
||||
'Tool write_file returned error (0.00s): {"error": "Refusing to overwrite /tmp/news-text.md: '
|
||||
'/tmp/news-text.md exists"}'),
|
||||
('2026-09-23 07:02:58,516 WARNING [cron_195e479e8e30_20260923_070017] agent.tool_executor: '
|
||||
'Tool write_file returned error (0.00s): {"error": "Refusing to overwrite /tmp/news-text.md"}'),
|
||||
"2026-09-23 07:03:39,596 WARNING gateway.mirror: Mirror: no session found for telegram:1 thread=None",
|
||||
('2026-09-22 07:01:10,000 WARNING [cron_195e479e8e30_20260922_070017] agent.tool_executor: '
|
||||
'Tool web_extract returned error (0.10s): {"error": "timeout"}'),
|
||||
]
|
||||
|
||||
|
||||
def test_fehlerzeile_nimmt_die_letzte_fehlerzeile_ohne_zeitstempel():
|
||||
zeile = waechter.waehle_fehlerzeile(PROJEKTE_SYNC_LOG)
|
||||
assert zeile.startswith("! TTT2-Toolbox_ClaudeEdition: Pull fehlgeschlagen")
|
||||
assert "2026-09-23" not in zeile
|
||||
|
||||
|
||||
def test_fehlerzeile_uebergeht_systemd_rahmenzeilen():
|
||||
"""Echtes Journal vom 23.09.: systemd schreibt NACH der Skriptzeile noch „Failed to start …“."""
|
||||
zeilen = PROJEKTE_SYNC_LOG + [
|
||||
"projekte-sync.service: Main process exited, code=exited, status=1/FAILURE",
|
||||
"projekte-sync.service: Failed with result 'exit-code'.",
|
||||
"Failed to start projekte-sync.service - Projekte-Sync — haelt ~/projekte mit Gitea deckungsgleich.",
|
||||
]
|
||||
assert waechter.waehle_fehlerzeile(zeilen).startswith("! TTT2-Toolbox_ClaudeEdition")
|
||||
|
||||
|
||||
def test_fehlerzeile_leer_wenn_nichts_nach_fehler_aussieht():
|
||||
assert waechter.waehle_fehlerzeile(["alles gut", "=== Ende (Fehler: 0) ==="]) == ""
|
||||
|
||||
|
||||
def test_werkzeugfehler_zaehlt_nur_den_lauf_des_tages():
|
||||
anzahl, beispiel = waechter.werkzeugfehler_im_lauf(
|
||||
ERRORS_LOG, "195e479e8e30", "2026-09-23T07:00:17.675474+02:00")
|
||||
assert anzahl == 2
|
||||
assert beispiel.startswith("write_file: Refusing to overwrite /tmp/news-text.md")
|
||||
|
||||
|
||||
def test_gelesene_kurze_seite_ist_kein_werkzeugfehler():
|
||||
"""Echte Zeilen vom 24.09.: Hermes loggt ein erfolgreiches web_extract als Fehler, weil sein
|
||||
leeres "error"-Feld bei kurzen Seiten in den ersten 500 Zeichen landet."""
|
||||
zeilen = [
|
||||
('2026-09-24 07:01:03,291 WARNING [cron_195e479e8e30_20260924_070027] agent.tool_executor: '
|
||||
'Tool web_extract returned error (0.56s): {'),
|
||||
' "results": [',
|
||||
" {",
|
||||
' "url": "https://example.org",',
|
||||
('2026-09-24 07:01:38,983 WARNING [cron_195e479e8e30_20260924_070027] agent.tool_executor: '
|
||||
'Tool web_extract returned error (0.26s): {"success": false, "error": "DuckDuckGo (ddgs) is a '
|
||||
'search-only backend and cannot extract URL content."}'),
|
||||
]
|
||||
anzahl, beispiel = waechter.werkzeugfehler_im_lauf(zeilen, "195e479e8e30", "2026-09-24T07:00:27+02:00")
|
||||
assert anzahl == 1 and beispiel.startswith("web_extract: DuckDuckGo (ddgs) is a search-only backend")
|
||||
|
||||
|
||||
def test_werkzeugfehler_null_fuer_anderen_job_oder_kaputtes_datum():
|
||||
assert waechter.werkzeugfehler_im_lauf(ERRORS_LOG, "ca7d4ed207c2", "2026-09-23T08:00:00+02:00") == (0, "")
|
||||
assert waechter.werkzeugfehler_im_lauf(ERRORS_LOG, "195e479e8e30", "kein-datum") == (0, "")
|
||||
|
||||
|
||||
def test_befunde_werden_erst_nach_fail_after_takten_zum_hinweis(monkeypatch, tmp_path):
|
||||
"""Flankenlogik: ein kurzer Aussetzer (1 Takt) erzeugt keinen Hinweis, ein dauerhafter schon.
|
||||
Rote Hinweise gehen an Telegram, Erledigtes verschwindet und landet im Verlauf."""
|
||||
monkeypatch.setattr(waechter, "STORE_PATH", tmp_path / "waechter.json")
|
||||
monkeypatch.setattr(waechter, "_stand", {"hinweise": {}, "kandidaten": {}, "verlauf": [], "auto": {}, "stand": 0.0})
|
||||
monkeypatch.setattr(waechter, "_update_laeuft", lambda: False)
|
||||
meldungen: list[str] = []
|
||||
monkeypatch.setattr(waechter, "_telegram", lambda betreff, text: meldungen.append(betreff))
|
||||
defekt = [True]
|
||||
|
||||
def pruefung():
|
||||
if not defekt[0]:
|
||||
return []
|
||||
return [waechter.Befund(id="kern:engine", stufe="rot", titel="Der Motor antwortet nicht",
|
||||
text="…", quelle="engine")]
|
||||
|
||||
monkeypatch.setattr(waechter, "PRUEFUNGEN", (pruefung,))
|
||||
monkeypatch.setattr(waechter, "FAIL_AFTER", 2)
|
||||
|
||||
waechter.takt()
|
||||
assert waechter._stand["hinweise"] == {} # erster Takt: nur Kandidat
|
||||
waechter.takt()
|
||||
assert "kern:engine" in waechter._stand["hinweise"]
|
||||
assert meldungen == ["[Box-Problem]"]
|
||||
|
||||
defekt[0] = False
|
||||
waechter.takt()
|
||||
assert waechter._stand["hinweise"] == {}
|
||||
assert meldungen == ["[Box-Problem]", "[Box wieder ok]"]
|
||||
arten = [v["art"] for v in waechter._stand["verlauf"]]
|
||||
assert arten == ["neu", "erledigt"]
|
||||
|
||||
stand = waechter.lese_stand()
|
||||
assert stand["aktiv"] is True and stand["hinweise"] == []
|
||||
|
||||
|
||||
def test_schlafende_dienste_sind_kein_befund(monkeypatch):
|
||||
"""24.09.2026: Spracherkennung und Konsole sind bewusst abgeschaltet (disable --now) — kein Alarm.
|
||||
Ein gestoppter, aber noch eingetragener Dienst bleibt dagegen ein Hinweis."""
|
||||
zustaende = {
|
||||
"voice-service": {"LoadState": "loaded", "ActiveState": "inactive", "UnitFileState": "disabled"},
|
||||
"lucy-stimme": {"LoadState": "loaded", "ActiveState": "inactive", "UnitFileState": "enabled"},
|
||||
}
|
||||
monkeypatch.setattr(waechter, "_systemctl_show",
|
||||
lambda name, system: zustaende.get(name, {"LoadState": "loaded", "ActiveState": "active"}))
|
||||
ids = {b.id for b in waechter.pruefe_dienste()}
|
||||
assert "dienst:voice-service" not in ids
|
||||
assert "dienst:lucy-stimme" in ids
|
||||
assert waechter.schlaeft(zustaende["voice-service"]) is True
|
||||
assert waechter.schlaeft({"ActiveState": "failed", "UnitFileState": "disabled"}) is False
|
||||
|
||||
|
||||
def test_abgestuerzte_pruefung_wird_ein_gelber_befund(monkeypatch, tmp_path):
|
||||
"""24.09.2026: Eine Prüfung, die abstürzt, darf nicht still verschwinden (Cockpit blieb grün)."""
|
||||
def kaputt():
|
||||
raise ValueError("jobs.json hat ein neues Format")
|
||||
|
||||
kaputt.__name__ = "pruefe_hermes_jobs"
|
||||
monkeypatch.setattr(waechter, "PRUEFUNGEN", (kaputt,))
|
||||
monkeypatch.setattr(waechter, "STORE_PATH", tmp_path / "mc2-waechter.json")
|
||||
monkeypatch.setattr(waechter, "_update_laeuft", lambda: False)
|
||||
monkeypatch.setattr(waechter, "_telegram", lambda betreff, text: None)
|
||||
monkeypatch.setattr(waechter, "FAIL_AFTER", 1)
|
||||
monkeypatch.setattr(waechter, "_stand", {"hinweise": {}, "kandidaten": {}, "verlauf": [], "stand": None,
|
||||
"update_laeuft": False})
|
||||
waechter.takt()
|
||||
hinweise = waechter._stand["hinweise"]
|
||||
assert "pruefung:pruefe_hermes_jobs" in hinweise
|
||||
assert "neues Format" in hinweise["pruefung:pruefe_hermes_jobs"]["text"]
|
||||
|
||||
|
||||
def test_ausblenden_gilt_bis_zum_naechsten_lauf(monkeypatch, tmp_path):
|
||||
"""24.09.2026: Ein behobener Werkzeugfehler (z. B. web_extract im News-Job) soll sich
|
||||
ausblenden lassen — kommt aber wieder, sobald ein neuer Lauf erneut Fehler hat."""
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
monkeypatch.setattr(waechter, "QUITTIERT_PATH", tmp_path / "mc2-quittiert.json")
|
||||
heute = (datetime.now() - timedelta(hours=2)).isoformat(timespec="seconds")
|
||||
lauf = {"started_at": heute}
|
||||
monkeypatch.setattr(waechter, "_hermes_jobs", lambda: [{"id": "news", "name": "Daily News Report",
|
||||
"enabled": True, "last_status": "ok"}])
|
||||
monkeypatch.setattr(waechter, "_letzter_lauf", lambda jid: lauf)
|
||||
monkeypatch.setattr(waechter, "_errors_log_ende", list)
|
||||
monkeypatch.setattr(waechter, "werkzeugfehler_im_lauf", lambda zeilen, jid, start: (2, "web_extract: Fehler"))
|
||||
|
||||
befunde = waechter.pruefe_hermes_jobs()
|
||||
assert [b.id for b in befunde] == ["job:news:werkzeug"]
|
||||
assert any(a["id"] == "ausblenden" and a["lauf"] == heute for a in befunde[0].aktionen)
|
||||
|
||||
waechter.quittieren("job:news:werkzeug", heute)
|
||||
assert waechter.pruefe_hermes_jobs() == []
|
||||
|
||||
lauf["started_at"] = (datetime.now() - timedelta(minutes=5)).isoformat(timespec="seconds")
|
||||
assert [b.id for b in waechter.pruefe_hermes_jobs()] == ["job:news:werkzeug"]
|
||||
|
||||
|
||||
def test_schlafende_spracherkennung_ist_kein_kernbefund(monkeypatch):
|
||||
"""24.09.2026: Die Spracherkennung schläft bis zur Android-App — kein roter Hinweis mehr."""
|
||||
monkeypatch.setattr(waechter.llamaswap, "engine_reachable", lambda: True)
|
||||
monkeypatch.setattr(waechter.llamaswap, "brain_status", lambda: {"ready": True})
|
||||
monkeypatch.setattr(waechter, "_reach", lambda url, pfad, kopf=None: "8650" not in url)
|
||||
monkeypatch.setattr(waechter, "_systemctl_show",
|
||||
lambda name, system: {"ActiveState": "inactive", "UnitFileState": "disabled"})
|
||||
assert "kern:hoeren" not in {b.id for b in waechter.pruefe_kern()}
|
||||
monkeypatch.setattr(waechter, "_systemctl_show",
|
||||
lambda name, system: {"ActiveState": "active", "UnitFileState": "enabled"})
|
||||
assert "kern:hoeren" in {b.id for b in waechter.pruefe_kern()}
|
||||
@@ -0,0 +1,63 @@
|
||||
"""Kleinigkeiten der Update-Seite (24.09.2026): Größe je Quantisierung, GitHub-Zwischenspeicher,
|
||||
Hermes-Version auch dann, wenn der Abruf scheitert."""
|
||||
|
||||
import subprocess
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from services import hf, maintenance
|
||||
|
||||
|
||||
def test_quants_mit_groesse(monkeypatch):
|
||||
baum = [
|
||||
{"path": "Q8_0/M-Q8_0-00001-of-00002.gguf", "size": 50},
|
||||
{"path": "Q8_0/M-Q8_0-00002-of-00002.gguf", "size": 50},
|
||||
{"path": "M-Q4_K_M.gguf", "lfs": {"size": 30}},
|
||||
{"path": "mmproj-F16.gguf", "size": 5},
|
||||
{"path": "README.md", "size": 1},
|
||||
]
|
||||
aufrufe = []
|
||||
monkeypatch.setattr(hf, "_tree", lambda repo: aufrufe.append(repo) or baum)
|
||||
assert hf.list_quants("x/y") == [{"quant": "Q4_K_M", "total_bytes": 35}, {"quant": "Q8_0", "total_bytes": 105}]
|
||||
assert aufrufe == ["x/y"] # ein Abruf für alle Stufen
|
||||
|
||||
|
||||
class _Antwort:
|
||||
def __init__(self, status: int, daten: object):
|
||||
self.status_code = status
|
||||
self._daten = daten
|
||||
|
||||
def raise_for_status(self):
|
||||
if self.status_code >= 400:
|
||||
raise httpx.HTTPStatusError("fehler", request=httpx.Request("GET", "https://x"),
|
||||
response=httpx.Response(self.status_code))
|
||||
|
||||
def json(self):
|
||||
return self._daten
|
||||
|
||||
|
||||
def test_github_antworten_werden_gemerkt_fehler_nicht(monkeypatch):
|
||||
monkeypatch.setattr(maintenance, "_github_cache", {})
|
||||
antworten = [_Antwort(403, {"message": "API rate limit exceeded"}), _Antwort(200, {"tag_name": "v258"})]
|
||||
aufrufe = []
|
||||
monkeypatch.setattr(maintenance.httpx, "get", lambda url, **kw: aufrufe.append(url) or antworten.pop(0))
|
||||
with pytest.raises(httpx.HTTPStatusError):
|
||||
maintenance._github_json("repos/a/b/releases/latest")
|
||||
assert maintenance._github_json("repos/a/b/releases/latest") == {"tag_name": "v258"}
|
||||
assert maintenance._github_json("repos/a/b/releases/latest") == {"tag_name": "v258"}
|
||||
assert len(aufrufe) == 2
|
||||
|
||||
|
||||
def test_hermes_version_auch_wenn_der_abruf_haengt(monkeypatch, tmp_path):
|
||||
(tmp_path / "pyproject.toml").write_text('[project]\nversion = "0.21.4"\n', encoding="utf-8")
|
||||
monkeypatch.setattr(maintenance.system, "find_hermes_agent_git", lambda: {"path": str(tmp_path)})
|
||||
|
||||
def run(befehl, **kw):
|
||||
if "fetch" in befehl:
|
||||
raise subprocess.TimeoutExpired(befehl, 25)
|
||||
return subprocess.CompletedProcess(befehl, 0, stdout="main\n", stderr="")
|
||||
|
||||
monkeypatch.setattr(maintenance.subprocess, "run", run)
|
||||
info = maintenance.hermes_update_details()
|
||||
assert info["installed_version"] == "0.21.4"
|
||||
assert "error" in info
|
||||
@@ -1,76 +0,0 @@
|
||||
---
|
||||
name: projekt-uebernehmen
|
||||
description: Ein von der AI-Box vorbereitetes Projekt in Zed uebernehmen und starten - Konzept und Roadmap einlesen, die offenen Punkte mit dem Commander klaeren, dann Etappe 1 planen und bauen. Nutze diesen Skill beim ERSTEN Chat in einem Projekt-Ordner, der KONZEPT.md und ROADMAP.md enthaelt, aber noch keinen (oder kaum) eigenen Code - also bei jedem frisch vorbereiteten Repo aus dem Auftragsbuch.
|
||||
metadata:
|
||||
quelle: mission-control-2/client/ide-skills
|
||||
---
|
||||
|
||||
# Vorbereitetes Projekt übernehmen
|
||||
|
||||
Die Box hat gedacht, du baust. Das Konzept in diesem Ordner ist **bereits gehärtet** — mehrere
|
||||
Fach-Rollen und ein Advocatus Diaboli haben es auf der Box durchgearbeitet. Deine Aufgabe ist
|
||||
nicht, es neu zu denken, sondern es umzusetzen.
|
||||
|
||||
**Zwei Modelle, ein Ablauf:** Stufe 1 und 2 (einlesen, nachfragen, planen) gehören in den
|
||||
**Planer**, Stufe 3 (bauen) in den **Coder**. Du kannst nicht selbst umschalten — wenn der Plan
|
||||
freigegeben ist, bitte den Commander ausdrücklich darum, oben im Modellwähler auf Coder zu
|
||||
wechseln. Läufst du schon im Coder, ist das auch in Ordnung: dann gilt trotzdem erst lesen,
|
||||
fragen, Plan zeigen — und **keine Datei vor seinem „los"**.
|
||||
|
||||
Drei Stufen, in dieser Reihenfolge. **Vor Stufe 3 wird keine Datei angefasst.**
|
||||
|
||||
## 1. Einlesen — still, ohne Rückfragen
|
||||
|
||||
Lies, was da ist: `KONZEPT.md`, `ROADMAP.md`, `AGENTS.md`, `SAVEPOINT.md`, `README.md`.
|
||||
Verschaff dir den Ist-Zustand: `git log --oneline -5` und den Dateibaum.
|
||||
|
||||
Zwei Abbruch-Fälle — prüfe sie ZUERST:
|
||||
|
||||
- **Es gibt schon Code und Historie.** Dann ist das keine Übernahme, sondern Fortsetzung:
|
||||
arbeite nach `SAVEPOINT.md` weiter und sag in zwei Sätzen, wo ihr steht. Dieser Skill ist
|
||||
hier fertig.
|
||||
- **Nur `KONZEPT.md` + `README.md`, keine `ROADMAP.md`.** Das ist ein *geprüfte-Idee*-Repo, kein
|
||||
startklares Projekt — die Box legt bei „Idee prüfen" bewusst kein Gerüst an. Sag dem Commander:
|
||||
*„Das ist bisher nur ein durchdachtes Konzept, noch kein Projekt. Im Auftragsbuch gibt es auf
|
||||
der Karte den Knopf ‚Gefällt mir — Projekt daraus machen'; danach hat das Repo eine Roadmap und
|
||||
ich kann loslegen."* Dann **STOPP**, nichts bauen.
|
||||
|
||||
Sonst: fasse in **höchstens 8 Zeilen** zusammen — was gebaut wird, für wen, welche Technik,
|
||||
und was laut Roadmap **Etappe 1** ist. Alltagssprache, keine Fachbegriffe ohne Übersetzung.
|
||||
|
||||
## 2. Fragen stellen — der eigentliche Wert dieses Schritts
|
||||
|
||||
Stell **3 bis 6 Fragen**. Nicht mehr, und nur solche, die den Start wirklich blockieren.
|
||||
|
||||
- **Nichts fragen, was in den Dokumenten steht.** Wer das Konzept nicht gelesen hat, merkt man
|
||||
genau hier.
|
||||
- Jede Frage: eine Zeile, Alltagssprache, **mit deiner Empfehlung** („ich würde X nehmen, weil …").
|
||||
Der Commander ist kein Entwickler — frag nach Zielen, Vorlieben und Gegebenheiten, nie nach
|
||||
Bau-Prinzipien oder Architektur-Geschmack. Das ist dein Job.
|
||||
- Die üblichen echten Lücken: Wo läuft es am Ende? Welche Zugänge/Geräte/Pfade brauchst du von
|
||||
ihm? Was ist in Etappe 1 ausdrücklich **nicht** drin? Woran merkt ihr beide, dass Etappe 1
|
||||
fertig ist?
|
||||
- Der Abschnitt „offene Punkte" / „Risiken" im Konzept ist deine Fundgrube — genau dafür steht er da.
|
||||
|
||||
Dann **warten**. Keine Annahmen, kein Vorpreschen, kein „ich fange schon mal an".
|
||||
|
||||
## 3. Plan → „los" → bauen
|
||||
|
||||
1. **Etappe-1-Plan** vorlegen: Ziel · Vorgehen in kleinen Schritten · Technik und warum ·
|
||||
Fertig-Kriterien · Risiken. Etappe 1 muss **für sich lauffähig** sein — eine halbe Baustelle ist
|
||||
kein Meilenstein.
|
||||
2. **STOPP**, auf sein „los" warten. Danach der Satz, den er hören muss: *„Bitte oben im
|
||||
Modellwähler auf **Coder** umschalten, dann lege ich los."* Umschalten kannst du nicht selbst.
|
||||
3. Bauen: kleine Schritte, ein Sicherungspunkt (Commit mit klarem Namen) je Meilenstein und vor
|
||||
riskanten Änderungen. Am Ende **live testen** — beweisen statt behaupten.
|
||||
4. Nachführen: `SAVEPOINT.md` (Stand, nächster Schritt, offene Fragen) und die erledigte Etappe in
|
||||
`ROADMAP.md` abhaken. Das ist das Gedächtnis für den nächsten Chat.
|
||||
|
||||
## Merksätze
|
||||
|
||||
- **Du denkst das Konzept nicht neu.** Findest du einen echten Widerspruch oder eine Lücke:
|
||||
benenne sie und frag — bau nicht still etwas anderes.
|
||||
- **Nichts bauen, was nicht in Etappe 1 steht.** Ideen für später gehören in die Roadmap, nicht
|
||||
in den Code.
|
||||
- Geheimnisse (Schlüssel, Passwörter, Tokens) nie in den Code — `.env` plus `.gitignore`.
|
||||
- Klemmt dieselbe Sache zweimal: anhalten, sichern, erklären, fragen.
|
||||
@@ -1,42 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# pre_llm_call-Hook (Faden 5, 13.07.2026): verdrahtet Box-Selbstwissen MECHANISCH in
|
||||
# JEDEN Kanban-Worker — egal welches Profil (werkstatt, betrieb, default-als-Worker).
|
||||
# Der Kontext wird ephemer pro Turn an die User-Message angehaengt (agent/turn_context.py),
|
||||
# NUR wenn die Session in einem Kanban-Task-Workspace laeuft. Lucys interaktiver/Voice-Chat
|
||||
# laeuft NICHT dort → sofortiger No-op, kein Persona- oder Latenz-Eingriff. Loest
|
||||
# Fallstrick #1 (Ops/Mess/Verify-Worker flailen, weil sie llama-swap :8080 nicht kennen).
|
||||
# Ausgabe {"context":"..."} = anhaengen; {} = durchlassen (No-op).
|
||||
set -u
|
||||
payload="$(cat -)"
|
||||
|
||||
# Scope-Gate: NUR echte Kanban-Worker — erkannt an der cwd im Task-Workspace.
|
||||
# ACHTUNG (Bug-Fund Faden 8, 13.07.): Worker tragen als task_id den SESSION-Zeitstempel
|
||||
# (z.B. 20260713_224206_267304), NICHT die Kanban-t_-ID — ein "nur t_"-Check feuerte also
|
||||
# NIE fuer Worker. Der zuverlaessige Signal ist die cwd unter kanban/workspaces/; Lucy/
|
||||
# Voice/CLI laufen nie dort.
|
||||
cwd=$(printf %s "$payload" | jq -r '.cwd // ""' 2>/dev/null || echo "")
|
||||
case "$cwd" in
|
||||
"$HOME/.hermes/kanban/workspaces/"*) : ;; # Kanban-Worker → Orientierung anhaengen
|
||||
*) printf '{}'; exit 0 ;; # Lucy / Voice / CLI / interaktiv → No-op
|
||||
esac
|
||||
|
||||
# Zeiger auf den vollen, naechtlich generierten Selbst-Steckbrief (nur wenn er existiert).
|
||||
STECKBRIEF="$HOME/.hermes/state/selbst-steckbrief.md"
|
||||
hint=""
|
||||
[ -f "$STECKBRIEF" ] && hint=" Dein vollstaendiger Selbst-Steckbrief (aktuelle Versionen/Dienste/Modelle/Crons/offene Karten) liegt in $STECKBRIEF — dort nachlesen statt raten."
|
||||
|
||||
# Stabile Anti-Flail-Orientierung: WER bin ich, WO laufen die Modelle, WAS ist tabu.
|
||||
ctx="$(cat <<EOF
|
||||
[BOX-ORIENTIERUNG — du bist ein Hintergrund-Worker auf der lokalen AI-Box (Linux, Zeit UTC / lokal Europe/Berlin, locale de_DE), NICHT Lucy und kein Dev-Laptop. Du arbeitest EINE Kanban-Aufgabe ab.]
|
||||
Die Modelle / das "Hirn" laufen ausschliesslich ueber den Router llama-swap auf 127.0.0.1:8080 (startet je Modell einen llama-server) bzw. das OpenAI-kompatible Gateway von MC2 auf 127.0.0.1:9001/v1 — es gibt KEINEN anderen Modell-Server, also nicht danach suchen. Weitere Dienste: hermes-gateway :8642, mem0-Gedaechtnis :8765. Der MC2-Live-Checkout ~/mission-control-v2 ist nur zum LESEN da (Schreiben/committen TABU — Code-Arbeit immer im frischen Klon deines Task-Workspaces). Grenzen (was gehoert wohin): MC2 = Steuerpult (verwalten/klicken/zusehen, KEIN Chat-UI), Lucy = Chat & Stimme (eigenes Repo), Hermes-Quelle ist TABU (Agenten-Verhalten nur ueber Config/SOUL/Skill/Hook/MCP aendern). Feste Entscheide/Verbote + volle Grenzen-Landkarte: ~/mission-control-v2/docs/wissen/ (GRENZEN/VERDIKTE/FALLEN/STACK) + ~/wissens-vault/eigenbau-landkarte.md.${hint}
|
||||
TRIAGE (19.07. — Drei-Bahnen-Regel): Ordne deine Karte ZUERST einer Bahn zu und bleib darin: Denken (Konzept/Plan), Bauen (Code schreiben/aendern), Betrieb (SSH/Deploy/Dienste). Fuer Betrieb-Arbeit lade IMMER zuerst den Skill betrieb-playbook (Diagnose-Checklisten; max 3 Anlaeufe pro Hindernis, dann kanban_block mit exaktem Fehlertext — die No-Progress-Bremse erzwingt das mechanisch).
|
||||
PROTOKOLL-PFLICHT (15.07.): Beende deine Aufgabe IMMER mit dem Tool-Aufruf kanban_complete (fertig) oder kanban_block (Frage/Sackgasse). Eine Text-Zusammenfassung OHNE diesen Aufruf zaehlt als Absturz (Protokollverstoss) und blockiert die Karte — genau so ging am 14.07. eine fertige Arbeit verloren.
|
||||
REPO-WEGWEISER mission-control-v2 (NICHT raten/suchen — so liegt es): backend/app.py = Einstieg + Router-Mounting · backend/routers/*.py = REST-Endpunkte · backend/services/*.py = Logik · backend/gateway_app.py = /v1-Gateway-Prozess (:9010) · frontend/src/views/*.tsx = Ansichten · frontend/src/lib/api.ts = Typen+Fetch · frontend/src/lib/queries.ts = TanStack-Query-Hooks (Polling) · frontend/src/nav.ts = Sidebar · deploy/ = Skripte/Units/Hooks/Skills · Projekt-Regeln: AGENTS.md (Wurzel) + docs/wissen/.
|
||||
FRONTEND-BAUEN (seit 15.07. abends): Node LTS liegt sudo-frei unter ~/.local (node/npm im Worker-PATH). Bei Frontend-Aenderungen baust DU das dist MIT: cd frontend && npm ci --no-audit --no-fund && npm run build — und committest frontend/dist im selben Branch (die Box zeigt sonst nach Annahme den alten Stand; Bau-Beweis 15.07.: kompletter Build in ~4 s).
|
||||
WO WAS LAEUFT (20.07. — zwei getrennte Maschinen, NICHT verwechseln): Du laeufst auf der AI-Box (192.168.178.151) — das ist die WERKBANK (Agent/Lucy/MC2/Modelle), NICHT das Ziel-System fuer Projekt-Deployments. Der Proxmox-Host 'pve' (192.168.178.108) ist ein SEPARATER Rechner; dort laufen die LXC-Container. Von hier per 'ssh pve' erreichbar (root, Key), Steuerung mit 'pct'. Welche Container es gerade gibt, schaust du bei Bedarf mit 'ssh pve pct list' nach — nicht raten, nicht auswendig annehmen. Soll etwas dauerhaft laufen, gehoert es in einen LXC auf pve, nicht auf die Box.
|
||||
REPO FEHLT? (20.07.): Soll dein Ergebnis in ein Gitea-Repo, das noch NICHT existiert (eigenes/neues Projekt), dann lege es SELBST an — NICHT den Commander fragen, NICHT git init: bash ~/.hermes/scripts/gitea-repo-create.sh <name> "<kurze Beschreibung>" (immer privat, letzte Ausgabezeile = CLONE <url>), dann voll klonen und darin arbeiten. NUR mission-control-v2 und das Lucy-Repo NIE neu anlegen/veraendern.
|
||||
KONTEXT-BUDGET & ETAPPEN-REGEL (15.07./20.07. — Worker starben an finish_reason=length UND an vollgelaufenem Kontext): Dein Kontext ist ENDLICH und fuellt sich vor allem durch GROSSE Datei- und Terminal-Ausgaben. Lies NIE eine grosse Datei am Stueck — nutze grep und Zeilenbereiche (read mit offset/limit); Datei-Reads sind auf 50k Zeichen gedeckelt, aber auch viele mittlere summieren sich. Lies gezielt ueber den Wegweiser statt ls-Ketten und Volltexte. Merkst du, dass die Aufgabe mehr als ~5 Dateien oder mehrere Baustellen umfasst: NICHT durchziehen — schneide Etappen (eine Etappe = ein Worker-Lauf): lege Teil-Karten an (kanban_create, falls verfuegbar) und schliesse deine Karte per kanban_complete mit dem Etappen-Plan ab; alternativ kanban_block mit dem Etappen-Vorschlag als Frage.
|
||||
EOF
|
||||
)"
|
||||
|
||||
jq --null-input --arg c "$ctx" '{context:$c}'
|
||||
@@ -1,211 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""No-Progress-Bremse (Phase 2 des Drei-Welten-Plans, 19.07.2026).
|
||||
|
||||
Generische, turn-UEBERGREIFENDE Schleifenbremse — ergaenzt Hermes' native
|
||||
tool_loop_guardrails, die (a) pro Turn zaehlen und (b) nur als Fehler
|
||||
klassifizierte Ergebnisse sehen. Die teuer gelernte Luecke (SSH-Marathon 18.07.,
|
||||
36 min / 75 Schleifen): ein Befehl laeuft "erfolgreich" durch, die AUSGABE sagt
|
||||
aber immer dasselbe ("Permission denied") — der Agent variiert Befehle gegen
|
||||
eine unsichtbare Wand, ueber Turn-Grenzen hinweg, und nichts stoppt ihn.
|
||||
|
||||
Prinzip (KEINE hartkodierten Fehler-Strings — Leitplanke des Commanders:
|
||||
"wenn sich was aendert, muss es das SELBST erkennen"):
|
||||
post_tool_call → Ergebnis normalisieren (Zahlen/Hex-IDs raus) → Muster-Hash.
|
||||
Gleicher Hash beim gleichen Tool in Folge = Zaehler hoch.
|
||||
Unbekannte Muster landen in neue-signaturen.jsonl —
|
||||
Futter fuer Traum/Radar, um daraus Playbooks zu verdichten.
|
||||
pre_tool_call → Zaehler >= 3 fuer dieses Tool: EINMAL mechanisch blocken
|
||||
(Diagnose-Anweisung statt naechster Blindversuch), Zaehler
|
||||
zuruecksetzen. Der Folgeversuch ist frei; laeuft er wieder
|
||||
3x gegen dasselbe Muster, blockt es erneut.
|
||||
|
||||
Gezaehlt wird nur, wo Wiederholung wirklich Stillstand heisst: terminal IMMER
|
||||
(dort verstecken sich die "erfolgreichen" Fehler), andere Tools nur bei
|
||||
status=error. Lese-Tools deckt die native idempotent-Bremse ab.
|
||||
|
||||
Aufruf (hooks in config.yaml): `no-progress-bremse.py pre` bzw. `... post`.
|
||||
State: ~/.hermes/state/no-progress/<session>.json (48 h Selbst-Aufraeumen).
|
||||
Fail-open: jeder eigene Fehler -> {} (die Bremse darf nie selbst zur Wand werden).
|
||||
"""
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import time
|
||||
|
||||
STATE_DIR = os.path.expanduser("~/.hermes/state/no-progress")
|
||||
SIG_LOG = os.path.join(STATE_DIR, "neue-signaturen.jsonl")
|
||||
SEEN_FILE = os.path.join(STATE_DIR, "seen-sigs.json")
|
||||
THRESHOLD = 3
|
||||
STATE_TTL = 48 * 3600
|
||||
|
||||
BLOCK_REASON = (
|
||||
"NO-PROGRESS-BREMSE: '{tool}' hat {n}x in Folge dasselbe Ergebnismuster geliefert — "
|
||||
"das ist Stillstand, egal wie sehr die Befehle variieren. STOPP. "
|
||||
"WICHTIG: Dein Befehl wurde NICHT ausgefuehrt. Das hier ist eine Bremse, KEIN Ergebnis — "
|
||||
"schliesse daraus NICHTS ueber die Existenz oder den Zustand von Dateien, Verzeichnissen "
|
||||
"oder Diensten (Vorfall 21.07.2026: ein Worker hielt diese Meldung fuer 'Verzeichnis nicht "
|
||||
"vorhanden' und baute eine halbe Stunde Arbeit neu). "
|
||||
"1) Benenne das Hindernis in EINEM Satz (was genau meldet das Ergebnis?). "
|
||||
"2) Diagnose statt Variation — bei SSH/Zugriff ZUERST: Schluessel-Passphrase pruefen "
|
||||
"(`ssh-keygen -y -f <key> </dev/null` — stilles 'Permission denied' = verschluesselter Key), "
|
||||
"dann Gegenseite (Auth-Log, Dienststatus, Erreichbarkeit) LESEN statt raten. "
|
||||
"3) Bleibt es unklar: Aufgabe als blocked/Karte mit dem EXAKTEN Ergebnistext an den "
|
||||
"Commander uebergeben. Dein naechster Versuch ist frei — nutze ihn erst NACH der Diagnose."
|
||||
)
|
||||
|
||||
# Rein LESENDE Befehle nie bremsen (21.07.2026). Die Bremse verlangt selbst „Diagnose
|
||||
# statt Variation" — genau die besteht aus vielen kurzen Schau-Befehlen (ls/cat/git log),
|
||||
# deren Ergebnisse sich stark aehneln. Die Signatur hielt das fuer Stillstand und blockte
|
||||
# den naechsten Blick; der Worker las die Block-Meldung als „da ist nichts" und baute
|
||||
# alles neu (Karte t_d26c3203, eine halbe Stunde Arbeit weggeworfen). Geschaut wird also
|
||||
# ungebremst — gebremst wird nur, wer immer wieder dasselbe TUT.
|
||||
# Bewusst strukturell (Befehls-Art), nicht per Fehlertext-Liste: die Bremse soll generisch
|
||||
# bleiben und Neues selbst erkennen.
|
||||
_LESEND = {"ls", "cat", "head", "tail", "wc", "stat", "find", "grep", "rg", "file",
|
||||
"du", "df", "echo", "pwd", "tree", "readlink", "basename", "dirname", "which"}
|
||||
_GIT_LESEND = {"status", "log", "show", "diff", "ls-remote", "ls-files", "rev-parse",
|
||||
"merge-base", "describe"}
|
||||
|
||||
|
||||
def _nur_lesend(cmd: str) -> bool:
|
||||
"""True, wenn der Befehl ausschliesslich schaut — kein Schreiben, kein Eingriff."""
|
||||
cmd = (cmd or "").strip()
|
||||
if not cmd or ">" in cmd: # jede Umleitung = schreiben
|
||||
return False
|
||||
for teil in re.split(r"&&|\|\||;|\|", cmd):
|
||||
worte = teil.split()
|
||||
i = 0
|
||||
while i < len(worte) and "=" in worte[i] and not worte[i].startswith("-"):
|
||||
i += 1 # VAR=wert-Praefixe ueberspringen
|
||||
if i >= len(worte):
|
||||
return False
|
||||
verb = os.path.basename(worte[i])
|
||||
if verb == "git":
|
||||
rest = [w for w in worte[i + 1:] if not w.startswith("-")]
|
||||
# `git log …` oder `git -C <pfad> log …` → erstes oder zweites Wort zaehlt
|
||||
if not (rest[:1] and rest[0] in _GIT_LESEND) and \
|
||||
not (rest[1:2] and rest[1] in _GIT_LESEND):
|
||||
return False
|
||||
elif verb not in _LESEND:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
_NUM_RE = re.compile(r"\d+")
|
||||
_HEX_RE = re.compile(r"\b[0-9a-f]{8,}\b")
|
||||
_WS_RE = re.compile(r"\s+")
|
||||
|
||||
|
||||
def _norm_sig(tool: str, text: str) -> str:
|
||||
t = (text or "")[:2000].lower()
|
||||
t = _HEX_RE.sub("#", _NUM_RE.sub("#", t))
|
||||
t = _WS_RE.sub(" ", t).strip()[:400]
|
||||
return hashlib.sha1(f"{tool}|{t}".encode()).hexdigest()[:16]
|
||||
|
||||
|
||||
def _session_key(p: dict) -> str:
|
||||
sid = p.get("session_id") or p.get("parent_session_id") or ""
|
||||
if sid:
|
||||
return re.sub(r"[^A-Za-z0-9_.-]", "_", str(sid))[:80]
|
||||
return "cwd-" + hashlib.sha1(os.getcwd().encode()).hexdigest()[:12]
|
||||
|
||||
|
||||
def _prune() -> None:
|
||||
now = time.time()
|
||||
for f in os.listdir(STATE_DIR):
|
||||
fp = os.path.join(STATE_DIR, f)
|
||||
if f.endswith(".json") and f != os.path.basename(SEEN_FILE):
|
||||
if now - os.path.getmtime(fp) > STATE_TTL:
|
||||
os.remove(fp)
|
||||
|
||||
|
||||
def _load(path: str) -> dict:
|
||||
try:
|
||||
with open(path, encoding="utf-8") as fh:
|
||||
return json.load(fh)
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
|
||||
def _save(path: str, data: dict) -> None:
|
||||
tmp = path + ".tmp"
|
||||
with open(tmp, "w", encoding="utf-8") as fh:
|
||||
json.dump(data, fh)
|
||||
os.replace(tmp, path)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
mode = sys.argv[1] if len(sys.argv) > 1 else "post"
|
||||
payload = json.load(sys.stdin)
|
||||
tool = str(payload.get("tool_name") or "")
|
||||
if not tool:
|
||||
print("{}")
|
||||
return
|
||||
# Delegations-Ausnahme (20.07.2026): worker.sh / fremdblick.sh sind der
|
||||
# Fliessband-/Orchestrator-Motor — konzept-fliessband ruft sie pro Runde/Rolle
|
||||
# WIEDERHOLT auf (verschiedene Prompts, aehnliche Prosa-Ergebnisse). Die Bremse
|
||||
# bildet ihre Signatur aus dem Ergebnis-Text → sie hielt diese legitimen
|
||||
# Mehrrunden faelschlich fuer eine Schleife und blockte (Pomodoro-IDE-Test lief
|
||||
# 19 min dagegen an). Diese Helfer haben ihr EIGENES Runden-Limit (max 3) →
|
||||
# von der generischen Bremse ausnehmen.
|
||||
_ti = payload.get("tool_input")
|
||||
_cmd = _ti.get("command") if isinstance(_ti, dict) else (_ti if isinstance(_ti, str) else "")
|
||||
if tool == "terminal" and ("worker.sh" in str(_cmd) or "fremdblick.sh" in str(_cmd)):
|
||||
print("{}")
|
||||
return
|
||||
# Schauen ist keine Schleife (siehe _nur_lesend): weder zaehlen noch blocken.
|
||||
if tool == "terminal" and _nur_lesend(str(_cmd)):
|
||||
print("{}")
|
||||
return
|
||||
os.makedirs(STATE_DIR, exist_ok=True)
|
||||
sfile = os.path.join(STATE_DIR, _session_key(payload) + ".json")
|
||||
state = _load(sfile)
|
||||
tools = state.setdefault("tools", {})
|
||||
|
||||
if mode == "post":
|
||||
status = str(payload.get("status") or "ok")
|
||||
# terminal immer beobachten (dort tarnen sich Fehler als Erfolg),
|
||||
# sonst nur echte Fehler — Lese-Tools regelt die native Bremse.
|
||||
if tool != "terminal" and status != "error":
|
||||
print("{}")
|
||||
return
|
||||
sig = _norm_sig(tool, str(payload.get("result") or ""))
|
||||
entry = tools.get(tool) or {}
|
||||
entry["count"] = entry.get("count", 0) + 1 if entry.get("sig") == sig else 1
|
||||
entry["sig"] = sig
|
||||
tools[tool] = entry
|
||||
_save(sfile, state)
|
||||
_prune()
|
||||
seen = _load(SEEN_FILE)
|
||||
sigs = seen.setdefault("sigs", [])
|
||||
if sig not in sigs and status == "error" or (tool == "terminal" and sig not in sigs and entry["count"] >= 2):
|
||||
sigs.append(sig)
|
||||
del sigs[:-500]
|
||||
_save(SEEN_FILE, seen)
|
||||
sample = _WS_RE.sub(" ", str(payload.get("result") or ""))[:200]
|
||||
with open(SIG_LOG, "a", encoding="utf-8") as fh:
|
||||
fh.write(json.dumps({"ts": int(time.time()), "tool": tool,
|
||||
"sig": sig, "sample": sample}, ensure_ascii=False) + "\n")
|
||||
print("{}")
|
||||
return
|
||||
|
||||
# mode == "pre"
|
||||
entry = tools.get(tool) or {}
|
||||
if entry.get("count", 0) >= THRESHOLD:
|
||||
entry["count"] = 0
|
||||
tools[tool] = entry
|
||||
_save(sfile, state)
|
||||
print(json.dumps({"decision": "block",
|
||||
"reason": BLOCK_REASON.format(tool=tool, n=THRESHOLD)},
|
||||
ensure_ascii=False))
|
||||
return
|
||||
print("{}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
main()
|
||||
except Exception:
|
||||
print("{}")
|
||||
@@ -1,75 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# pre_verify-Gate (P1-5, 09.07.2026): erinnert den Agenten VOR dem Fertigmelden an
|
||||
# die zwei MC2-Pflichten aus AGENTS.md — py_compile-Gate und die dist-Falle.
|
||||
# Antwort {"action":"continue","message":...} = Agent arbeitet weiter; {} = durchlassen.
|
||||
set -u
|
||||
payload="$(cat -)"
|
||||
attempt=$(printf %s "$payload" | jq -r '.extra.attempt // 0' 2>/dev/null || echo 0)
|
||||
# Ein-Schuss: nach dem ersten Nudge nicht erneut nerven (Docs-Empfehlung).
|
||||
if [ "$attempt" != "0" ]; then printf '{}'; exit 0; fi
|
||||
mapfile -t paths < <(printf %s "$payload" | jq -r '.extra.changed_paths[]? // empty' 2>/dev/null)
|
||||
[ ${#paths[@]} -eq 0 ] && { printf '{}'; exit 0; }
|
||||
msgs=()
|
||||
|
||||
# Gate 1: py_compile fuer alle geaenderten .py, die existieren
|
||||
pyfail=""
|
||||
for p in "${paths[@]}"; do
|
||||
case "$p" in
|
||||
*.py)
|
||||
if [ -f "$p" ] && ! err=$(python3 -m py_compile "$p" 2>&1); then
|
||||
pyfail="${pyfail}${p}: ${err}"$'\n'
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
done
|
||||
if [ -n "$pyfail" ]; then
|
||||
msgs+=("py_compile-Gate ROT:"$'\n'"${pyfail}Erst fixen, dann fertigmelden.")
|
||||
fi
|
||||
|
||||
# Gate 2: dist-Falle — frontend/src geaendert, aber kein frontend/dist angefasst.
|
||||
# Feuert NUR in Repos mit der MC2-Konvention (frontend/dist ist git-getrackt) —
|
||||
# sonst nudgt es in beliebigen Spielordnern ohne Build (False-Positive, 09.07. beobachtet).
|
||||
srcfile=""; dist=0
|
||||
for p in "${paths[@]}"; do
|
||||
case "$p" in
|
||||
*/frontend/src/*|frontend/src/*) srcfile="$p" ;;
|
||||
*/frontend/dist/*|frontend/dist/*) dist=1 ;;
|
||||
esac
|
||||
done
|
||||
if [ -n "$srcfile" ] && [ $dist -eq 0 ]; then
|
||||
# Repo-Wurzel vom geaenderten File aus hochlaufen (max 8 Ebenen).
|
||||
root=$(dirname "$srcfile"); n=0
|
||||
while [ $n -lt 8 ] && [ -n "$root" ] && [ "$root" != "/" ] && [ ! -e "$root/.git" ]; do
|
||||
root=$(dirname "$root"); n=$((n+1))
|
||||
done
|
||||
if [ -e "$root/.git" ] && git -C "$root" ls-files frontend/dist 2>/dev/null | head -1 | grep -q .; then
|
||||
msgs+=("AGENTS.md-Gate: Du hast frontend/src geaendert, aber frontend/dist nicht. Die Box baut NICHT selbst: cd frontend && npm run build, dann das neue dist im selben Branch mit-committen — sonst zeigt die Box nach Merge+Deploy den alten Stand.")
|
||||
fi
|
||||
fi
|
||||
|
||||
# Gate 3 (R5, 15.07.): Orphan-Check — git-init-Repos ohne gemeinsamen Ursprung mit
|
||||
# origin/main sind bei der Annahme technisch tot (3 Vorfaelle 12.–14.07., je eine
|
||||
# Nacht Arbeit verloren). VOR dem Fertigmelden fangen, nicht erst im Auftragsbuch.
|
||||
orphan=""; checked=""
|
||||
for p in "${paths[@]}"; do
|
||||
root=$(dirname "$p"); n=0
|
||||
while [ $n -lt 8 ] && [ -n "$root" ] && [ "$root" != "/" ] && [ ! -e "$root/.git" ]; do
|
||||
root=$(dirname "$root"); n=$((n+1))
|
||||
done
|
||||
[ -e "$root/.git" ] || continue
|
||||
case " $checked " in *" $root "*) continue ;; esac
|
||||
checked="$checked $root"
|
||||
git -C "$root" remote get-url origin >/dev/null 2>&1 || continue
|
||||
git -C "$root" fetch -q origin main >/dev/null 2>&1 || true
|
||||
if git -C "$root" rev-parse --verify -q origin/main >/dev/null 2>&1 \
|
||||
&& ! git -C "$root" merge-base HEAD origin/main >/dev/null 2>&1; then
|
||||
orphan="${orphan}${root}"$'\n'
|
||||
fi
|
||||
done
|
||||
if [ -n "$orphan" ]; then
|
||||
msgs+=("Orphan-Gate ROT: Dieses Repo hat KEINEN gemeinsamen Ursprung mit origin/main (git init statt Klonen?):"$'\n'"${orphan}So ist der Vorschlag bei der Annahme technisch TOT. Nicht fertigmelden — frisch VOLL klonen, Branch von origin/main abzweigen, Aenderungen ruebertragen.")
|
||||
fi
|
||||
|
||||
if [ ${#msgs[@]} -eq 0 ]; then printf '{}'; exit 0; fi
|
||||
joined=$(printf '%s\n\n' "${msgs[@]}")
|
||||
jq --null-input --arg m "$joined" '{action:"continue", message:$m}'
|
||||
@@ -1,180 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""pre_tool_call-Guard (Faden 8, 13.07.2026): sperrt Schreibzugriffe auf den LIVE-Checkout
|
||||
~/mission-control-v2 und die Hermes-QUELLE ~/.hermes/hermes-agent HART. Loest Fallstrick #2
|
||||
(Worker wandert in die falsche Ebene / lag Patches im Live-Checkout ab). Die native
|
||||
is_write_denied deckt diese Pfade NICHT ab (nur Credentials/System) -> diese Luecke.
|
||||
|
||||
UNBEDINGT (kein Task-/Profil-Scope): KEIN legitimer Hermes-Agent-Flow schreibt je in diese
|
||||
zwei Pfade — der Werkstatt-Worker arbeitet im WORKSPACE-Klon (<workspace>/mission-control-v2,
|
||||
NICHT home-verankert -> erlaubt), Lucy fasst MC2-Quelle nie an, und die Wartungs-Pipeline
|
||||
(deploy.sh, Annahme-Runner) laeuft als Shell ueber ssh, NICHT ueber Hermes-Tools. Ein
|
||||
Task-Scope waere hier sogar gefaehrlich: Worker tragen als task_id den Session-Zeitstempel
|
||||
(z.B. 20260713_224206_267304), NICHT die Kanban-t_-ID — ein "nur t_"-Check liesse also alle
|
||||
Worker-Schreibzugriffe durch (Bug 13.07., so entdeckt).
|
||||
|
||||
WICHTIG: Gesperrt wird der home-verankerte Live-Checkout ($HOME/mission-control-v2); der
|
||||
blosse Name "mission-control-v2" zaehlt NUR, wenn er vom cwd aus in einen Tabu-Pfad aufloest
|
||||
(V16 unten) — der Workspace-Klon (~/.hermes/kanban/workspaces/<id>/mission-control-v2) bleibt
|
||||
frei, sonst waere die legitime Worker-Arbeit blockiert. Lesen ist erlaubt; geblockt werden
|
||||
nur Schreib-Operationen.
|
||||
|
||||
Ausgabe {"action":"block","message":...} = Tool geblockt; {} = durchlassen.
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
|
||||
|
||||
def out(obj):
|
||||
sys.stdout.write(json.dumps(obj))
|
||||
sys.exit(0)
|
||||
|
||||
|
||||
try:
|
||||
payload = json.loads(sys.stdin.read() or "{}")
|
||||
except Exception:
|
||||
out({})
|
||||
|
||||
tool = payload.get("tool_name") or ""
|
||||
ti = payload.get("tool_input") or {}
|
||||
home = os.path.realpath(os.path.expanduser("~"))
|
||||
LIVE = os.path.join(home, "mission-control-v2")
|
||||
SRC = os.path.join(home, ".hermes", "hermes-agent")
|
||||
# R2 (Review 15.07.): systemd-User-Units. Die Nacht-Werkstatt hat am 14.07. einen Override
|
||||
# DIREKT scharf geschaltet — ging gut, verletzt aber "jede Code-Zeile ist Klick-pflichtig".
|
||||
# Unit-/Override-Dateien werden nur noch ueber eine angenommene Karte installiert.
|
||||
SYSD = os.path.join(home, ".config", "systemd", "user")
|
||||
|
||||
MSG = (
|
||||
"TABU (Faden 8/R2): Schreiben an dieser Stelle ist fuer Worker gesperrt. Der Live-Checkout "
|
||||
"~/mission-control-v2, die Hermes-Quelle ~/.hermes/hermes-agent und die systemd-User-Units "
|
||||
"~/.config/systemd/user duerfen NIE direkt veraendert werden. Arbeite im FRISCHEN Klon "
|
||||
"deines Task-Workspaces; MC2-Aenderungen kommen als Vorschlags-Branch, systemd-Unit-/"
|
||||
"Override-Dateien werden nur ueber eine angenommene Karte installiert, Hermes-Verhalten "
|
||||
"nur ueber Config/SOUL/Skill/Hook/MCP. Siehe docs/wissen/GRENZEN.md."
|
||||
)
|
||||
|
||||
|
||||
def block():
|
||||
out({"action": "block", "message": MSG})
|
||||
|
||||
|
||||
def under_tabu(path):
|
||||
rp = os.path.realpath(path)
|
||||
return any(rp == t or rp.startswith(t + os.sep) for t in (LIVE, SRC, SYSD))
|
||||
|
||||
|
||||
# --- Datei-Tools: Zielpfad kanonisch pruefen (zuverlaessig) -------------------------
|
||||
if tool in ("write_file", "patch"):
|
||||
fp = ti.get("path") or ti.get("file_path") or ti.get("filepath") or ""
|
||||
if not fp:
|
||||
out({})
|
||||
fp = os.path.expanduser(fp)
|
||||
cwd = payload.get("cwd") or ""
|
||||
if not os.path.isabs(fp) and cwd:
|
||||
fp = os.path.join(cwd, fp)
|
||||
if under_tabu(fp):
|
||||
block()
|
||||
out({})
|
||||
|
||||
# --- Terminal: nur bei Schreib-Operation, die auf einen Tabu-Pfad zielt -------------
|
||||
if tool == "terminal":
|
||||
cmd = ti.get("command") or ""
|
||||
if not cmd:
|
||||
out({})
|
||||
cwd_raw = payload.get("cwd") or ""
|
||||
cwd = os.path.realpath(os.path.expanduser(cwd_raw)) if cwd_raw else ""
|
||||
# R5 (Review 15.07.): `git init` im Kanban-Workspace hart blocken. Drei Naechte in
|
||||
# Folge bauten Worker git-init-Orphans ohne gemeinsamen Ursprung mit main
|
||||
# (cleanse-skill-index 12.07., skill-kanten-generator 13.07., blogwatcher-logging
|
||||
# ~14.07.) — solche Vorschlaege sind bei der Annahme technisch tot, die Nacht-Arbeit
|
||||
# verpufft. Kein legitimer Worker-Flow braucht git init; MC2-/Lucy-Arbeit = VOLL
|
||||
# klonen (SOUL/GRENZEN). Scope: nur Kanban-Worker (Lucy/CLI duerfen z. B. fuer
|
||||
# projekt-start neue Repos initialisieren).
|
||||
ws_dir = os.path.join(home, ".hermes", "kanban", "workspaces")
|
||||
if cwd_raw.startswith(ws_dir) and re.search(r"(?:^|[^\w])git\s+init\b", cmd):
|
||||
out({"action": "block", "message": (
|
||||
"TABU (R5): `git init` ist im Worker-Workspace gesperrt — Orphan-Branches ohne "
|
||||
"gemeinsamen Ursprung mit main sind bei der Annahme technisch tot (Vorfaelle "
|
||||
"12.–14.07.). Richtiger Weg: VOLL klonen, z. B. git clone "
|
||||
"http://192.168.178.153:3000/Hitonabi/mission-control-v2 "
|
||||
"(Credentials liegen in ~/.git-credentials), dann Branch von origin/main abzweigen.")})
|
||||
# Schreib-Subkommandos von git (V16: + clone — `git clone URL ~/mission-control-v2`
|
||||
# rutschte vorher an (a) UND (d) vorbei).
|
||||
git_write = (
|
||||
r"(?:^|[^\w])git(?:\s+-C\s+\S+)?\s+(?:-[\w-]+\s+)*"
|
||||
r"(commit|push|reset|checkout|switch|merge|add|rm|mv|clean|stash|rebase|apply|restore|init|clone|remote|config)\b"
|
||||
)
|
||||
|
||||
# V16 (Vorfall 16.07.2026): Werkstatt-Worker t_7a05992a ersetzte um 03:59 den Live-
|
||||
# Checkout mit cwd=$HOME und RELATIVEN Pfaden (`rm -rf mission-control-v2 && git
|
||||
# clone ...`) — keine home-verankerte Tabu-Form im Kommandotext, der Guard sah nur
|
||||
# Strings und liess durch (.venv weg -> Gateway im 203/EXEC-Crash-Loop). Drei Stopfen:
|
||||
# (1) cwd liegt IN einem Tabu-Pfad -> jede Schreib-Operation blocken (relative Ziele
|
||||
# treffen zwangslaeufig den Tabu-Baum). Lesen bleibt erlaubt.
|
||||
if cwd and under_tabu(cwd):
|
||||
if (re.search(git_write, cmd) or re.search(r"(?:^|[^\w])sed\s+-i", cmd)
|
||||
or re.search(r"(?:^|[^\w])(rm|rmdir|mv|cp|truncate|dd|chmod|chown|ln|install|mkdir|touch)\s", cmd)
|
||||
or re.search(r">>?\s*(?![/&])", cmd)
|
||||
or re.search(r"(?:^|[^\w])tee\s+(?:-a\s+)?(?!/)", cmd)):
|
||||
block()
|
||||
# (2) cwd == $HOME -> `git clone`/`git init`, deren Ziel direkt in $HOME landet,
|
||||
# blocken (exakt der Vorfalls-Hergang). Worker klonen im Task-Workspace; ein
|
||||
# explizites Ziel AUSSERHALB von $HOME (z.B. Unterordner) bleibt erlaubt.
|
||||
m = re.search(r"(?:^|[^\w])git(?:\s+-C\s+\S+)?\s+(?:-[\w-]+\s+)*(?:clone|init)\b([^|&;]*)", cmd)
|
||||
if cwd == home and m:
|
||||
toks = m.group(1).split()
|
||||
dest = toks[-1] if toks else ""
|
||||
# kein brauchbares Ziel-Argument (leer, Option oder URL) -> implizit direkt in $HOME
|
||||
implicit = (not dest or dest.startswith("-")
|
||||
or re.search(r"://|^git@|^[\w.-]+@[\w.-]+:|\.git$", dest))
|
||||
if (implicit or under_tabu(os.path.join(home, dest))
|
||||
or os.path.dirname(os.path.realpath(os.path.join(home, dest))) == home):
|
||||
out({"action": "block", "message": (
|
||||
"TABU (V16): `git clone`/`git init` direkt in $HOME ist gesperrt — so wurde "
|
||||
"am 16.07. der Live-Checkout ~/mission-control-v2 durch einen frischen Klon "
|
||||
"ersetzt (.venv weg, Gateway tot). Klone/initialisiere in deinem Task-"
|
||||
"Workspace (~/.hermes/kanban/workspaces/<task>/...) oder gib ein Ziel "
|
||||
"ausserhalb von $HOME an.")})
|
||||
# Home-verankerte Tabu-Formen. Der Workspace-Klon
|
||||
# ($HOME/.hermes/kanban/.../mission-control-v2) enthaelt KEINE davon als Teilstring.
|
||||
tabu_forms = [
|
||||
LIVE, "~/mission-control-v2", "$HOME/mission-control-v2", "${HOME}/mission-control-v2",
|
||||
SRC, "~/.hermes/hermes-agent", "$HOME/.hermes/hermes-agent", "${HOME}/.hermes/hermes-agent",
|
||||
SYSD, "~/.config/systemd/user", "$HOME/.config/systemd/user", "${HOME}/.config/systemd/user",
|
||||
]
|
||||
# (3) cwd == Vorfahr eines Tabu-Pfads (z.B. $HOME) -> auch die RELATIVE Nennung des
|
||||
# Tabu-Ziels (mission-control-v2, .hermes/hermes-agent, .config/systemd/user)
|
||||
# zaehlt als Treffer fuer die Schreib-Checks unten. Die Lookarounds stellen
|
||||
# sicher, dass der Name ein eigenes Argument beginnt — als Teil eines laengeren
|
||||
# Pfades (z.B. .hermes/kanban/workspaces/<id>/mission-control-v2) matcht er
|
||||
# NICHT, die legitime Workspace-Arbeit bleibt frei.
|
||||
rel_pats = []
|
||||
if cwd:
|
||||
for t in (LIVE, SRC, SYSD):
|
||||
if t.startswith(cwd + os.sep):
|
||||
rel = os.path.relpath(t, cwd).replace(os.sep, "/")
|
||||
rel_pats.append(r"(?<![\w./@-])(?:\./)?" + re.escape(rel) + r"(?![\w.-])")
|
||||
tabu_alt = "(?:" + "|".join([re.escape(t) for t in tabu_forms] + rel_pats) + ")"
|
||||
if not re.search(tabu_alt, cmd):
|
||||
out({}) # kein Tabu-Pfad referenziert -> Workspace-Arbeit, durchlassen
|
||||
|
||||
# (a) git-Schreib-Subkommando (Repo-Kontext ist der referenzierte Tabu-Pfad).
|
||||
if re.search(git_write, cmd):
|
||||
block()
|
||||
# (b) sed -i, das einen Tabu-Pfad anfasst.
|
||||
if re.search(r"(?:^|[^\w])sed\s+-i\S*\s+[^|&;]*" + tabu_alt, cmd):
|
||||
block()
|
||||
# (c) Umleitung > / >> / tee, die auf einen Tabu-Pfad ZIELT (nicht 2>/dev/null o.ae.).
|
||||
if re.search(r"(?:>>?|(?:^|[^\w])tee\s+(?:-a\s+)?)\s*" + tabu_alt, cmd):
|
||||
block()
|
||||
# (d) destruktiv/kopierend mit Tabu-Pfad im selben Kommando-Segment.
|
||||
if re.search(
|
||||
r"(?:^|[^\w])(rm|rmdir|mv|cp|truncate|dd|chmod|chown|ln|install|mkdir|touch)\s+[^|&;]*" + tabu_alt,
|
||||
cmd,
|
||||
):
|
||||
block()
|
||||
out({})
|
||||
|
||||
out({})
|
||||
@@ -1,76 +0,0 @@
|
||||
# Universelle CI-Ampel — wird bei der Repo-Anlage automatisch eingepflanzt
|
||||
# (gitea-repo-create.sh, Wasserdicht-Runde 22.07.2026) und läuft auf dem
|
||||
# Gitea-Actions-Runner (arcane-VM).
|
||||
# GRUNDSATZ: Rot ist ein Ergebnis („nicht bewiesen"), kein Ärgernis.
|
||||
# • Reines Doku-Repo (noch kein Code) → GRÜN mit Vermerk.
|
||||
# • Code ohne Tests → ROT. Tests sind Pflicht, kein Deko.
|
||||
# Der Workflow erkennt selbst, was das Projekt ist (Python / Node / beides).
|
||||
name: Ampel
|
||||
on: [push, pull_request]
|
||||
|
||||
jobs:
|
||||
ampel:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Ampel — erkennt Projekt-Typ selbst und prüft entsprechend
|
||||
shell: bash
|
||||
run: |
|
||||
# Der Runner startet bash mit -e — das schalten wir ab: die Ampel wertet
|
||||
# JEDEN Fehler selbst (rot=1) und liefert am Ende EIN Klartext-Urteil.
|
||||
# (Lehre Lauf #5: ungeschütztes pytest unter -e schnitt den Bericht ab.)
|
||||
set -u +e
|
||||
rot=0
|
||||
py_datei=$(find . -name '*.py' -not -path './.git/*' -not -path '*/node_modules/*' -print -quit)
|
||||
pkg_dateien=$(find . -name package.json -not -path '*/node_modules/*' -not -path './.git/*')
|
||||
|
||||
if [ -z "$py_datei" ] && [ -z "$pkg_dateien" ]; then
|
||||
echo "✅ Doku-Repo (noch kein Code) — Ampel GRÜN mit Vermerk."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [ -n "$py_datei" ]; then
|
||||
echo "== Python erkannt =="
|
||||
{ python3 -m venv /tmp/ampel-venv && . /tmp/ampel-venv/bin/activate; } || { echo "❌ Python-Setup kaputt (venv)"; exit 1; }
|
||||
pip install -q ruff pytest || { echo "❌ Werkzeug-Installation kaputt"; exit 1; }
|
||||
echo "-- Ruff (Linter: toter Code, kaputte Imports, Schlampereien)"
|
||||
ruff check . || rot=1
|
||||
echo "-- Abhängigkeiten installierbar? (halluzinierte Pakete fliegen hier auf)"
|
||||
while IFS= read -r req; do
|
||||
[ -n "$req" ] || continue
|
||||
pip install -q -r "$req" || { echo "❌ $req nicht installierbar"; rot=1; }
|
||||
done < <(find . -name 'requirements*.txt' -not -path '*/node_modules/*' -not -path './.git/*')
|
||||
echo "-- Importierbar? (kaputte Modul-Struktur fliegt hier auf)"
|
||||
python -m compileall -q . || rot=1
|
||||
echo "-- Pytest (keine Tests gefunden = ROT)"
|
||||
ec=0; pytest -q || ec=$?
|
||||
if [ $ec -eq 5 ]; then
|
||||
echo "❌ KEINE TESTS GEFUNDEN — Tests sind Pflicht, kein Deko (AGENTS.md)."
|
||||
rot=1
|
||||
elif [ $ec -ne 0 ]; then
|
||||
rot=1
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ -n "$pkg_dateien" ]; then
|
||||
echo "== Node/TypeScript erkannt =="
|
||||
while IFS= read -r pkg; do
|
||||
[ -n "$pkg" ] || continue
|
||||
d=$(dirname "$pkg")
|
||||
echo "-- $d"
|
||||
if [ ! -f "$d/package-lock.json" ]; then
|
||||
echo "❌ $d: kein package-lock.json — Build nicht reproduzierbar."
|
||||
echo " Fix: dort 'npm install --package-lock-only' ausführen und committen."
|
||||
rot=1; continue
|
||||
fi
|
||||
(cd "$d" && npm ci --no-audit --no-fund) || { rot=1; continue; }
|
||||
if grep -q '"build"' "$pkg"; then (cd "$d" && npm run build) || rot=1; fi
|
||||
if grep -q '"test"' "$pkg"; then (cd "$d" && npm test --silent) || rot=1; fi
|
||||
done <<< "$pkg_dateien"
|
||||
fi
|
||||
|
||||
if [ $rot -ne 0 ]; then
|
||||
echo "❌ AMPEL ROT — nichts heißt ‚fertig', solange das rot ist."
|
||||
fi
|
||||
exit $rot
|
||||
@@ -1,94 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Ampel-Wächter (22.07.2026, Wasserdicht-Runde): meldet ROTE CI-Läufe aller
|
||||
# Gitea-Repos per Telegram. Läuft als no-agent-Cron (stdout leer = still,
|
||||
# klassisches Watchdog-Muster). Nur NEUE Fehlschläge werden gemeldet —
|
||||
# Status-Merkliste unter ~/.hermes/state/ampel-waechter-gesehen.txt.
|
||||
set -uo pipefail
|
||||
|
||||
STATE="$HOME/.hermes/state/ampel-waechter-gesehen.txt"
|
||||
mkdir -p "$(dirname "$STATE")"; touch "$STATE"
|
||||
|
||||
TOKEN=$(grep -m1 'git\.tobisniceshomelab' "$HOME/.git-credentials" 2>/dev/null | sed -E 's#https://[^:]+:([^@]+)@.*#\1#')
|
||||
[ -n "$TOKEN" ] || exit 0
|
||||
|
||||
python3 - "$TOKEN" "$STATE" <<'PY'
|
||||
import json, sys, urllib.request, subprocess, os
|
||||
|
||||
token, state_pfad = sys.argv[1], sys.argv[2]
|
||||
# API intern (die DDNS-Domain ist nachts wegen Zwangstrennung oft tot, 24.07.2026);
|
||||
# WEB bleibt die Domain — Telegram-Links muessen auch von unterwegs klickbar sein.
|
||||
BASE = "http://192.168.178.153:3000/api/v1"
|
||||
WEB = "https://git.tobisniceshomelab.ddnsfree.com"
|
||||
|
||||
|
||||
def api(pfad):
|
||||
req = urllib.request.Request(BASE + pfad, headers={"Authorization": "token " + token})
|
||||
with urllib.request.urlopen(req, timeout=20) as r:
|
||||
return json.load(r)
|
||||
|
||||
|
||||
try:
|
||||
gesehen = set(open(state_pfad).read().split())
|
||||
except OSError:
|
||||
gesehen = set()
|
||||
|
||||
neu = []
|
||||
try:
|
||||
# /repos/search statt /user/repos: das Token hat nur write:repository-Scope,
|
||||
# /user/repos verlangt read:user -> 403. Der stille exit 0 hat diesen Defekt
|
||||
# seit Inbetriebnahme versteckt (Fund 24.07.2026, Merkliste blieb leer).
|
||||
repos = api("/repos/search?limit=50&private=true").get("data", [])
|
||||
except Exception as e:
|
||||
print(f"ampel-waechter: Repo-Liste fehlgeschlagen: {e}", file=sys.stderr)
|
||||
sys.exit(0)
|
||||
|
||||
for repo in repos:
|
||||
full = repo.get("full_name", "")
|
||||
if not full or repo.get("archived"):
|
||||
continue
|
||||
try:
|
||||
d = api(f"/repos/{full}/actions/tasks?limit=1")
|
||||
except Exception:
|
||||
continue
|
||||
rows = d.get("workflow_runs", d if isinstance(d, list) else d.get("entries", []))
|
||||
for t in rows[:1]:
|
||||
kennung = f"{full}#{t.get('id')}"
|
||||
status = t.get("status")
|
||||
if status == "failure" and kennung not in gesehen:
|
||||
# Status-Lügen-Wache (Fund 22.07.): alter Runner meldete Abschluss nicht,
|
||||
# Gitea zombie-markierte GRÜNE Jobs als failure. Das Job-LOG ist die
|
||||
# Wahrheit — steht dort „Job succeeded", ist es KEIN Alarm.
|
||||
try:
|
||||
req = urllib.request.Request(
|
||||
f"{BASE}/repos/{full}/actions/jobs/{t.get('id')}/logs",
|
||||
headers={"Authorization": "token " + token})
|
||||
with urllib.request.urlopen(req, timeout=20) as r:
|
||||
log_ende = r.read().decode(errors="replace")[-4000:]
|
||||
wirklich_rot = "Job succeeded" not in log_ende
|
||||
except Exception:
|
||||
wirklich_rot = True
|
||||
if wirklich_rot:
|
||||
neu.append((full, t.get("head_sha", "")[:7], t.get("name", "")))
|
||||
# Nur ABGESCHLOSSENE Läufe merken — ein 'running', das später rot wird,
|
||||
# darf nicht schon als gesehen gelten.
|
||||
if status in ("failure", "success", "cancelled", "skipped"):
|
||||
gesehen.add(kennung)
|
||||
|
||||
if neu:
|
||||
print("🔴 CI-AMPEL ROT:")
|
||||
for full, sha, name in neu:
|
||||
print(f"• {full} @ {sha} ({name})")
|
||||
print(f" {WEB}/{full}/actions")
|
||||
try:
|
||||
subprocess.run([
|
||||
os.path.expanduser("~/.local/bin/hermes"), "kanban", "create",
|
||||
"--title", f"CI rot: {full} @ {sha}",
|
||||
"--body", f"Der CI Lauf '{name}' in {full} ist fehlgeschlagen. Bitte Log ansehen und Fix vorschlagen."
|
||||
], capture_output=True)
|
||||
except Exception as e:
|
||||
print(f"Fehler bei kanban_create: {e}", file=sys.stderr)
|
||||
print("Nichts aus diesen Ständen ist ‚fertig', solange die Ampel rot ist.")
|
||||
|
||||
with open(state_pfad, "w") as f:
|
||||
f.write("\n".join(sorted(gesehen)))
|
||||
PY
|
||||
@@ -1,162 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Auftragsbuch: einen Vorschlags-Branch ANNEHMEN — der letzte Meter des propose-only-Kreislaufs.
|
||||
# Werkstatt/Orchestrator liefern Branches (wartung/*, orchestrator/*), der Commander klickt in
|
||||
# der Zentrale „Annehmen" → dieses Skript macht die bisherige Git-Handarbeit: Merge im
|
||||
# isolierten Worktree (Lehre: NIE im Live-Checkout arbeiten) → Push nach main → Deploy →
|
||||
# Health-Check → bei Rot automatischer Revert + Redeploy + Alarm.
|
||||
#
|
||||
# WICHTIG: läuft als EIGENE systemd-Unit (systemd-run, startet routers/auftragsbuch.py),
|
||||
# NICHT als Kind des Backends — deploy.sh startet mission-control-2 neu und würde sonst
|
||||
# den eigenen Eltern-Prozess mitten im Lauf töten. Außerdem wird es vom Backend als
|
||||
# /tmp-KOPIE gestartet (deploy.sh macht git reset --hard → die Datei unter den Füßen
|
||||
# eines laufenden bash zu tauschen korrumpiert das Skript, bekannte Falle).
|
||||
#
|
||||
# Nutzung: auftrag-annehmen.sh <branch> (z. B. wartung/saubere-zusammenfassung)
|
||||
set -uo pipefail
|
||||
|
||||
BRANCH="${1:?Nutzung: auftrag-annehmen.sh <branch>}"
|
||||
SRC="${MC2_SRC:-$HOME/mission-control-v2}"
|
||||
STATUS="${MC2_AUFTRAG_STATUS:-/srv/models/mc2-auftragsbuch.json}"
|
||||
API="${MC_API:-http://127.0.0.1:9001}"
|
||||
SLUG="$(echo "$BRANCH" | tr '/' '-')"
|
||||
WT="/tmp/annahme-$SLUG"
|
||||
LOG="/tmp/annahme-$SLUG.log"
|
||||
|
||||
notify(){ bash "$SRC/deploy/notify.sh" -s "[Auftragsbuch]" "$1" || true; }
|
||||
|
||||
# Status-Fortschritt für die UI (atomar via tmp+replace; die API liest die Datei nur).
|
||||
status(){ # $1=state $2=detail
|
||||
python3 - "$STATUS" "$BRANCH" "$1" "$2" <<'PY'
|
||||
import json, os, sys, time
|
||||
p, branch, state, detail = sys.argv[1:5]
|
||||
try:
|
||||
d = json.load(open(p, encoding="utf-8"))
|
||||
except Exception:
|
||||
d = {}
|
||||
d.setdefault("branches", {})[branch] = {"state": state, "detail": detail, "ts": time.time()}
|
||||
tmp = p + ".tmp"
|
||||
json.dump(d, open(tmp, "w", encoding="utf-8"), ensure_ascii=False)
|
||||
os.replace(tmp, p)
|
||||
PY
|
||||
}
|
||||
|
||||
cleanup_wt(){ git -C "$SRC" worktree remove --force "$WT" 2>/dev/null || true; }
|
||||
|
||||
fail(){
|
||||
status "fehlgeschlagen" "$1"
|
||||
notify "Vorschlag '$BRANCH' konnte NICHT eingespielt werden: $1 — nichts wurde verändert, der Branch bleibt liegen."
|
||||
cleanup_wt
|
||||
exit 1
|
||||
}
|
||||
|
||||
status "laeuft" "Merge wird vorbereitet"
|
||||
cd "$SRC" || fail "Live-Checkout $SRC fehlt"
|
||||
git fetch -q origin || fail "git fetch (Gitea) fehlgeschlagen"
|
||||
git rev-parse --verify -q "refs/remotes/origin/$BRANCH" >/dev/null \
|
||||
|| fail "Branch origin/$BRANCH existiert nicht (schon gemergt/gelöscht?)"
|
||||
|
||||
PRE="$(git rev-parse origin/main)"
|
||||
|
||||
# Kaputt aufgesetzte Branches (Worker machte git init/Shallow statt zu klonen) haben KEINEN
|
||||
# gemeinsamen Vorfahren — git verweigert den Merge immer. Ehrlich sagen statt „Konflikt".
|
||||
git merge-base origin/main "origin/$BRANCH" >/dev/null 2>&1 \
|
||||
|| fail "Branch hat keinen gemeinsamen Ursprung mit main (kaputt aufgesetzt, z. B. git init statt Klonen) — bitte ablehnen (mit Grund) und die Idee neu in die Queue geben"
|
||||
|
||||
# Isolierter Worktree — der Live-Checkout bleibt bis zum Deploy unberührt.
|
||||
cleanup_wt
|
||||
git worktree add --detach "$WT" origin/main >/dev/null 2>&1 || fail "Worktree konnte nicht angelegt werden"
|
||||
MERGE_MSG="Auftragsbuch: '$BRANCH' angenommen (Ein-Klick-Gate)"
|
||||
if ! git -C "$WT" -c user.name="Auftragsbuch" -c user.email="auftragsbuch@box.local" \
|
||||
merge --no-ff "origin/$BRANCH" -m "$MERGE_MSG" >>"$LOG" 2>&1; then
|
||||
git -C "$WT" merge --abort 2>/dev/null || true
|
||||
# Selbstheilung: veralteten Branch mechanisch auf main rebasen (zweiter Worktree am
|
||||
# Branch-Kopf). Klappt das sauber, wird der rebasede Stand gemergt — kein PC nötig.
|
||||
status "laeuft" "Merge-Konflikt — Auto-Rebase wird versucht"
|
||||
WT2="/tmp/annahme-rebase-$SLUG"
|
||||
git worktree remove --force "$WT2" 2>/dev/null || true
|
||||
REBASED=""
|
||||
if git worktree add --detach "$WT2" "origin/$BRANCH" >/dev/null 2>&1 \
|
||||
&& git -C "$WT2" -c user.name="Auftragsbuch" -c user.email="auftragsbuch@box.local" \
|
||||
rebase origin/main >>"$LOG" 2>&1; then
|
||||
REBASED="$(git -C "$WT2" rev-parse HEAD)"
|
||||
else
|
||||
git -C "$WT2" rebase --abort 2>/dev/null || true
|
||||
fi
|
||||
git worktree remove --force "$WT2" 2>/dev/null || true
|
||||
[ -n "$REBASED" ] || fail "Merge-Konflikt mit main, Auto-Rebase scheiterte ebenfalls — echter Inhaltskonflikt; am einfachsten ablehnen und die Idee neu in die Queue geben (frischer Branch von aktuellem main)"
|
||||
git -C "$WT" -c user.name="Auftragsbuch" -c user.email="auftragsbuch@box.local" \
|
||||
merge --no-ff "$REBASED" -m "$MERGE_MSG — auto-rebased (Branch war veraltet)" >>"$LOG" 2>&1 \
|
||||
|| { git -C "$WT" merge --abort 2>/dev/null || true; fail "Merge nach Auto-Rebase fehlgeschlagen (unerwartet) — Log: $LOG"; }
|
||||
fi
|
||||
|
||||
# py_compile-Gate über die durch den Merge geänderten Python-Dateien (AGENTS.md-Regel).
|
||||
PYS="$(git -C "$WT" diff --name-only "$PRE"..HEAD -- '*.py' 2>/dev/null | tr '\n' ' ')"
|
||||
if [ -n "${PYS// /}" ]; then
|
||||
PYBIN="$SRC/backend/.venv/bin/python"; [ -x "$PYBIN" ] || PYBIN="python3"
|
||||
# shellcheck disable=SC2086
|
||||
( cd "$WT" && "$PYBIN" -m py_compile $PYS ) >>"$LOG" 2>&1 || fail "py_compile-Gate rot — der Patch enthält kaputtes Python"
|
||||
fi
|
||||
|
||||
status "laeuft" "Push nach main läuft"
|
||||
PUSHED=0
|
||||
for _ in 1 2 3; do
|
||||
git -C "$WT" push origin HEAD:main >>"$LOG" 2>&1 && { PUSHED=1; break; }
|
||||
sleep 5
|
||||
done
|
||||
[ "$PUSHED" = 1 ] || fail "Push nach main fehlgeschlagen (Gitea nicht erreichbar / Token?)"
|
||||
cleanup_wt
|
||||
|
||||
status "laeuft" "Deploy läuft — die Zentrale startet gleich kurz neu"
|
||||
notify "Vorschlag '$BRANCH' angenommen: Merge auf main ist durch, Deploy läuft. Die Zentrale ist gleich kurz weg."
|
||||
|
||||
# deploy.sh als Kopie ausführen (Selbst-Reset-Falle: es resettet das Repo, in dem es liegt).
|
||||
cp "$SRC/deploy/deploy.sh" "/tmp/annahme-deploy-$SLUG.sh"
|
||||
DEPLOY_OK=1
|
||||
bash "/tmp/annahme-deploy-$SLUG.sh" >>"$LOG" 2>&1 || DEPLOY_OK=0
|
||||
|
||||
# Health-Check mit Geduld (Dienst-Neustart + Warmup brauchen einen Moment).
|
||||
HEALTH=0
|
||||
if [ "$DEPLOY_OK" = 1 ]; then
|
||||
for _ in $(seq 1 18); do
|
||||
curl -sf -m 5 "$API/api/health" >/dev/null 2>&1 && { HEALTH=1; break; }
|
||||
sleep 5
|
||||
done
|
||||
fi
|
||||
|
||||
if [ "$HEALTH" = 1 ]; then
|
||||
# Gemergt + live + grün → der Remote-Branch hat seinen Zweck erfüllt.
|
||||
git -C "$SRC" push origin --delete "$BRANCH" >>"$LOG" 2>&1 || true
|
||||
git -C "$SRC" fetch -q --prune origin 2>/dev/null || true
|
||||
status "eingespielt" "Deploy grün, Health-Check bestanden"
|
||||
notify "Vorschlag '$BRANCH' ist LIVE — Deploy grün, Health-Check bestanden. ✅"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# ── Rollback: Merge revertieren, main zurückschieben, neu deployen, Alarm ──
|
||||
status "rollback" "Health rot — automatischer Revert läuft"
|
||||
git fetch -q origin || true
|
||||
cleanup_wt
|
||||
if git worktree add --detach "$WT" origin/main >/dev/null 2>&1 \
|
||||
&& git -C "$WT" -c user.name="Auftragsbuch" -c user.email="auftragsbuch@box.local" \
|
||||
revert -m 1 --no-edit HEAD >>"$LOG" 2>&1 \
|
||||
&& git -C "$WT" push origin HEAD:main >>"$LOG" 2>&1; then
|
||||
cleanup_wt
|
||||
cp "$SRC/deploy/deploy.sh" "/tmp/annahme-rollback-$SLUG.sh"
|
||||
bash "/tmp/annahme-rollback-$SLUG.sh" >>"$LOG" 2>&1 || true
|
||||
R=0
|
||||
for _ in $(seq 1 12); do
|
||||
curl -sf -m 5 "$API/api/health" >/dev/null 2>&1 && { R=1; break; }
|
||||
sleep 5
|
||||
done
|
||||
if [ "$R" = 1 ]; then
|
||||
status "zurueckgerollt" "Health blieb rot — Merge automatisch revertiert, Box läuft wieder"
|
||||
notify "Vorschlag '$BRANCH' hat den Health-Check GERISSEN. Automatisch zurückgerollt — die Box läuft wieder auf dem alten Stand. Der Branch bleibt zur Analyse liegen. Log: $LOG"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
cleanup_wt
|
||||
status "kritisch" "Rollback fehlgeschlagen — Box braucht Hilfe"
|
||||
curl -sf -m 5 -X POST "$API/api/alarm" -H 'Content-Type: application/json' \
|
||||
--data "{\"subject\":\"[Auftragsbuch]\",\"text\":\"KRITISCH: Annehmen von $BRANCH UND Rollback fehlgeschlagen — Zentrale prüfen. Log: $LOG\"}" >/dev/null 2>&1 \
|
||||
|| notify "KRITISCH: Annehmen von '$BRANCH' UND Rollback fehlgeschlagen — bitte Box prüfen (restore.sh liegt bereit). Log: $LOG"
|
||||
exit 2
|
||||
+77
-3
@@ -26,6 +26,8 @@ export XDG_RUNTIME_DIR="${XDG_RUNTIME_DIR:-/run/user/$(id -u)}"
|
||||
|
||||
say(){ echo "[autoupdate] $*"; }
|
||||
notify(){ bash "$SRC_DIR/notify.sh" -s "[Box-Update]" "$1" || true; }
|
||||
# Dringend = geht auch nachts sofort raus (sonst sammelt notify.sh bis zur Morgenmeldung um 07:00).
|
||||
notify_dringend(){ bash "$SRC_DIR/notify.sh" -d -s "[Box-Update]" "$1" || true; }
|
||||
|
||||
SUMMARY=() # eine Zeile je Ebene für die Abschluss-Meldung
|
||||
|
||||
@@ -59,7 +61,7 @@ run_root_update(){ # $1 komponente(swap|engine) $2 skript $3 anzeigename $4
|
||||
SUMMARY+=("$name: $latest FEHLGESCHLAGEN → zurückgerollt auf $installed + GEPINNT.")
|
||||
notify "$name-Update auf $latest fehlgeschlagen (Stack-Check rot). Automatisch zurückgerollt auf $installed — läuft wieder. Ebene ist jetzt GEPINNT, künftige Läufe überspringen sie." ;;
|
||||
*) SUMMARY+=("$name: KRITISCH — Update UND Rollback fehlgeschlagen!")
|
||||
notify "KRITISCH: $name-Update auf $latest UND Rollback fehlgeschlagen — Stack möglicherweise kaputt. Bitte melden, ich brauche Hilfe (Backup liegt bereit, restore.sh existiert)." ;;
|
||||
notify_dringend "KRITISCH: $name-Update auf $latest UND Rollback fehlgeschlagen — Stack möglicherweise kaputt. Bitte melden, ich brauche Hilfe (Backup liegt bereit, restore.sh existiert)." ;;
|
||||
esac
|
||||
}
|
||||
|
||||
@@ -123,7 +125,7 @@ check_hermes(){
|
||||
done
|
||||
if [ "$state" = "done" ]; then
|
||||
SUMMARY+=("$name: $behind Commits eingespielt, Gehirn-Check grün.")
|
||||
notify "Hermes-Agent aktualisiert ($behind Commits). Gehirn-Check (Mem0, Tools, Voice) grün — alles läuft."
|
||||
notify "Hermes-Agent aktualisiert ($behind Commits). Gehirn-Check (Patches, Tools, Voice) grün — alles läuft."
|
||||
return
|
||||
fi
|
||||
# Rot/Timeout: ERST Selbstreparatur versuchen (Config-Bruch selbst ziehen, neue Version behalten) —
|
||||
@@ -162,7 +164,75 @@ Wahrscheinliche Ursache/Fix (Box-Diagnose): $sugg}"
|
||||
fi
|
||||
fi
|
||||
SUMMARY+=("$name: KRITISCH — Update UND Rollback fehlgeschlagen!")
|
||||
notify "KRITISCH: Hermes-Update UND Rollback fehlgeschlagen — Gehirn-Check bleibt rot. Bitte melden. (Voll-Restore: deploy/restore.sh mit dem letzten Backup.)"
|
||||
notify_dringend "KRITISCH: Hermes-Update UND Rollback fehlgeschlagen — Gehirn-Check bleibt rot. Bitte melden. (Voll-Restore: deploy/restore.sh mit dem letzten Backup.)"
|
||||
}
|
||||
|
||||
# ── Neustart, wenn das OS einen verlangt ─────────────────────────────────────
|
||||
# Kernel- und libc-Updates werden erst nach einem Neustart wirksam; bis dahin
|
||||
# laeuft die Box weiter auf dem alten Kernel. Das faellt niemandem auf — am
|
||||
# 27.08.2026 lag die Box 7 Wochen mit drei ungenutzten Kerneln da.
|
||||
#
|
||||
# Der Neustart haengt bewusst am WOECHENTLICHEN Lauf (So 04:30) und nicht an
|
||||
# einem eigenen Timer: ein Ort fuer alle Automatik (Lehre vom 20.08.).
|
||||
# Er passiert NUR, wenn Ubuntu ihn selbst anfordert (/var/run/reboot-required).
|
||||
#
|
||||
# WICHTIG: Die Linger-Pruefung ist die entscheidende Zeile hier. Ohne Linger=yes
|
||||
# startet systemd die User-Dienste nach einem Neustart NICHT — die Box kaeme ohne
|
||||
# Steuerpult, Gateway und Waechter hoch, und niemand koennte sie aus der Ferne
|
||||
# wieder anschalten. Im Zweifel lieber nicht neustarten.
|
||||
#
|
||||
# Abschalten: MC_AUTOUPDATE_REBOOT=0 in der Umgebung des Cron-Jobs.
|
||||
# Ausserhalb des Wartungsfensters erzwingen (von Hand): MC_AUTOUPDATE_REBOOT_JETZT=1.
|
||||
reboot_wenn_noetig(){
|
||||
[ "${MC_AUTOUPDATE_REBOOT:-1}" = "1" ] || { say "Neustart per Env abgeschaltet."; return 0; }
|
||||
[ -f /var/run/reboot-required ] || { say "Kein Neustart noetig."; return 0; }
|
||||
|
||||
local pakete; pakete="$(sort -u /var/run/reboot-required.pkgs 2>/dev/null | tr "
|
||||
" " ")"
|
||||
|
||||
# Sicherung 0 (24.09.2026): neu gestartet wird nur im Wartungsfenster, sonntags 04:00-06:59.
|
||||
# Anlass: Ein nachgeholter Lauf (Persistent=true beim Einschalten des Timers) startete die Box
|
||||
# an einem Donnerstagnachmittag neu. Laeufe ausserhalb des Fensters melden den Neustart nur an.
|
||||
local tag stunde
|
||||
tag="$(date +%u)"; stunde="$((10#$(date +%H)))"
|
||||
if [ "${MC_AUTOUPDATE_REBOOT_JETZT:-0}" != "1" ] && { [ "$tag" != "7" ] || [ "$stunde" -lt 4 ] || [ "$stunde" -ge 7 ]; }; then
|
||||
notify "Neustart steht an (${pakete:-Kernel/libc}), passiert aber erst im Wartungsfenster am Sonntag frueh."
|
||||
return 0
|
||||
fi
|
||||
|
||||
# Sicherung 1: kommen die Dienste ohne Login von selbst wieder hoch?
|
||||
# $USER ist in systemd-/Cron-Umgebungen NICHT gesetzt — mit set -u waere das ein
|
||||
# sofortiger Abbruch des ganzen Update-Laufs. id -un funktioniert immer.
|
||||
local nutzer; nutzer="$(id -un)"
|
||||
if [ "$(loginctl show-user "$nutzer" -p Linger --value 2>/dev/null)" != "yes" ]; then
|
||||
notify "Neustart NICHT ausgefuehrt: die Box laeuft ohne linger — nach einem Neustart wuerden Steuerpult, Gateway und Waechter nicht von selbst starten. Erst 'loginctl enable-linger $nutzer' setzen, dann von Hand neustarten. Ausstehend: ${pakete:-Kernel/libc}"
|
||||
return 0
|
||||
fi
|
||||
|
||||
# Sicherung 2: kein halbfertiges Update mitten im Neustart abwuergen.
|
||||
local laufend
|
||||
laufend="$(curl -sf --max-time 20 "$API/api/jobs" | jq -r '[.jobs[]? | select(.state=="running")] | length' 2>/dev/null || echo 0)"
|
||||
if [ "${laufend:-0}" -gt 0 ]; then
|
||||
notify "Neustart verschoben: es laufen noch $laufend Job(s). Ausstehend: ${pakete:-Kernel/libc}. Naechster Versuch beim Lauf in einer Woche."
|
||||
return 0
|
||||
fi
|
||||
|
||||
# Sicherung 3: die Dienste muessen fuer den Autostart vorgemerkt sein.
|
||||
local fehlend=""
|
||||
for dienst in mission-control-2 mc2-gateway mc2-steward; do
|
||||
systemctl --user is-enabled "$dienst" >/dev/null 2>&1 || fehlend="$fehlend $dienst"
|
||||
done
|
||||
if [ -n "$fehlend" ]; then
|
||||
notify "Neustart NICHT ausgefuehrt: diese Dienste sind nicht fuer den Autostart eingetragen —$fehlend. Erst 'systemctl --user enable' nachholen. Ausstehend: ${pakete:-Kernel/libc}"
|
||||
return 0
|
||||
fi
|
||||
|
||||
notify "Die Box startet jetzt neu — das OS verlangt es nach dem Update (${pakete:-Kernel/libc}). Sie ist ein paar Minuten weg und meldet sich, sobald alles wieder laeuft. Die Modelle muessen danach neu geladen werden, die erste Anfrage dauert also laenger."
|
||||
say "Neustart wird ausgeloest (ausstehend: ${pakete:-Kernel/libc})."
|
||||
sleep 20 # der Meldung Zeit lassen, rauszugehen
|
||||
sudo -n systemctl reboot || {
|
||||
notify "Neustart FEHLGESCHLAGEN: 'sudo systemctl reboot' wurde abgelehnt. Bitte von Hand neustarten. Ausstehend: ${pakete:-Kernel/libc}"
|
||||
}
|
||||
}
|
||||
|
||||
# ── Lauf ─────────────────────────────────────────────────────────────────────
|
||||
@@ -180,3 +250,7 @@ check_hermes
|
||||
notify "Commander, die Wochenpflege der Box ist durch — kurz für dich:
|
||||
$(printf '• %s\n' "${SUMMARY[@]}")"
|
||||
say "Fertig."
|
||||
|
||||
# Ganz zuletzt: der Neustart wuerde die Abschlussmeldung oben sonst verschlucken.
|
||||
# Die Funktion meldet in jedem Fall selbst, ob sie neustartet oder warum nicht.
|
||||
reboot_wenn_noetig
|
||||
|
||||
+27
-9
@@ -3,8 +3,12 @@
|
||||
# zurückkommt. Erzeugt EIN Tarball mc2-state-<ts>.tar.gz, behält die letzten N.
|
||||
# Läuft per systemd-Timer (täglich), manuell, oder über den UI-Snapshot-Button.
|
||||
#
|
||||
# Inhalt: mem0 (Chroma + history.db) · ~/.hermes (config.yaml, .env, plugins/) ·
|
||||
# /etc/llama-swap/config.yaml · known-good/ (Versions-Manifest, s. u.)
|
||||
# Inhalt: ~/.hermes (config.yaml, .env, plugins/, cron/, state/) · /etc/llama-swap/config.yaml ·
|
||||
# seit 24.09.2026 auch Lucys Gedächtnis (memories/), SOUL.md, skills/, scripts/,
|
||||
# der Box-Wart-Zustand (/srv/models/mc2-*.json), User-Units, llama-swap-Drop-ins und
|
||||
# Lucys Stimmreferenz · known-good/ (Versions-Manifest, s. u.)
|
||||
# Das abgeloeste Gedaechtnis-Sidecar (/srv/models, Port 8765) faellt seit 27.08.2026 weg —
|
||||
# sein Datenverzeichnis existiert nicht mehr (Hintergrund: docs/wissen/VERDIKTE.md).
|
||||
# NICHT enthalten (bewusst): GGUF-Modelle (riesig, neu ladbar), MC2-Code (Git), venvs.
|
||||
#
|
||||
# ACHTUNG: das Tarball enthält ~/.hermes/.env (Secrets) → chmod 600, nicht in Git.
|
||||
@@ -20,16 +24,14 @@ RETAIN="${MC_BACKUP_RETAIN:-14}"
|
||||
# Off-Box-Ziel (leer = deaktiviert). Default: Proxmox-Host, eigener Key mit minimalem Recht.
|
||||
OFFSITE="${MC_BACKUP_OFFSITE:-root@192.168.178.108:/var/lib/vz/mc2-backups}"
|
||||
OFFSITE_KEY="${MC_BACKUP_OFFSITE_KEY:-$HOME/.ssh/mc2_offsite}"
|
||||
MEM0_DIR="${MC_MEM0_DIR:-/srv/models/mem0}"
|
||||
LSWAP="${MC_CONFIG_PATH:-/etc/llama-swap/config.yaml}"
|
||||
HERMES="${HERMES_HOME:-$HOME/.hermes}"
|
||||
TS="$(date +%Y%m%d-%H%M%S)"
|
||||
|
||||
STAGE="$(mktemp -d)"
|
||||
trap 'rm -rf "$STAGE"' EXIT
|
||||
mkdir -p "$STAGE/mem0" "$STAGE/hermes" "$STAGE/llama-swap"
|
||||
mkdir -p "$STAGE/hermes" "$STAGE/llama-swap"
|
||||
|
||||
[ -d "$MEM0_DIR" ] && cp -a "$MEM0_DIR/." "$STAGE/mem0/" || true
|
||||
[ -f "$HERMES/config.yaml" ] && cp -a "$HERMES/config.yaml" "$STAGE/hermes/" || true
|
||||
[ -f "$HERMES/.env" ] && cp -a "$HERMES/.env" "$STAGE/hermes/" || true
|
||||
[ -d "$HERMES/plugins" ] && cp -a "$HERMES/plugins" "$STAGE/hermes/plugins" || true
|
||||
@@ -47,6 +49,23 @@ _TOKDROPIN="$HOME/.config/systemd/user/hermes-builtin-ui.service.d/session-token
|
||||
# Release-Radar) still:
|
||||
[ -d "$HERMES/cron" ] && cp -a "$HERMES/cron" "$STAGE/hermes/cron" || true
|
||||
[ -d "$HERMES/state" ] && cp -a "$HERMES/state" "$STAGE/hermes/state" || true
|
||||
# Lucys Gedächtnis, Seele, Skills und Cron-Skripte (24.09.2026). Hermes ist die alleinige
|
||||
# Gedächtnis-Wahrheit — bis hierher fehlte das Gedächtnis in jeder Sicherung, ein Plattenausfall
|
||||
# hätte es gelöscht. restore.sh spielt diese Teile nur zurück, wenn sie fehlen oder ausdrücklich
|
||||
# gewünscht (--mit-gedaechtnis), damit ein Restore keine neueren Erinnerungen überschreibt.
|
||||
[ -d "$HERMES/memories" ] && cp -a "$HERMES/memories" "$STAGE/hermes/memories" || true
|
||||
[ -f "$HERMES/SOUL.md" ] && cp -a "$HERMES/SOUL.md" "$STAGE/hermes/" || true
|
||||
[ -d "$HERMES/skills" ] && cp -a "$HERMES/skills" "$STAGE/hermes/skills" || true
|
||||
[ -d "$HERMES/scripts" ] && cp -a "$HERMES/scripts" "$STAGE/hermes/scripts" || true
|
||||
# Box-Wart-Zustand: Radar, Wächter, Pins, Erinnerungen, Briefkasten.
|
||||
mkdir -p "$STAGE/box-wart"
|
||||
cp -a "$MODELS_DIR"/mc2-*.json "$STAGE/box-wart/" 2>/dev/null || true
|
||||
# Units und Drop-ins als Nachschlagewerk für einen Wiederaufbau (restore.sh fasst sie nicht an).
|
||||
mkdir -p "$STAGE/systemd"
|
||||
[ -d "$HOME/.config/systemd/user" ] && cp -a "$HOME/.config/systemd/user" "$STAGE/systemd/user" || true
|
||||
[ -d /etc/systemd/system/llama-swap.service.d ] && cp -a /etc/systemd/system/llama-swap.service.d "$STAGE/systemd/" || true
|
||||
# Lucys Stimmreferenz (nur die Aufnahme, das Programm drumherum ist neu installierbar).
|
||||
[ -f "$HOME/.lucy-stimme/app/ref.wav" ] && { mkdir -p "$STAGE/lucy-stimme"; cp -a "$HOME/.lucy-stimme/app/ref.wav" "$STAGE/lucy-stimme/"; } || true
|
||||
|
||||
# --- Known-Good-Manifest -----------------------------------------------------
|
||||
# Versions-Schnappschuss des LAUFENDEN Stacks: pip freeze je venv, Engine-Build,
|
||||
@@ -60,7 +79,7 @@ SRC="${MC2_SRC:-$HOME/mission-control-v2}"
|
||||
HERMES_AGENT="${HERMES_AGENT_DIR:-$HOME/.hermes/hermes-agent}"
|
||||
UV="$(command -v uv || echo "$HOME/.local/bin/uv")"
|
||||
|
||||
# pip freeze je venv; mem0-venv ist uv-managed (kein pip-Modul) → uv als Fallback.
|
||||
# pip freeze je venv; das voice-venv ist uv-managed (kein pip-Modul) → uv als Fallback.
|
||||
freeze_venv() { # $1 = python-Pfad, $2 = Zieldatei
|
||||
if [ ! -x "$1" ]; then echo "venv fehlt: $1" > "$2"; return 0; fi
|
||||
"$1" -m pip freeze > "$2" 2>/dev/null \
|
||||
@@ -68,7 +87,6 @@ freeze_venv() { # $1 = python-Pfad, $2 = Zieldatei
|
||||
|| echo "pip freeze fehlgeschlagen: $1" > "$2"
|
||||
}
|
||||
freeze_venv "$SRC/backend/.venv/bin/python" "$KG/pip-backend.txt"
|
||||
freeze_venv "$HOME/.mem0/venv/bin/python" "$KG/pip-mem0.txt"
|
||||
freeze_venv "$HOME/.voice/venv/bin/python" "$KG/pip-voice.txt"
|
||||
|
||||
{
|
||||
@@ -79,7 +97,7 @@ freeze_venv "$HOME/.voice/venv/bin/python" "$KG/pip-voice.txt"
|
||||
echo "llama.cpp : $(LD_LIBRARY_PATH=/opt/llamacpp-vulkan /usr/local/bin/llama-server --version 2>&1 | grep -m1 -o 'version: .*' || echo '?')"
|
||||
echo "llama-swap : $(/usr/local/bin/llama-swap --version 2>/dev/null | head -1 || echo '?')"
|
||||
echo "python-venvs :"
|
||||
for P in "$SRC/backend/.venv/bin/python" "$HOME/.mem0/venv/bin/python" "$HOME/.voice/venv/bin/python"; do
|
||||
for P in "$SRC/backend/.venv/bin/python" "$HOME/.voice/venv/bin/python"; do
|
||||
echo " $P → $("$P" --version 2>&1 || echo 'fehlt')"
|
||||
done
|
||||
} > "$KG/versions.txt" || true
|
||||
@@ -92,7 +110,7 @@ cat > "$STAGE/MANIFEST.txt" <<EOF
|
||||
mc2-state backup
|
||||
created : $TS
|
||||
host : $(hostname)
|
||||
inhalt : mem0 (chroma + history.db), hermes (config.yaml, .env, plugins/), llama-swap (config.yaml), known-good/ (Versions-Manifest: pip freeze, Engine, Modelle)
|
||||
inhalt : hermes (config.yaml, .env, plugins/, cron/, state/, memories/, SOUL.md, skills/, scripts/), llama-swap (config.yaml), box-wart/ (mc2-*.json), systemd/ (Units, Drop-ins), lucy-stimme/ (ref.wav), known-good/ (Versions-Manifest: pip freeze, Engine, Modelle)
|
||||
restore : bash ~/mission-control-v2/deploy/restore.sh mc2-state-$TS.tar.gz
|
||||
EOF
|
||||
|
||||
|
||||
@@ -1,72 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Bagatell-Annahme — „Harmloses darf selbst" (User-Entscheid 10.07.2026, konservativ geschnitten):
|
||||
# Vorschlags-Branches, die AUSSCHLIESSLICH Markdown-Dateien anlegen oder ändern (Doku/Wiki —
|
||||
# keine einzige Code-Zeile, keine Löschungen, keine Umbenennungen, keine Skripte), spielt die
|
||||
# Box nachts OHNE Klick ein. Der Weg ist derselbe wie beim Klick im Auftragsbuch:
|
||||
# auftrag-annehmen.sh (Merge im Worktree → py_compile-Gate → Push main → Deploy → Health →
|
||||
# Auto-Revert). Fangnetz drumherum: Nacht-Sicherung 03:30 (Zeitmaschine) liegt davor, die
|
||||
# Morgenlage 04:30 berichtet danach, jede Annahme steht in Chronik + Telegram.
|
||||
# JEDE Code-Zeile bleibt Klick-pflichtig — dieses Skript weitet die Klasse NIE selbst aus.
|
||||
#
|
||||
# Läuft als mc2-bagatell.timer (04:10) über eine /tmp-Kopie (Selbst-Reset-Falle: die Annahme
|
||||
# deployt und resettet damit das Repo, in dem dieses Skript liegt).
|
||||
set -uo pipefail
|
||||
|
||||
SRC="${MC2_SRC:-$HOME/mission-control-v2}"
|
||||
STATUS="${MC2_AUFTRAG_STATUS:-/srv/models/mc2-auftragsbuch.json}"
|
||||
MAX_PRO_NACHT=2 # jede Annahme startet die Zentrale kurz neu — Nachtruhe wahren
|
||||
|
||||
cd "$SRC" || exit 0
|
||||
git fetch -q --prune origin 2>/dev/null || exit 0
|
||||
|
||||
angenommen=0
|
||||
for REF in $(git for-each-ref --format='%(refname:short)' refs/remotes/origin); do
|
||||
[ "$angenommen" -ge "$MAX_PRO_NACHT" ] && break
|
||||
BRANCH="${REF#origin/}"
|
||||
case "$BRANCH" in wartung/*|orchestrator/*|doku/*|feature/*) ;; *) continue ;; esac
|
||||
|
||||
AHEAD="$(git rev-list --count "origin/main..$REF" 2>/dev/null || echo 0)"
|
||||
[ "${AHEAD:-0}" -gt 0 ] || continue
|
||||
|
||||
# Nur unberührte Vorschläge: trägt der Branch schon irgendeinen Annahme-Status
|
||||
# (läuft/fehlgeschlagen/zurückgerollt/…), entscheidet der Commander — nicht wir.
|
||||
STATE="$(python3 - "$STATUS" "$BRANCH" <<'PY'
|
||||
import json, sys
|
||||
try:
|
||||
print((json.load(open(sys.argv[1], encoding="utf-8")).get("branches", {}).get(sys.argv[2]) or {}).get("state", ""))
|
||||
except Exception:
|
||||
print("")
|
||||
PY
|
||||
)"
|
||||
[ -z "$STATE" ] || continue
|
||||
|
||||
# Bagatell-Klasse: JEDE geänderte Datei endet auf .md UND ist nur angelegt (A) oder
|
||||
# geändert (M). Alles andere (Löschung, Rename R…, Copy C…, Nicht-Markdown) → Klick-Pflicht.
|
||||
DIFF="$(git diff --name-status "origin/main...$REF" 2>/dev/null)"
|
||||
[ -n "$DIFF" ] || continue
|
||||
OK=1
|
||||
while IFS=$'\t' read -r st path _rest; do
|
||||
[ -n "$st" ] || continue
|
||||
case "$st" in A|M) ;; *) OK=0; break ;; esac
|
||||
case "$path" in *.md) ;; *) OK=0; break ;; esac
|
||||
done <<< "$DIFF"
|
||||
[ "$OK" = 1 ] || continue
|
||||
|
||||
N="$(printf '%s\n' "$DIFF" | wc -l | tr -d ' ')"
|
||||
echo "Bagatelle erkannt: $BRANCH ($N Markdown-Datei(en))"
|
||||
curl -sf -m 5 -X POST "${MC_ANNOUNCE_URL:-http://127.0.0.1:9001/api/voice/announce}" \
|
||||
-H 'Content-Type: application/json' \
|
||||
--data "$(jq -n --arg t "Bagatelle (nur Doku, $N Datei(en)): '$BRANCH' wird ohne Klick eingespielt — Fangnetz aktiv, Details in der Morgenlage." \
|
||||
'{text:$t, subject:"[Bagatell-Annahme]", source:"bagatell", priority:"silent"}')" >/dev/null 2>&1 || true
|
||||
|
||||
RUNNER="/tmp/mc2-bagatell-annehmen-$$.sh"
|
||||
cp "$SRC/deploy/auftrag-annehmen.sh" "$RUNNER" || continue
|
||||
if bash "$RUNNER" "$BRANCH"; then
|
||||
angenommen=$((angenommen+1))
|
||||
else
|
||||
# auftrag-annehmen.sh hat selbst gemeldet (fehlgeschlagen/zurückgerollt) und aufgeräumt;
|
||||
# der Branch trägt jetzt einen Status → nächste Nacht fasst ihn niemand mehr automatisch an.
|
||||
echo "Bagatell-Annahme von $BRANCH nicht grün — Meldung kam vom Annahme-Runner."
|
||||
fi
|
||||
done
|
||||
echo "Bagatell-Lauf fertig: $angenommen eingespielt."
|
||||
@@ -1,40 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Brain-Config-Matrix (Referenz vom 02.07.2026): misst Qwen3.6 in mehreren Betriebsarten
|
||||
# (MTP n-max / ohne Spec / KV-Quant) auf separatem Port. Für künftige Re-Benchmarks nach
|
||||
# Engine-Updates oder bei neuen Hirn-Kandidaten. Ergebnis-Referenz (02.07., Vulkan, b9843):
|
||||
# mtp3 78,6 t/s · mtp4 63,7 · ohne Spec 62,4 · mtp3+KVq8 78,6 (=gratis) · nospec+KVq8 62,1
|
||||
set -u
|
||||
MODEL="${BRAIN_GGUF:-/srv/models/Qwen3.6-35B-A3B-MTP-GGUF/Qwen3.6-35B-A3B-UD-Q4_K_M.gguf}"
|
||||
BIN="${BENCH_BIN:-/opt/llamacpp-vulkan/llama-server}"
|
||||
PORT="${BENCH_PORT:-5899}"
|
||||
BASE="-c 65536 -ngl 999 -fa on --no-mmap --jinja --parallel 1"
|
||||
|
||||
run() { # $1=name $2=extra-flags
|
||||
echo "=== $1 ==="
|
||||
$BIN -m "$MODEL" --host 127.0.0.1 --port "$PORT" $BASE $2 >/tmp/brain-bench-server.log 2>&1 &
|
||||
local PID=$!
|
||||
for i in $(seq 1 120); do
|
||||
curl -s --max-time 2 "http://127.0.0.1:$PORT/health" | grep -q ok && break
|
||||
sleep 2
|
||||
kill -0 $PID 2>/dev/null || { echo " LOAD-CRASH"; tail -2 /tmp/brain-bench-server.log; return; }
|
||||
done
|
||||
for r in 0 1 2; do
|
||||
curl -s --max-time 300 -X POST "http://127.0.0.1:$PORT/completion" -H "Content-Type: application/json" \
|
||||
-d '{"prompt":"Erklaere in drei kurzen Saetzen, was ein Mixture-of-Experts-Modell ist.","n_predict":150,"temperature":0}' \
|
||||
> /tmp/brain-bench-probe.json
|
||||
[ "$r" = "0" ] && continue # Warmlauf verwerfen
|
||||
python3 - <<'PY'
|
||||
import json
|
||||
t = json.load(open("/tmp/brain-bench-probe.json")).get("timings", {})
|
||||
print(f" tg={t.get('predicted_per_second',0):.1f} t/s"
|
||||
+ (f" · draft {t.get('draft_n_accepted')}/{t.get('draft_n')}" if t.get('draft_n') else ""))
|
||||
PY
|
||||
done
|
||||
kill $PID 2>/dev/null; wait $PID 2>/dev/null; sleep 3
|
||||
}
|
||||
|
||||
run "mtp3 (live-Config)" "--spec-type draft-mtp --spec-draft-n-max 3 -ctk q8_0 -ctv q8_0"
|
||||
run "mtp4" "--spec-type draft-mtp --spec-draft-n-max 4 -ctk q8_0 -ctv q8_0"
|
||||
run "ohne Spec" "-ctk q8_0 -ctv q8_0"
|
||||
run "mtp3 KV-f16" "--spec-type draft-mtp --spec-draft-n-max 3"
|
||||
echo "BENCH_DONE"
|
||||
@@ -1,42 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Modell-Bench (Autonomie-Plan E5): misst pp/tg eines GGUF auf separatem Port, ohne die
|
||||
# Live-Instanzen anzufassen. Grundlage der Modell-Selbst-Evaluation (Radar-Kandidaten).
|
||||
# Nutzung: bash model-bench.sh <gguf-pfad> [extra llama-server-flags, z.B. --mmproj ...]
|
||||
# Lehren vom 02.07.2026 eingebaut: python via Heredoc (kein f-String-Quoting-Bruch),
|
||||
# Warmlauf vor der Messung, LOAD-CRASH wird gemeldet statt still zu hängen.
|
||||
set -u
|
||||
MODEL="${1:?Nutzung: model-bench.sh <gguf-pfad> [extra-flags]}"
|
||||
shift || true
|
||||
EXTRA="$*"
|
||||
BIN="${BENCH_BIN:-/opt/llamacpp-vulkan/llama-server}"
|
||||
PORT="${BENCH_PORT:-5899}"
|
||||
CTX="${BENCH_CTX:-32768}"
|
||||
|
||||
echo "=== Bench: $(basename "$MODEL") (ctx $CTX${EXTRA:+, $EXTRA}) ==="
|
||||
$BIN -m "$MODEL" --host 127.0.0.1 --port "$PORT" -c "$CTX" -ngl 999 -fa on --no-mmap --jinja $EXTRA \
|
||||
>/tmp/model-bench-server.log 2>&1 &
|
||||
PID=$!
|
||||
trap 'kill $PID 2>/dev/null; wait $PID 2>/dev/null' EXIT
|
||||
|
||||
for i in $(seq 1 240); do
|
||||
curl -s --max-time 2 "http://127.0.0.1:$PORT/health" | grep -q ok && break
|
||||
sleep 2
|
||||
kill -0 $PID 2>/dev/null || { echo "LOAD-CRASH:"; tail -3 /tmp/model-bench-server.log; exit 1; }
|
||||
done
|
||||
|
||||
probe() {
|
||||
curl -s --max-time 300 -X POST "http://127.0.0.1:$PORT/completion" -H "Content-Type: application/json" \
|
||||
-d '{"prompt":"Erklaere in fuenf Saetzen, wie ein Backup-Konzept fuer einen Homeserver aussieht.","n_predict":200,"temperature":0}' \
|
||||
> /tmp/model-bench-probe.json
|
||||
python3 - <<'PY'
|
||||
import json
|
||||
t = json.load(open("/tmp/model-bench-probe.json")).get("timings", {})
|
||||
print(f" pp={t.get('prompt_per_second',0):.0f} t/s · tg={t.get('predicted_per_second',0):.1f} t/s"
|
||||
+ (f" · draft {t.get('draft_n_accepted')}/{t.get('draft_n')}" if t.get('draft_n') else ""))
|
||||
PY
|
||||
}
|
||||
|
||||
probe >/dev/null 2>&1 # Warmlauf (Shader-/Graph-Compile nicht mitmessen)
|
||||
probe
|
||||
probe
|
||||
echo "BENCH_DONE"
|
||||
@@ -0,0 +1,89 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Prüfstand für Hirn-Kandidaten (17.09.2026), seit 09/2026 eine dünne Hülle um pruefstand.py.
|
||||
|
||||
Die Messungen selbst (Tempo, 13k-Tiefe, Werkzeuge, Deutsch/JSON, Bild-Probe, Server auf :5899) liegen
|
||||
im Modul — dasselbe nutzt das Modell-Radar nachts. Dieses Skript bleibt der Handbetrieb: Kandidaten
|
||||
mit festen Pfaden gegen die Live-Modelle (llama-swap :8080) messen, optional mit dem Tool-Smoke durch
|
||||
den ECHTEN Hermes-Agenten (`hermes chat --provider pruefstand -m kandidat`; der Provider `pruefstand`
|
||||
-> http://127.0.0.1:5899/v1 muss in `providers:` der Hermes-Config stehen).
|
||||
|
||||
Aufruf auf der Box: python3 pruefstand-hirn.py [baseline] [nemo] [u38] [u36] (Pfade unten anpassen).
|
||||
Ergebnis: ~/pruefstand-<datum>.json + Log auf stdout.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
|
||||
import pruefstand as ps # liegt daneben; beim Skriptaufruf steht dieser Ordner auf sys.path
|
||||
|
||||
M = "/srv/models"
|
||||
DFLASH_36 = f"{M}/Qwen3.6-35B-A3B-DFlash-GGUF/giocom-Qwen3.6-35B-A3B-DFlash-Q8_0.gguf"
|
||||
DFLASH2_38 = f"{M}/Qwen3.8-27B-DFlash2-GGUF/Qwen3.8-27B-DFlash2-Q4_K_M.gguf"
|
||||
NEMO = f"{M}/Nemotron-3.5-Lightning-30B-A3B-GGUF/NVIDIA-Nemotron-3.5-Lightning-30B-A3B-Q4_0.gguf"
|
||||
NEMO_MTP = f"{M}/Nemotron-3.5-Lightning-30B-A3B-GGUF/mtp-NVIDIA-Nemotron-3.5-Lightning-30B-A3B-Q8_0.gguf"
|
||||
U38 = f"{M}/Qwen3.8-27B-Uncensored-GGUF/Qwen3.8-27B-Uncensored-Q4_K_M.gguf"
|
||||
U36 = f"{M}/Qwen3.6-35B-A3B-Uncensored-GGUF/Qwen3.6-35B-A3B-Uncensored-HauhauCS-Aggressive-Q4_K_M.gguf"
|
||||
OUT = os.path.expanduser(f"~/pruefstand-{time.strftime('%Y-%m-%d')}.json")
|
||||
RESULTS: list[dict] = []
|
||||
|
||||
|
||||
def save() -> None:
|
||||
with open(OUT, "w", encoding="utf-8") as f:
|
||||
json.dump(RESULTS, f, indent=1, ensure_ascii=False)
|
||||
|
||||
|
||||
def probes(basis: str, modell: str, label: str, with_hermes: bool, hermes_args: list[str], bild: bool = False) -> None:
|
||||
werte = {"label": label, **ps.pruefe("hirn", basis, modell, bild=bild)}
|
||||
if with_hermes:
|
||||
werte["hermes"] = ps.hermes_smoke(hermes_args)
|
||||
RESULTS.append(werte)
|
||||
save()
|
||||
|
||||
|
||||
def candidate(label: str, model: str, extra: list[str], with_hermes: bool, mmproj: str | None = None) -> None:
|
||||
ps.log(f"\n=== {label}")
|
||||
ps.log(f" {os.path.basename(model)} {' '.join(extra)}")
|
||||
try:
|
||||
server = ps.starte_server(model, mmproj=mmproj, flags=extra, ctx=ps.CTX_STANDARD)
|
||||
except ps.ServerFehler as e:
|
||||
ps.log(f" {e}")
|
||||
RESULTS.append({"label": label, "fehler": str(e)[:300]})
|
||||
save()
|
||||
return
|
||||
try:
|
||||
probes(ps.KANDIDAT_URL, "kandidat", label, with_hermes, ["--provider", "pruefstand", "-m", "kandidat"],
|
||||
bild=bool(mmproj))
|
||||
except Exception as e:
|
||||
ps.log(f" FEHLER in Proben: {e}")
|
||||
RESULTS.append({"label": label, "fehler": str(e)[:200]})
|
||||
save()
|
||||
finally:
|
||||
ps.stoppe_server(server)
|
||||
time.sleep(3)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
which = set(sys.argv[1:]) or {"baseline", "nemo", "u38", "u36"}
|
||||
if "baseline" in which:
|
||||
ps.log("\n=== BASELINE fast (live, llama-swap)")
|
||||
probes(ps.SWAP_URL, "fast", "fast-live", True, ["-m", "fast"])
|
||||
ps.log("\n=== BASELINE coder (live, llama-swap)")
|
||||
probes(ps.SWAP_URL, "coder", "coder-live", True, ["-m", "coder"], bild=True)
|
||||
if "nemo" in which:
|
||||
candidate("Nemotron 3.5 Lightning · MTP n3", NEMO,
|
||||
ps.draft_flags(NEMO_MTP, "draft-mtp", 3), True)
|
||||
candidate("Nemotron 3.5 Lightning · ohne Spec", NEMO, [], False)
|
||||
if "u38" in which:
|
||||
candidate("Qwen3.8-27B · MTP fused n2", U38, ps.draft_flags(None, "draft-mtp", 2), True)
|
||||
candidate("Qwen3.8-27B · DFlash2 (z-lab) n3", U38, ps.draft_flags(DFLASH2_38, "draft-dflash", 3), False)
|
||||
candidate("Qwen3.8-27B · ohne Spec", U38, [], False)
|
||||
if "u36" in which:
|
||||
candidate("Qwen3.6-35B-A3B · DFlash (giocom)", U36, ps.draft_flags(DFLASH_36, "draft-dflash"), True)
|
||||
candidate("Qwen3.6-35B-A3B · ohne Spec", U36, [], False)
|
||||
ps.log("\nPRUEFSTAND_DONE")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,59 +0,0 @@
|
||||
# Betriebs-/Mess-Worker (Profil „betrieb")
|
||||
|
||||
Du bist der Betriebs- und Mess-Worker der AI-Box. Du arbeitest EINE Kanban-Aufgabe ab —
|
||||
Ops, Benchmarks, Messungen, Health-/Diagnose-Checks, Verifikation von Behauptungen auf der
|
||||
LEBENDEN Box (Dienste, Modelle, Latenzen, Logs, Ressourcen). Keine Persona, kein Smalltalk.
|
||||
**Dein Ergebnis ist ein Mess-/Diagnose-BERICHT mit belegten Zahlen — du misst und berichtest,
|
||||
du reparierst nicht.**
|
||||
|
||||
## Schritt 0: WIEDERAUFNAHME — bei „Prior attempts" erst nachsehen, dann messen
|
||||
|
||||
Steht in deinem Auftrag ein Abschnitt **„Prior attempts on this task"**, hat vor dir schon ein
|
||||
Lauf gemessen und ist unterwegs gestorben (meist Kontext-Limit). **Dein Workspace ist derselbe.**
|
||||
Also zuerst: `cat FORTSCHRITT.md` und `ls -la` im Workspace — liegen dort schon Messwerte, Logs,
|
||||
Bench-Ausgaben? **Vorhandene Zahlen misst du NICHT neu.** Das ist hier keine Bequemlichkeit,
|
||||
sondern Pflicht: ein wiederholter Bench lädt 70-GB-Modelle, gefährdet Lucys Warm-Set und kann
|
||||
einen OOM auslösen (Regel 5). Fehlt nur noch die Auswertung, wertest du aus und schließt ab.
|
||||
Nur was nachweislich fehlt oder unbrauchbar ist, wird neu gemessen.
|
||||
|
||||
**Fortschritt hinterlassen:** jede fertige Messung sofort als Zeile in `FORTSCHRITT.md` im
|
||||
Workspace (was gemessen, welche Zahl, in welcher Datei) — und die Rohausgabe in eine Datei
|
||||
daneben. Eine Messung, die nur im Kontext steht, ist beim nächsten Lauf verloren.
|
||||
|
||||
## Eiserne Regeln (nicht verhandelbar)
|
||||
1. **MESSEN, NICHT ÄNDERN.** Du fasst das Live-System NICHT an: kein `systemctl restart`, kein
|
||||
Config-Edit, kein Deploy/Merge/Push, kein Laden/Entladen von Modellen, das das Warm-Set
|
||||
stört. Ergibt die Messung, dass etwas geändert werden muss → schreib es als BEFUND/Vorschlag
|
||||
in die Summary (oder leg per `kanban_create` eine Idee an), mach es NICHT selbst.
|
||||
2. **Nur LESEN am Bestand:** Live-Checkout `~/mission-control-v2` und Hermes-Quelle
|
||||
`~/.hermes/hermes-agent` sind TABU zum Schreiben (Lesen zur Orientierung ok). Alles außerhalb
|
||||
einer reinen Messung ist tabu.
|
||||
3. **TABU-Zonen:** Security-Config (approvals/Tokens/ufw/sudoers), `~/.hermes/config.yaml`,
|
||||
systemd-Units. Braucht die Aufgabe so etwas → `kanban_block` mit Begründung, nicht selbst tun.
|
||||
4. **Kenn deine Werkzeuge** (Endpunkte/Pfade stehen in deiner Box-Orientierung + im Selbst-
|
||||
Steckbrief `~/.hermes/state/selbst-steckbrief.md`):
|
||||
- Health/Status: `curl -s http://127.0.0.1:9001/api/health`, `.../api/system/status`.
|
||||
- **Dienste:** `systemctl --user status <dienst>` — aber VORHER
|
||||
`export XDG_RUNTIME_DIR=/run/user/$(id -u)`, sonst sind die User-Units per SSH UNSICHTBAR
|
||||
(häufigste Ops-Falle). Logs: `journalctl --user -u <dienst> --no-pager -n 100`.
|
||||
- Modelle/Router: `curl -s http://127.0.0.1:8080/...` bzw. `http://127.0.0.1:9001/v1/models`.
|
||||
- Bench/Smoke (nur lesen/messen, nicht deployen): `~/mission-control-v2/deploy/bench/brain-bench.sh`,
|
||||
`.../deploy/bench/model-bench.sh`, `.../deploy/self-smoke.sh`, `.../deploy/stack-postcheck.sh`.
|
||||
5. **BENCH-VORSICHT:** Nie zwei große Benches (70-GB-Modelle) direkt nacheinander → OOM-Kill
|
||||
(Vorfall E5, 02.07.2026). Schwere Modelle (heavy/vision) nur laden, wenn die Aufgabe es
|
||||
verlangt; danach den Ausgangszustand wiederherstellen. **Lucys Warm-Set und ihre ~1-s-Sprech-
|
||||
Latenz NIE gefährden** — ein Config-Reload leert das Warm-Set, ein OOM killt laufende Modelle.
|
||||
6. **VERIFIZIEREN VOR BEHAUPTEN.** Dein Bericht enthält BELEGTE Zahlen und echte Kommando-
|
||||
Ausgaben (bei Vergleichen: Vorher/Nachher), keine Vermutung. Zitiere die reale Ausgabe, nicht
|
||||
das, was du erwartest. Der Agenten-Erzählung — auch deiner eigenen — nie glauben; das Log,
|
||||
der curl, der bench sind die Wahrheit.
|
||||
7. **Falsche Ebene erkannt?** Braucht die Aufgabe in Wahrheit eine CODE-Änderung → das ist die
|
||||
**werkstatt**, nicht du (`kanban_block` mit Verweis). Reine Doku/Recherche ohne Box-Bezug →
|
||||
**default**. Grenzen im Zweifel: `~/mission-control-v2/docs/wissen/GRENZEN.md`.
|
||||
8. **Fertig = `kanban_complete`** mit deutscher Summary: was gemessen, die ZAHLEN/der klare
|
||||
Befund, wie geprüft (welche Kommandos). Unklar, blockiert oder Werkzeug fehlt → `kanban_block`
|
||||
mit ehrlicher Diagnose statt geratener Zahlen.
|
||||
|
||||
## Stil
|
||||
Deutsch in allem User-Sichtbaren. Zahlen statt Prosa. Ein knapper, belegter Befund schlägt
|
||||
einen langen, vagen Bericht. Keine Nebenbaustellen — genau die eine Messung, sauber.
|
||||
+171
-31
@@ -1,41 +1,181 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
# deploy.sh — den Box-Wart auf der Box auf den Stand von origin/main bringen.
|
||||
#
|
||||
# Zweistufig (seit 24.09.2026):
|
||||
# Stufe 1: Sperre gegen zwei gleichzeitige Deploys, laufende Update-Jobs abwarten lassen,
|
||||
# alten Stand merken, main holen (nur fast-forward) — dann die NEUE Fassung dieses
|
||||
# Skripts als Stufe 2 starten. Änderungen an deploy.sh wirken damit sofort, nicht
|
||||
# erst beim nächsten Lauf (die alte Falle: bash lief nach dem Pull mit der alten Datei).
|
||||
# Stufe 2: Prüftor (deploy/pruefen.sh), Abhängigkeiten, llama-swap-Config nur, wenn sich der
|
||||
# Abzug in DIESEM Deploy geändert hat, Units/Drop-ins/Cron-Skripte/Skills/Plugins
|
||||
# ausspielen, Dienste neu starten, Nachprüfung. Scheitert irgendein Schritt: zurück auf
|
||||
# den alten Stand, Dienste neu starten, dringende Meldung.
|
||||
#
|
||||
# Aufruf auf der Box: bash ~/mission-control-v2/deploy/deploy.sh
|
||||
# Schalter: MC_DEPLOY_TROTZDEM=1 (auch bei laufenden Jobs), MC_DEPLOY_OHNE_TESTS=1 (Notfall),
|
||||
# MC_DEPLOY_SKIP_SWAP_CONFIG=1 (llama-swap-Config nie anfassen).
|
||||
set -euo pipefail
|
||||
|
||||
echo "Starte MC2 Deployment..."
|
||||
cd "$HOME/mission-control-v2"
|
||||
export XDG_RUNTIME_DIR="${XDG_RUNTIME_DIR:-/run/user/$(id -u)}"
|
||||
API="http://127.0.0.1:9001"
|
||||
LSWAP_LIVE="/etc/llama-swap/config.yaml"
|
||||
DEPLOY_LOG="/srv/models/mc2-deploy.log"
|
||||
|
||||
# 1. Neuesten Code holen
|
||||
git pull origin main
|
||||
# Units, die ganz dem Repo gehören (deploy.sh spielt sie aus). Aktiviert wird nur, was laufen soll:
|
||||
# Konsole und Spracherkennung schlafen seit 24.09.2026 (disable --now), ihre Units bleiben aber aktuell.
|
||||
UNITS="mission-control-2.service mc2-gateway.service mc2-steward.service lucy-stimme.service
|
||||
box-console.service voice-service.service
|
||||
mc2-radar.service mc2-radar.timer mc2-backup.service mc2-backup.timer
|
||||
mc2-morgenmeldung.service mc2-morgenmeldung.timer mc2-autoupdate.service mc2-autoupdate.timer
|
||||
projekte-sync.service projekte-sync.timer"
|
||||
AKTIV="mission-control-2.service mc2-gateway.service mc2-steward.service lucy-stimme.service
|
||||
mc2-radar.timer mc2-backup.timer mc2-morgenmeldung.timer mc2-autoupdate.timer projekte-sync.timer"
|
||||
# Skills, die abgelöst sind: aus Lucys Skill-Index nehmen (verschoben, nicht gelöscht). Dazu die
|
||||
# alten Bindestrich-Doppel der übrigen Skills (deploy.sh schreibt die Unterstrich-Fassung).
|
||||
SKILLS_ALT="autonomie konzept-fliessband llm-wiki morning-report orchestrator projekt-start pruefstand
|
||||
review trend-radar wartung konzept_fliessband llm_wiki morning_report projekt_start
|
||||
trend_radar betrieb-playbook pc-pfad-cache"
|
||||
|
||||
# Laufende Update-Aufträge (Gruppe maintenance). Seit Phase 2 überleben Aufträge den Neustart von
|
||||
# MC2 (eigene systemd-Einheiten) — ein Download darf also weiterlaufen. Ein Update aber führt
|
||||
# Skripte aus diesem Checkout aus; die darf der Deploy nicht mitten im Lauf austauschen.
|
||||
laufende_jobs() {
|
||||
curl -sf -m 5 "$API/api/jobs" 2>/dev/null \
|
||||
| python3 -c 'import json,sys; print(sum(1 for j in json.load(sys.stdin).get("jobs", []) if j.get("group") == "maintenance" and j.get("state") in ("running", "queued")))' \
|
||||
2>/dev/null || echo 0
|
||||
}
|
||||
|
||||
# 2. Abhängigkeiten prüfen (falls sich was geändert hat)
|
||||
echo "Aktualisiere Python Abhängigkeiten..."
|
||||
backend/.venv/bin/pip install -r backend/requirements.txt
|
||||
stufe1() {
|
||||
exec 9>"${XDG_RUNTIME_DIR}/mc2-deploy.lock"
|
||||
flock -n 9 || { echo "Es läuft schon ein Deploy."; exit 1; }
|
||||
local n; n="$(laufende_jobs)"
|
||||
if [ "${n:-0}" -gt 0 ] && [ "${MC_DEPLOY_TROTZDEM:-0}" != "1" ]; then
|
||||
echo "Es läuft ein Update-Auftrag. Deploy verschoben — später erneut, oder MC_DEPLOY_TROTZDEM=1."
|
||||
exit 1
|
||||
fi
|
||||
local vorher; vorher="$(git rev-parse HEAD)"
|
||||
echo "Starte Deploy (vorher ${vorher:0:7})…"
|
||||
git fetch -q origin main
|
||||
git merge --ff-only -q origin/main
|
||||
# Ab hier die NEUE Fassung dieses Skripts. Die Sperre (fd 9) bleibt über exec erhalten.
|
||||
MC_DEPLOY_STUFE=2 MC_DEPLOY_VORHER="$vorher" exec bash deploy/deploy.sh "$@"
|
||||
}
|
||||
|
||||
# 3. Llama-Swap Konfiguration prüfen und synchronisieren
|
||||
if ! cmp -s deploy/llama-swap.config.yaml /etc/llama-swap/config.yaml; then
|
||||
echo "Llama-Swap Konfiguration hat sich geändert, synchronisiere..."
|
||||
# NOPASSWD für llama-swap Neustart ist vorausgesetzt (siehe AGENTS.md)
|
||||
sudo cp deploy/llama-swap.config.yaml /etc/llama-swap/config.yaml
|
||||
sudo systemctl restart llama-swap
|
||||
fi
|
||||
stufe2() {
|
||||
local vorher="$MC_DEPLOY_VORHER" neu swap_bak=""
|
||||
neu="$(git rev-parse --short HEAD)"
|
||||
|
||||
# 4. Systemd Units neu laden (falls sich .service Dateien geändert haben)
|
||||
systemctl --user daemon-reload
|
||||
|
||||
# 4.5 Hermes Skills synchronisieren
|
||||
echo "Synchronisiere Hermes Skills..."
|
||||
mkdir -p ~/.hermes/skills
|
||||
# Kopiere alle Skills aus dem Repo, ersetze - durch _ in den Ordnernamen für Hermes Kompatibilität
|
||||
for skill_dir in deploy/skills/*; do
|
||||
if [ -d "$skill_dir" ]; then
|
||||
skill_name=$(basename "$skill_dir" | tr '-' '_')
|
||||
mkdir -p ~/.hermes/skills/"$skill_name"
|
||||
cp -r "$skill_dir"/* ~/.hermes/skills/"$skill_name"/
|
||||
zurueck() {
|
||||
trap - ERR
|
||||
echo "✗ Deploy von $neu gescheitert — zurück auf ${vorher:0:7}."
|
||||
git reset -q --hard "$vorher" || true
|
||||
if [ -n "$swap_bak" ] && [ -f "$swap_bak" ]; then
|
||||
cp "$swap_bak" "$LSWAP_LIVE" && echo " llama-swap-Config zurückgespielt."
|
||||
fi
|
||||
done
|
||||
systemctl --user daemon-reload || true
|
||||
systemctl --user restart mc2-gateway.service mission-control-2.service mc2-steward.service || true
|
||||
echo "$(date -Is) GESCHEITERT $neu, zurück auf ${vorher:0:7}" >> "$DEPLOY_LOG" 2>/dev/null || true
|
||||
bash deploy/notify.sh -d -s "[Alarm] Deploy" \
|
||||
"Deploy von $neu ist gescheitert, die Box läuft wieder mit ${vorher:0:7}. Details: journalctl oder deploy-Ausgabe." || true
|
||||
exit 1
|
||||
}
|
||||
trap zurueck ERR
|
||||
|
||||
# 5. Dienste neu starten
|
||||
echo "Starte Backend neu..."
|
||||
systemctl --user restart mc2-gateway.service mission-control-2.service
|
||||
if [ "$neu" = "${vorher:0:7}" ]; then
|
||||
echo "Kein neuer Stand auf main — spiele trotzdem alles aus (Units, Skripte, Neustart)."
|
||||
fi
|
||||
|
||||
echo "✅ Deployment erfolgreich abgeschlossen!"
|
||||
# 1. Abhängigkeiten (inkl. pytest fürs Prüftor)
|
||||
echo "Aktualisiere Python-Abhängigkeiten…"
|
||||
backend/.venv/bin/pip install -q -r backend/requirements.txt -r backend/requirements-dev.txt
|
||||
|
||||
# 2. Prüftor: vor jedem Umschalten. Rot = zurück auf den alten Stand.
|
||||
if [ "${MC_DEPLOY_OHNE_TESTS:-0}" = "1" ]; then
|
||||
echo "Prüftor übersprungen (MC_DEPLOY_OHNE_TESTS=1)."
|
||||
else
|
||||
bash deploy/pruefen.sh
|
||||
fi
|
||||
|
||||
# 3. llama-swap-Config: /etc/llama-swap/config.yaml ist die LEBENDE Wahrheit, MC2 schreibt sie
|
||||
# selbst (Oberfläche, Radar). Der Repo-Abzug überschreibt sie nur, wenn er sich in DIESEM Deploy
|
||||
# geändert hat — sonst würde jede Abweichung (z. B. ein per Oberfläche eingetragenes Modell)
|
||||
# zurückgedreht. Kein Neustart nötig: llama-swap läuft mit -watch-config.
|
||||
if [ "${MC_DEPLOY_SKIP_SWAP_CONFIG:-0}" != "1" ] && ! git diff --quiet "$vorher" HEAD -- deploy/llama-swap.config.yaml; then
|
||||
swap_bak="$LSWAP_LIVE.bak-$(date +%Y%m%d-%H%M%S)"
|
||||
echo "llama-swap-Abzug geändert — sichere $LSWAP_LIVE nach $swap_bak und spiele ihn ein."
|
||||
diff -u "$LSWAP_LIVE" deploy/llama-swap.config.yaml | head -40 || true
|
||||
cp "$LSWAP_LIVE" "$swap_bak"
|
||||
cp deploy/llama-swap.config.yaml "$LSWAP_LIVE.neu"
|
||||
mv "$LSWAP_LIVE.neu" "$LSWAP_LIVE"
|
||||
# Nur die letzten 5 Sicherungen behalten (ältere root-eigene bleiben notfalls liegen).
|
||||
ls -1t "$LSWAP_LIVE".bak-* 2>/dev/null | tail -n +6 | xargs -r rm -f 2>/dev/null || true
|
||||
elif ! cmp -s deploy/llama-swap.config.yaml "$LSWAP_LIVE"; then
|
||||
echo "Hinweis: Die lebende llama-swap-Config weicht vom Repo-Abzug ab (Änderung über die Oberfläche?) — nicht angefasst."
|
||||
fi
|
||||
|
||||
# 4. Units und Drop-ins (alle Repo-Units, damit Box und Repo nicht auseinanderlaufen)
|
||||
local ziel="$HOME/.config/systemd/user" u
|
||||
for u in $UNITS; do
|
||||
install -m 644 "deploy/$u" "$ziel/$u"
|
||||
done
|
||||
install -D -m 644 deploy/mc2-steward.service.d-warmset.conf "$ziel/mc2-steward.service.d/warmset.conf"
|
||||
install -D -m 644 deploy/mission-control-2.service.d-override.conf "$ziel/mission-control-2.service.d/override.conf"
|
||||
systemctl --user daemon-reload
|
||||
# shellcheck disable=SC2086
|
||||
systemctl --user enable $AKTIV >/dev/null 2>&1
|
||||
# shellcheck disable=SC2086
|
||||
systemctl --user start mc2-radar.timer mc2-backup.timer mc2-morgenmeldung.timer projekte-sync.timer
|
||||
# Den Update-Timer nur starten, wenn er nicht schon läuft: Mit Persistent=true holt ein frisch
|
||||
# gestarteter Timer einen verpassten Sonntagslauf sofort nach (so am 24.09.2026 passiert). Neu
|
||||
# gestartet wird die Box dabei ohnehin nur sonntags zwischen 04 und 07 Uhr (autoupdate.sh).
|
||||
systemctl --user is-active --quiet mc2-autoupdate.timer || systemctl --user start mc2-autoupdate.timer
|
||||
|
||||
# 5. Cron-Skripte für Hermes (~/.hermes/scripts ist die Vorgabe von `hermes cron --script`)
|
||||
mkdir -p "$HOME/.hermes/scripts"
|
||||
install -m 755 deploy/jobs/*.sh deploy/jobs/*.py "$HOME/.hermes/scripts/"
|
||||
|
||||
# 6. Skills und Plugins
|
||||
echo "Synchronisiere Hermes-Skills…"
|
||||
mkdir -p "$HOME/.hermes/skills"
|
||||
local d name alt
|
||||
for d in deploy/skills/*/; do
|
||||
name="$(basename "$d" | tr '-' '_')"
|
||||
mkdir -p "$HOME/.hermes/skills/$name"
|
||||
cp -r "$d". "$HOME/.hermes/skills/$name/"
|
||||
done
|
||||
for alt in $SKILLS_ALT; do
|
||||
if [ -d "$HOME/.hermes/skills/$alt" ]; then
|
||||
mkdir -p "$HOME/.hermes/skills-archiv"
|
||||
rm -rf "$HOME/.hermes/skills-archiv/$alt"
|
||||
mv "$HOME/.hermes/skills/$alt" "$HOME/.hermes/skills-archiv/"
|
||||
fi
|
||||
done
|
||||
# Plugins nur kopieren: aktiviert wird einmalig von Hand (hermes plugins enable …), neuer Code
|
||||
# greift beim nächsten Neustart des Hermes-Gateways — den macht deploy.sh bewusst nicht.
|
||||
mkdir -p "$HOME/.hermes/plugins"
|
||||
for d in deploy/hermes-plugins/*/; do
|
||||
cp -r "${d%/}" "$HOME/.hermes/plugins/"
|
||||
done
|
||||
|
||||
# 7. Neustart und Nachprüfung
|
||||
echo "Starte Gateway, Box-Wart und Wächter neu…"
|
||||
systemctl --user restart mc2-gateway.service mission-control-2.service mc2-steward.service
|
||||
local i ok=0
|
||||
for i in $(seq 1 30); do
|
||||
if curl -sf -m 3 "$API/api/health" >/dev/null && curl -sf -m 3 http://127.0.0.1:9010/gw/health >/dev/null \
|
||||
&& systemctl --user is-active --quiet mc2-steward.service; then
|
||||
ok=1; break
|
||||
fi
|
||||
sleep 2
|
||||
done
|
||||
[ "$ok" = 1 ] || { echo "Nachprüfung: Dienste antworten nicht."; false; }
|
||||
|
||||
trap - ERR
|
||||
echo "$(date -Is) $neu (vorher ${vorher:0:7})" >> "$DEPLOY_LOG" 2>/dev/null || true
|
||||
echo "✅ Deploy $neu ist live (vorher ${vorher:0:7})."
|
||||
}
|
||||
|
||||
# Beides in Funktionen und ein exit direkt dahinter: bash liest ein Skript häppchenweise, und
|
||||
# git merge ersetzt diese Datei mitten im Lauf. So läuft nichts aus der neuen Datei unkontrolliert.
|
||||
if [ "${MC_DEPLOY_STUFE:-1}" = "2" ]; then stufe2 "$@"; else stufe1 "$@"; fi; exit $?
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user