From 2be4620453427eaf991e8631296ec4eea19715a2 Mon Sep 17 00:00:00 2001 From: ciregenz Date: Tue, 23 Jun 2026 02:04:05 -0700 Subject: [PATCH] [eric] agents: extract view-builder render state into its own module --- backend/apps/agents/agent_manager.py | 36 +++++++++---------- .../apps/agents/manager/view_builder_state.py | 12 +++++++ backend/tests/test_session_cleanup.py | 8 ++--- 3 files changed, 34 insertions(+), 22 deletions(-) create mode 100644 backend/apps/agents/manager/view_builder_state.py diff --git a/backend/apps/agents/agent_manager.py b/backend/apps/agents/agent_manager.py index dd16790b..1922a773 100644 --- a/backend/apps/agents/agent_manager.py +++ b/backend/apps/agents/agent_manager.py @@ -60,6 +60,11 @@ from backend.apps.agents.manager.streaming.state import ThinkingState, TurnState from backend.apps.agents.manager.streaming.hook_context import HookContext from backend.apps.agents.manager.streaming import thinking as thinking_mod from backend.apps.agents.manager.permissions import gate_hooks +from backend.apps.agents.manager.view_builder_state import ( + VIEW_BUILDER_RENDER_MAX_RETRIES, + view_builder_render_retry_counts, + view_builder_dirty_sessions, +) from backend.apps.agents.manager.session.workspace_git import _detect_git_identity, _ensure_cwd_git_repo from backend.apps.agents.manager.prompt.tool_catalog import ( FULL_TOOLS, @@ -99,11 +104,6 @@ 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() - - class AgentManager: def __init__(self): self.sessions: dict[str, AgentSession] = {} @@ -508,7 +508,7 @@ class AgentManager: )) if session.mode == "view-builder" and (wrote_frontend_file or installed_pkg): - p_view_builder_dirty_sessions.add(session.id) + view_builder_dirty_sessions.add(session.id) try: from backend.apps.outputs.runtime import ( manager as outputs_runtime_manager, @@ -1107,7 +1107,7 @@ class AgentManager: MAX_RETRIES then lets the stop through.""" if session.mode != "view-builder": return {} - if session.id not in p_view_builder_dirty_sessions: + if session.id not in view_builder_dirty_sessions: return {} from backend.apps.outputs.runtime import ( manager as outputs_runtime_manager, @@ -1122,31 +1122,31 @@ class AgentManager: 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) + view_builder_render_retry_counts.pop(session.id, None) + 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: + attempts = view_builder_render_retry_counts.get(session.id, 0) + if attempts >= 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) + view_builder_render_retry_counts.pop(session.id, None) + view_builder_dirty_sessions.discard(session.id) return {} - p_view_builder_render_retry_counts[session.id] = attempts + 1 + 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, + attempts + 1, 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"{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." @@ -3325,8 +3325,8 @@ class AgentManager: self.sessions.pop(session_id, None) self.tasks.pop(session_id, None) self._live_partial.pop(session_id, None) - p_view_builder_render_retry_counts.pop(session_id, None) - p_view_builder_dirty_sessions.discard(session_id) + view_builder_render_retry_counts.pop(session_id, None) + view_builder_dirty_sessions.discard(session_id) async def delete_session(self, session_id: str) -> None: """Permanently delete a session: remove from memory and JSON file. diff --git a/backend/apps/agents/manager/view_builder_state.py b/backend/apps/agents/manager/view_builder_state.py new file mode 100644 index 00000000..814922d7 --- /dev/null +++ b/backend/apps/agents/manager/view_builder_state.py @@ -0,0 +1,12 @@ +"""Cross-call view-builder render state, shared between the agent loop (which runs the +capped render-retry) and the post-tool hook (which marks a session dirty after a frontend +write/install). Module-level singletons on purpose: the retry counter and dirty set must +persist across turns and be the SAME objects both readers mutate.""" + +from typing import Dict, Set + +VIEW_BUILDER_RENDER_MAX_RETRIES = 2 +# session_id -> consecutive view-builder render attempts (capped, then it gives up). +view_builder_render_retry_counts: Dict[str, int] = {} +# session_ids whose view-builder workspace was written/installed since the last render. +view_builder_dirty_sessions: Set[str] = set() diff --git a/backend/tests/test_session_cleanup.py b/backend/tests/test_session_cleanup.py index a31d4f1b..a8b9313d 100644 --- a/backend/tests/test_session_cleanup.py +++ b/backend/tests/test_session_cleanup.py @@ -18,16 +18,16 @@ def test_purge_session_memory_clears_every_structure(): mgr.sessions = {"dead": object(), "alive": object()} mgr.tasks = {"dead": object()} mgr._live_partial = {"dead": {"text": "half a reply"}} - am.p_view_builder_render_retry_counts["dead"] = 4 - am.p_view_builder_dirty_sessions.add("dead") + am.view_builder_render_retry_counts["dead"] = 4 + am.view_builder_dirty_sessions.add("dead") mgr._purge_session_memory("dead") assert "dead" not in mgr.sessions assert "dead" not in mgr.tasks assert "dead" not in mgr._live_partial - assert "dead" not in am.p_view_builder_render_retry_counts - assert "dead" not in am.p_view_builder_dirty_sessions + assert "dead" not in am.view_builder_render_retry_counts + assert "dead" not in am.view_builder_dirty_sessions # Only the target id is purged; an unrelated live session survives. assert "alive" in mgr.sessions