From faeb9d3254f489e3c0b5c480e551dbbec164bef7 Mon Sep 17 00:00:00 2001 From: Nico Date: Sun, 29 Mar 2026 19:16:15 +0200 Subject: [PATCH] v0.15.7: Fix action routing for v4, WS error handling, stable nodes panel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- agent/api.py | 20 ++++++++++++++------ agent/frame_engine.py | 6 ++++-- static/js/awareness.js | 32 +++++++++++++++++++++++--------- 3 files changed, 41 insertions(+), 17 deletions(-) diff --git a/agent/api.py b/agent/api.py index 474eb06..6daa686 100644 --- a/agent/api.py +++ b/agent/api.py @@ -153,12 +153,20 @@ def register_routes(app): msg = json.loads(data) # Always use current runtime (may change after graph switch) rt = _active_runtime or runtime - if msg.get("type") == "action": - await rt.handle_action(msg.get("action", "unknown"), msg.get("data")) - elif msg.get("type") == "cancel_process": - rt.process_manager.cancel(msg.get("pid", 0)) - else: - await rt.handle_message(msg.get("text", ""), dashboard=msg.get("dashboard")) + try: + if msg.get("type") == "action": + await rt.handle_action(msg.get("action", "unknown"), msg.get("data")) + elif msg.get("type") == "cancel_process": + rt.process_manager.cancel(msg.get("pid", 0)) + else: + 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: if _active_runtime: _active_runtime.detach_ws() diff --git a/agent/frame_engine.py b/agent/frame_engine.py index 7df7c1b..90cf6b0 100644 --- a/agent/frame_engine.py +++ b/agent/frame_engine.py @@ -523,7 +523,7 @@ class FrameEngine: return self._make_result(result) # 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}" if data: @@ -535,7 +535,9 @@ class FrameEngine: analysis=InputAnalysis(intent="action", topic=action, complexity="simple"), 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) else: return await self._run_thinker_pipeline(command, mem_ctx, dashboard) diff --git a/static/js/awareness.js b/static/js/awareness.js index 05e7b82..9dc3114 100644 --- a/static/js/awareness.js +++ b/static/js/awareness.js @@ -9,15 +9,21 @@ let _sensorReadings = {}; // --- Node state tracker --- 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) { - if (!_nodeState[name]) { - _nodeState[name] = { + const key = _normName(name); + if (!_nodeState[key]) { + _nodeState[key] = { model: '', tokens: 0, maxTokens: 0, fillPct: 0, lastEvent: '', lastDetail: '', status: 'idle', toolCalls: 0, lastTool: '', }; } - return _nodeState[name]; + return _nodeState[key]; } export function updateNodeFromHud(node, event, data) { @@ -74,15 +80,23 @@ export function updateNodeFromHud(node, event, data) { 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() { 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 statusOrder = { thinking: 0, tool: 0, streaming: 0, planned: 1, done: 2, idle: 3 }; - const sorted = Object.entries(_nodeState) - .filter(([name]) => name !== 'runtime' && name !== 'frame_engine') - .sort((a, b) => (statusOrder[a[1].status] || 3) - (statusOrder[b[1].status] || 3)); + const entries = Object.entries(_nodeState) + .filter(([name]) => name !== 'runtime' && name !== 'frame_engine'); + + const sorted = entries.sort((a, b) => { + 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 = ''; for (const [name, n] of sorted) {