v0.15.7: Fix action routing for v4, WS error handling, stable nodes panel

Action routing:
- Button clicks now route through PA→Expert in v4 (was missing has_pa check)
- Previously crashed with KeyError on missing thinker node

WS error handling:
- Exceptions in WS handler caught and logged, not crash
- Frontend receives error HUD event instead of disconnect
- Prevents 1006 reconnect loops on action errors

Nodes panel:
- Fixed pipeline order (no re-sorting on events)
- Deduplicated node names (pa_v1→pa, expert_eras→eras)
- Normalized names in state tracker

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Nico 2026-03-29 19:16:15 +02:00
parent 84fa0830d8
commit faeb9d3254
3 changed files with 41 additions and 17 deletions

View File

@ -153,12 +153,20 @@ def register_routes(app):
msg = json.loads(data) msg = json.loads(data)
# Always use current runtime (may change after graph switch) # Always use current runtime (may change after graph switch)
rt = _active_runtime or runtime rt = _active_runtime or runtime
try:
if msg.get("type") == "action": if msg.get("type") == "action":
await rt.handle_action(msg.get("action", "unknown"), msg.get("data")) await rt.handle_action(msg.get("action", "unknown"), msg.get("data"))
elif msg.get("type") == "cancel_process": elif msg.get("type") == "cancel_process":
rt.process_manager.cancel(msg.get("pid", 0)) rt.process_manager.cancel(msg.get("pid", 0))
else: else:
await rt.handle_message(msg.get("text", ""), dashboard=msg.get("dashboard")) await rt.handle_message(msg.get("text", ""), dashboard=msg.get("dashboard"))
except Exception as e:
import traceback
log.error(f"[ws] handler error: {e}\n{traceback.format_exc()}")
try:
await ws.send_text(json.dumps({"type": "hud", "node": "runtime", "event": "error", "detail": str(e)[:200]}))
except Exception:
pass
except WebSocketDisconnect: except WebSocketDisconnect:
if _active_runtime: if _active_runtime:
_active_runtime.detach_ws() _active_runtime.detach_ws()

View File

@ -523,7 +523,7 @@ class FrameEngine:
return self._make_result(result) return self._make_result(result)
# Complex action — needs full pipeline # Complex action — needs full pipeline
self._end_frame(rec, output_summary="no local handler", route="director/thinker") self._end_frame(rec, output_summary="no local handler", route="pa/director/thinker")
action_desc = f"ACTION: {action}" action_desc = f"ACTION: {action}"
if data: if data:
@ -535,7 +535,9 @@ class FrameEngine:
analysis=InputAnalysis(intent="action", topic=action, complexity="simple"), analysis=InputAnalysis(intent="action", topic=action, complexity="simple"),
source_text=action_desc) source_text=action_desc)
if self.has_director: if self.has_pa:
return await self._run_expert_pipeline(command, mem_ctx, dashboard)
elif self.has_director:
return await self._run_director_pipeline(command, mem_ctx, dashboard) return await self._run_director_pipeline(command, mem_ctx, dashboard)
else: else:
return await self._run_thinker_pipeline(command, mem_ctx, dashboard) return await self._run_thinker_pipeline(command, mem_ctx, dashboard)

View File

@ -9,15 +9,21 @@ let _sensorReadings = {};
// --- Node state tracker --- // --- Node state tracker ---
const _nodeState = {}; // { nodeName: { model, tokens, maxTokens, fillPct, lastEvent, lastDetail, status, toolCalls, startedAt } } const _nodeState = {}; // { nodeName: { model, tokens, maxTokens, fillPct, lastEvent, lastDetail, status, toolCalls, startedAt } }
// Normalize node names to avoid duplicates (pa_v1→pa, expert_eras→eras, etc.)
function _normName(name) {
return name.replace('_v1', '').replace('_v2', '').replace('expert_', '');
}
function _getNode(name) { function _getNode(name) {
if (!_nodeState[name]) { const key = _normName(name);
_nodeState[name] = { if (!_nodeState[key]) {
_nodeState[key] = {
model: '', tokens: 0, maxTokens: 0, fillPct: 0, model: '', tokens: 0, maxTokens: 0, fillPct: 0,
lastEvent: '', lastDetail: '', status: 'idle', lastEvent: '', lastDetail: '', status: 'idle',
toolCalls: 0, lastTool: '', toolCalls: 0, lastTool: '',
}; };
} }
return _nodeState[name]; return _nodeState[key];
} }
export function updateNodeFromHud(node, event, data) { export function updateNodeFromHud(node, event, data) {
@ -74,15 +80,23 @@ export function updateNodeFromHud(node, event, data) {
renderNodes(); renderNodes();
} }
// Fixed pipeline order — no re-sorting
// Fixed pipeline order using normalized names
const PIPELINE_ORDER = ['input', 'pa', 'director', 'eras', 'plankiste',
'thinker', 'interpreter', 'output', 'memorizer', 'ui', 'sensor'];
function renderNodes() { function renderNodes() {
const el = document.getElementById('node-metrics'); const el = document.getElementById('node-metrics');
if (!el) { console.warn('[nodes] #node-metrics not found'); return; } if (!el) return;
// Sort: active nodes first, then by name const entries = Object.entries(_nodeState)
const statusOrder = { thinking: 0, tool: 0, streaming: 0, planned: 1, done: 2, idle: 3 }; .filter(([name]) => name !== 'runtime' && name !== 'frame_engine');
const sorted = Object.entries(_nodeState)
.filter(([name]) => name !== 'runtime' && name !== 'frame_engine') const sorted = entries.sort((a, b) => {
.sort((a, b) => (statusOrder[a[1].status] || 3) - (statusOrder[b[1].status] || 3)); const ia = PIPELINE_ORDER.indexOf(a[0]);
const ib = PIPELINE_ORDER.indexOf(b[0]);
return (ia === -1 ? 99 : ia) - (ib === -1 ? 99 : ib);
});
let html = ''; let html = '';
for (const [name, n] of sorted) { for (const [name, n] of sorted) {