aiagent-runner 0.1.3__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,687 @@
1
+ # src/aiagent_runner/coordinator.py
2
+ # Coordinator - Single orchestrator for all Agent Instances
3
+ # Reference: docs/plan/PHASE4_COORDINATOR_ARCHITECTURE.md
4
+
5
+ import asyncio
6
+ import json
7
+ import logging
8
+ import os
9
+ import subprocess
10
+ from dataclasses import dataclass
11
+ from datetime import datetime
12
+ from pathlib import Path
13
+ from typing import Optional, TextIO
14
+
15
+ from aiagent_runner.coordinator_config import CoordinatorConfig
16
+ from aiagent_runner.mcp_client import MCPClient, MCPError
17
+
18
+ logger = logging.getLogger(__name__)
19
+
20
+
21
+ @dataclass
22
+ class AgentInstanceKey:
23
+ """Unique key for an Agent Instance: (agent_id, project_id)."""
24
+ agent_id: str
25
+ project_id: str
26
+
27
+ def __hash__(self):
28
+ return hash((self.agent_id, self.project_id))
29
+
30
+ def __eq__(self, other):
31
+ if not isinstance(other, AgentInstanceKey):
32
+ return False
33
+ return self.agent_id == other.agent_id and self.project_id == other.project_id
34
+
35
+
36
+ @dataclass
37
+ class AgentInstanceInfo:
38
+ """Information about a running Agent Instance."""
39
+ key: AgentInstanceKey
40
+ process: subprocess.Popen
41
+ working_directory: str
42
+ provider: str # "claude", "gemini", "openai", "other"
43
+ model: Optional[str] # "claude-sonnet-4-5", "gemini-2.0-flash", etc.
44
+ started_at: datetime
45
+ log_file_handle: Optional["TextIO"] = None # Keep file handle open during process lifetime
46
+ task_id: Optional[str] = None # Phase 4: ログファイルパス登録用
47
+ log_file_path: Optional[str] = None # Phase 4: ログファイルパス
48
+
49
+
50
+ class Coordinator:
51
+ """Single orchestrator that manages all Agent Instances.
52
+
53
+ The Coordinator operates in a polling loop:
54
+ 1. health_check() - Verify MCP server is available
55
+ 2. list_active_projects_with_agents() - Get all projects and their agents
56
+ 3. For each (agent_id, project_id) pair:
57
+ - Check if we have a passkey configured
58
+ - get_agent_action(agent_id, project_id) - Check what action to take
59
+ - Spawn Agent Instance if needed
60
+ 4. Clean up finished processes
61
+ 5. Wait for polling interval
62
+ 6. Repeat
63
+
64
+ Key differences from the old Runner:
65
+ - Single instance manages ALL (agent_id, project_id) combinations
66
+ - Does NOT authenticate - spawned Agent Instances do that
67
+ - Tracks running processes to avoid duplicates
68
+ """
69
+
70
+ def __init__(self, config: CoordinatorConfig):
71
+ """Initialize Coordinator.
72
+
73
+ Args:
74
+ config: Coordinator configuration with agents and ai_providers
75
+ """
76
+ self.config = config
77
+ # Phase 5: Pass coordinator_token for Coordinator-only API authorization
78
+ logger.debug(f"Initializing MCPClient with coordinator_token: {'set' if config.coordinator_token else 'NOT SET'}")
79
+ self.mcp_client = MCPClient(
80
+ config.mcp_socket_path,
81
+ coordinator_token=config.coordinator_token
82
+ )
83
+
84
+ self._running = False
85
+ self._instances: dict[AgentInstanceKey, AgentInstanceInfo] = {}
86
+
87
+ @property
88
+ def log_directory(self) -> Path:
89
+ """Get log directory, creating if needed."""
90
+ if self.config.log_directory:
91
+ log_dir = Path(self.config.log_directory).expanduser()
92
+ else:
93
+ log_dir = Path.home() / ".aiagent-coordinator" / "logs"
94
+ log_dir.mkdir(parents=True, exist_ok=True)
95
+ return log_dir
96
+
97
+ def _get_log_directory(self, working_dir: Optional[str], agent_id: str) -> Path:
98
+ """Get log directory for an agent.
99
+
100
+ Args:
101
+ working_dir: Project working directory (None or empty string for fallback)
102
+ agent_id: Agent ID
103
+
104
+ Returns:
105
+ Path to log directory
106
+ """
107
+ if working_dir:
108
+ # プロジェクトのワーキングディレクトリ基準
109
+ log_dir = Path(working_dir) / ".aiagent" / "logs" / agent_id
110
+ else:
111
+ # フォールバック: アプリ管轄ディレクトリ
112
+ log_dir = (
113
+ Path.home()
114
+ / "Library" / "Application Support" / "AIAgentPM"
115
+ / "agent_logs" / agent_id
116
+ )
117
+
118
+ log_dir.mkdir(parents=True, exist_ok=True)
119
+ return log_dir
120
+
121
+ async def start(self) -> None:
122
+ """Start the Coordinator loop.
123
+
124
+ Runs until stop() is called or an unrecoverable error occurs.
125
+ """
126
+ logger.info(
127
+ f"Starting Coordinator, polling every {self.config.polling_interval}s, "
128
+ f"max_concurrent={self.config.max_concurrent}"
129
+ )
130
+ logger.info(f"Configured agents: {list(self.config.agents.keys())}")
131
+
132
+ # Multi-device: Log root_agent_id if set
133
+ if self.config.root_agent_id:
134
+ logger.info(f"Multi-device mode: root_agent_id={self.config.root_agent_id}")
135
+
136
+ self._running = True
137
+
138
+ while self._running:
139
+ try:
140
+ await self._run_once()
141
+ except MCPError as e:
142
+ logger.error(f"MCP error: {e}")
143
+ except Exception as e:
144
+ logger.exception(f"Unexpected error: {e}")
145
+
146
+ if self._running:
147
+ await asyncio.sleep(self.config.polling_interval)
148
+
149
+ def stop(self) -> None:
150
+ """Stop the Coordinator loop."""
151
+ logger.info("Stopping Coordinator")
152
+ self._running = False
153
+
154
+ # Terminate all running instances
155
+ for key, info in list(self._instances.items()):
156
+ logger.info(f"Terminating {key.agent_id}/{key.project_id}")
157
+ try:
158
+ info.process.terminate()
159
+ except Exception as e:
160
+ logger.warning(f"Failed to terminate {key}: {e}")
161
+
162
+ async def _run_once(self) -> None:
163
+ """Run one iteration of the polling loop."""
164
+ # Step 1: Health check
165
+ try:
166
+ health = await self.mcp_client.health_check()
167
+ if health.status != "ok":
168
+ logger.warning(f"MCP server unhealthy: {health.status}")
169
+ return
170
+ except MCPError as e:
171
+ logger.error(f"MCP server not available: {e}")
172
+ return
173
+
174
+ # Step 2: Get active projects with agents
175
+ # Multi-device: Pass root_agent_id for working directory resolution
176
+ try:
177
+ projects = await self.mcp_client.list_active_projects_with_agents(
178
+ root_agent_id=self.config.root_agent_id
179
+ )
180
+ except MCPError as e:
181
+ logger.error(f"Failed to get project list: {e}")
182
+ return
183
+
184
+ logger.debug(f"Found {len(projects)} active projects")
185
+
186
+ # Debug: Log project details including agents
187
+ for project in projects:
188
+ logger.debug(
189
+ f"Project {project.project_id}: agents={project.agents}, "
190
+ f"working_dir={project.working_directory}"
191
+ )
192
+
193
+ # Step 3: Clean up finished processes, register log file paths, and invalidate sessions
194
+ finished_instances = self._cleanup_finished()
195
+ for key, info, exit_code in finished_instances:
196
+ # Register log file path (if available)
197
+ if info.task_id and info.log_file_path:
198
+ try:
199
+ success = await self.mcp_client.register_execution_log_file(
200
+ agent_id=key.agent_id,
201
+ task_id=info.task_id,
202
+ log_file_path=info.log_file_path
203
+ )
204
+ if success:
205
+ logger.info(
206
+ f"Registered log file for {key.agent_id}/{key.project_id}: "
207
+ f"{info.log_file_path}"
208
+ )
209
+ else:
210
+ logger.warning(
211
+ f"Failed to register log file for {key.agent_id}/{key.project_id}"
212
+ )
213
+ except MCPError as e:
214
+ logger.error(
215
+ f"Error registering log file for {key.agent_id}/{key.project_id}: {e}"
216
+ )
217
+
218
+ # If process exited with error, report to chat
219
+ if exit_code != 0 and info.log_file_path:
220
+ error_msg = self._extract_error_from_log(info.log_file_path)
221
+ if error_msg:
222
+ try:
223
+ success = await self.mcp_client.report_agent_error(
224
+ agent_id=key.agent_id,
225
+ project_id=key.project_id,
226
+ error_message=error_msg
227
+ )
228
+ if success:
229
+ logger.info(
230
+ f"Reported error for {key.agent_id}/{key.project_id}: {error_msg[:50]}..."
231
+ )
232
+ else:
233
+ logger.warning(
234
+ f"Failed to report error for {key.agent_id}/{key.project_id}"
235
+ )
236
+ except MCPError as e:
237
+ logger.error(
238
+ f"Error reporting error for {key.agent_id}/{key.project_id}: {e}"
239
+ )
240
+
241
+ # Invalidate session so shouldStart returns True for next instance
242
+ try:
243
+ success = await self.mcp_client.invalidate_session(
244
+ agent_id=key.agent_id,
245
+ project_id=key.project_id
246
+ )
247
+ if success:
248
+ logger.info(
249
+ f"Invalidated session for {key.agent_id}/{key.project_id}"
250
+ )
251
+ else:
252
+ logger.warning(
253
+ f"Failed to invalidate session for {key.agent_id}/{key.project_id}"
254
+ )
255
+ except MCPError as e:
256
+ logger.error(
257
+ f"Error invalidating session for {key.agent_id}/{key.project_id}: {e}"
258
+ )
259
+
260
+ # Step 4: For each (agent_id, project_id), check if should start
261
+ for project in projects:
262
+ project_id = project.project_id
263
+ working_dir = project.working_directory
264
+
265
+ logger.debug(f"Processing project {project_id}, agents: {project.agents}")
266
+
267
+ for agent_id in project.agents:
268
+ key = AgentInstanceKey(agent_id, project_id)
269
+ logger.debug(f"Checking agent {agent_id} for project {project_id}")
270
+
271
+ # Skip if we don't have passkey configured
272
+ passkey = self.config.get_agent_passkey(agent_id)
273
+ logger.debug(f"Passkey for {agent_id}: {'configured' if passkey else 'NOT FOUND'}")
274
+ if not passkey:
275
+ logger.debug(f"No passkey configured for {agent_id}, skipping")
276
+ continue
277
+
278
+ # Check if instance is already running
279
+ instance_running = key in self._instances
280
+
281
+ # UC008: Always check get_agent_action for running instances to detect stop
282
+ if instance_running:
283
+ logger.debug(f"Instance {agent_id}/{project_id} running, checking for stop action")
284
+ try:
285
+ result = await self.mcp_client.get_agent_action(agent_id, project_id)
286
+ logger.debug(f"get_agent_action for running instance: action={result.action}, reason={result.reason}")
287
+ if result.action == "stop":
288
+ logger.info(f"Stopping instance {agent_id}/{project_id} due to {result.reason}")
289
+ await self._stop_instance(key)
290
+ except MCPError as e:
291
+ logger.error(f"Failed to check stop action for {agent_id}/{project_id}: {e}")
292
+ continue
293
+
294
+ # Skip if at max concurrent
295
+ if len(self._instances) >= self.config.max_concurrent:
296
+ logger.debug(f"At max concurrent ({self.config.max_concurrent}), skipping")
297
+ break
298
+
299
+ # Check what action to take
300
+ logger.debug(f"Calling get_agent_action({agent_id}, {project_id})")
301
+ try:
302
+ result = await self.mcp_client.get_agent_action(agent_id, project_id)
303
+ logger.debug(
304
+ f"get_agent_action result: action={result.action}, reason={result.reason}, "
305
+ f"provider: {result.provider}, model: {result.model}, "
306
+ f"kick_command: {result.kick_command}, task_id: {result.task_id}"
307
+ )
308
+ if result.action == "start":
309
+ provider = result.provider or "claude"
310
+ self._spawn_instance(
311
+ agent_id=agent_id,
312
+ project_id=project_id,
313
+ passkey=passkey,
314
+ working_dir=working_dir,
315
+ provider=provider,
316
+ model=result.model,
317
+ kick_command=result.kick_command,
318
+ task_id=result.task_id
319
+ )
320
+ else:
321
+ logger.debug(f"get_agent_action returned action='{result.action}' (reason: {result.reason}) for {agent_id}/{project_id}")
322
+ except MCPError as e:
323
+ logger.error(f"Failed to get_agent_action for {agent_id}/{project_id}: {e}")
324
+
325
+ async def _stop_instance(self, key: AgentInstanceKey) -> None:
326
+ """Stop a running Agent Instance.
327
+
328
+ Args:
329
+ key: The AgentInstanceKey identifying the instance to stop.
330
+ """
331
+ info = self._instances.get(key)
332
+ if not info:
333
+ logger.warning(f"Instance {key.agent_id}/{key.project_id} not found in _instances")
334
+ return
335
+
336
+ logger.info(f"Terminating instance {key.agent_id}/{key.project_id} (PID: {info.process.pid})")
337
+
338
+ try:
339
+ info.process.terminate()
340
+ # Wait a short time for graceful shutdown
341
+ try:
342
+ info.process.wait(timeout=5)
343
+ except subprocess.TimeoutExpired:
344
+ logger.warning(f"Instance {key.agent_id}/{key.project_id} did not terminate, killing")
345
+ info.process.kill()
346
+ except Exception as e:
347
+ logger.error(f"Error terminating process: {e}")
348
+
349
+ # Close log file handle
350
+ if info.log_file_handle:
351
+ try:
352
+ info.log_file_handle.close()
353
+ except Exception:
354
+ pass
355
+
356
+ # Remove from instances
357
+ del self._instances[key]
358
+ logger.info(f"Instance {key.agent_id}/{key.project_id} stopped and removed")
359
+
360
+ def _cleanup_finished(self) -> list[tuple[AgentInstanceKey, AgentInstanceInfo, int]]:
361
+ """Clean up finished Agent Instance processes.
362
+
363
+ Returns:
364
+ List of (key, info, exit_code) tuples for finished instances
365
+ that need log file path registration.
366
+ """
367
+ finished: list[tuple[AgentInstanceKey, AgentInstanceInfo, int]] = []
368
+ for key, info in self._instances.items():
369
+ retcode = info.process.poll()
370
+ if retcode is not None:
371
+ logger.info(
372
+ f"Instance {key.agent_id}/{key.project_id} finished with code {retcode}"
373
+ )
374
+ # Close log file handle
375
+ if info.log_file_handle:
376
+ try:
377
+ info.log_file_handle.close()
378
+ except Exception:
379
+ pass
380
+ finished.append((key, info, retcode))
381
+
382
+ for key, _, _ in finished:
383
+ del self._instances[key]
384
+
385
+ return finished
386
+
387
+ def _extract_error_from_log(self, log_file_path: str) -> Optional[str]:
388
+ """Extract error message from log file.
389
+
390
+ Looks for common error patterns in the last 50 lines of the log.
391
+
392
+ Args:
393
+ log_file_path: Path to the log file
394
+
395
+ Returns:
396
+ Error message if found, None otherwise
397
+ """
398
+ try:
399
+ with open(log_file_path, "r") as f:
400
+ lines = f.readlines()
401
+
402
+ # Check last 50 lines for errors
403
+ last_lines = lines[-50:] if len(lines) > 50 else lines
404
+
405
+ error_patterns = [
406
+ "[API Error:",
407
+ "Error:",
408
+ "ERROR:",
409
+ "error:",
410
+ "quota",
411
+ "rate limit",
412
+ "exhausted",
413
+ "unauthorized",
414
+ "authentication failed",
415
+ ]
416
+
417
+ for line in reversed(last_lines):
418
+ line_lower = line.lower()
419
+ for pattern in error_patterns:
420
+ if pattern.lower() in line_lower:
421
+ # Found an error line, return it (cleaned up)
422
+ return line.strip()
423
+
424
+ # If no specific error found but process failed, return generic message
425
+ return None
426
+ except Exception as e:
427
+ logger.warning(f"Failed to read log file {log_file_path}: {e}")
428
+ return None
429
+
430
+ def _spawn_instance(
431
+ self,
432
+ agent_id: str,
433
+ project_id: str,
434
+ passkey: str,
435
+ working_dir: str,
436
+ provider: str,
437
+ model: Optional[str] = None,
438
+ kick_command: Optional[str] = None,
439
+ task_id: Optional[str] = None
440
+ ) -> None:
441
+ """Spawn an Agent Instance process.
442
+
443
+ The Agent Instance (Claude Code) will:
444
+ 1. authenticate(agent_id, passkey, project_id)
445
+ 2. get_my_task()
446
+ 3. Execute the task
447
+ 4. report_completed()
448
+ 5. Exit
449
+
450
+ Args:
451
+ agent_id: Agent ID
452
+ project_id: Project ID
453
+ passkey: Agent passkey
454
+ working_dir: Working directory for the task
455
+ provider: AI provider (claude, gemini, openai, other)
456
+ model: Specific model (claude-sonnet-4-5, gemini-2.0-flash, etc.)
457
+ kick_command: Custom CLI command (takes priority if set)
458
+ task_id: Task ID (for log file path registration)
459
+ """
460
+ # kick_command takes priority over provider-based selection
461
+ if kick_command:
462
+ # Parse kick_command into command and args
463
+ parts = kick_command.split()
464
+ cli_command = parts[0]
465
+ cli_args = parts[1:] if len(parts) > 1 else []
466
+ logger.info(f"Using kick_command: {kick_command}")
467
+ else:
468
+ # Use provider-based CLI selection
469
+ provider_config = self.config.get_provider(provider)
470
+ cli_command = provider_config.cli_command
471
+ cli_args = provider_config.cli_args
472
+
473
+ # Build prompt for the Agent Instance
474
+ prompt = self._build_agent_prompt(agent_id, project_id, passkey)
475
+
476
+ # Generate log file path (use working_dir-based path for project context)
477
+ timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
478
+ log_dir = self._get_log_directory(working_dir, agent_id)
479
+ log_file = log_dir / f"{timestamp}.log"
480
+
481
+ # Build MCP config for Agent Instance (Unix Socket transport)
482
+ # Agent Instance connects to the SAME MCP daemon that the app started
483
+ # This ensures all components share the same database and state
484
+ socket_path = self.config.mcp_socket_path
485
+ if socket_path:
486
+ # Always expand tilde in socket path
487
+ socket_path = os.path.expanduser(socket_path)
488
+ else:
489
+ socket_path = os.path.expanduser(
490
+ "~/Library/Application Support/AIAgentPM/mcp.sock"
491
+ )
492
+
493
+ mcp_config_dict = {
494
+ "mcpServers": {
495
+ "agent-pm": {
496
+ "command": "nc",
497
+ "args": ["-U", socket_path]
498
+ }
499
+ }
500
+ }
501
+
502
+ mcp_config = json.dumps(mcp_config_dict)
503
+
504
+ # Debug: Log the MCP config
505
+ logger.debug(f"MCP config: {mcp_config}")
506
+ logger.info(f"Agent Instance will connect via Unix Socket: {socket_path}")
507
+
508
+ # Handle provider-specific MCP configuration
509
+ # Gemini CLI uses file-based config (.gemini/settings.json)
510
+ # Claude CLI uses inline JSON via --mcp-config flag
511
+ if provider == "gemini":
512
+ self._prepare_gemini_mcp_config(working_dir, socket_path)
513
+ logger.debug("Prepared Gemini MCP config file")
514
+
515
+ # Build command
516
+ cmd = [
517
+ cli_command,
518
+ *cli_args,
519
+ ]
520
+
521
+ # Add MCP config (only for non-Gemini providers)
522
+ # Gemini reads from .gemini/settings.json automatically
523
+ if provider != "gemini":
524
+ cmd.extend(["--mcp-config", mcp_config])
525
+
526
+ # Add model flag if specified
527
+ # Note: Gemini uses -m, Claude uses --model
528
+ if model:
529
+ model_flag = "-m" if provider == "gemini" else "--model"
530
+ cmd.extend([model_flag, model])
531
+ logger.debug(f"Using model: {model} (flag: {model_flag})")
532
+
533
+ # Add verbose flag for debugging if enabled
534
+ if self.config.debug_mode:
535
+ if provider == "gemini":
536
+ cmd.append("--debug")
537
+ else:
538
+ cmd.append("--verbose")
539
+
540
+ # Add prompt
541
+ # Gemini: uses positional argument for one-shot mode (-p is deprecated)
542
+ # Claude: uses -p flag
543
+ if provider == "gemini":
544
+ cmd.append(prompt) # Positional argument at the end for one-shot mode
545
+ else:
546
+ cmd.extend(["-p", prompt])
547
+
548
+ model_desc = f"{provider}/{model}" if model else provider
549
+ logger.info(
550
+ f"Spawning {model_desc} instance for {agent_id}/{project_id} "
551
+ f"at {working_dir}"
552
+ )
553
+ logger.debug(f"Command: {' '.join(cmd[:5])}...")
554
+
555
+ # Ensure working directory exists
556
+ Path(working_dir).mkdir(parents=True, exist_ok=True)
557
+
558
+ # Open log file (keep handle open during process lifetime)
559
+ log_f = open(log_file, "w")
560
+
561
+ # Spawn process
562
+ process = subprocess.Popen(
563
+ cmd,
564
+ cwd=working_dir,
565
+ stdout=log_f,
566
+ stderr=subprocess.STDOUT,
567
+ env={
568
+ **os.environ,
569
+ "AGENT_ID": agent_id,
570
+ "PROJECT_ID": project_id,
571
+ "AGENT_PASSKEY": passkey,
572
+ "WORKING_DIRECTORY": working_dir,
573
+ }
574
+ )
575
+
576
+ key = AgentInstanceKey(agent_id, project_id)
577
+ self._instances[key] = AgentInstanceInfo(
578
+ key=key,
579
+ process=process,
580
+ working_directory=working_dir,
581
+ provider=provider,
582
+ model=model,
583
+ started_at=datetime.now(),
584
+ log_file_handle=log_f,
585
+ task_id=task_id,
586
+ log_file_path=str(log_file)
587
+ )
588
+
589
+ logger.info(f"Spawned instance {agent_id}/{project_id} (PID: {process.pid})")
590
+
591
+ def _prepare_gemini_mcp_config(self, working_dir: str, socket_path: str) -> None:
592
+ """Prepare MCP config file for Gemini CLI.
593
+
594
+ Gemini CLI reads MCP configuration from .gemini/settings.json in the
595
+ working directory, unlike Claude CLI which accepts --mcp-config flag.
596
+
597
+ Args:
598
+ working_dir: Working directory where .gemini/settings.json will be created
599
+ socket_path: Unix socket path for MCP connection
600
+ """
601
+ gemini_dir = Path(working_dir) / ".gemini"
602
+ gemini_dir.mkdir(parents=True, exist_ok=True)
603
+
604
+ config = {
605
+ "mcpServers": {
606
+ "agent-pm": {
607
+ "command": "nc",
608
+ "args": ["-U", socket_path],
609
+ "trust": True # Auto-approve tool calls
610
+ }
611
+ }
612
+ }
613
+
614
+ config_file = gemini_dir / "settings.json"
615
+ with open(config_file, "w") as f:
616
+ json.dump(config, f, indent=2)
617
+
618
+ logger.debug(f"Created Gemini MCP config at {config_file}")
619
+
620
+ def _build_agent_prompt(self, agent_id: str, project_id: str, passkey: str) -> str:
621
+ """Build the prompt for an Agent Instance.
622
+
623
+ The Agent Instance will use this prompt to know how to authenticate
624
+ and what to do. Uses state-driven workflow control via get_next_action.
625
+
626
+ Args:
627
+ agent_id: Agent ID
628
+ project_id: Project ID
629
+ passkey: Agent passkey
630
+
631
+ Returns:
632
+ Prompt string for the Agent Instance
633
+ """
634
+ return f"""You are an AI Agent Instance managed by the AI Agent PM system.
635
+
636
+ ## Authentication
637
+ Call `authenticate` with:
638
+ - agent_id: "{agent_id}"
639
+ - passkey: "{passkey}"
640
+ - project_id: "{project_id}"
641
+
642
+ Save the session_token from the response.
643
+
644
+ ## Workflow (CRITICAL: Follow Exactly)
645
+ After authenticating, you MUST follow this loop WITHOUT exception:
646
+
647
+ 1. Call `get_next_action` with your session_token
648
+ 2. Read the `action` and `instruction` fields
649
+ 3. Execute ONLY what the `instruction` tells you to do
650
+ 4. Call `get_next_action` again (ALWAYS return to step 1)
651
+
652
+ NEVER skip step 4. ALWAYS call `get_next_action` after completing each instruction.
653
+
654
+ ## Task Decomposition (Required)
655
+ Before executing any actual work, you MUST decompose the task into sub-tasks:
656
+ - When `get_next_action` returns action="create_subtasks", use `create_task` tool
657
+ - Create 2-5 concrete sub-tasks with `parent_task_id` set to the main task ID
658
+ - Only after sub-tasks are created will `get_next_action` guide you to execute them
659
+
660
+ ## Important Rules
661
+ - ONLY follow instructions from `get_next_action` - do NOT execute task.description directly
662
+ - Task description is for context/understanding only, not for direct execution
663
+ - The system controls the workflow; you execute the steps
664
+ - If you receive a system_prompt from authenticate, adopt that role
665
+ - You are working in the project directory
666
+
667
+ Begin by calling `authenticate`.
668
+ """
669
+
670
+
671
+ async def run_coordinator_async(config: CoordinatorConfig) -> None:
672
+ """Run the Coordinator asynchronously.
673
+
674
+ Args:
675
+ config: Coordinator configuration
676
+ """
677
+ coordinator = Coordinator(config)
678
+ await coordinator.start()
679
+
680
+
681
+ def run_coordinator(config: CoordinatorConfig) -> None:
682
+ """Run the Coordinator synchronously.
683
+
684
+ Args:
685
+ config: Coordinator configuration
686
+ """
687
+ asyncio.run(run_coordinator_async(config))