[aidan] ux: app error agent loop

This commit is contained in:
abccodes
2026-06-15 18:02:45 -07:00
parent 67ccd8b61f
commit f70bdfc1a6
9 changed files with 297 additions and 39 deletions
+85 -14
View File
@@ -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,
+27
View File
@@ -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
+33 -4
View File
@@ -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:
@@ -35,8 +35,34 @@ class ErrorBoundary extends React.Component<ErrorBoundaryProps, ErrorBoundarySta
return { error };
}
componentDidMount(): void {
// Clean first mount: nothing caught, so the app is in a good render
// state and the ready beacon is allowed.
if (!this.state.error) {
window.__openswarm_render_failed = false;
window.__openswarm_last_error = '';
}
}
componentDidUpdate(_prevProps: ErrorBoundaryProps, prevState: ErrorBoundaryState): void {
// Fast Refresh retried the previously-broken subtree and it rendered:
// re-allow the ready beacon so index.tsx's vite:afterUpdate can report ok.
if (prevState.error && !this.state.error) {
window.__openswarm_render_failed = false;
window.__openswarm_last_error = '';
}
}
componentDidCatch(error: Error, errorInfo: React.ErrorInfo): void {
this.setState({ errorInfo });
// Suppress the post-mount "ready" beacon in index.tsx and re-arm the
// window-error gate: the app is not in a rendered state right now.
// Stash the message so the HMR handler can re-assert this error after
// an unrelated edit (which resets the host's render-state to None).
window.__openswarm_render_failed = true;
window.__openswarm_rendered = false;
window.__openswarm_last_error =
`${error?.message ?? String(error)}\n${errorInfo?.componentStack ?? ''}`.trim();
// Two channels so the OpenSwarm host's webview-preload bridge can
// pick this up regardless of which one it taps:
// 1. console.error — forwarded as a `[FRONTEND]` line into the
@@ -3,21 +3,67 @@ import { createRoot } from 'react-dom/client';
import Main from './app/Main';
import ErrorBoundary from './app/components/ErrorBoundary';
console.log('[App] Bootstrapping React app');
// Render-health beacons the OpenSwarm host reads (via the forwarded preview
// console) to decide, at the end of an agent turn, whether the app renders.
// The ErrorBoundary covers React render crashes; the listeners here cover
// what never reaches a boundary: module-load / pre-mount throws and vite
// transform errors.
function reportRender(ok: boolean, detail?: string) {
if (ok) {
window.__openswarm_rendered = true;
// eslint-disable-next-line no-console
console.log('[openswarm:app-ready]');
} else {
// eslint-disable-next-line no-console
console.error('[openswarm:app-error]', detail ?? '');
}
}
// Gate on __openswarm_rendered so a throw inside a click handler after a good
// render (a bug, but not "the app won't render") doesn't block the turn.
window.addEventListener('error', (e) => {
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(
<ErrorBoundary>
<Main />
</ErrorBoundary>,
);
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);
});
}
@@ -1,2 +1,9 @@
/// <reference types="vite/client" />
/// <reference types="vite-plugin-pages/client-react" />
// 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;
}
@@ -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: {
@@ -80,6 +80,32 @@ const DashboardViewCard: React.FC<Props> = ({
const [inputData] = useState<Record<string, any>>(() => getDefault(output.input_schema));
const [backendResult] = useState<Record<string, any> | 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<number | null>(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<Props> = ({
inputData={inputData}
backendResult={backendResult}
/>
<BuildingOverlay sessionId={output.session_id ?? null} />
<BuildingOverlay show={showBuildingOverlay} />
</Box>
{/* Resize handles */}
@@ -484,15 +510,13 @@ const DashboardViewCard: React.FC<Props> = ({
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 (
<Fade in={isBuilding} timeout={{ enter: 200, exit: 220 }} unmountOnExit>
<Fade in={show} timeout={{ enter: 200, exit: 220 }} unmountOnExit>
<Box
sx={{
position: 'absolute',
@@ -549,6 +573,33 @@ const DashboardOutputPreview: React.FC<{
isNewMode,
});
// Declared above every early-return below so React's hook order stays
// stable; moving it below would trigger "Rendered more hooks than during
// the previous render."
const handleConsoleMessage = useCallback((level: string, text: string) => {
if (!text || !workspaceId) return;
const tok = getAuthToken();
const headers: Record<string, string> = { '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}
/>
);
};
@@ -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 = [