From f70bdfc1a61fa22633f76131d9e7b4bee1e4336a Mon Sep 17 00:00:00 2001 From: abccodes Date: Mon, 15 Jun 2026 18:02:45 -0700 Subject: [PATCH] [aidan] ux: app error agent loop --- backend/apps/agents/agent_manager.py | 99 ++++++++++++++++--- backend/apps/outputs/outputs.py | 27 +++++ backend/apps/outputs/runtime.py | 37 ++++++- .../src/app/components/ErrorBoundary.tsx | 26 +++++ .../webapp_template/frontend/src/index.tsx | 58 +++++++++-- .../frontend/src/vite-env.d.ts | 7 ++ .../webapp_template/frontend/vite.config.ts | 8 +- .../Dashboard/cards/DashboardViewCard.tsx | 68 +++++++++++-- .../src/shared/state/dashboardLayoutSlice.ts | 6 -- 9 files changed, 297 insertions(+), 39 deletions(-) diff --git a/backend/apps/agents/agent_manager.py b/backend/apps/agents/agent_manager.py index cef90c76..bbd4b4d9 100644 --- a/backend/apps/agents/agent_manager.py +++ b/backend/apps/agents/agent_manager.py @@ -79,6 +79,11 @@ logger = logging.getLogger(__name__) os.environ.setdefault("CLAUDE_CODE_STREAM_CLOSE_TIMEOUT", "3600000") +p_VIEW_BUILDER_RENDER_MAX_RETRIES = 2 +p_view_builder_render_retry_counts: dict[str, int] = {} +p_view_builder_dirty_sessions: set[str] = set() + + def _apply_context_window(session, settings=None) -> None: """Set session.context_window from the registry for its (provider, model). @@ -912,26 +917,38 @@ class AgentManager: except Exception: content = str(raw_response) - # When the agent writes/edits a file inside a live App - # Builder workspace, surface any build-server errors - # (vite/babel/tsc/uvicorn) that landed in the runtime's - # stderr in the moments after the write. Without this the - # agent walks away from broken JSX, the iframe shows a red - # overlay, and the user has to copy-paste the error back. - # ~400ms gives vite's file watcher + babel parse enough - # time to react; the post_tool_hook runs once per tool so - # the added latency is acceptable for the win. hook_tool_name_for_errors = input_data.get("tool_name", "") - if hook_tool_name_for_errors in ("Write", "Edit", "MultiEdit"): - tool_in = input_data.get("tool_input") or {} - file_path = tool_in.get("file_path") or tool_in.get("path") or "" + wrote_files = hook_tool_name_for_errors in ("Write", "Edit", "MultiEdit") + tool_in = input_data.get("tool_input") or {} + file_path = tool_in.get("file_path") or tool_in.get("path") or "" + wrote_frontend_file = wrote_files and "/frontend/" in file_path + installed_pkg = False + if hook_tool_name_for_errors == "Bash": + bash_in = input_data.get("tool_input") or {} + cmd = (bash_in.get("command") or "").lower() + installed_pkg = any(s in cmd for s in ( + "npm install", "npm i ", "npm uninstall", "npm ci", + "pnpm add", "pnpm install", "pnpm remove", + "yarn add", "yarn install", "yarn remove", + )) + + if session.mode == "view-builder" and (wrote_frontend_file or installed_pkg): + p_view_builder_dirty_sessions.add(session.id) + try: + from backend.apps.outputs.runtime import ( + manager as outputs_runtime_manager, + ) + outputs_runtime_manager.reset_render_state_for_workspace(session.id) + except Exception: + pass + elif wrote_files: if file_path: try: await asyncio.sleep(0.4) from backend.apps.outputs.runtime import ( - manager as _outputs_runtime_manager, + manager as outputs_runtime_manager, ) - errs = _outputs_runtime_manager.drain_errors_for_path(file_path) + errs = outputs_runtime_manager.drain_errors_for_path(file_path) except Exception: errs = [] if errs: @@ -1478,6 +1495,59 @@ class AgentManager: if len(_stderr_buffer) > 500: del _stderr_buffer[:250] + async def stop_hook(input_data, tool_use_id, context): + """End-of-turn render gate for App Builder sessions. Reads the + browser-reported render-state of the preview; if the app fails + to render, blocks with the error so the agent fixes it, up to + MAX_RETRIES then lets the stop through.""" + if session.mode != "view-builder": + return {} + if session.id not in p_view_builder_dirty_sessions: + return {} + from backend.apps.outputs.runtime import ( + manager as outputs_runtime_manager, + ) + if outputs_runtime_manager.get(session.id) is None: + return {} + state, error_text = outputs_runtime_manager.get_render_state_for_workspace(session.id) + waited = 0.0 + while state is None and waited < 5.0: + await asyncio.sleep(0.25) + waited += 0.25 + state, error_text = outputs_runtime_manager.get_render_state_for_workspace(session.id) + + if state != "error": + p_view_builder_render_retry_counts.pop(session.id, None) + p_view_builder_dirty_sessions.discard(session.id) + return {} + + attempts = p_view_builder_render_retry_counts.get(session.id, 0) + if attempts >= p_VIEW_BUILDER_RENDER_MAX_RETRIES: + logger.warning( + "view-builder preview still failing after %s attempts for session %s; allowing stop", + attempts, session.id, + ) + p_view_builder_render_retry_counts.pop(session.id, None) + p_view_builder_dirty_sessions.discard(session.id) + return {} + + p_view_builder_render_retry_counts[session.id] = attempts + 1 + logger.info( + "view-builder render block (attempt %s/%s) for session %s", + attempts + 1, p_VIEW_BUILDER_RENDER_MAX_RETRIES, session.id, + ) + trimmed = error_text[-3000:] if len(error_text) > 3000 else error_text + return { + "decision": "block", + "reason": ( + f"The preview failed to render (attempt {attempts + 1}/" + f"{p_VIEW_BUILDER_RENDER_MAX_RETRIES}):\n\n" + f"{trimmed}\n\n" + "Fix this so the app renders before finishing; the user " + "currently sees an error instead of the app." + ), + } + options_kwargs = { "model": resolved_model, # 64 MB ceiling on the SDK <-> CLI JSON-RPC channel. The @@ -1493,6 +1563,7 @@ class AgentManager: "hooks": { "PreToolUse": [HookMatcher(matcher=None, hooks=[pre_tool_hook])], "PostToolUse": [HookMatcher(matcher=None, hooks=[post_tool_hook])], + "Stop": [HookMatcher(matcher=None, hooks=[stop_hook])], }, "allowed_tools": effective_allowed, "disallowed_tools": effective_disallowed, diff --git a/backend/apps/outputs/outputs.py b/backend/apps/outputs/outputs.py index 27923c7e..3b2d241e 100644 --- a/backend/apps/outputs/outputs.py +++ b/backend/apps/outputs/outputs.py @@ -454,6 +454,33 @@ async def runtime_get_status(workspace_id: str): return _runtime_status_payload(workspace_id) +@outputs.router.post("/workspace/{workspace_id}/runtime/report-error") +async def runtime_report_error(workspace_id: str, body: dict): + from backend.apps.outputs.runtime import manager as runtime_manager + rt = runtime_manager.get(workspace_id) + if rt is None: + return {"ok": False, "recorded": 0} + message = (body.get("message") or "").strip() + component_stack = (body.get("componentStack") or "").strip() + if not message: + return {"ok": False, "recorded": 0} + composed = message + if component_stack: + composed = f"{composed}\n{component_stack}" + rt.set_render_error(composed) + return {"ok": True, "recorded": 1} + + +@outputs.router.post("/workspace/{workspace_id}/runtime/report-ready") +async def runtime_report_ready(workspace_id: str): + from backend.apps.outputs.runtime import manager as runtime_manager + rt = runtime_manager.get(workspace_id) + if rt is None: + return {"ok": False} + rt.set_render_ok() + return {"ok": True} + + @outputs.router.post("/shutdown-all") async def runtime_shutdown_all(): """Reap every workspace subprocess. Electron POSTs this during diff --git a/backend/apps/outputs/runtime.py b/backend/apps/outputs/runtime.py index 77f5fe64..b83b9abd 100644 --- a/backend/apps/outputs/runtime.py +++ b/backend/apps/outputs/runtime.py @@ -99,6 +99,8 @@ class AppRuntime: # vite/babel/uvicorn errors in its next turn and can self-fix # instead of leaving the user with a red iframe overlay. self.recent_errors: deque[str] = deque(maxlen=_RECENT_ERRORS_MAX) + self.render_state: Optional[str] = None + self.render_error_text: str = "" self._stdout_task: Optional[asyncio.Task] = None self._stderr_task: Optional[asyncio.Task] = None self._wait_task: Optional[asyncio.Task] = None @@ -113,6 +115,18 @@ class AppRuntime: self.recent_errors.clear() return out + def set_render_ok(self) -> None: + self.render_state = "ok" + self.render_error_text = "" + + def set_render_error(self, text: str) -> None: + self.render_state = "error" + self.render_error_text = (text or "").strip() + + def reset_render_state(self) -> None: + self.render_state = None + self.render_error_text = "" + @property def running(self) -> bool: return self.process is not None and self.process.returncode is None @@ -460,13 +474,16 @@ class AppRuntime: pass def _maybe_capture_error(self, text: str) -> None: - """If a stderr/stdout line matches a known build-error pattern, - record it for the next agent-tool drain. Tests every line , - cheap (single regex search) and only the matching ones land in - the buffer.""" if _ERROR_PATTERNS.search(text): self.recent_errors.append(text.rstrip()) + def p_maybe_capture_render_beacon(self, text: str) -> None: + if "[openswarm:app-ready]" in text: + self.set_render_ok() + elif "[openswarm:app-error]" in text: + idx = text.index("[openswarm:app-error]") + len("[openswarm:app-error]") + self.set_render_error(text[idx:].strip()) + async def _pipe_stream(self, stream: Optional[asyncio.StreamReader], name: str) -> None: if stream is None: return @@ -480,6 +497,7 @@ class AppRuntime: self._broadcast(LogLine(name, text)) if name == "stderr" or name == "stdout": self._maybe_capture_error(text) + self.p_maybe_capture_render_beacon(text) except Exception: logger.exception("log pipe error (%s) for %s", name, self.workspace_id) @@ -638,6 +656,17 @@ class AppRuntimeManager: return rt.drain_errors() return [] + def get_render_state_for_workspace(self, workspace_id: str) -> tuple[Optional[str], str]: + rt = self.runtimes.get(workspace_id) or self._idle_lru.get(workspace_id) + if rt is None: + return None, "" + return rt.render_state, rt.render_error_text + + def reset_render_state_for_workspace(self, workspace_id: str) -> None: + rt = self.runtimes.get(workspace_id) or self._idle_lru.get(workspace_id) + if rt is not None: + rt.reset_render_state() + async def restart(self, workspace_id: str, workspace_path: Optional[str] = None) -> Optional[AppRuntime]: rt = self.runtimes.get(workspace_id) or self._idle_lru.get(workspace_id) if rt is None: diff --git a/backend/apps/outputs/webapp_template/frontend/src/app/components/ErrorBoundary.tsx b/backend/apps/outputs/webapp_template/frontend/src/app/components/ErrorBoundary.tsx index 6fdacd98..ced4433d 100644 --- a/backend/apps/outputs/webapp_template/frontend/src/app/components/ErrorBoundary.tsx +++ b/backend/apps/outputs/webapp_template/frontend/src/app/components/ErrorBoundary.tsx @@ -35,8 +35,34 @@ class ErrorBoundary extends React.Component { + if (!window.__openswarm_rendered) reportRender(false, e.message || String(e.error ?? e)); +}); +window.addEventListener('unhandledrejection', (e) => { + if (!window.__openswarm_rendered) reportRender(false, String(e.reason ?? e)); +}); + +if (import.meta.hot) { + const hot = import.meta.hot; + hot.on('vite:error', (payload) => { + const err = payload?.err; + reportRender(false, err?.message || err?.plugin || 'vite error'); + }); + // Re-assert the real state after every HMR update: if the ErrorBoundary is + // still showing its fallback, report the error again (an unrelated edit that + // didn't fix it must not flip the gate to "ready"); otherwise report ready. + hot.on('vite:afterUpdate', () => { + if (window.__openswarm_render_failed) { + reportRender(false, window.__openswarm_last_error || 'app still failing to render'); + } else { + reportRender(true); + } + }); +} + const rootEl = document.getElementById('root'); if (!rootEl) { - console.error('[App] FATAL: #root element not found in DOM'); + console.error('[openswarm:app-error]', '#root element not found in DOM'); } else { // Wrap Main in an ErrorBoundary so any runtime crash from agent // edits (missing imports, hook-rules violations, etc.) shows a // readable error card in the preview pane instead of unmounting - // to a blank screen. The boundary also forwards the error via - // console.error + postMessage so the agent sees it on its next - // turn. + // to a blank screen. The boundary forwards the error via + // console.error + postMessage so the agent sees it on its next turn. createRoot(rootEl).render(
, ); - console.log('[App] React root mounted'); + // Defer a frame so a synchronous render crash sets __openswarm_render_failed + // (via the boundary) before we'd wrongly report ready. + requestAnimationFrame(() => { + if (window.__openswarm_render_failed) return; + reportRender(true); + }); } diff --git a/backend/apps/outputs/webapp_template/frontend/src/vite-env.d.ts b/backend/apps/outputs/webapp_template/frontend/src/vite-env.d.ts index f07ec82e..97d2dc49 100644 --- a/backend/apps/outputs/webapp_template/frontend/src/vite-env.d.ts +++ b/backend/apps/outputs/webapp_template/frontend/src/vite-env.d.ts @@ -1,2 +1,9 @@ /// /// + +// Render-health beacon flags the OpenSwarm App Builder host reads off the preview. +interface Window { + __openswarm_rendered?: boolean; + __openswarm_render_failed?: boolean; + __openswarm_last_error?: string; +} diff --git a/backend/apps/outputs/webapp_template/frontend/vite.config.ts b/backend/apps/outputs/webapp_template/frontend/vite.config.ts index ae0498af..086c468f 100644 --- a/backend/apps/outputs/webapp_template/frontend/vite.config.ts +++ b/backend/apps/outputs/webapp_template/frontend/vite.config.ts @@ -52,7 +52,13 @@ export default defineConfig(({ mode }) => { plugins: [ react(), Pages({ dirs: 'src/pages', extensions: ['tsx'] }), - terminal({ console: 'terminal', output: ['terminal', 'console'] }), + // vite-plugin-terminal provides a `virtual:terminal/console` module + // that only exists in dev; loading it during `vite build` errors + // out, so the End-of-turn build-verify gate would fail on every + // brand-new workspace. + ...(mode === 'development' + ? [terminal({ console: 'terminal', output: ['terminal', 'console'] })] + : []), ], resolve: { alias: { diff --git a/frontend/src/app/pages/Dashboard/cards/DashboardViewCard.tsx b/frontend/src/app/pages/Dashboard/cards/DashboardViewCard.tsx index af3d5d2e..a5e8adf7 100644 --- a/frontend/src/app/pages/Dashboard/cards/DashboardViewCard.tsx +++ b/frontend/src/app/pages/Dashboard/cards/DashboardViewCard.tsx @@ -80,6 +80,32 @@ const DashboardViewCard: React.FC = ({ const [inputData] = useState>(() => getDefault(output.input_schema)); const [backendResult] = useState | null>(null); + // Reload the preview when the session finishes a turn: React holds the + // ErrorBoundary's snag page until a reload, so without this the user keeps + // seeing the old error even after the agent fixed it. The overlay lingers + // through the reload (finishing) so the stale page never flashes. + const linkedStatus = useAppSelector( + (s) => (output.session_id ? s.agents.sessions[output.session_id]?.status : undefined), + ); + const [finishing, setFinishing] = useState(false); + const wasBuildingRef = useRef(false); + const finishTimerRef = useRef(null); + useEffect(() => { + const building = linkedStatus === 'running' || linkedStatus === 'waiting_approval'; + if (wasBuildingRef.current && !building) { + previewRef.current?.reload(); + setFinishing(true); + if (finishTimerRef.current) clearTimeout(finishTimerRef.current); + finishTimerRef.current = window.setTimeout(() => setFinishing(false), 1200); + } + wasBuildingRef.current = building; + }, [linkedStatus]); + useEffect(() => () => { + if (finishTimerRef.current) clearTimeout(finishTimerRef.current); + }, []); + const showBuildingOverlay = linkedStatus === 'running' + || linkedStatus === 'waiting_approval' || finishing; + const DRAG_THRESHOLD = 3; const dragState = useRef<{ startX: number; startY: number; origX: number; origY: number; startPanX: number; startPanY: number } | null>(null); const [isDragging, setIsDragging] = useState(false); @@ -408,7 +434,7 @@ const DashboardViewCard: React.FC = ({ inputData={inputData} backendResult={backendResult} /> - + {/* Resize handles */} @@ -484,15 +510,13 @@ const DashboardViewCard: React.FC = ({ export default React.memo(DashboardViewCard); // Calm overlay shown while the App Builder chat that owns this output is -// actively editing it. Hides whatever transient half-broken state the agent -// might be writing through (a missing import, a syntax error mid-keystroke) -// so the user sees "Building..." instead of an error iframe. Fades in/out. -const BuildingOverlay: React.FC<{ sessionId: string | null }> = ({ sessionId }) => { +// actively editing it (and through the post-turn reload). Hides whatever +// transient half-broken state the agent might be writing through so the +// user sees "Building..." instead of an error iframe. Fades in/out. +const BuildingOverlay: React.FC<{ show: boolean }> = ({ show }) => { const c = useClaudeTokens(); - const status = useAppSelector((s) => (sessionId ? s.agents.sessions[sessionId]?.status : undefined)); - const isBuilding = status === 'running' || status === 'waiting_approval'; return ( - + { + if (!text || !workspaceId) return; + const tok = getAuthToken(); + const headers: Record = { 'Content-Type': 'application/json' }; + if (tok) headers.Authorization = `Bearer ${tok}`; + if (text.includes('[openswarm:app-ready]')) { + fetch(`${API_BASE}/outputs/workspace/${workspaceId}/runtime/report-ready`, { + method: 'POST', headers, + }).catch(() => {}); + return; + } + if (level !== 'error' || !text.includes('[openswarm:app-error]')) return; + const idx = text.indexOf('[openswarm:app-error]'); + const tail = text.slice(idx + '[openswarm:app-error]'.length).trim(); + const firstNewline = tail.indexOf('\n'); + const message = firstNewline >= 0 ? tail.slice(0, firstNewline).trim() : tail; + const componentStack = firstNewline >= 0 ? tail.slice(firstNewline + 1).trim() : ''; + fetch(`${API_BASE}/outputs/workspace/${workspaceId}/runtime/report-error`, { + method: 'POST', + headers, + body: JSON.stringify({ message, componentStack }), + }).catch(() => {}); + }, [workspaceId]); + // An orphaned record (files deleted on disk) used to render the raw 404 JSON // inside the card, or spin on "Starting preview" forever; probe once instead. const [filesMissing, setFilesMissing] = useState(false); @@ -634,6 +685,7 @@ const DashboardOutputPreview: React.FC<{ frontendCode={output.files?.['index.html'] ?? ''} inputData={inputData} backendResult={backendResult} + onConsoleMessage={handleConsoleMessage} /> ); }; diff --git a/frontend/src/shared/state/dashboardLayoutSlice.ts b/frontend/src/shared/state/dashboardLayoutSlice.ts index 388e438c..650d03cc 100644 --- a/frontend/src/shared/state/dashboardLayoutSlice.ts +++ b/frontend/src/shared/state/dashboardLayoutSlice.ts @@ -38,7 +38,6 @@ export interface ViewCardPosition { width: number; height: number; zOrder: number; - /** Chat session that spawned this view card; drives column-snap on initial placement. */ parent_session_id?: string | null; } @@ -534,11 +533,6 @@ const dashboardLayoutSlice = createSlice({ } else { const parentCard = parentSessionId ? state.cards[parentSessionId] : null; if (parentCard) { - // Mirror the agent-spawned browser flow (WebSocketManager.ts): drop the - // new card into a column to the right of its parent chat, stacking - // under any siblings (browser OR view) already in that column so a - // single chat's outputs read as one cluster instead of scattering - // across the canvas via the global grid scan. const targetX = parentCard.x + parentCard.width + GRID_GAP * 12; let targetY = parentCard.y; const siblings = [