diff --git a/ROADMAP.md b/ROADMAP.md index 17f1eae..31e26e3 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -529,3 +529,36 @@ PhoneWork/ - [ ] Host client reconnects → re-registered, messages flow again - [ ] Long CC task on node finishes → router forwards completion notification to Feishu - [ ] Wrong `ROUTER_SECRET` → connection rejected with 401 + +--- + +## M3 Implementation Notes (from M2 code review) + +Three concrete details discovered from reading the actual M2 code that must be handled +during M3 implementation: + +### 1. `bot/commands.py` accesses node-local state directly + +The current `commands.py` calls `agent._active_conv`, `manager.list_sessions()`, +`task_runner.list_tasks()`, `scheduler` — all of which move to the host client in M3. + +**Resolution:** At the router, `bot/commands.py` is reduced to two commands: +`/nodes` and `/node `. All other slash commands (`/new`, `/status`, `/close`, +`/switch`, `/direct`, `/smart`, `/shell`, `/tasks`, `/remind`) are forwarded to the +active node as-is — the node's mailboy handles them using its local `commands.py`. +The node's command handler remains unchanged from M2. + +### 2. `chat_id` must be forwarded to the node + +`bot/handler.py` calls `set_current_chat(chat_id)` before invoking the agent. +In M3, `handler.py` stays at the router but the agent (and `set_current_chat`) moves +to the node. The `chat_id` travels in `ForwardRequest` (already planned), and +`host_client/main.py` must call `set_current_chat(msg.chat_id)` before invoking the +local `agent.run()`. This is essential for `FileSendTool` and `SchedulerTool` to work. + +### 3. `orchestrator/tools.py` imports `config.WORKING_DIR` + +`_resolve_dir()` imports `WORKING_DIR` from root `config.py`. When `orchestrator/` +moves to the host client, this import must switch to `host_client/config.py`. +In standalone mode, `host_client/config.py` can re-export from root `config.py` to +keep a single `keyring.yaml`. diff --git a/agent/task_runner.py b/agent/task_runner.py index b3bac78..58b3fcf 100644 --- a/agent/task_runner.py +++ b/agent/task_runner.py @@ -30,6 +30,7 @@ class BackgroundTask: result: Optional[str] = None error: Optional[str] = None notify_chat_id: Optional[str] = None + user_id: Optional[str] = None @property def elapsed(self) -> float: @@ -44,12 +45,18 @@ class TaskRunner: def __init__(self) -> None: self._tasks: Dict[str, BackgroundTask] = {} self._lock = asyncio.Lock() + self._notification_handler: Optional[Callable] = None + + def set_notification_handler(self, handler: Optional[Callable]) -> None: + """Set custom notification handler for M3 mode (host client -> router).""" + self._notification_handler = handler async def submit( self, coro: Callable[[], Any], description: str, notify_chat_id: Optional[str] = None, + user_id: Optional[str] = None, ) -> str: """Submit a coroutine as a background task.""" task_id = str(uuid.uuid4())[:8] @@ -59,6 +66,7 @@ class TaskRunner: started_at=time.time(), status=TaskStatus.PENDING, notify_chat_id=notify_chat_id, + user_id=user_id, ) async with self._lock: @@ -94,7 +102,10 @@ class TaskRunner: logger.exception("Task %s failed: %s", task_id, exc) if task.notify_chat_id: - await self._send_notification(task) + if self._notification_handler: + await self._notification_handler(task) + else: + await self._send_notification(task) async def _send_notification(self, task: BackgroundTask) -> None: """Send Feishu notification about task completion.""" diff --git a/bot/commands.py b/bot/commands.py index 55fcb00..d05d739 100644 --- a/bot/commands.py +++ b/bot/commands.py @@ -67,6 +67,8 @@ async def handle_command(user_id: str, text: str) -> Optional[str]: return await _cmd_shell(args) elif cmd == "/remind": return await _cmd_remind(args) + elif cmd in ("/nodes", "/node"): + return await _cmd_nodes(user_id, args) else: return None @@ -278,6 +280,39 @@ async def _cmd_remind(args: str) -> str: return f"⏰ Reminder #{job_id} set for {value}{unit} from now" +async def _cmd_nodes(user_id: str, args: str) -> str: + """List nodes or switch active node.""" + from config import ROUTER_MODE + if not ROUTER_MODE: + return "Not in router mode. Run standalone.py for multi-host support." + + from router.nodes import get_node_registry + registry = get_node_registry() + + if args: + args = args.strip() + if registry.set_active_node(user_id, args): + return f"✓ Active node set to: {args}" + return f"Error: Node '{args}' not found" + + nodes = registry.list_nodes() + if not nodes: + return "No nodes connected." + + active_node_id = None + active_node = registry.get_active_node(user_id) + if active_node: + active_node_id = active_node.node_id + + lines = ["**Connected Nodes:**\n"] + for n in nodes: + marker = "→ " if n["node_id"] == active_node_id else " " + status = "🟢" if n["status"] == "online" else "🔴" + lines.append(f"{marker}{n['display_name']} {status} sessions={n['sessions']}") + lines.append("\nUse `/node ` to switch active node.") + return "\n".join(lines) + + def _cmd_help() -> str: """Show help.""" return """**Commands:** @@ -290,5 +325,7 @@ def _cmd_help() -> str: /shell - Run shell command (bypasses LLM) /remind