diff --git a/backend/apps/agents/agent_manager.py b/backend/apps/agents/agent_manager.py index a5257024..71752421 100644 --- a/backend/apps/agents/agent_manager.py +++ b/backend/apps/agents/agent_manager.py @@ -82,6 +82,10 @@ class AgentManager(SessionLifecycleMixin, SessionPersistenceMixin, MessagingMixi # stop can persist the partial reply instantly instead of waiting out the # multi-second SDK teardown the cancel handler sits behind. self.live_partial: Dict[str, LivePartial] = {} + # Per-session cancel signal: the loop stashes its asyncio.Event here so a + # stop/close can set it. Lives on the manager, not the AgentSession model, + # so it stays out of serialization (an Event can't be model_dump'd). + self.cancel_events: Dict[str, asyncio.Event] = {} diff --git a/backend/apps/agents/browser/browser_agent.py b/backend/apps/agents/browser/browser_agent.py index 4546e0a9..d09ddd4d 100644 --- a/backend/apps/agents/browser/browser_agent.py +++ b/backend/apps/agents/browser/browser_agent.py @@ -375,7 +375,7 @@ async def run_browser_agent( system_prompt=SYSTEM_PROMPT, parent_session_id=parent_session_id, ) - session._cancel_event = cancel_event + agent_manager.cancel_events[session_id] = cancel_event agent_manager.sessions[session_id] = session # If parent was already stopped before we registered, bail immediately diff --git a/backend/apps/agents/manager/SessionControlMixin.py b/backend/apps/agents/manager/SessionControlMixin.py index 39566225..de283d71 100644 --- a/backend/apps/agents/manager/SessionControlMixin.py +++ b/backend/apps/agents/manager/SessionControlMixin.py @@ -31,8 +31,9 @@ class SessionControlMixin: if session: # Set cancel event BEFORE cancelling the task so in-flight # browser agent loops see it immediately - if hasattr(session, '_cancel_event'): - session._cancel_event.set() + ev = self.cancel_events.get(session_id) + if ev: + ev.set() for req in list(session.pending_approvals): ws_manager.resolve_approval(req.id, {"behavior": "deny", "message": "Agent stopped"}) diff --git a/backend/apps/agents/manager/session/SessionLifecycleMixin.py b/backend/apps/agents/manager/session/SessionLifecycleMixin.py index 7de4627f..374b0df0 100644 --- a/backend/apps/agents/manager/session/SessionLifecycleMixin.py +++ b/backend/apps/agents/manager/session/SessionLifecycleMixin.py @@ -69,8 +69,9 @@ class SessionLifecycleMixin: ws_manager.resolve_approval(req.id, {"behavior": "deny", "message": "Session closed"}) session.pending_approvals = [] - if hasattr(session, '_cancel_event'): - session._cancel_event.set() + ev = self.cancel_events.get(session_id) + if ev: + ev.set() self.sync_session_close(session) @@ -103,6 +104,7 @@ class SessionLifecycleMixin: self.sessions.pop(session_id, None) self.tasks.pop(session_id, None) self.live_partial.pop(session_id, None) + self.cancel_events.pop(session_id, None) view_builder_render_retry_counts.pop(session_id, None) view_builder_dirty_sessions.discard(session_id) diff --git a/backend/apps/outputs/view_builder_templates.py b/backend/apps/outputs/view_builder_templates.py index a75370d3..e0d3c09e 100644 --- a/backend/apps/outputs/view_builder_templates.py +++ b/backend/apps/outputs/view_builder_templates.py @@ -69,8 +69,8 @@ WEBAPP_TEMPLATE_DIR = os.path.join(os.path.dirname(__file__), "webapp_template") # Bundled default; used as the read-once fallback if the user-editable # copy at ~/.claude/skills/app_builder_skill.md has been removed despite # the built-in flag (defensive; shouldn't happen in normal use). -with open(APP_BUILDER_SKILL_SOURCE_PATH, encoding="utf-8") as _f: - APP_BUILDER_SKILL_DEFAULT = _f.read() +with open(APP_BUILDER_SKILL_SOURCE_PATH, encoding="utf-8") as p_f: + APP_BUILDER_SKILL_DEFAULT = p_f.read() def load_app_builder_skill() -> str: diff --git a/backend/config/Apps.py b/backend/config/Apps.py index 9d6e8adc..f94c3e34 100644 --- a/backend/config/Apps.py +++ b/backend/config/Apps.py @@ -38,14 +38,14 @@ class MainApp: p_boot_t0 = time.perf_counter() for sub_app in sub_apps: debug(sub_app.name) - _t0 = time.perf_counter() + p_t0 = time.perf_counter() await stack.enter_async_context(sub_app.lifespan()) - _dt = (time.perf_counter() - _t0) * 1000 - if _dt > 50: # only flag a slow lifespan; keeps boot logs quiet - print(f"[perf] lifespan {sub_app.name} t={_dt:.0f}ms", flush=True) + p_dt = (time.perf_counter() - p_t0) * 1000 + if p_dt > 50: # only flag a slow lifespan; keeps boot logs quiet + print(f"[perf] lifespan {sub_app.name} t={p_dt:.0f}ms", flush=True) print(f"[perf] lifespans-total t={(time.perf_counter() - p_boot_t0) * 1000:.0f}ms", flush=True) - _port = os.environ.get("OPENSWARM_PORT", "8324") - print(f"\nCheck out the API docs at: http://127.0.0.1:{_port}/docs\n") + p_port = os.environ.get("OPENSWARM_PORT", "8324") + print(f"\nCheck out the API docs at: http://127.0.0.1:{p_port}/docs\n") yield self.app = FastAPI(lifespan=lifespan) diff --git a/backend/config/install_id.py b/backend/config/install_id.py index 5d3b0245..706fa87a 100644 --- a/backend/config/install_id.py +++ b/backend/config/install_id.py @@ -8,21 +8,21 @@ import uuid from backend.config.paths import DATA_ROOT P_INSTALL_ID_FILE = os.path.join(DATA_ROOT, "install_id") -_cached: str | None = None +p_cached: str | None = None def get_install_id() -> str: """Return the persistent install_id, generating and persisting on first call.""" - global _cached - if _cached: - return _cached + global p_cached + if p_cached: + return p_cached try: with open(P_INSTALL_ID_FILE, "r", encoding="utf-8") as f: existing = f.read().strip() if p_looks_like_uuid(existing): - _cached = existing - return _cached + p_cached = existing + return p_cached except FileNotFoundError: pass except Exception: @@ -35,8 +35,8 @@ def get_install_id() -> str: os.write(fd, fresh.encode("utf-8")) finally: os.close(fd) - _cached = fresh - return _cached + p_cached = fresh + return p_cached def p_looks_like_uuid(s: str) -> bool: diff --git a/backend/main.py b/backend/main.py index e0d07f11..5a698017 100644 --- a/backend/main.py +++ b/backend/main.py @@ -10,9 +10,9 @@ from uuid import uuid4 # handlers; uvicorn's own access logs are untouched. p_backend_logger = logging.getLogger("backend") if not p_backend_logger.handlers: - _h = logging.StreamHandler() - _h.setFormatter(logging.Formatter("%(asctime)s %(levelname).1s %(name)s: %(message)s", "%H:%M:%S")) - p_backend_logger.addHandler(_h) + p_h = logging.StreamHandler() + p_h.setFormatter(logging.Formatter("%(asctime)s %(levelname).1s %(name)s: %(message)s", "%H:%M:%S")) + p_backend_logger.addHandler(p_h) p_backend_logger.setLevel(logging.INFO) p_backend_logger.propagate = False diff --git a/backend/tests/test_free_trial.py b/backend/tests/test_free_trial.py index 89abf708..eefefe26 100644 --- a/backend/tests/test_free_trial.py +++ b/backend/tests/test_free_trial.py @@ -132,7 +132,7 @@ async def test_arm_with_no_sub_is_bounded_and_falls_through_to_arm(monkeypatch): monkeypatch.setattr(ft, "p_has_connected_subscription", never_sub) # Short-circuit before the cloud mint so the test stays offline + deterministic; # reaching this branch proves arm did NOT falsely conclude has_model. - monkeypatch.setattr(ft, "p_fingerprint", lambda _s: None) + monkeypatch.setattr(ft, "p_fingerprint", lambda p_s: None) s = AppSettings() t = time.monotonic()