fix: namespaced browser tab groups
This commit is contained in:
@@ -743,6 +743,18 @@ async def create_queen(
|
||||
|
||||
async def _queen_loop():
|
||||
logger.debug("[_queen_loop] Starting queen loop for session %s", session.id)
|
||||
# Scope the browser profile to this session so parallel queens each
|
||||
# drive their own Chrome tab group instead of fighting over "default".
|
||||
# Browser tools run in a stdio MCP subprocess, so we can't set a
|
||||
# contextvar across processes — instead we inject `profile` as a
|
||||
# CONTEXT_PARAM that ToolRegistry passes into every MCP call. The
|
||||
# token stays local to this task.
|
||||
try:
|
||||
from framework.loader.tool_registry import ToolRegistry
|
||||
|
||||
ToolRegistry.set_execution_context(profile=session.id)
|
||||
except Exception:
|
||||
logger.debug("Queen: failed to set browser profile for session %s", session.id, exc_info=True)
|
||||
try:
|
||||
lc = _queen_loop_config
|
||||
queen_loop_config = LoopConfig(
|
||||
|
||||
@@ -671,8 +671,21 @@ class SessionManager:
|
||||
event_bus=session.event_bus,
|
||||
)
|
||||
|
||||
# Start the worker's agent loop in the background
|
||||
session.queen_task = asyncio.create_task(session.queen_executor.run(initial_message=initial_prompt))
|
||||
# Start the worker's agent loop in the background.
|
||||
# Scope browser profile per-session so parallel sessions drive
|
||||
# independent Chrome tab groups. Browser tools live in an MCP
|
||||
# subprocess; we inject `profile` via the ToolRegistry execution
|
||||
# context (a CONTEXT_PARAM) so it flows into every tool call.
|
||||
async def _run_worker():
|
||||
try:
|
||||
from framework.loader.tool_registry import ToolRegistry
|
||||
|
||||
ToolRegistry.set_execution_context(profile=session.id)
|
||||
except Exception:
|
||||
logger.debug("Worker: failed to set browser profile", exc_info=True)
|
||||
await session.queen_executor.run(initial_message=initial_prompt)
|
||||
|
||||
session.queen_task = asyncio.create_task(_run_worker())
|
||||
|
||||
# Set up event persistence
|
||||
if session.event_bus and queen_dir:
|
||||
|
||||
@@ -15,7 +15,10 @@ import { useColony } from "@/context/ColonyContext";
|
||||
|
||||
export default function Sidebar() {
|
||||
const navigate = useNavigate();
|
||||
const { colonies, queenProfiles, sidebarCollapsed, setSidebarCollapsed } = useColony();
|
||||
const { colonies, queens, queenProfiles, sidebarCollapsed, setSidebarCollapsed } = useColony();
|
||||
const activeQueenIds = new Set(
|
||||
queens.filter((q) => q.status === "online").map((q) => q.id),
|
||||
);
|
||||
const [coloniesExpanded, setColoniesExpanded] = useState(true);
|
||||
const [queensExpanded, setQueensExpanded] = useState(true);
|
||||
|
||||
@@ -148,7 +151,11 @@ export default function Sidebar() {
|
||||
{queensExpanded && (
|
||||
<div className="flex flex-col gap-0.5 mt-0.5">
|
||||
{queenProfiles.map((queen) => (
|
||||
<SidebarQueenItem key={queen.id} queen={queen} />
|
||||
<SidebarQueenItem
|
||||
key={queen.id}
|
||||
queen={queen}
|
||||
isActive={activeQueenIds.has(queen.id)}
|
||||
/>
|
||||
))}
|
||||
{queenProfiles.length === 0 && (
|
||||
<p className="px-5 py-2 text-xs text-sidebar-muted">
|
||||
|
||||
@@ -3,22 +3,29 @@ import type { QueenProfileSummary } from "@/types/colony";
|
||||
|
||||
interface SidebarQueenItemProps {
|
||||
queen: QueenProfileSummary;
|
||||
isActive?: boolean;
|
||||
}
|
||||
|
||||
export default function SidebarQueenItem({ queen }: SidebarQueenItemProps) {
|
||||
export default function SidebarQueenItem({ queen, isActive }: SidebarQueenItemProps) {
|
||||
return (
|
||||
<NavLink
|
||||
to={`/queen/${queen.id}`}
|
||||
className={({ isActive }) =>
|
||||
className={({ isActive: isRouteActive }) =>
|
||||
`group flex items-center gap-2.5 px-3 py-1.5 mx-2 rounded-md text-sm transition-colors ${
|
||||
isActive
|
||||
isRouteActive
|
||||
? "bg-sidebar-active-bg text-foreground font-medium"
|
||||
: "text-foreground/70 hover:bg-sidebar-item-hover hover:text-foreground"
|
||||
}`
|
||||
}
|
||||
>
|
||||
<span className="flex-shrink-0 w-6 h-6 rounded-full bg-primary/15 flex items-center justify-center text-[10px] font-bold text-primary">
|
||||
<span className="relative flex-shrink-0 w-6 h-6 rounded-full bg-primary/15 flex items-center justify-center text-[10px] font-bold text-primary">
|
||||
{queen.name.charAt(0)}
|
||||
{isActive && (
|
||||
<span
|
||||
className="absolute -bottom-0.5 -right-0.5 w-2 h-2 rounded-full bg-emerald-500 ring-2 ring-sidebar-bg"
|
||||
title="Session running"
|
||||
/>
|
||||
)}
|
||||
</span>
|
||||
<div className="min-w-0 flex-1 flex items-center gap-2">
|
||||
<span className="font-medium truncate">{queen.name}</span>
|
||||
|
||||
@@ -80,6 +80,19 @@ async def _adaptive_poll_sleep(elapsed_s: float) -> None:
|
||||
_interaction_highlights: dict[int, dict] = {}
|
||||
|
||||
|
||||
def clear_tab_highlights(tab_ids) -> None:
|
||||
"""Drop cached interaction highlights for the given tab_ids.
|
||||
|
||||
Called when a profile's context is destroyed so stale highlight
|
||||
rects can't reappear on a later tab that Chrome happens to assign
|
||||
the same id. Accepts a single id or any iterable.
|
||||
"""
|
||||
if isinstance(tab_ids, int):
|
||||
tab_ids = (tab_ids,)
|
||||
for tid in tab_ids:
|
||||
_interaction_highlights.pop(tid, None)
|
||||
|
||||
|
||||
# Compact descriptor of document.activeElement. Returned by both click()
|
||||
# and click_coordinate() so the agent can verify it focused what it
|
||||
# intended, then decide whether to follow up with browser_type_focused(text=...).
|
||||
@@ -464,11 +477,14 @@ class BeelineBridge:
|
||||
"""Close a tab by ID."""
|
||||
result = await self._send("tab.close", tabId=tab_id)
|
||||
# Drop per-tab state — the id may be reused by Chrome much
|
||||
# later, and carrying a stale highlight or "attached" flag
|
||||
# forward would misannotate screenshots or skip a needed
|
||||
# reattach on the reused id.
|
||||
# later, and carrying a stale highlight, scale, or "attached"
|
||||
# flag forward would misannotate screenshots, misalign click
|
||||
# coordinates, or skip a needed reattach on the reused id.
|
||||
self._cdp_attached.discard(tab_id)
|
||||
_interaction_highlights.pop(tab_id, None)
|
||||
from .tools.inspection import clear_tab_state
|
||||
|
||||
clear_tab_state(tab_id)
|
||||
return result
|
||||
|
||||
async def list_tabs(self, group_id: int | None = None) -> dict:
|
||||
@@ -1113,7 +1129,9 @@ class BeelineBridge:
|
||||
# element (e.g. via browser_click_coordinate). Just clear the
|
||||
# active element if requested, then insert text directly.
|
||||
if clear_first:
|
||||
await self.evaluate(tab_id, """
|
||||
await self.evaluate(
|
||||
tab_id,
|
||||
"""
|
||||
(function() {
|
||||
const el = document.activeElement;
|
||||
if (!el) return;
|
||||
@@ -1125,7 +1143,8 @@ class BeelineBridge:
|
||||
el.dispatchEvent(new Event('input', {bubbles: true}));
|
||||
}
|
||||
})();
|
||||
""")
|
||||
""",
|
||||
)
|
||||
|
||||
if use_insert_text and delay_ms <= 0:
|
||||
# CDP Input.insertText is the most reliable way to insert
|
||||
@@ -1194,7 +1213,9 @@ class BeelineBridge:
|
||||
)
|
||||
rect = (rect_result or {}).get("result")
|
||||
if rect:
|
||||
await self.highlight_rect(tab_id, rect["x"], rect["y"], rect["w"], rect["h"], label="active element", border_style="dashed")
|
||||
await self.highlight_rect(
|
||||
tab_id, rect["x"], rect["y"], rect["w"], rect["h"], label="active element", border_style="dashed"
|
||||
)
|
||||
return {"ok": True, "action": "type", "selector": selector, "length": len(text)}
|
||||
|
||||
# CDP Input.dispatchKeyEvent modifiers bitmask.
|
||||
|
||||
@@ -32,6 +32,20 @@ _screenshot_scales: dict[int, float] = {}
|
||||
_screenshot_css_scales: dict[int, float] = {}
|
||||
|
||||
|
||||
def clear_tab_state(tab_ids) -> None:
|
||||
"""Drop cached screenshot scales for the given tab_ids.
|
||||
|
||||
Called when a tab closes or a profile's context is destroyed so stale
|
||||
scale values can't bleed into a later tab that Chrome happens to assign
|
||||
the same id. Accepts a single id or any iterable.
|
||||
"""
|
||||
if isinstance(tab_ids, int):
|
||||
tab_ids = (tab_ids,)
|
||||
for tid in tab_ids:
|
||||
_screenshot_scales.pop(tid, None)
|
||||
_screenshot_css_scales.pop(tid, None)
|
||||
|
||||
|
||||
def _resize_and_annotate(
|
||||
data: str,
|
||||
css_width: int,
|
||||
|
||||
@@ -35,6 +35,23 @@ def _resolve_profile(profile: str | None) -> str:
|
||||
_EXTENSION_PATH = (Path(__file__).parent.parent.parent.parent.parent / "browser-extension").resolve()
|
||||
|
||||
|
||||
def _clear_profile_tab_caches(ctx: dict[str, Any]) -> None:
|
||||
"""Clear per-tab caches for every tab the profile knew about.
|
||||
|
||||
Individual tab closes go through ``bridge.close_tab`` which clears
|
||||
caches per-tab; context destroys close every tab at once without
|
||||
per-tab notifications, so we clear them here from the tracked set.
|
||||
"""
|
||||
tab_ids = ctx.get("tabs") or set()
|
||||
if not tab_ids:
|
||||
return
|
||||
from ..bridge import clear_tab_highlights
|
||||
from .inspection import clear_tab_state
|
||||
|
||||
clear_tab_state(tab_ids)
|
||||
clear_tab_highlights(tab_ids)
|
||||
|
||||
|
||||
async def shutdown_all_contexts() -> None:
|
||||
"""Close all active browser contexts. Called at GCU server shutdown."""
|
||||
if not _contexts:
|
||||
@@ -42,6 +59,7 @@ async def shutdown_all_contexts() -> None:
|
||||
bridge = get_bridge()
|
||||
for profile_name, ctx in list(_contexts.items()):
|
||||
group_id = ctx.get("groupId")
|
||||
_clear_profile_tab_caches(ctx)
|
||||
if group_id is not None and bridge and bridge.is_connected:
|
||||
try:
|
||||
await bridge.destroy_context(group_id)
|
||||
@@ -232,6 +250,7 @@ def register_lifecycle_tools(mcp: FastMCP) -> None:
|
||||
"groupId": group_id,
|
||||
"activeTabId": tab_id,
|
||||
"_seedTabId": tab_id, # reused by first browser_open call
|
||||
"tabs": {tab_id} if tab_id is not None else set(),
|
||||
}
|
||||
|
||||
logger.info(
|
||||
@@ -299,6 +318,9 @@ def register_lifecycle_tools(mcp: FastMCP) -> None:
|
||||
try:
|
||||
group_id = ctx.get("groupId")
|
||||
closed_tabs = 0
|
||||
# Clear per-tab caches before tearing down the group — once
|
||||
# destroyed we won't get per-tab close notifications.
|
||||
_clear_profile_tab_caches(ctx)
|
||||
if group_id is not None:
|
||||
result = await bridge.destroy_context(group_id)
|
||||
closed_tabs = result.get("closedTabs", 0)
|
||||
|
||||
@@ -134,6 +134,11 @@ def register_tab_tools(mcp: FastMCP) -> None:
|
||||
result = await bridge.create_tab(url=url, group_id=ctx.get("groupId"))
|
||||
tab_id = result.get("tabId")
|
||||
|
||||
# Track tab_ids so browser_stop can clear per-tab caches
|
||||
# for every tab in this profile at once.
|
||||
if tab_id is not None:
|
||||
ctx.setdefault("tabs", set()).add(tab_id)
|
||||
|
||||
# Update active tab if not background
|
||||
if not background and tab_id is not None:
|
||||
ctx["activeTabId"] = tab_id
|
||||
@@ -201,6 +206,12 @@ def register_tab_tools(mcp: FastMCP) -> None:
|
||||
try:
|
||||
await bridge.close_tab(target_tab)
|
||||
|
||||
# Forget the closed tab so ctx["tabs"] only reflects tabs
|
||||
# that could still get per-tab cache activity.
|
||||
tabs_set = ctx.get("tabs")
|
||||
if isinstance(tabs_set, set):
|
||||
tabs_set.discard(target_tab)
|
||||
|
||||
# Update active tab if we closed it
|
||||
if ctx.get("activeTabId") == target_tab:
|
||||
result = await bridge.list_tabs(ctx.get("groupId"))
|
||||
@@ -300,6 +311,7 @@ def register_tab_tools(mcp: FastMCP) -> None:
|
||||
active_tab_id = ctx.get("activeTabId")
|
||||
|
||||
closed = 0
|
||||
tabs_set = ctx.get("tabs") if isinstance(ctx.get("tabs"), set) else None
|
||||
for tab in tabs:
|
||||
tid = tab.get("id")
|
||||
if keep_active and tid == active_tab_id:
|
||||
@@ -307,6 +319,8 @@ def register_tab_tools(mcp: FastMCP) -> None:
|
||||
try:
|
||||
await bridge.close_tab(tid)
|
||||
closed += 1
|
||||
if tabs_set is not None and tid is not None:
|
||||
tabs_set.discard(tid)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
Reference in New Issue
Block a user