Migrate all repos into monorepo context folders
Bahn: aisupport, Analyse-O2C-C2S, awesome-bahn-mcp-servers, beam-mcp,
Confluence_Bot, db-planet-mcp-server, O2C-Harness, project-audit,
Projekt-KIQ-HP, teamlandkarte-mcp
Dhive: Jury-Voting
Privat: CV, NoteGraph (NOTE: NoteGraph needs complete redo after consolidation)
Shared: AI-Orchestrator, OrgMyLife, power_skills_and_more
Shared/references: symphony (read-only)
Bahn repos remain available as independent remotes - this monorepo
pulls them in via subtree, the originals are untouched.
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
"""AI-Orchestrator: A Python implementation of the Symphony specification."""
|
||||
|
||||
__version__ = "0.1.0"
|
||||
@@ -0,0 +1,364 @@
|
||||
"""Agent runner — Codex app-server subprocess integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable
|
||||
|
||||
from ai_orchestrator.config import ServiceConfig
|
||||
from ai_orchestrator.models import Issue, LiveSession
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class AgentError(Exception):
|
||||
"""Base class for agent runner errors."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class AgentStartupError(AgentError):
|
||||
"""Raised when the agent process fails to start."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class AgentTurnError(AgentError):
|
||||
"""Raised when an agent turn fails."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class AgentTimeoutError(AgentError):
|
||||
"""Raised when an agent turn times out."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
@dataclass
|
||||
class AgentEvent:
|
||||
"""Structured event from the agent process."""
|
||||
|
||||
event: str
|
||||
timestamp: datetime
|
||||
codex_app_server_pid: str | None = None
|
||||
usage: dict[str, int] | None = None
|
||||
payload: dict[str, Any] | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class TurnResult:
|
||||
"""Result of a single agent turn."""
|
||||
|
||||
success: bool
|
||||
session: LiveSession
|
||||
error: str | None = None
|
||||
|
||||
|
||||
# Type alias for event callback
|
||||
EventCallback = Callable[[str, AgentEvent], None]
|
||||
|
||||
|
||||
class AgentSession:
|
||||
"""Manages a Codex app-server subprocess session."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
config: ServiceConfig,
|
||||
workspace_path: str,
|
||||
issue: Issue,
|
||||
) -> None:
|
||||
self._config = config
|
||||
self._workspace_path = workspace_path
|
||||
self._issue = issue
|
||||
self._process: asyncio.subprocess.Process | None = None
|
||||
self._session: LiveSession = LiveSession()
|
||||
self._started = False
|
||||
|
||||
@property
|
||||
def session(self) -> LiveSession:
|
||||
"""Get the current live session state."""
|
||||
return self._session
|
||||
|
||||
async def start(self) -> LiveSession:
|
||||
"""Start the Codex app-server subprocess.
|
||||
|
||||
Returns:
|
||||
LiveSession with initial state.
|
||||
|
||||
Raises:
|
||||
AgentStartupError: If the process fails to start.
|
||||
"""
|
||||
workspace = Path(self._workspace_path)
|
||||
if not workspace.is_dir():
|
||||
raise AgentStartupError(
|
||||
f"Invalid workspace directory: {self._workspace_path}"
|
||||
)
|
||||
|
||||
command = self._config.codex.command
|
||||
logger.info(
|
||||
f"Starting agent session for {self._issue.identifier} "
|
||||
f"in {self._workspace_path}"
|
||||
)
|
||||
|
||||
try:
|
||||
self._process = await asyncio.create_subprocess_exec(
|
||||
"bash", "-lc", command,
|
||||
cwd=self._workspace_path,
|
||||
stdin=asyncio.subprocess.PIPE,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
except FileNotFoundError as e:
|
||||
raise AgentStartupError(f"codex command not found: {e}") from e
|
||||
except OSError as e:
|
||||
raise AgentStartupError(f"Failed to start agent: {e}") from e
|
||||
|
||||
self._started = True
|
||||
|
||||
# Send initialization message
|
||||
init_msg = self._build_init_message()
|
||||
await self._send_message(init_msg)
|
||||
|
||||
# Wait for initialization response
|
||||
try:
|
||||
response = await asyncio.wait_for(
|
||||
self._read_message(),
|
||||
timeout=self._config.codex.read_timeout_ms / 1000.0,
|
||||
)
|
||||
except asyncio.TimeoutError:
|
||||
await self.stop()
|
||||
raise AgentStartupError("Agent startup timed out")
|
||||
|
||||
if response and response.get("type") == "error":
|
||||
await self.stop()
|
||||
raise AgentStartupError(
|
||||
f"Agent startup error: {response.get('message', 'unknown')}"
|
||||
)
|
||||
|
||||
# Extract session identifiers
|
||||
thread_id = response.get("thread_id", "") if response else ""
|
||||
self._session.thread_id = thread_id
|
||||
self._session.session_id = thread_id
|
||||
|
||||
if self._process.pid:
|
||||
self._session.codex_app_server_pid = str(self._process.pid)
|
||||
|
||||
logger.info(
|
||||
f"Agent session started: thread_id={thread_id}, "
|
||||
f"pid={self._process.pid}"
|
||||
)
|
||||
|
||||
return self._session
|
||||
|
||||
async def run_turn(
|
||||
self,
|
||||
prompt: str,
|
||||
on_event: EventCallback | None = None,
|
||||
) -> TurnResult:
|
||||
"""Run a single agent turn.
|
||||
|
||||
Args:
|
||||
prompt: The prompt to send for this turn.
|
||||
on_event: Optional callback for streaming events.
|
||||
|
||||
Returns:
|
||||
TurnResult indicating success/failure.
|
||||
|
||||
Raises:
|
||||
AgentTurnError: If the turn fails.
|
||||
AgentTimeoutError: If the turn times out.
|
||||
"""
|
||||
if not self._started or not self._process:
|
||||
raise AgentTurnError("Session not started")
|
||||
|
||||
self._session.turn_count += 1
|
||||
|
||||
# Send turn start message
|
||||
turn_msg = self._build_turn_message(prompt)
|
||||
await self._send_message(turn_msg)
|
||||
|
||||
# Stream turn events until completion
|
||||
turn_timeout = self._config.codex.turn_timeout_ms / 1000.0
|
||||
start_time = time.monotonic()
|
||||
|
||||
try:
|
||||
while True:
|
||||
elapsed = time.monotonic() - start_time
|
||||
remaining = turn_timeout - elapsed
|
||||
if remaining <= 0:
|
||||
raise asyncio.TimeoutError()
|
||||
|
||||
message = await asyncio.wait_for(
|
||||
self._read_message(), timeout=remaining
|
||||
)
|
||||
|
||||
if message is None:
|
||||
# Process exited
|
||||
return TurnResult(
|
||||
success=False,
|
||||
session=self._session,
|
||||
error="Agent process exited unexpectedly",
|
||||
)
|
||||
|
||||
event = self._process_message(message)
|
||||
if event and on_event:
|
||||
on_event(self._issue.id, event)
|
||||
|
||||
# Check for turn completion
|
||||
msg_type = message.get("type", "")
|
||||
if msg_type in ("turn_completed", "turn_complete"):
|
||||
self._extract_usage(message)
|
||||
return TurnResult(success=True, session=self._session)
|
||||
elif msg_type in ("turn_failed", "turn_cancelled", "error"):
|
||||
error_msg = message.get("message", msg_type)
|
||||
return TurnResult(
|
||||
success=False,
|
||||
session=self._session,
|
||||
error=error_msg,
|
||||
)
|
||||
elif msg_type == "turn_input_required":
|
||||
# Per spec: user-input-required is treated as failure
|
||||
return TurnResult(
|
||||
success=False,
|
||||
session=self._session,
|
||||
error="Agent requested user input (not supported)",
|
||||
)
|
||||
|
||||
except asyncio.TimeoutError:
|
||||
raise AgentTimeoutError(
|
||||
f"Turn timed out after {self._config.codex.turn_timeout_ms}ms"
|
||||
)
|
||||
|
||||
async def stop(self) -> None:
|
||||
"""Stop the agent subprocess."""
|
||||
if self._process and self._process.returncode is None:
|
||||
try:
|
||||
self._process.terminate()
|
||||
await asyncio.wait_for(self._process.wait(), timeout=5.0)
|
||||
except asyncio.TimeoutError:
|
||||
self._process.kill()
|
||||
await self._process.wait()
|
||||
except ProcessLookupError:
|
||||
pass
|
||||
|
||||
self._started = False
|
||||
logger.info(f"Agent session stopped for {self._issue.identifier}")
|
||||
|
||||
async def _send_message(self, message: dict[str, Any]) -> None:
|
||||
"""Send a JSON message to the agent process stdin."""
|
||||
if not self._process or not self._process.stdin:
|
||||
raise AgentTurnError("Process stdin not available")
|
||||
|
||||
line = json.dumps(message) + "\n"
|
||||
self._process.stdin.write(line.encode("utf-8"))
|
||||
await self._process.stdin.drain()
|
||||
|
||||
async def _read_message(self) -> dict[str, Any] | None:
|
||||
"""Read a JSON message from the agent process stdout.
|
||||
|
||||
Returns:
|
||||
Parsed message dict, or None if process exited.
|
||||
"""
|
||||
if not self._process or not self._process.stdout:
|
||||
return None
|
||||
|
||||
try:
|
||||
line = await self._process.stdout.readline()
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
if not line:
|
||||
return None
|
||||
|
||||
try:
|
||||
return json.loads(line.decode("utf-8"))
|
||||
except (json.JSONDecodeError, UnicodeDecodeError) as e:
|
||||
logger.warning(f"Malformed agent message: {e}")
|
||||
return {"type": "malformed", "raw": line.decode("utf-8", errors="replace")}
|
||||
|
||||
def _build_init_message(self) -> dict[str, Any]:
|
||||
"""Build the session initialization message."""
|
||||
return {
|
||||
"type": "thread_start",
|
||||
"cwd": self._workspace_path,
|
||||
"approval_policy": self._config.codex.approval_policy,
|
||||
"sandbox": self._config.codex.thread_sandbox,
|
||||
}
|
||||
|
||||
def _build_turn_message(self, prompt: str) -> dict[str, Any]:
|
||||
"""Build a turn start message."""
|
||||
return {
|
||||
"type": "turn_start",
|
||||
"prompt": prompt,
|
||||
"title": f"{self._issue.identifier}: {self._issue.title}",
|
||||
"cwd": self._workspace_path,
|
||||
"sandbox_policy": self._config.codex.turn_sandbox_policy,
|
||||
}
|
||||
|
||||
def _process_message(self, message: dict[str, Any]) -> AgentEvent | None:
|
||||
"""Process an incoming agent message and update session state."""
|
||||
msg_type = message.get("type", "unknown")
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
self._session.last_codex_event = msg_type
|
||||
self._session.last_codex_timestamp = now
|
||||
|
||||
# Extract turn_id if present
|
||||
turn_id = message.get("turn_id", "")
|
||||
if turn_id:
|
||||
self._session.turn_id = turn_id
|
||||
self._session.session_id = (
|
||||
f"{self._session.thread_id}-{turn_id}"
|
||||
)
|
||||
|
||||
# Extract usage/tokens
|
||||
self._extract_usage(message)
|
||||
|
||||
# Build summary message
|
||||
summary = message.get("message", "")
|
||||
if not summary and msg_type == "notification":
|
||||
summary = message.get("text", "")
|
||||
if summary:
|
||||
self._session.last_codex_message = summary[:200]
|
||||
|
||||
return AgentEvent(
|
||||
event=msg_type,
|
||||
timestamp=now,
|
||||
codex_app_server_pid=self._session.codex_app_server_pid,
|
||||
usage=message.get("usage"),
|
||||
payload=message,
|
||||
)
|
||||
|
||||
def _extract_usage(self, message: dict[str, Any]) -> None:
|
||||
"""Extract token usage from a message."""
|
||||
usage = message.get("usage") or message.get("total_token_usage") or {}
|
||||
if not usage:
|
||||
return
|
||||
|
||||
input_tokens = usage.get("input_tokens", 0) or usage.get("prompt_tokens", 0)
|
||||
output_tokens = usage.get("output_tokens", 0) or usage.get("completion_tokens", 0)
|
||||
total_tokens = usage.get("total_tokens", 0)
|
||||
|
||||
if not total_tokens:
|
||||
total_tokens = input_tokens + output_tokens
|
||||
|
||||
# Use absolute totals with delta tracking
|
||||
if input_tokens > self._session.last_reported_input_tokens:
|
||||
delta_in = input_tokens - self._session.last_reported_input_tokens
|
||||
delta_out = output_tokens - self._session.last_reported_output_tokens
|
||||
delta_total = total_tokens - self._session.last_reported_total_tokens
|
||||
|
||||
self._session.codex_input_tokens += max(delta_in, 0)
|
||||
self._session.codex_output_tokens += max(delta_out, 0)
|
||||
self._session.codex_total_tokens += max(delta_total, 0)
|
||||
|
||||
self._session.last_reported_input_tokens = input_tokens
|
||||
self._session.last_reported_output_tokens = output_tokens
|
||||
self._session.last_reported_total_tokens = total_tokens
|
||||
@@ -0,0 +1,132 @@
|
||||
"""CLI entry point for the AI-Orchestrator."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import logging
|
||||
import signal
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from ai_orchestrator.orchestrator import Orchestrator
|
||||
from ai_orchestrator.watcher import WorkflowWatcher
|
||||
|
||||
|
||||
def setup_logging() -> None:
|
||||
"""Configure structured logging."""
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
|
||||
datefmt="%Y-%m-%dT%H:%M:%S",
|
||||
stream=sys.stderr,
|
||||
)
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
"""Parse command-line arguments."""
|
||||
parser = argparse.ArgumentParser(
|
||||
prog="ai-orchestrator",
|
||||
description="AI-Orchestrator: Orchestrate coding agents for project work.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"workflow_path",
|
||||
nargs="?",
|
||||
default="./WORKFLOW.md",
|
||||
help="Path to WORKFLOW.md (default: ./WORKFLOW.md)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--port",
|
||||
type=int,
|
||||
default=None,
|
||||
help="Enable HTTP server on this port",
|
||||
)
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
async def run(workflow_path: Path, port: int | None) -> None:
|
||||
"""Main async entry point."""
|
||||
logger = logging.getLogger("ai_orchestrator")
|
||||
|
||||
# Validate workflow path exists
|
||||
if not workflow_path.exists():
|
||||
logger.error(f"Workflow file not found: {workflow_path}")
|
||||
sys.exit(1)
|
||||
|
||||
# Create orchestrator
|
||||
orchestrator = Orchestrator(workflow_path=workflow_path, port=port)
|
||||
|
||||
# Set up workflow file watcher
|
||||
watcher = WorkflowWatcher(
|
||||
workflow_path=workflow_path,
|
||||
on_change=orchestrator._reload_workflow,
|
||||
)
|
||||
watcher.start()
|
||||
|
||||
# Handle shutdown signals
|
||||
loop = asyncio.get_event_loop()
|
||||
shutdown_event = asyncio.Event()
|
||||
|
||||
def handle_signal() -> None:
|
||||
logger.info("Shutdown signal received")
|
||||
shutdown_event.set()
|
||||
|
||||
for sig in (signal.SIGINT, signal.SIGTERM):
|
||||
try:
|
||||
loop.add_signal_handler(sig, handle_signal)
|
||||
except NotImplementedError:
|
||||
# Windows doesn't support add_signal_handler
|
||||
signal.signal(sig, lambda s, f: handle_signal())
|
||||
|
||||
# Start orchestrator
|
||||
try:
|
||||
await orchestrator.start()
|
||||
|
||||
# Start HTTP server if port specified
|
||||
http_server = None
|
||||
if port is not None or (orchestrator.config and orchestrator.config.server.port):
|
||||
effective_port = port or (
|
||||
orchestrator.config.server.port if orchestrator.config else None
|
||||
)
|
||||
if effective_port is not None:
|
||||
from ai_orchestrator.http_server import create_app
|
||||
import uvicorn
|
||||
|
||||
app = create_app(orchestrator)
|
||||
config = uvicorn.Config(
|
||||
app,
|
||||
host="127.0.0.1",
|
||||
port=effective_port,
|
||||
log_level="warning",
|
||||
)
|
||||
http_server = uvicorn.Server(config)
|
||||
asyncio.create_task(http_server.serve())
|
||||
logger.info(f"HTTP server started on http://127.0.0.1:{effective_port}")
|
||||
|
||||
# Wait for shutdown
|
||||
await shutdown_event.wait()
|
||||
|
||||
finally:
|
||||
# Cleanup
|
||||
watcher.stop()
|
||||
await orchestrator.stop()
|
||||
logger.info("Shutdown complete.")
|
||||
|
||||
|
||||
def main() -> None:
|
||||
"""CLI entry point."""
|
||||
setup_logging()
|
||||
args = parse_args()
|
||||
|
||||
workflow_path = Path(args.workflow_path).resolve()
|
||||
|
||||
try:
|
||||
asyncio.run(run(workflow_path, args.port))
|
||||
except KeyboardInterrupt:
|
||||
pass
|
||||
except SystemExit as e:
|
||||
sys.exit(e.code)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,271 @@
|
||||
"""Configuration layer — typed getters, defaults, env resolution, validation."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import tempfile
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ConfigValidationError(Exception):
|
||||
"""Raised when configuration validation fails."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
@dataclass
|
||||
class TrackerConfig:
|
||||
"""Tracker configuration."""
|
||||
|
||||
kind: str = ""
|
||||
endpoint: str = ""
|
||||
api_key: str = ""
|
||||
project_slug: str = ""
|
||||
active_states: list[str] = field(default_factory=lambda: ["Todo", "In Progress"])
|
||||
terminal_states: list[str] = field(
|
||||
default_factory=lambda: ["Closed", "Cancelled", "Canceled", "Duplicate", "Done"]
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class PollingConfig:
|
||||
"""Polling configuration."""
|
||||
|
||||
interval_ms: int = 30000
|
||||
|
||||
|
||||
@dataclass
|
||||
class WorkspaceConfig:
|
||||
"""Workspace configuration."""
|
||||
|
||||
root: str = ""
|
||||
|
||||
|
||||
@dataclass
|
||||
class HooksConfig:
|
||||
"""Hooks configuration."""
|
||||
|
||||
after_create: str | None = None
|
||||
before_run: str | None = None
|
||||
after_run: str | None = None
|
||||
before_remove: str | None = None
|
||||
timeout_ms: int = 60000
|
||||
|
||||
|
||||
@dataclass
|
||||
class AgentConfig:
|
||||
"""Agent configuration."""
|
||||
|
||||
max_concurrent_agents: int = 10
|
||||
max_turns: int = 20
|
||||
max_retry_backoff_ms: int = 300000
|
||||
max_concurrent_agents_by_state: dict[str, int] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass
|
||||
class CodexConfig:
|
||||
"""Codex configuration."""
|
||||
|
||||
command: str = "codex app-server"
|
||||
approval_policy: str = "auto-edit"
|
||||
thread_sandbox: str = "local-network"
|
||||
turn_sandbox_policy: str = "permissive"
|
||||
turn_timeout_ms: int = 3600000
|
||||
read_timeout_ms: int = 5000
|
||||
stall_timeout_ms: int = 300000
|
||||
|
||||
|
||||
@dataclass
|
||||
class ServerConfig:
|
||||
"""HTTP server extension configuration."""
|
||||
|
||||
port: int | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class ServiceConfig:
|
||||
"""Complete typed service configuration."""
|
||||
|
||||
tracker: TrackerConfig = field(default_factory=TrackerConfig)
|
||||
polling: PollingConfig = field(default_factory=PollingConfig)
|
||||
workspace: WorkspaceConfig = field(default_factory=WorkspaceConfig)
|
||||
hooks: HooksConfig = field(default_factory=HooksConfig)
|
||||
agent: AgentConfig = field(default_factory=AgentConfig)
|
||||
codex: CodexConfig = field(default_factory=CodexConfig)
|
||||
server: ServerConfig = field(default_factory=ServerConfig)
|
||||
|
||||
|
||||
def resolve_env_var(value: str) -> str:
|
||||
"""Resolve $VAR_NAME references in a string value.
|
||||
|
||||
Args:
|
||||
value: String that may contain $VAR_NAME.
|
||||
|
||||
Returns:
|
||||
Resolved string value.
|
||||
"""
|
||||
if not isinstance(value, str):
|
||||
return value
|
||||
if value.startswith("$"):
|
||||
var_name = value[1:]
|
||||
resolved = os.environ.get(var_name, "")
|
||||
return resolved
|
||||
return value
|
||||
|
||||
|
||||
def expand_path(value: str, workflow_dir: Path) -> str:
|
||||
"""Expand ~ and resolve relative paths.
|
||||
|
||||
Args:
|
||||
value: Path string to expand.
|
||||
workflow_dir: Directory containing WORKFLOW.md for relative path resolution.
|
||||
|
||||
Returns:
|
||||
Absolute path string.
|
||||
"""
|
||||
if not value:
|
||||
return str(Path(tempfile.gettempdir()) / "symphony_workspaces")
|
||||
|
||||
# Resolve env vars in path
|
||||
value = resolve_env_var(value)
|
||||
|
||||
# Expand ~
|
||||
expanded = Path(os.path.expanduser(value))
|
||||
|
||||
# Resolve relative paths against workflow directory
|
||||
if not expanded.is_absolute():
|
||||
expanded = workflow_dir / expanded
|
||||
|
||||
return str(expanded.resolve())
|
||||
|
||||
|
||||
def build_config(raw_config: dict[str, Any], workflow_dir: Path) -> ServiceConfig:
|
||||
"""Build typed ServiceConfig from raw YAML config dict.
|
||||
|
||||
Args:
|
||||
raw_config: Parsed YAML front matter.
|
||||
workflow_dir: Directory containing the WORKFLOW.md file.
|
||||
|
||||
Returns:
|
||||
Fully resolved ServiceConfig.
|
||||
"""
|
||||
config = ServiceConfig()
|
||||
|
||||
# Tracker
|
||||
tracker_raw = raw_config.get("tracker", {}) or {}
|
||||
config.tracker.kind = str(tracker_raw.get("kind", ""))
|
||||
config.tracker.project_slug = str(tracker_raw.get("project_slug", ""))
|
||||
|
||||
# Endpoint defaults
|
||||
if config.tracker.kind == "linear":
|
||||
config.tracker.endpoint = str(
|
||||
tracker_raw.get("endpoint", "https://api.linear.app/graphql")
|
||||
)
|
||||
elif config.tracker.kind == "orgmylife":
|
||||
config.tracker.endpoint = str(
|
||||
tracker_raw.get("endpoint", "http://localhost:8000")
|
||||
)
|
||||
else:
|
||||
config.tracker.endpoint = str(tracker_raw.get("endpoint", ""))
|
||||
|
||||
# API key with env var resolution
|
||||
api_key_raw = str(tracker_raw.get("api_key", "$LINEAR_API_KEY"))
|
||||
config.tracker.api_key = resolve_env_var(api_key_raw)
|
||||
|
||||
# States
|
||||
if "active_states" in tracker_raw:
|
||||
config.tracker.active_states = [str(s) for s in tracker_raw["active_states"]]
|
||||
if "terminal_states" in tracker_raw:
|
||||
config.tracker.terminal_states = [str(s) for s in tracker_raw["terminal_states"]]
|
||||
|
||||
# Polling
|
||||
polling_raw = raw_config.get("polling", {}) or {}
|
||||
config.polling.interval_ms = int(polling_raw.get("interval_ms", 30000))
|
||||
|
||||
# Workspace
|
||||
workspace_raw = raw_config.get("workspace", {}) or {}
|
||||
config.workspace.root = expand_path(
|
||||
str(workspace_raw.get("root", "")), workflow_dir
|
||||
)
|
||||
|
||||
# Hooks
|
||||
hooks_raw = raw_config.get("hooks", {}) or {}
|
||||
config.hooks.after_create = hooks_raw.get("after_create")
|
||||
config.hooks.before_run = hooks_raw.get("before_run")
|
||||
config.hooks.after_run = hooks_raw.get("after_run")
|
||||
config.hooks.before_remove = hooks_raw.get("before_remove")
|
||||
config.hooks.timeout_ms = int(hooks_raw.get("timeout_ms", 60000))
|
||||
|
||||
# Agent
|
||||
agent_raw = raw_config.get("agent", {}) or {}
|
||||
config.agent.max_concurrent_agents = int(agent_raw.get("max_concurrent_agents", 10))
|
||||
config.agent.max_turns = int(agent_raw.get("max_turns", 20))
|
||||
config.agent.max_retry_backoff_ms = int(agent_raw.get("max_retry_backoff_ms", 300000))
|
||||
|
||||
by_state_raw = agent_raw.get("max_concurrent_agents_by_state", {}) or {}
|
||||
for state_name, limit in by_state_raw.items():
|
||||
try:
|
||||
limit_int = int(limit)
|
||||
if limit_int > 0:
|
||||
config.agent.max_concurrent_agents_by_state[state_name.lower()] = limit_int
|
||||
except (ValueError, TypeError):
|
||||
pass # Ignore invalid entries per spec
|
||||
|
||||
# Codex
|
||||
codex_raw = raw_config.get("codex", {}) or {}
|
||||
config.codex.command = str(codex_raw.get("command", "codex app-server"))
|
||||
config.codex.approval_policy = str(codex_raw.get("approval_policy", "auto-edit"))
|
||||
config.codex.thread_sandbox = str(codex_raw.get("thread_sandbox", "local-network"))
|
||||
config.codex.turn_sandbox_policy = str(codex_raw.get("turn_sandbox_policy", "permissive"))
|
||||
config.codex.turn_timeout_ms = int(codex_raw.get("turn_timeout_ms", 3600000))
|
||||
config.codex.read_timeout_ms = int(codex_raw.get("read_timeout_ms", 5000))
|
||||
config.codex.stall_timeout_ms = int(codex_raw.get("stall_timeout_ms", 300000))
|
||||
|
||||
# Server (extension)
|
||||
server_raw = raw_config.get("server", {}) or {}
|
||||
port = server_raw.get("port")
|
||||
config.server.port = int(port) if port is not None else None
|
||||
|
||||
return config
|
||||
|
||||
|
||||
def validate_dispatch_config(config: ServiceConfig) -> list[str]:
|
||||
"""Validate configuration for dispatch readiness.
|
||||
|
||||
Args:
|
||||
config: The service configuration to validate.
|
||||
|
||||
Returns:
|
||||
List of validation error messages. Empty list means valid.
|
||||
"""
|
||||
errors: list[str] = []
|
||||
|
||||
if not config.tracker.kind:
|
||||
errors.append("tracker.kind is required")
|
||||
elif config.tracker.kind not in ("linear", "orgmylife"):
|
||||
errors.append(f"Unsupported tracker.kind: {config.tracker.kind}")
|
||||
|
||||
if config.tracker.kind == "linear" and not config.tracker.api_key:
|
||||
errors.append("tracker.api_key is required (set LINEAR_API_KEY env var)")
|
||||
|
||||
if config.tracker.kind == "linear" and not config.tracker.project_slug:
|
||||
errors.append("tracker.project_slug is required for Linear tracker")
|
||||
|
||||
if config.tracker.kind == "orgmylife" and not config.tracker.endpoint:
|
||||
errors.append("tracker.endpoint is required for OrgMyLife tracker")
|
||||
|
||||
if not config.codex.command:
|
||||
errors.append("codex.command must be non-empty")
|
||||
|
||||
if config.agent.max_turns < 1:
|
||||
errors.append("agent.max_turns must be a positive integer")
|
||||
|
||||
if config.hooks.timeout_ms < 0:
|
||||
errors.append("hooks.timeout_ms must be non-negative")
|
||||
|
||||
return errors
|
||||
@@ -0,0 +1,227 @@
|
||||
"""HTTP server extension — REST API and dashboard for observability."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from starlette.applications import Starlette
|
||||
from starlette.requests import Request
|
||||
from starlette.responses import HTMLResponse, JSONResponse, Response
|
||||
from starlette.routing import Route
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ai_orchestrator.orchestrator import Orchestrator
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def create_app(orchestrator: "Orchestrator") -> Starlette:
|
||||
"""Create the Starlette ASGI application.
|
||||
|
||||
Args:
|
||||
orchestrator: The orchestrator instance for state access.
|
||||
|
||||
Returns:
|
||||
Configured Starlette application.
|
||||
"""
|
||||
|
||||
async def dashboard(request: Request) -> Response:
|
||||
"""Serve the human-readable dashboard."""
|
||||
snapshot = orchestrator.get_snapshot()
|
||||
html = _render_dashboard(snapshot)
|
||||
return HTMLResponse(html)
|
||||
|
||||
async def api_state(request: Request) -> Response:
|
||||
"""GET /api/v1/state — current system state."""
|
||||
snapshot = orchestrator.get_snapshot()
|
||||
return JSONResponse(snapshot)
|
||||
|
||||
async def api_issue(request: Request) -> Response:
|
||||
"""GET /api/v1/{issue_identifier} — issue-specific details."""
|
||||
identifier = request.path_params["identifier"]
|
||||
|
||||
# Search running entries
|
||||
for entry in orchestrator.state.running.values():
|
||||
if entry.identifier == identifier:
|
||||
return JSONResponse({
|
||||
"issue_identifier": entry.identifier,
|
||||
"issue_id": entry.issue_id,
|
||||
"status": "running",
|
||||
"workspace": {
|
||||
"path": f"{orchestrator.config.workspace.root}/{entry.identifier}"
|
||||
if orchestrator.config
|
||||
else None,
|
||||
},
|
||||
"attempts": {
|
||||
"restart_count": entry.retry_attempt,
|
||||
"current_retry_attempt": entry.retry_attempt,
|
||||
},
|
||||
"running": {
|
||||
"session_id": entry.session.session_id,
|
||||
"turn_count": entry.session.turn_count,
|
||||
"state": entry.issue.state,
|
||||
"started_at": (
|
||||
entry.started_at.isoformat()
|
||||
if entry.started_at
|
||||
else None
|
||||
),
|
||||
"last_event": entry.session.last_codex_event,
|
||||
"last_message": entry.session.last_codex_message,
|
||||
"last_event_at": (
|
||||
entry.session.last_codex_timestamp.isoformat()
|
||||
if entry.session.last_codex_timestamp
|
||||
else None
|
||||
),
|
||||
"tokens": {
|
||||
"input_tokens": entry.session.codex_input_tokens,
|
||||
"output_tokens": entry.session.codex_output_tokens,
|
||||
"total_tokens": entry.session.codex_total_tokens,
|
||||
},
|
||||
},
|
||||
"retry": None,
|
||||
"last_error": None,
|
||||
})
|
||||
|
||||
# Search retry entries
|
||||
for retry in orchestrator.state.retry_attempts.values():
|
||||
if retry.identifier == identifier:
|
||||
return JSONResponse({
|
||||
"issue_identifier": retry.identifier,
|
||||
"issue_id": retry.issue_id,
|
||||
"status": "retrying",
|
||||
"workspace": {
|
||||
"path": f"{orchestrator.config.workspace.root}/{retry.identifier}"
|
||||
if orchestrator.config
|
||||
else None,
|
||||
},
|
||||
"attempts": {
|
||||
"restart_count": retry.attempt,
|
||||
"current_retry_attempt": retry.attempt,
|
||||
},
|
||||
"running": None,
|
||||
"retry": {
|
||||
"attempt": retry.attempt,
|
||||
"due_at": retry.due_at_ms,
|
||||
"error": retry.error,
|
||||
},
|
||||
"last_error": retry.error,
|
||||
})
|
||||
|
||||
return JSONResponse(
|
||||
{"error": {"code": "issue_not_found", "message": f"Issue {identifier} not found"}},
|
||||
status_code=404,
|
||||
)
|
||||
|
||||
async def api_refresh(request: Request) -> Response:
|
||||
"""POST /api/v1/refresh — trigger immediate poll cycle."""
|
||||
# Queue an immediate tick (best-effort)
|
||||
import asyncio
|
||||
from datetime import datetime, timezone
|
||||
|
||||
asyncio.create_task(orchestrator._tick())
|
||||
|
||||
return JSONResponse(
|
||||
{
|
||||
"queued": True,
|
||||
"coalesced": False,
|
||||
"requested_at": datetime.now(timezone.utc).isoformat(),
|
||||
"operations": ["poll", "reconcile"],
|
||||
},
|
||||
status_code=202,
|
||||
)
|
||||
|
||||
routes = [
|
||||
Route("/", dashboard),
|
||||
Route("/api/v1/state", api_state, methods=["GET"]),
|
||||
Route("/api/v1/refresh", api_refresh, methods=["POST"]),
|
||||
Route("/api/v1/{identifier}", api_issue, methods=["GET"]),
|
||||
]
|
||||
|
||||
return Starlette(routes=routes)
|
||||
|
||||
|
||||
def _render_dashboard(snapshot: dict) -> str:
|
||||
"""Render a simple HTML dashboard from the snapshot."""
|
||||
running_count = snapshot["counts"]["running"]
|
||||
retrying_count = snapshot["counts"]["retrying"]
|
||||
totals = snapshot["codex_totals"]
|
||||
|
||||
running_rows = ""
|
||||
for r in snapshot["running"]:
|
||||
running_rows += f"""
|
||||
<tr>
|
||||
<td>{r['issue_identifier']}</td>
|
||||
<td>{r['state']}</td>
|
||||
<td>{r['turn_count']}</td>
|
||||
<td>{r['last_event'] or '-'}</td>
|
||||
<td>{r['last_message'] or '-'}</td>
|
||||
<td>{r['tokens']['total_tokens']}</td>
|
||||
</tr>"""
|
||||
|
||||
retry_rows = ""
|
||||
for r in snapshot["retrying"]:
|
||||
retry_rows += f"""
|
||||
<tr>
|
||||
<td>{r['issue_identifier']}</td>
|
||||
<td>{r['attempt']}</td>
|
||||
<td>{r['error'] or '-'}</td>
|
||||
</tr>"""
|
||||
|
||||
return f"""<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>AI-Orchestrator Dashboard</title>
|
||||
<meta http-equiv="refresh" content="10">
|
||||
<style>
|
||||
body {{ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||
margin: 2rem; background: #1a1a2e; color: #e0e0e0; }}
|
||||
h1 {{ color: #00d4aa; }}
|
||||
h2 {{ color: #7c83ff; margin-top: 2rem; }}
|
||||
table {{ border-collapse: collapse; width: 100%; margin-top: 1rem; }}
|
||||
th, td {{ border: 1px solid #333; padding: 0.5rem 1rem; text-align: left; }}
|
||||
th {{ background: #16213e; color: #00d4aa; }}
|
||||
tr:nth-child(even) {{ background: #0f3460; }}
|
||||
.stats {{ display: flex; gap: 2rem; margin: 1rem 0; }}
|
||||
.stat {{ background: #16213e; padding: 1rem 2rem; border-radius: 8px; }}
|
||||
.stat-value {{ font-size: 2rem; font-weight: bold; color: #00d4aa; }}
|
||||
.stat-label {{ color: #888; font-size: 0.9rem; }}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>🎵 AI-Orchestrator</h1>
|
||||
<p>Generated: {snapshot['generated_at']}</p>
|
||||
|
||||
<div class="stats">
|
||||
<div class="stat">
|
||||
<div class="stat-value">{running_count}</div>
|
||||
<div class="stat-label">Running</div>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<div class="stat-value">{retrying_count}</div>
|
||||
<div class="stat-label">Retrying</div>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<div class="stat-value">{totals['total_tokens']:,}</div>
|
||||
<div class="stat-label">Total Tokens</div>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<div class="stat-value">{totals['seconds_running']:.0f}s</div>
|
||||
<div class="stat-label">Runtime</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2>Running Sessions</h2>
|
||||
<table>
|
||||
<tr><th>Issue</th><th>State</th><th>Turns</th><th>Last Event</th><th>Message</th><th>Tokens</th></tr>
|
||||
{running_rows or '<tr><td colspan="6">No active sessions</td></tr>'}
|
||||
</table>
|
||||
|
||||
<h2>Retry Queue</h2>
|
||||
<table>
|
||||
<tr><th>Issue</th><th>Attempt</th><th>Error</th></tr>
|
||||
{retry_rows or '<tr><td colspan="3">No retries queued</td></tr>'}
|
||||
</table>
|
||||
</body>
|
||||
</html>"""
|
||||
@@ -0,0 +1,174 @@
|
||||
"""Core domain models for the AI-Orchestrator."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from enum import Enum
|
||||
from typing import Any
|
||||
|
||||
|
||||
# --- Issue Model ---
|
||||
|
||||
|
||||
@dataclass
|
||||
class BlockerRef:
|
||||
"""A reference to a blocking issue."""
|
||||
|
||||
id: str | None = None
|
||||
identifier: str | None = None
|
||||
state: str | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class Issue:
|
||||
"""Normalized issue record from the tracker."""
|
||||
|
||||
id: str
|
||||
identifier: str
|
||||
title: str
|
||||
description: str | None = None
|
||||
priority: int | None = None
|
||||
state: str = ""
|
||||
branch_name: str | None = None
|
||||
url: str | None = None
|
||||
labels: list[str] = field(default_factory=list)
|
||||
blocked_by: list[BlockerRef] = field(default_factory=list)
|
||||
created_at: datetime | None = None
|
||||
updated_at: datetime | None = None
|
||||
|
||||
|
||||
# --- Workflow Model ---
|
||||
|
||||
|
||||
@dataclass
|
||||
class WorkflowDefinition:
|
||||
"""Parsed WORKFLOW.md payload."""
|
||||
|
||||
config: dict[str, Any]
|
||||
prompt_template: str
|
||||
|
||||
|
||||
# --- Workspace Model ---
|
||||
|
||||
|
||||
@dataclass
|
||||
class Workspace:
|
||||
"""Filesystem workspace assigned to one issue."""
|
||||
|
||||
path: str
|
||||
workspace_key: str
|
||||
created_now: bool = False
|
||||
|
||||
|
||||
# --- Run Attempt Model ---
|
||||
|
||||
|
||||
class RunStatus(Enum):
|
||||
"""Status of a run attempt."""
|
||||
|
||||
PREPARING_WORKSPACE = "preparing_workspace"
|
||||
BUILDING_PROMPT = "building_prompt"
|
||||
LAUNCHING_AGENT = "launching_agent"
|
||||
INITIALIZING_SESSION = "initializing_session"
|
||||
STREAMING_TURN = "streaming_turn"
|
||||
FINISHING = "finishing"
|
||||
SUCCEEDED = "succeeded"
|
||||
FAILED = "failed"
|
||||
TIMED_OUT = "timed_out"
|
||||
STALLED = "stalled"
|
||||
CANCELED = "canceled_by_reconciliation"
|
||||
|
||||
|
||||
@dataclass
|
||||
class RunAttempt:
|
||||
"""One execution attempt for one issue."""
|
||||
|
||||
issue_id: str
|
||||
issue_identifier: str
|
||||
attempt: int | None = None
|
||||
workspace_path: str = ""
|
||||
started_at: datetime | None = None
|
||||
status: RunStatus = RunStatus.PREPARING_WORKSPACE
|
||||
error: str | None = None
|
||||
|
||||
|
||||
# --- Live Session Model ---
|
||||
|
||||
|
||||
@dataclass
|
||||
class LiveSession:
|
||||
"""State tracked while a coding-agent subprocess is running."""
|
||||
|
||||
session_id: str = ""
|
||||
thread_id: str = ""
|
||||
turn_id: str = ""
|
||||
codex_app_server_pid: str | None = None
|
||||
last_codex_event: str | None = None
|
||||
last_codex_timestamp: datetime | None = None
|
||||
last_codex_message: str = ""
|
||||
codex_input_tokens: int = 0
|
||||
codex_output_tokens: int = 0
|
||||
codex_total_tokens: int = 0
|
||||
last_reported_input_tokens: int = 0
|
||||
last_reported_output_tokens: int = 0
|
||||
last_reported_total_tokens: int = 0
|
||||
turn_count: int = 0
|
||||
|
||||
|
||||
# --- Retry Entry Model ---
|
||||
|
||||
|
||||
@dataclass
|
||||
class RetryEntry:
|
||||
"""Scheduled retry state for an issue."""
|
||||
|
||||
issue_id: str
|
||||
identifier: str
|
||||
attempt: int
|
||||
due_at_ms: float
|
||||
timer_handle: Any = None
|
||||
error: str | None = None
|
||||
|
||||
|
||||
# --- Running Entry Model ---
|
||||
|
||||
|
||||
@dataclass
|
||||
class RunningEntry:
|
||||
"""Entry in the orchestrator's running map."""
|
||||
|
||||
issue_id: str
|
||||
identifier: str
|
||||
issue: Issue
|
||||
session: LiveSession = field(default_factory=LiveSession)
|
||||
retry_attempt: int = 0
|
||||
started_at: datetime | None = None
|
||||
worker_task: Any = None
|
||||
|
||||
|
||||
# --- Orchestrator State ---
|
||||
|
||||
|
||||
@dataclass
|
||||
class CodexTotals:
|
||||
"""Aggregate token and runtime totals."""
|
||||
|
||||
input_tokens: int = 0
|
||||
output_tokens: int = 0
|
||||
total_tokens: int = 0
|
||||
seconds_running: float = 0.0
|
||||
|
||||
|
||||
@dataclass
|
||||
class OrchestratorState:
|
||||
"""Single authoritative in-memory state owned by the orchestrator."""
|
||||
|
||||
poll_interval_ms: int = 30000
|
||||
max_concurrent_agents: int = 10
|
||||
running: dict[str, RunningEntry] = field(default_factory=dict)
|
||||
claimed: set[str] = field(default_factory=set)
|
||||
retry_attempts: dict[str, RetryEntry] = field(default_factory=dict)
|
||||
completed: set[str] = field(default_factory=set)
|
||||
codex_totals: CodexTotals = field(default_factory=CodexTotals)
|
||||
codex_rate_limits: dict[str, Any] | None = None
|
||||
@@ -0,0 +1,779 @@
|
||||
"""Orchestrator — poll loop, dispatch, concurrency, retries, reconciliation."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from ai_orchestrator.agent_runner import (
|
||||
AgentError,
|
||||
AgentSession,
|
||||
AgentTimeoutError,
|
||||
AgentEvent,
|
||||
)
|
||||
from ai_orchestrator.config import (
|
||||
ServiceConfig,
|
||||
build_config,
|
||||
validate_dispatch_config,
|
||||
)
|
||||
from ai_orchestrator.models import (
|
||||
CodexTotals,
|
||||
Issue,
|
||||
LiveSession,
|
||||
OrchestratorState,
|
||||
RetryEntry,
|
||||
RunningEntry,
|
||||
)
|
||||
from ai_orchestrator.prompt import (
|
||||
TemplateRenderError,
|
||||
build_continuation_prompt,
|
||||
render_prompt,
|
||||
)
|
||||
from ai_orchestrator.tracker import LinearClient, TrackerError
|
||||
from ai_orchestrator.tracker_orgmylife import OrgMyLifeClient
|
||||
from ai_orchestrator.workflow import WorkflowDefinition, WorkflowError, load_workflow
|
||||
from ai_orchestrator.workspace import (
|
||||
WorkspaceError,
|
||||
cleanup_workspace,
|
||||
create_workspace,
|
||||
run_hook,
|
||||
run_hook_best_effort,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class Orchestrator:
|
||||
"""Main orchestration engine.
|
||||
|
||||
Owns the poll tick, in-memory runtime state, and dispatch decisions.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
workflow_path: Path,
|
||||
port: int | None = None,
|
||||
) -> None:
|
||||
self._workflow_path = workflow_path.resolve()
|
||||
self._workflow_dir = self._workflow_path.parent
|
||||
self._port = port
|
||||
|
||||
self._state = OrchestratorState()
|
||||
self._config: ServiceConfig | None = None
|
||||
self._workflow: WorkflowDefinition | None = None
|
||||
self._tracker: LinearClient | None = None
|
||||
self._dispatch_lock = asyncio.Lock() # Prevent race conditions
|
||||
|
||||
self._running = False
|
||||
self._tick_task: asyncio.Task[None] | None = None
|
||||
self._observer_callbacks: list[Any] = []
|
||||
|
||||
@property
|
||||
def state(self) -> OrchestratorState:
|
||||
"""Get the current orchestrator state."""
|
||||
return self._state
|
||||
|
||||
@property
|
||||
def config(self) -> ServiceConfig | None:
|
||||
"""Get the current service configuration."""
|
||||
return self._config
|
||||
|
||||
async def start(self) -> None:
|
||||
"""Start the orchestrator service.
|
||||
|
||||
Raises:
|
||||
WorkflowError: If workflow cannot be loaded.
|
||||
ConfigValidationError: If config validation fails at startup.
|
||||
"""
|
||||
logger.info("Starting AI-Orchestrator...")
|
||||
|
||||
# Load and validate workflow
|
||||
self._reload_workflow()
|
||||
|
||||
assert self._config is not None
|
||||
|
||||
# Validate config
|
||||
errors = validate_dispatch_config(self._config)
|
||||
if errors:
|
||||
error_msg = "; ".join(errors)
|
||||
logger.error(f"Startup validation failed: {error_msg}")
|
||||
raise SystemExit(f"Configuration errors: {error_msg}")
|
||||
|
||||
# Initialize tracker client based on tracker.kind
|
||||
if self._config.tracker.kind == "orgmylife":
|
||||
self._tracker = OrgMyLifeClient(self._config)
|
||||
else:
|
||||
self._tracker = LinearClient(self._config)
|
||||
|
||||
# Apply config to state
|
||||
self._state.poll_interval_ms = self._config.polling.interval_ms
|
||||
self._state.max_concurrent_agents = self._config.agent.max_concurrent_agents
|
||||
|
||||
# Startup terminal workspace cleanup
|
||||
await self._startup_cleanup()
|
||||
|
||||
# Start polling
|
||||
self._running = True
|
||||
self._tick_task = asyncio.create_task(self._poll_loop())
|
||||
|
||||
logger.info(
|
||||
f"Orchestrator started. Polling every {self._state.poll_interval_ms}ms, "
|
||||
f"max {self._state.max_concurrent_agents} concurrent agents."
|
||||
)
|
||||
|
||||
async def stop(self) -> None:
|
||||
"""Stop the orchestrator gracefully."""
|
||||
logger.info("Stopping orchestrator...")
|
||||
self._running = False
|
||||
|
||||
if self._tick_task:
|
||||
self._tick_task.cancel()
|
||||
try:
|
||||
await self._tick_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
# Stop all running workers
|
||||
for issue_id, entry in list(self._state.running.items()):
|
||||
if entry.worker_task and not entry.worker_task.done():
|
||||
entry.worker_task.cancel()
|
||||
|
||||
# Cancel retry timers
|
||||
for issue_id, retry in list(self._state.retry_attempts.items()):
|
||||
if retry.timer_handle:
|
||||
retry.timer_handle.cancel()
|
||||
|
||||
# Close tracker client
|
||||
if self._tracker:
|
||||
await self._tracker.close()
|
||||
|
||||
logger.info("Orchestrator stopped.")
|
||||
|
||||
def _reload_workflow(self) -> bool:
|
||||
"""Reload workflow from disk.
|
||||
|
||||
Returns:
|
||||
True if reload succeeded, False otherwise.
|
||||
"""
|
||||
try:
|
||||
self._workflow = load_workflow(self._workflow_path)
|
||||
self._config = build_config(
|
||||
self._workflow.config, self._workflow_dir
|
||||
)
|
||||
|
||||
# Re-apply dynamic settings
|
||||
self._state.poll_interval_ms = self._config.polling.interval_ms
|
||||
self._state.max_concurrent_agents = self._config.agent.max_concurrent_agents
|
||||
|
||||
logger.info("Workflow reloaded successfully")
|
||||
return True
|
||||
except WorkflowError as e:
|
||||
logger.error(f"Workflow reload failed: {e}")
|
||||
return False
|
||||
|
||||
async def _startup_cleanup(self) -> None:
|
||||
"""Clean workspaces for issues in terminal states."""
|
||||
assert self._config is not None
|
||||
assert self._tracker is not None
|
||||
|
||||
try:
|
||||
terminal_issues = await self._tracker.fetch_issues_by_states(
|
||||
self._config.tracker.terminal_states
|
||||
)
|
||||
for issue in terminal_issues:
|
||||
await cleanup_workspace(self._config, issue.identifier)
|
||||
except TrackerError as e:
|
||||
logger.warning(f"Startup terminal cleanup failed: {e}")
|
||||
|
||||
async def _poll_loop(self) -> None:
|
||||
"""Main polling loop."""
|
||||
# Immediate first tick
|
||||
await self._tick()
|
||||
|
||||
while self._running:
|
||||
await asyncio.sleep(self._state.poll_interval_ms / 1000.0)
|
||||
if self._running:
|
||||
await self._tick()
|
||||
|
||||
async def _tick(self) -> None:
|
||||
"""Execute one poll-and-dispatch tick."""
|
||||
assert self._config is not None
|
||||
assert self._tracker is not None
|
||||
|
||||
# Step 1: Reconcile running issues
|
||||
await self._reconcile()
|
||||
|
||||
# Step 2: Validate config
|
||||
errors = validate_dispatch_config(self._config)
|
||||
if errors:
|
||||
logger.error(f"Dispatch validation failed: {'; '.join(errors)}")
|
||||
self._notify_observers()
|
||||
return
|
||||
|
||||
# Step 3: Fetch candidate issues
|
||||
try:
|
||||
candidates = await self._tracker.fetch_candidate_issues()
|
||||
except TrackerError as e:
|
||||
logger.error(f"Failed to fetch candidates: {e}")
|
||||
self._notify_observers()
|
||||
return
|
||||
|
||||
# Step 4: Sort by dispatch priority
|
||||
candidates = self._sort_for_dispatch(candidates)
|
||||
|
||||
# Step 5: Dispatch eligible issues
|
||||
for issue in candidates:
|
||||
if self._available_slots() <= 0:
|
||||
break
|
||||
if self._should_dispatch(issue):
|
||||
await self._dispatch_issue(issue, attempt=None)
|
||||
|
||||
# Step 6: Notify observers
|
||||
self._notify_observers()
|
||||
|
||||
def _available_slots(self) -> int:
|
||||
"""Calculate available global concurrency slots."""
|
||||
return max(
|
||||
self._state.max_concurrent_agents - len(self._state.running), 0
|
||||
)
|
||||
|
||||
def _available_slots_for_state(self, state: str) -> int:
|
||||
"""Calculate available per-state concurrency slots."""
|
||||
assert self._config is not None
|
||||
|
||||
state_lower = state.lower()
|
||||
by_state = self._config.agent.max_concurrent_agents_by_state
|
||||
|
||||
if state_lower not in by_state:
|
||||
return self._available_slots()
|
||||
|
||||
max_for_state = by_state[state_lower]
|
||||
current_in_state = sum(
|
||||
1
|
||||
for entry in self._state.running.values()
|
||||
if entry.issue.state.lower() == state_lower
|
||||
)
|
||||
return max(max_for_state - current_in_state, 0)
|
||||
|
||||
def _should_dispatch(self, issue: Issue) -> bool:
|
||||
"""Check if an issue is eligible for dispatch."""
|
||||
assert self._config is not None
|
||||
|
||||
# Required fields
|
||||
if not all([issue.id, issue.identifier, issue.title, issue.state]):
|
||||
return False
|
||||
|
||||
# State checks
|
||||
state_lower = issue.state.lower()
|
||||
active_lower = [s.lower() for s in self._config.tracker.active_states]
|
||||
terminal_lower = [s.lower() for s in self._config.tracker.terminal_states]
|
||||
|
||||
if state_lower not in active_lower:
|
||||
return False
|
||||
if state_lower in terminal_lower:
|
||||
return False
|
||||
|
||||
# Already running or claimed
|
||||
if issue.id in self._state.running:
|
||||
return False
|
||||
if issue.id in self._state.claimed:
|
||||
return False
|
||||
|
||||
# Per-state concurrency
|
||||
if self._available_slots_for_state(issue.state) <= 0:
|
||||
return False
|
||||
|
||||
# Blocker rule for Todo state
|
||||
if state_lower == "todo" and issue.blocked_by:
|
||||
terminal_states_lower = set(terminal_lower)
|
||||
for blocker in issue.blocked_by:
|
||||
blocker_state = (blocker.state or "").lower()
|
||||
if blocker_state not in terminal_states_lower:
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
def _sort_for_dispatch(self, issues: list[Issue]) -> list[Issue]:
|
||||
"""Sort issues by dispatch priority."""
|
||||
|
||||
def sort_key(issue: Issue) -> tuple:
|
||||
# Priority ascending (1..4 preferred, None sorts last)
|
||||
priority = issue.priority if issue.priority is not None else 999
|
||||
# Created at oldest first
|
||||
created = issue.created_at or datetime.max.replace(tzinfo=timezone.utc)
|
||||
# Identifier as tiebreaker
|
||||
return (priority, created, issue.identifier)
|
||||
|
||||
return sorted(issues, key=sort_key)
|
||||
|
||||
async def _dispatch_issue(
|
||||
self, issue: Issue, attempt: int | None
|
||||
) -> None:
|
||||
"""Dispatch a single issue for execution."""
|
||||
logger.info(
|
||||
f"Dispatching {issue.identifier} "
|
||||
f"(attempt={'first' if attempt is None else attempt})"
|
||||
)
|
||||
|
||||
# Claim the issue
|
||||
self._state.claimed.add(issue.id)
|
||||
|
||||
# Remove from retry queue if present
|
||||
if issue.id in self._state.retry_attempts:
|
||||
retry = self._state.retry_attempts.pop(issue.id)
|
||||
if retry.timer_handle:
|
||||
retry.timer_handle.cancel()
|
||||
|
||||
# Mark as in_progress in the tracker (OrgMyLife two-way sync)
|
||||
if self._config and self._config.tracker.kind == "orgmylife":
|
||||
from ai_orchestrator.tracker_orgmylife import OrgMyLifeClient
|
||||
if isinstance(self._tracker, OrgMyLifeClient):
|
||||
await self._tracker.update_task_state(issue.id, "in_progress")
|
||||
|
||||
# Create running entry
|
||||
entry = RunningEntry(
|
||||
issue_id=issue.id,
|
||||
identifier=issue.identifier,
|
||||
issue=issue,
|
||||
session=LiveSession(),
|
||||
retry_attempt=attempt or 0,
|
||||
started_at=datetime.now(timezone.utc),
|
||||
)
|
||||
|
||||
# Spawn worker task
|
||||
entry.worker_task = asyncio.create_task(
|
||||
self._run_worker(issue, attempt)
|
||||
)
|
||||
self._state.running[issue.id] = entry
|
||||
|
||||
async def _run_worker(self, issue: Issue, attempt: int | None) -> None:
|
||||
"""Worker task: workspace + prompt + agent turns."""
|
||||
assert self._config is not None
|
||||
assert self._workflow is not None
|
||||
|
||||
try:
|
||||
# Create/reuse workspace
|
||||
workspace = await create_workspace(self._config, issue.identifier)
|
||||
|
||||
# Run before_run hook
|
||||
if self._config.hooks.before_run:
|
||||
await run_hook(
|
||||
self._config.hooks.before_run,
|
||||
Path(workspace.path),
|
||||
self._config.hooks.timeout_ms,
|
||||
)
|
||||
|
||||
# Start agent session
|
||||
agent = AgentSession(self._config, workspace.path, issue)
|
||||
session = await agent.start()
|
||||
|
||||
# Update running entry with session info
|
||||
if issue.id in self._state.running:
|
||||
self._state.running[issue.id].session = session
|
||||
|
||||
# Turn loop
|
||||
max_turns = self._config.agent.max_turns
|
||||
for turn_number in range(1, max_turns + 1):
|
||||
# Build prompt
|
||||
if turn_number == 1:
|
||||
prompt = render_prompt(
|
||||
self._workflow.prompt_template, issue, attempt
|
||||
)
|
||||
else:
|
||||
prompt = build_continuation_prompt(
|
||||
issue, turn_number, max_turns
|
||||
)
|
||||
|
||||
# Run turn
|
||||
result = await agent.run_turn(
|
||||
prompt,
|
||||
on_event=self._on_agent_event,
|
||||
)
|
||||
|
||||
if not result.success:
|
||||
await agent.stop()
|
||||
await run_hook_best_effort(
|
||||
self._config.hooks.after_run,
|
||||
Path(workspace.path),
|
||||
self._config.hooks.timeout_ms,
|
||||
)
|
||||
raise AgentError(result.error or "Turn failed")
|
||||
|
||||
# Re-check issue state
|
||||
try:
|
||||
refreshed = await self._tracker.fetch_issue_states_by_ids(
|
||||
[issue.id]
|
||||
)
|
||||
if refreshed:
|
||||
current_state = refreshed[0].state.lower()
|
||||
active_lower = [
|
||||
s.lower()
|
||||
for s in self._config.tracker.active_states
|
||||
]
|
||||
if current_state not in active_lower:
|
||||
break
|
||||
# Update issue state
|
||||
issue.state = refreshed[0].state
|
||||
except TrackerError:
|
||||
# State refresh failed — stop the worker
|
||||
await agent.stop()
|
||||
await run_hook_best_effort(
|
||||
self._config.hooks.after_run,
|
||||
Path(workspace.path),
|
||||
self._config.hooks.timeout_ms,
|
||||
)
|
||||
raise AgentError("Issue state refresh failed")
|
||||
|
||||
# Clean exit
|
||||
await agent.stop()
|
||||
await run_hook_best_effort(
|
||||
self._config.hooks.after_run,
|
||||
Path(workspace.path),
|
||||
self._config.hooks.timeout_ms,
|
||||
)
|
||||
|
||||
# Normal exit — schedule continuation retry
|
||||
self._on_worker_exit(issue.id, normal=True)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Worker failed for {issue.identifier}: {e}",
|
||||
exc_info=True,
|
||||
)
|
||||
self._on_worker_exit(issue.id, normal=False, error=str(e))
|
||||
|
||||
def _on_worker_exit(
|
||||
self, issue_id: str, normal: bool, error: str | None = None
|
||||
) -> None:
|
||||
"""Handle worker task completion."""
|
||||
entry = self._state.running.pop(issue_id, None)
|
||||
if not entry:
|
||||
return
|
||||
|
||||
# Add runtime seconds to totals
|
||||
if entry.started_at:
|
||||
elapsed = (
|
||||
datetime.now(timezone.utc) - entry.started_at
|
||||
).total_seconds()
|
||||
self._state.codex_totals.seconds_running += elapsed
|
||||
|
||||
# Add token totals
|
||||
self._state.codex_totals.input_tokens += entry.session.codex_input_tokens
|
||||
self._state.codex_totals.output_tokens += entry.session.codex_output_tokens
|
||||
self._state.codex_totals.total_tokens += entry.session.codex_total_tokens
|
||||
|
||||
if normal:
|
||||
self._state.completed.add(issue_id)
|
||||
# Schedule short continuation retry
|
||||
self._schedule_retry(
|
||||
issue_id,
|
||||
attempt=1,
|
||||
identifier=entry.identifier,
|
||||
delay_ms=1000,
|
||||
error=None,
|
||||
)
|
||||
else:
|
||||
# Exponential backoff retry
|
||||
next_attempt = entry.retry_attempt + 1
|
||||
self._schedule_retry(
|
||||
issue_id,
|
||||
attempt=next_attempt,
|
||||
identifier=entry.identifier,
|
||||
delay_ms=self._compute_backoff(next_attempt),
|
||||
error=error,
|
||||
)
|
||||
|
||||
def _compute_backoff(self, attempt: int) -> int:
|
||||
"""Compute exponential backoff delay.
|
||||
|
||||
Formula: min(10000 * 2^(attempt-1), max_retry_backoff_ms)
|
||||
"""
|
||||
assert self._config is not None
|
||||
delay = 10000 * (2 ** (attempt - 1))
|
||||
return min(delay, self._config.agent.max_retry_backoff_ms)
|
||||
|
||||
def _schedule_retry(
|
||||
self,
|
||||
issue_id: str,
|
||||
attempt: int,
|
||||
identifier: str,
|
||||
delay_ms: int,
|
||||
error: str | None,
|
||||
) -> None:
|
||||
"""Schedule a retry for an issue."""
|
||||
# Cancel existing retry timer
|
||||
if issue_id in self._state.retry_attempts:
|
||||
old = self._state.retry_attempts[issue_id]
|
||||
if old.timer_handle:
|
||||
old.timer_handle.cancel()
|
||||
|
||||
due_at_ms = time.monotonic() * 1000 + delay_ms
|
||||
|
||||
# Create timer
|
||||
loop = asyncio.get_event_loop()
|
||||
timer = loop.call_later(
|
||||
delay_ms / 1000.0,
|
||||
lambda: asyncio.create_task(self._on_retry_timer(issue_id)),
|
||||
)
|
||||
|
||||
self._state.retry_attempts[issue_id] = RetryEntry(
|
||||
issue_id=issue_id,
|
||||
identifier=identifier,
|
||||
attempt=attempt,
|
||||
due_at_ms=due_at_ms,
|
||||
timer_handle=timer,
|
||||
error=error,
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"Scheduled retry for {identifier}: "
|
||||
f"attempt={attempt}, delay={delay_ms}ms"
|
||||
+ (f", error={error}" if error else "")
|
||||
)
|
||||
|
||||
async def _on_retry_timer(self, issue_id: str) -> None:
|
||||
"""Handle a retry timer firing."""
|
||||
async with self._dispatch_lock:
|
||||
await self._on_retry_timer_inner(issue_id)
|
||||
|
||||
async def _on_retry_timer_inner(self, issue_id: str) -> None:
|
||||
"""Inner retry logic (called under lock)."""
|
||||
assert self._config is not None
|
||||
assert self._tracker is not None
|
||||
|
||||
retry = self._state.retry_attempts.pop(issue_id, None)
|
||||
if not retry:
|
||||
return
|
||||
|
||||
# Fetch active candidates
|
||||
try:
|
||||
candidates = await self._tracker.fetch_candidate_issues()
|
||||
except TrackerError as e:
|
||||
# Re-schedule retry
|
||||
self._schedule_retry(
|
||||
issue_id,
|
||||
attempt=retry.attempt + 1,
|
||||
identifier=retry.identifier,
|
||||
delay_ms=self._compute_backoff(retry.attempt + 1),
|
||||
error=f"retry poll failed: {e}",
|
||||
)
|
||||
return
|
||||
|
||||
# Find the issue
|
||||
issue = next((i for i in candidates if i.id == issue_id), None)
|
||||
|
||||
if issue is None:
|
||||
# Issue no longer active — release claim
|
||||
self._state.claimed.discard(issue_id)
|
||||
logger.info(f"Released claim on {retry.identifier} (no longer active)")
|
||||
return
|
||||
|
||||
# Check slots
|
||||
if self._available_slots() <= 0:
|
||||
self._schedule_retry(
|
||||
issue_id,
|
||||
attempt=retry.attempt + 1,
|
||||
identifier=retry.identifier,
|
||||
delay_ms=self._compute_backoff(retry.attempt + 1),
|
||||
error="no available orchestrator slots",
|
||||
)
|
||||
return
|
||||
|
||||
# Re-dispatch
|
||||
await self._dispatch_issue(issue, attempt=retry.attempt)
|
||||
|
||||
async def _reconcile(self) -> None:
|
||||
"""Reconcile running issues with tracker state."""
|
||||
assert self._config is not None
|
||||
assert self._tracker is not None
|
||||
|
||||
if not self._state.running:
|
||||
return
|
||||
|
||||
# Part A: Stall detection
|
||||
stall_timeout_ms = self._config.codex.stall_timeout_ms
|
||||
if stall_timeout_ms > 0:
|
||||
now = datetime.now(timezone.utc)
|
||||
for issue_id, entry in list(self._state.running.items()):
|
||||
reference_time = (
|
||||
entry.session.last_codex_timestamp or entry.started_at
|
||||
)
|
||||
if reference_time:
|
||||
elapsed_ms = (now - reference_time).total_seconds() * 1000
|
||||
if elapsed_ms > stall_timeout_ms:
|
||||
logger.warning(
|
||||
f"Stall detected for {entry.identifier} "
|
||||
f"({elapsed_ms:.0f}ms since last event)"
|
||||
)
|
||||
await self._terminate_worker(issue_id, cleanup=False)
|
||||
self._schedule_retry(
|
||||
issue_id,
|
||||
attempt=entry.retry_attempt + 1,
|
||||
identifier=entry.identifier,
|
||||
delay_ms=self._compute_backoff(
|
||||
entry.retry_attempt + 1
|
||||
),
|
||||
error="stall detected",
|
||||
)
|
||||
|
||||
# Part B: Tracker state refresh
|
||||
running_ids = list(self._state.running.keys())
|
||||
if not running_ids:
|
||||
return
|
||||
|
||||
try:
|
||||
refreshed = await self._tracker.fetch_issue_states_by_ids(running_ids)
|
||||
except TrackerError as e:
|
||||
logger.debug(f"State refresh failed, keeping workers: {e}")
|
||||
return
|
||||
|
||||
terminal_lower = {
|
||||
s.lower() for s in self._config.tracker.terminal_states
|
||||
}
|
||||
active_lower = {
|
||||
s.lower() for s in self._config.tracker.active_states
|
||||
}
|
||||
|
||||
refreshed_map = {issue.id: issue for issue in refreshed}
|
||||
|
||||
for issue_id in running_ids:
|
||||
if issue_id not in self._state.running:
|
||||
continue # Already removed by stall detection
|
||||
|
||||
issue = refreshed_map.get(issue_id)
|
||||
if not issue:
|
||||
continue
|
||||
|
||||
state_lower = issue.state.lower()
|
||||
|
||||
if state_lower in terminal_lower:
|
||||
await self._terminate_worker(issue_id, cleanup=True)
|
||||
elif state_lower in active_lower:
|
||||
# Update the issue snapshot
|
||||
self._state.running[issue_id].issue.state = issue.state
|
||||
else:
|
||||
# Non-active, non-terminal — stop without cleanup
|
||||
await self._terminate_worker(issue_id, cleanup=False)
|
||||
|
||||
async def _terminate_worker(
|
||||
self, issue_id: str, cleanup: bool
|
||||
) -> None:
|
||||
"""Terminate a running worker."""
|
||||
assert self._config is not None
|
||||
|
||||
entry = self._state.running.pop(issue_id, None)
|
||||
if not entry:
|
||||
return
|
||||
|
||||
# Cancel the worker task
|
||||
if entry.worker_task and not entry.worker_task.done():
|
||||
entry.worker_task.cancel()
|
||||
|
||||
# Release claim
|
||||
self._state.claimed.discard(issue_id)
|
||||
|
||||
# Add runtime
|
||||
if entry.started_at:
|
||||
elapsed = (
|
||||
datetime.now(timezone.utc) - entry.started_at
|
||||
).total_seconds()
|
||||
self._state.codex_totals.seconds_running += elapsed
|
||||
|
||||
logger.info(
|
||||
f"Terminated worker for {entry.identifier} "
|
||||
f"(cleanup={'yes' if cleanup else 'no'})"
|
||||
)
|
||||
|
||||
# Cleanup workspace if terminal
|
||||
if cleanup:
|
||||
await cleanup_workspace(self._config, entry.identifier)
|
||||
|
||||
def _on_agent_event(self, issue_id: str, event: AgentEvent) -> None:
|
||||
"""Handle an agent event callback."""
|
||||
if issue_id in self._state.running:
|
||||
entry = self._state.running[issue_id]
|
||||
entry.session.last_codex_event = event.event
|
||||
entry.session.last_codex_timestamp = event.timestamp
|
||||
|
||||
if event.codex_app_server_pid:
|
||||
entry.session.codex_app_server_pid = event.codex_app_server_pid
|
||||
|
||||
# Update rate limits if present
|
||||
if event.payload and "rate_limits" in event.payload:
|
||||
self._state.codex_rate_limits = event.payload["rate_limits"]
|
||||
|
||||
def _notify_observers(self) -> None:
|
||||
"""Notify registered observer callbacks."""
|
||||
for callback in self._observer_callbacks:
|
||||
try:
|
||||
callback(self._state)
|
||||
except Exception as e:
|
||||
logger.warning(f"Observer callback failed: {e}")
|
||||
|
||||
def get_snapshot(self) -> dict[str, Any]:
|
||||
"""Get a runtime snapshot for monitoring/API.
|
||||
|
||||
Returns:
|
||||
Dict with running, retrying, totals, and rate limits.
|
||||
"""
|
||||
now = datetime.now(timezone.utc)
|
||||
|
||||
running_rows = []
|
||||
for entry in self._state.running.values():
|
||||
running_rows.append({
|
||||
"issue_id": entry.issue_id,
|
||||
"issue_identifier": entry.identifier,
|
||||
"state": entry.issue.state,
|
||||
"session_id": entry.session.session_id,
|
||||
"turn_count": entry.session.turn_count,
|
||||
"last_event": entry.session.last_codex_event,
|
||||
"last_message": entry.session.last_codex_message,
|
||||
"started_at": entry.started_at.isoformat() if entry.started_at else None,
|
||||
"last_event_at": (
|
||||
entry.session.last_codex_timestamp.isoformat()
|
||||
if entry.session.last_codex_timestamp
|
||||
else None
|
||||
),
|
||||
"tokens": {
|
||||
"input_tokens": entry.session.codex_input_tokens,
|
||||
"output_tokens": entry.session.codex_output_tokens,
|
||||
"total_tokens": entry.session.codex_total_tokens,
|
||||
},
|
||||
})
|
||||
|
||||
retry_rows = []
|
||||
for retry in self._state.retry_attempts.values():
|
||||
retry_rows.append({
|
||||
"issue_id": retry.issue_id,
|
||||
"issue_identifier": retry.identifier,
|
||||
"attempt": retry.attempt,
|
||||
"due_at": retry.due_at_ms,
|
||||
"error": retry.error,
|
||||
})
|
||||
|
||||
# Compute live seconds_running including active sessions
|
||||
total_seconds = self._state.codex_totals.seconds_running
|
||||
for entry in self._state.running.values():
|
||||
if entry.started_at:
|
||||
total_seconds += (now - entry.started_at).total_seconds()
|
||||
|
||||
return {
|
||||
"generated_at": now.isoformat(),
|
||||
"counts": {
|
||||
"running": len(self._state.running),
|
||||
"retrying": len(self._state.retry_attempts),
|
||||
},
|
||||
"running": running_rows,
|
||||
"retrying": retry_rows,
|
||||
"codex_totals": {
|
||||
"input_tokens": self._state.codex_totals.input_tokens,
|
||||
"output_tokens": self._state.codex_totals.output_tokens,
|
||||
"total_tokens": self._state.codex_totals.total_tokens,
|
||||
"seconds_running": round(total_seconds, 1),
|
||||
},
|
||||
"rate_limits": self._state.codex_rate_limits,
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
"""Prompt builder — renders issue prompts from workflow templates."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from dataclasses import asdict
|
||||
from typing import Any
|
||||
|
||||
from jinja2 import Environment, StrictUndefined, TemplateSyntaxError, UndefinedError
|
||||
|
||||
from ai_orchestrator.models import Issue
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class TemplateParseError(Exception):
|
||||
"""Raised when the prompt template cannot be parsed."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class TemplateRenderError(Exception):
|
||||
"""Raised when prompt rendering fails (unknown variable/filter)."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
# Default fallback prompt when workflow body is empty
|
||||
DEFAULT_PROMPT = "You are working on an issue from Linear."
|
||||
|
||||
|
||||
def render_prompt(
|
||||
template_str: str,
|
||||
issue: Issue,
|
||||
attempt: int | None = None,
|
||||
) -> str:
|
||||
"""Render a prompt from the workflow template and issue context.
|
||||
|
||||
Uses Jinja2 with strict undefined checking (unknown variables fail).
|
||||
|
||||
Args:
|
||||
template_str: The prompt template string from WORKFLOW.md body.
|
||||
issue: The normalized issue object.
|
||||
attempt: Retry/continuation attempt number, or None for first run.
|
||||
|
||||
Returns:
|
||||
Rendered prompt string.
|
||||
|
||||
Raises:
|
||||
TemplateParseError: If the template has syntax errors.
|
||||
TemplateRenderError: If rendering fails (unknown variable/filter).
|
||||
"""
|
||||
if not template_str:
|
||||
return DEFAULT_PROMPT
|
||||
|
||||
env = Environment(undefined=StrictUndefined)
|
||||
|
||||
try:
|
||||
template = env.from_string(template_str)
|
||||
except TemplateSyntaxError as e:
|
||||
raise TemplateParseError(f"Template syntax error: {e}") from e
|
||||
|
||||
# Build template context
|
||||
issue_dict = _issue_to_template_dict(issue)
|
||||
context: dict[str, Any] = {
|
||||
"issue": issue_dict,
|
||||
"attempt": attempt,
|
||||
}
|
||||
|
||||
try:
|
||||
return template.render(**context)
|
||||
except UndefinedError as e:
|
||||
raise TemplateRenderError(f"Unknown template variable: {e}") from e
|
||||
except Exception as e:
|
||||
raise TemplateRenderError(f"Template render error: {e}") from e
|
||||
|
||||
|
||||
def build_continuation_prompt(
|
||||
issue: Issue, turn_number: int, max_turns: int
|
||||
) -> str:
|
||||
"""Build a continuation prompt for subsequent turns.
|
||||
|
||||
Args:
|
||||
issue: The issue being worked on.
|
||||
turn_number: Current turn number.
|
||||
max_turns: Maximum allowed turns.
|
||||
|
||||
Returns:
|
||||
Continuation guidance prompt.
|
||||
"""
|
||||
return (
|
||||
f"Continue working on {issue.identifier}: {issue.title}. "
|
||||
f"This is turn {turn_number} of {max_turns}. "
|
||||
f"Review your previous work and continue from where you left off."
|
||||
)
|
||||
|
||||
|
||||
def _issue_to_template_dict(issue: Issue) -> dict[str, Any]:
|
||||
"""Convert an Issue to a template-friendly dict.
|
||||
|
||||
Converts all fields to strings/primitives for template compatibility.
|
||||
Preserves nested arrays/maps for iteration.
|
||||
"""
|
||||
d = asdict(issue)
|
||||
|
||||
# Convert datetime objects to ISO strings
|
||||
if d.get("created_at"):
|
||||
d["created_at"] = issue.created_at.isoformat() if issue.created_at else None
|
||||
if d.get("updated_at"):
|
||||
d["updated_at"] = issue.updated_at.isoformat() if issue.updated_at else None
|
||||
|
||||
return d
|
||||
@@ -0,0 +1,356 @@
|
||||
"""Issue tracker client — Linear GraphQL adapter."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
from ai_orchestrator.config import ServiceConfig
|
||||
from ai_orchestrator.models import BlockerRef, Issue
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# GraphQL queries for Linear
|
||||
CANDIDATE_ISSUES_QUERY = """
|
||||
query CandidateIssues($projectSlug: String!, $states: [String!]!, $after: String) {
|
||||
issues(
|
||||
filter: {
|
||||
project: { slugId: { eq: $projectSlug } }
|
||||
state: { name: { in: $states } }
|
||||
}
|
||||
first: 50
|
||||
after: $after
|
||||
orderBy: createdAt
|
||||
) {
|
||||
pageInfo {
|
||||
hasNextPage
|
||||
endCursor
|
||||
}
|
||||
nodes {
|
||||
id
|
||||
identifier
|
||||
title
|
||||
description
|
||||
priority
|
||||
state { name }
|
||||
branchName
|
||||
url
|
||||
labels { nodes { name } }
|
||||
relations(first: 50) {
|
||||
nodes {
|
||||
type
|
||||
relatedIssue {
|
||||
id
|
||||
identifier
|
||||
state { name }
|
||||
}
|
||||
}
|
||||
}
|
||||
createdAt
|
||||
updatedAt
|
||||
}
|
||||
}
|
||||
}
|
||||
"""
|
||||
|
||||
ISSUES_BY_STATES_QUERY = """
|
||||
query IssuesByStates($projectSlug: String!, $states: [String!]!, $after: String) {
|
||||
issues(
|
||||
filter: {
|
||||
project: { slugId: { eq: $projectSlug } }
|
||||
state: { name: { in: $states } }
|
||||
}
|
||||
first: 50
|
||||
after: $after
|
||||
) {
|
||||
pageInfo {
|
||||
hasNextPage
|
||||
endCursor
|
||||
}
|
||||
nodes {
|
||||
id
|
||||
identifier
|
||||
state { name }
|
||||
}
|
||||
}
|
||||
}
|
||||
"""
|
||||
|
||||
ISSUES_BY_IDS_QUERY = """
|
||||
query IssuesByIds($ids: [ID!]!) {
|
||||
nodes(ids: $ids) {
|
||||
... on Issue {
|
||||
id
|
||||
identifier
|
||||
title
|
||||
state { name }
|
||||
priority
|
||||
labels { nodes { name } }
|
||||
updatedAt
|
||||
}
|
||||
}
|
||||
}
|
||||
"""
|
||||
|
||||
|
||||
class TrackerError(Exception):
|
||||
"""Base class for tracker errors."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class TrackerAPIError(TrackerError):
|
||||
"""Raised on API transport failures."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class TrackerGraphQLError(TrackerError):
|
||||
"""Raised when GraphQL returns errors."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class LinearClient:
|
||||
"""Linear issue tracker client."""
|
||||
|
||||
def __init__(self, config: ServiceConfig) -> None:
|
||||
self._config = config
|
||||
self._client = httpx.AsyncClient(
|
||||
base_url=config.tracker.endpoint,
|
||||
headers={
|
||||
"Authorization": config.tracker.api_key,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
timeout=30.0,
|
||||
)
|
||||
|
||||
async def close(self) -> None:
|
||||
"""Close the HTTP client."""
|
||||
await self._client.aclose()
|
||||
|
||||
async def _execute_query(
|
||||
self, query: str, variables: dict[str, Any]
|
||||
) -> dict[str, Any]:
|
||||
"""Execute a GraphQL query against Linear.
|
||||
|
||||
Args:
|
||||
query: GraphQL query string.
|
||||
variables: Query variables.
|
||||
|
||||
Returns:
|
||||
Response data dict.
|
||||
|
||||
Raises:
|
||||
TrackerAPIError: On transport failures.
|
||||
TrackerGraphQLError: On GraphQL errors.
|
||||
"""
|
||||
try:
|
||||
response = await self._client.post(
|
||||
"", json={"query": query, "variables": variables}
|
||||
)
|
||||
except httpx.HTTPError as e:
|
||||
raise TrackerAPIError(f"Linear API request failed: {e}") from e
|
||||
|
||||
if response.status_code != 200:
|
||||
raise TrackerAPIError(
|
||||
f"Linear API returned status {response.status_code}: {response.text}"
|
||||
)
|
||||
|
||||
data = response.json()
|
||||
|
||||
if "errors" in data:
|
||||
raise TrackerGraphQLError(
|
||||
f"Linear GraphQL errors: {data['errors']}"
|
||||
)
|
||||
|
||||
return data.get("data", {})
|
||||
|
||||
async def fetch_candidate_issues(self) -> list[Issue]:
|
||||
"""Fetch issues in active states for the configured project.
|
||||
|
||||
Returns:
|
||||
List of normalized Issue objects.
|
||||
"""
|
||||
all_issues: list[Issue] = []
|
||||
cursor: str | None = None
|
||||
|
||||
while True:
|
||||
variables: dict[str, Any] = {
|
||||
"projectSlug": self._config.tracker.project_slug,
|
||||
"states": self._config.tracker.active_states,
|
||||
}
|
||||
if cursor:
|
||||
variables["after"] = cursor
|
||||
|
||||
data = await self._execute_query(CANDIDATE_ISSUES_QUERY, variables)
|
||||
issues_data = data.get("issues", {})
|
||||
nodes = issues_data.get("nodes", [])
|
||||
|
||||
for node in nodes:
|
||||
issue = self._normalize_issue(node)
|
||||
if issue:
|
||||
all_issues.append(issue)
|
||||
|
||||
page_info = issues_data.get("pageInfo", {})
|
||||
if page_info.get("hasNextPage") and page_info.get("endCursor"):
|
||||
cursor = page_info["endCursor"]
|
||||
else:
|
||||
break
|
||||
|
||||
return all_issues
|
||||
|
||||
async def fetch_issues_by_states(self, states: list[str]) -> list[Issue]:
|
||||
"""Fetch issues in specified states (used for terminal cleanup).
|
||||
|
||||
Args:
|
||||
states: List of state names to query.
|
||||
|
||||
Returns:
|
||||
List of normalized Issue objects.
|
||||
"""
|
||||
if not states:
|
||||
return []
|
||||
|
||||
all_issues: list[Issue] = []
|
||||
cursor: str | None = None
|
||||
|
||||
while True:
|
||||
variables: dict[str, Any] = {
|
||||
"projectSlug": self._config.tracker.project_slug,
|
||||
"states": states,
|
||||
}
|
||||
if cursor:
|
||||
variables["after"] = cursor
|
||||
|
||||
data = await self._execute_query(ISSUES_BY_STATES_QUERY, variables)
|
||||
issues_data = data.get("issues", {})
|
||||
nodes = issues_data.get("nodes", [])
|
||||
|
||||
for node in nodes:
|
||||
issue = Issue(
|
||||
id=node["id"],
|
||||
identifier=node["identifier"],
|
||||
title="",
|
||||
state=node.get("state", {}).get("name", ""),
|
||||
)
|
||||
all_issues.append(issue)
|
||||
|
||||
page_info = issues_data.get("pageInfo", {})
|
||||
if page_info.get("hasNextPage") and page_info.get("endCursor"):
|
||||
cursor = page_info["endCursor"]
|
||||
else:
|
||||
break
|
||||
|
||||
return all_issues
|
||||
|
||||
async def fetch_issue_states_by_ids(self, issue_ids: list[str]) -> list[Issue]:
|
||||
"""Fetch current states for specific issue IDs (reconciliation).
|
||||
|
||||
Args:
|
||||
issue_ids: List of issue IDs to query.
|
||||
|
||||
Returns:
|
||||
List of normalized Issue objects with current state.
|
||||
"""
|
||||
if not issue_ids:
|
||||
return []
|
||||
|
||||
data = await self._execute_query(
|
||||
ISSUES_BY_IDS_QUERY, {"ids": issue_ids}
|
||||
)
|
||||
nodes = data.get("nodes", [])
|
||||
|
||||
issues: list[Issue] = []
|
||||
for node in nodes:
|
||||
if node and "id" in node:
|
||||
issues.append(
|
||||
Issue(
|
||||
id=node["id"],
|
||||
identifier=node.get("identifier", ""),
|
||||
title=node.get("title", ""),
|
||||
state=node.get("state", {}).get("name", ""),
|
||||
priority=node.get("priority"),
|
||||
labels=[
|
||||
lbl["name"].lower()
|
||||
for lbl in (node.get("labels", {}).get("nodes", []))
|
||||
],
|
||||
updated_at=_parse_timestamp(node.get("updatedAt")),
|
||||
)
|
||||
)
|
||||
|
||||
return issues
|
||||
|
||||
def _normalize_issue(self, node: dict[str, Any]) -> Issue | None:
|
||||
"""Normalize a raw Linear issue node into an Issue model.
|
||||
|
||||
Args:
|
||||
node: Raw GraphQL issue node.
|
||||
|
||||
Returns:
|
||||
Normalized Issue or None if missing required fields.
|
||||
"""
|
||||
issue_id = node.get("id")
|
||||
identifier = node.get("identifier")
|
||||
title = node.get("title")
|
||||
state = node.get("state", {}).get("name")
|
||||
|
||||
if not all([issue_id, identifier, title, state]):
|
||||
return None
|
||||
|
||||
# Normalize labels to lowercase
|
||||
labels = [
|
||||
lbl["name"].lower()
|
||||
for lbl in (node.get("labels", {}).get("nodes", []))
|
||||
]
|
||||
|
||||
# Normalize blockers from inverse relations
|
||||
blocked_by: list[BlockerRef] = []
|
||||
for relation in node.get("relations", {}).get("nodes", []):
|
||||
if relation.get("type") == "blocks":
|
||||
related = relation.get("relatedIssue", {})
|
||||
blocked_by.append(
|
||||
BlockerRef(
|
||||
id=related.get("id"),
|
||||
identifier=related.get("identifier"),
|
||||
state=related.get("state", {}).get("name"),
|
||||
)
|
||||
)
|
||||
|
||||
return Issue(
|
||||
id=issue_id,
|
||||
identifier=identifier,
|
||||
title=title,
|
||||
description=node.get("description"),
|
||||
priority=_parse_priority(node.get("priority")),
|
||||
state=state,
|
||||
branch_name=node.get("branchName"),
|
||||
url=node.get("url"),
|
||||
labels=labels,
|
||||
blocked_by=blocked_by,
|
||||
created_at=_parse_timestamp(node.get("createdAt")),
|
||||
updated_at=_parse_timestamp(node.get("updatedAt")),
|
||||
)
|
||||
|
||||
|
||||
def _parse_priority(value: Any) -> int | None:
|
||||
"""Parse priority to int or None."""
|
||||
if value is None:
|
||||
return None
|
||||
try:
|
||||
return int(value)
|
||||
except (ValueError, TypeError):
|
||||
return None
|
||||
|
||||
|
||||
def _parse_timestamp(value: Any) -> datetime | None:
|
||||
"""Parse ISO-8601 timestamp string."""
|
||||
if not value:
|
||||
return None
|
||||
try:
|
||||
return datetime.fromisoformat(value.replace("Z", "+00:00"))
|
||||
except (ValueError, TypeError):
|
||||
return None
|
||||
@@ -0,0 +1,211 @@
|
||||
"""OrgMyLife tracker adapter — polls your personal task board instead of Linear."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
from ai_orchestrator.config import ServiceConfig
|
||||
from ai_orchestrator.models import Issue
|
||||
from ai_orchestrator.tracker import TrackerError, TrackerAPIError
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class OrgMyLifeClient:
|
||||
"""OrgMyLife issue tracker client.
|
||||
|
||||
Connects to the OrgMyLife FastAPI backend to fetch agent-ready tasks
|
||||
and update their state when work completes.
|
||||
"""
|
||||
|
||||
def __init__(self, config: ServiceConfig) -> None:
|
||||
self._config = config
|
||||
# OrgMyLife endpoint is stored in tracker.endpoint
|
||||
self._base_url = config.tracker.endpoint.rstrip("/")
|
||||
self._api_key = config.tracker.api_key # Optional auth token
|
||||
|
||||
headers: dict[str, str] = {"Content-Type": "application/json"}
|
||||
if self._api_key:
|
||||
headers["Authorization"] = f"Bearer {self._api_key}"
|
||||
|
||||
self._client = httpx.AsyncClient(
|
||||
base_url=self._base_url,
|
||||
headers=headers,
|
||||
timeout=30.0,
|
||||
)
|
||||
|
||||
async def close(self) -> None:
|
||||
"""Close the HTTP client."""
|
||||
await self._client.aclose()
|
||||
|
||||
async def fetch_candidate_issues(self) -> list[Issue]:
|
||||
"""Fetch tasks marked as agent_ready in active states.
|
||||
|
||||
Returns:
|
||||
List of normalized Issue objects.
|
||||
"""
|
||||
try:
|
||||
response = await self._client.get("/api/orchestrator/tasks")
|
||||
except httpx.HTTPError as e:
|
||||
raise TrackerAPIError(f"OrgMyLife API request failed: {e}") from e
|
||||
|
||||
if response.status_code != 200:
|
||||
raise TrackerAPIError(
|
||||
f"OrgMyLife API returned status {response.status_code}: {response.text}"
|
||||
)
|
||||
|
||||
data = response.json()
|
||||
tasks = data.get("tasks", [])
|
||||
|
||||
issues: list[Issue] = []
|
||||
for task in tasks:
|
||||
issue = self._normalize_task(task)
|
||||
if issue:
|
||||
issues.append(issue)
|
||||
|
||||
return issues
|
||||
|
||||
async def fetch_issues_by_states(self, states: list[str]) -> list[Issue]:
|
||||
"""Fetch tasks in specified states (used for terminal cleanup).
|
||||
|
||||
Args:
|
||||
states: List of state names to query.
|
||||
|
||||
Returns:
|
||||
List of normalized Issue objects.
|
||||
"""
|
||||
if not states:
|
||||
return []
|
||||
|
||||
states_param = ",".join(states)
|
||||
try:
|
||||
response = await self._client.get(
|
||||
"/api/orchestrator/tasks/by-states",
|
||||
params={"states": states_param},
|
||||
)
|
||||
except httpx.HTTPError as e:
|
||||
raise TrackerAPIError(f"OrgMyLife API request failed: {e}") from e
|
||||
|
||||
if response.status_code != 200:
|
||||
raise TrackerAPIError(
|
||||
f"OrgMyLife API returned status {response.status_code}"
|
||||
)
|
||||
|
||||
data = response.json()
|
||||
tasks = data.get("tasks", [])
|
||||
|
||||
return [
|
||||
Issue(
|
||||
id=task["id"],
|
||||
identifier=task["identifier"],
|
||||
title=task.get("title", ""),
|
||||
state=task.get("state", ""),
|
||||
)
|
||||
for task in tasks
|
||||
]
|
||||
|
||||
async def fetch_issue_states_by_ids(self, issue_ids: list[str]) -> list[Issue]:
|
||||
"""Fetch current states for specific task IDs (reconciliation).
|
||||
|
||||
Since OrgMyLife doesn't have a batch-by-ID endpoint, we fetch all
|
||||
orchestrator tasks and filter locally.
|
||||
|
||||
Args:
|
||||
issue_ids: List of task IDs to query.
|
||||
|
||||
Returns:
|
||||
List of normalized Issue objects with current state.
|
||||
"""
|
||||
if not issue_ids:
|
||||
return []
|
||||
|
||||
# Fetch all active + completed tasks and filter
|
||||
try:
|
||||
response = await self._client.get(
|
||||
"/api/orchestrator/tasks/by-states",
|
||||
params={"states": "open,in_progress,completed"},
|
||||
)
|
||||
except httpx.HTTPError as e:
|
||||
raise TrackerAPIError(f"OrgMyLife API request failed: {e}") from e
|
||||
|
||||
if response.status_code != 200:
|
||||
raise TrackerAPIError(
|
||||
f"OrgMyLife API returned status {response.status_code}"
|
||||
)
|
||||
|
||||
data = response.json()
|
||||
tasks = data.get("tasks", [])
|
||||
|
||||
id_set = set(issue_ids)
|
||||
return [
|
||||
Issue(
|
||||
id=task["id"],
|
||||
identifier=task["identifier"],
|
||||
title=task.get("title", ""),
|
||||
state=task.get("state", ""),
|
||||
)
|
||||
for task in tasks
|
||||
if task["id"] in id_set
|
||||
]
|
||||
|
||||
async def update_task_state(self, task_id: str, new_state: str) -> bool:
|
||||
"""Push a state update back to OrgMyLife.
|
||||
|
||||
Args:
|
||||
task_id: The task ID (numeric string).
|
||||
new_state: New status (open, in_progress, completed).
|
||||
|
||||
Returns:
|
||||
True if update succeeded.
|
||||
"""
|
||||
try:
|
||||
response = await self._client.post(
|
||||
f"/api/orchestrator/tasks/{task_id}/state",
|
||||
json={"status": new_state},
|
||||
)
|
||||
return response.status_code == 200
|
||||
except httpx.HTTPError as e:
|
||||
logger.warning(f"Failed to update task {task_id} state: {e}")
|
||||
return False
|
||||
|
||||
def _normalize_task(self, task: dict[str, Any]) -> Issue | None:
|
||||
"""Normalize an OrgMyLife task into an Issue model."""
|
||||
task_id = task.get("id")
|
||||
identifier = task.get("identifier")
|
||||
title = task.get("title")
|
||||
state = task.get("state")
|
||||
|
||||
if not all([task_id, identifier, title, state]):
|
||||
return None
|
||||
|
||||
# Map OrgMyLife states to Symphony-compatible states
|
||||
state_map = {
|
||||
"open": "Todo",
|
||||
"in_progress": "In Progress",
|
||||
"completed": "Done",
|
||||
}
|
||||
mapped_state = state_map.get(state, state)
|
||||
|
||||
created_at = None
|
||||
if task.get("created_at"):
|
||||
try:
|
||||
created_at = datetime.fromisoformat(
|
||||
task["created_at"].replace("Z", "+00:00")
|
||||
)
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
return Issue(
|
||||
id=str(task_id),
|
||||
identifier=identifier,
|
||||
title=title,
|
||||
description=task.get("description"),
|
||||
priority=task.get("priority"),
|
||||
state=mapped_state,
|
||||
labels=task.get("labels", []),
|
||||
created_at=created_at,
|
||||
)
|
||||
@@ -0,0 +1,61 @@
|
||||
"""Workflow file watcher — detects WORKFLOW.md changes for dynamic reload."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Callable
|
||||
|
||||
from watchdog.events import FileModifiedEvent, FileSystemEventHandler
|
||||
from watchdog.observers import Observer
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class WorkflowFileHandler(FileSystemEventHandler):
|
||||
"""Watches for changes to the WORKFLOW.md file."""
|
||||
|
||||
def __init__(self, workflow_path: Path, on_change: Callable[[], None]) -> None:
|
||||
self._workflow_path = workflow_path.resolve()
|
||||
self._on_change = on_change
|
||||
|
||||
def on_modified(self, event: FileModifiedEvent) -> None: # type: ignore[override]
|
||||
if event.is_directory:
|
||||
return
|
||||
|
||||
modified_path = Path(str(event.src_path)).resolve()
|
||||
if modified_path == self._workflow_path:
|
||||
logger.info("WORKFLOW.md changed, triggering reload...")
|
||||
try:
|
||||
self._on_change()
|
||||
except Exception as e:
|
||||
logger.error(f"Workflow reload callback failed: {e}")
|
||||
|
||||
|
||||
class WorkflowWatcher:
|
||||
"""Manages filesystem watching for WORKFLOW.md changes."""
|
||||
|
||||
def __init__(self, workflow_path: Path, on_change: Callable[[], None]) -> None:
|
||||
self._workflow_path = workflow_path.resolve()
|
||||
self._on_change = on_change
|
||||
self._observer: Observer | None = None
|
||||
|
||||
def start(self) -> None:
|
||||
"""Start watching for workflow file changes."""
|
||||
handler = WorkflowFileHandler(self._workflow_path, self._on_change)
|
||||
self._observer = Observer()
|
||||
self._observer.schedule(
|
||||
handler,
|
||||
str(self._workflow_path.parent),
|
||||
recursive=False,
|
||||
)
|
||||
self._observer.daemon = True
|
||||
self._observer.start()
|
||||
logger.info(f"Watching {self._workflow_path} for changes")
|
||||
|
||||
def stop(self) -> None:
|
||||
"""Stop the file watcher."""
|
||||
if self._observer:
|
||||
self._observer.stop()
|
||||
self._observer.join(timeout=5)
|
||||
self._observer = None
|
||||
@@ -0,0 +1,99 @@
|
||||
"""Workflow loader — reads and parses WORKFLOW.md files."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
import yaml
|
||||
|
||||
from ai_orchestrator.models import WorkflowDefinition
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class WorkflowError(Exception):
|
||||
"""Base class for workflow-related errors."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class MissingWorkflowFileError(WorkflowError):
|
||||
"""Raised when WORKFLOW.md cannot be found."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class WorkflowParseError(WorkflowError):
|
||||
"""Raised when WORKFLOW.md cannot be parsed."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class WorkflowFrontMatterNotAMapError(WorkflowError):
|
||||
"""Raised when front matter is not a YAML map."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
def load_workflow(path: Path) -> WorkflowDefinition:
|
||||
"""Load and parse a WORKFLOW.md file.
|
||||
|
||||
Args:
|
||||
path: Path to the WORKFLOW.md file.
|
||||
|
||||
Returns:
|
||||
WorkflowDefinition with config and prompt_template.
|
||||
|
||||
Raises:
|
||||
MissingWorkflowFileError: If the file doesn't exist or can't be read.
|
||||
WorkflowParseError: If YAML parsing fails.
|
||||
WorkflowFrontMatterNotAMapError: If front matter is not a map/dict.
|
||||
"""
|
||||
if not path.exists():
|
||||
raise MissingWorkflowFileError(f"Workflow file not found: {path}")
|
||||
|
||||
try:
|
||||
content = path.read_text(encoding="utf-8")
|
||||
except OSError as e:
|
||||
raise MissingWorkflowFileError(f"Cannot read workflow file: {e}") from e
|
||||
|
||||
return parse_workflow(content)
|
||||
|
||||
|
||||
def parse_workflow(content: str) -> WorkflowDefinition:
|
||||
"""Parse workflow content into config and prompt template.
|
||||
|
||||
Args:
|
||||
content: Raw content of the WORKFLOW.md file.
|
||||
|
||||
Returns:
|
||||
WorkflowDefinition with config and prompt_template.
|
||||
"""
|
||||
config: dict = {}
|
||||
prompt_template: str = content
|
||||
|
||||
# Check for YAML front matter
|
||||
if content.startswith("---"):
|
||||
parts = content.split("---", 2)
|
||||
if len(parts) >= 3:
|
||||
front_matter_raw = parts[1]
|
||||
prompt_template = parts[2]
|
||||
|
||||
try:
|
||||
parsed = yaml.safe_load(front_matter_raw)
|
||||
except yaml.YAMLError as e:
|
||||
raise WorkflowParseError(f"Invalid YAML front matter: {e}") from e
|
||||
|
||||
if parsed is None:
|
||||
config = {}
|
||||
elif not isinstance(parsed, dict):
|
||||
raise WorkflowFrontMatterNotAMapError(
|
||||
f"Front matter must be a YAML map, got: {type(parsed).__name__}"
|
||||
)
|
||||
else:
|
||||
config = parsed
|
||||
|
||||
prompt_template = prompt_template.strip()
|
||||
|
||||
return WorkflowDefinition(config=config, prompt_template=prompt_template)
|
||||
@@ -0,0 +1,226 @@
|
||||
"""Workspace manager — per-issue workspace lifecycle and hooks."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import re
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
from ai_orchestrator.config import ServiceConfig
|
||||
from ai_orchestrator.models import Workspace
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Only allow safe characters in workspace directory names
|
||||
SAFE_CHARS_RE = re.compile(r"[^A-Za-z0-9._\-]")
|
||||
|
||||
|
||||
class WorkspaceError(Exception):
|
||||
"""Base class for workspace errors."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class WorkspacePathError(WorkspaceError):
|
||||
"""Raised when workspace path is invalid or outside root."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class WorkspaceHookError(WorkspaceError):
|
||||
"""Raised when a workspace hook fails."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
def sanitize_workspace_key(identifier: str) -> str:
|
||||
"""Sanitize an issue identifier for use as a workspace directory name.
|
||||
|
||||
Replaces any character not in [A-Za-z0-9._-] with underscore.
|
||||
|
||||
Args:
|
||||
identifier: The issue identifier (e.g., "ABC-123").
|
||||
|
||||
Returns:
|
||||
Sanitized workspace key.
|
||||
"""
|
||||
return SAFE_CHARS_RE.sub("_", identifier)
|
||||
|
||||
|
||||
def get_workspace_path(workspace_root: str, identifier: str) -> Path:
|
||||
"""Compute the workspace path for an issue.
|
||||
|
||||
Args:
|
||||
workspace_root: Absolute path to workspace root directory.
|
||||
identifier: Issue identifier.
|
||||
|
||||
Returns:
|
||||
Absolute path to the issue's workspace directory.
|
||||
|
||||
Raises:
|
||||
WorkspacePathError: If the computed path is outside workspace root.
|
||||
"""
|
||||
root = Path(workspace_root).resolve()
|
||||
key = sanitize_workspace_key(identifier)
|
||||
workspace_path = (root / key).resolve()
|
||||
|
||||
# Safety: ensure workspace stays inside root
|
||||
if not str(workspace_path).startswith(str(root)):
|
||||
raise WorkspacePathError(
|
||||
f"Workspace path {workspace_path} is outside root {root}"
|
||||
)
|
||||
|
||||
return workspace_path
|
||||
|
||||
|
||||
async def create_workspace(
|
||||
config: ServiceConfig, identifier: str
|
||||
) -> Workspace:
|
||||
"""Create or reuse a workspace for an issue.
|
||||
|
||||
Args:
|
||||
config: Service configuration.
|
||||
identifier: Issue identifier.
|
||||
|
||||
Returns:
|
||||
Workspace object with path and creation status.
|
||||
|
||||
Raises:
|
||||
WorkspaceError: On workspace creation failure.
|
||||
WorkspaceHookError: If after_create hook fails.
|
||||
"""
|
||||
workspace_path = get_workspace_path(config.workspace.root, identifier)
|
||||
key = sanitize_workspace_key(identifier)
|
||||
|
||||
created_now = False
|
||||
if not workspace_path.exists():
|
||||
try:
|
||||
workspace_path.mkdir(parents=True, exist_ok=True)
|
||||
created_now = True
|
||||
except OSError as e:
|
||||
raise WorkspaceError(f"Failed to create workspace: {e}") from e
|
||||
elif not workspace_path.is_dir():
|
||||
# Handle case where path exists but is not a directory
|
||||
raise WorkspaceError(
|
||||
f"Workspace path exists but is not a directory: {workspace_path}"
|
||||
)
|
||||
|
||||
workspace = Workspace(
|
||||
path=str(workspace_path),
|
||||
workspace_key=key,
|
||||
created_now=created_now,
|
||||
)
|
||||
|
||||
# Run after_create hook only for newly created workspaces
|
||||
if created_now and config.hooks.after_create:
|
||||
try:
|
||||
await run_hook(
|
||||
config.hooks.after_create,
|
||||
workspace_path,
|
||||
config.hooks.timeout_ms,
|
||||
)
|
||||
except WorkspaceHookError:
|
||||
# after_create failure is fatal — remove the workspace
|
||||
try:
|
||||
import shutil
|
||||
shutil.rmtree(workspace_path, ignore_errors=True)
|
||||
except Exception:
|
||||
pass
|
||||
raise
|
||||
|
||||
return workspace
|
||||
|
||||
|
||||
async def run_hook(
|
||||
script: str, cwd: Path, timeout_ms: int
|
||||
) -> None:
|
||||
"""Run a workspace hook script.
|
||||
|
||||
Args:
|
||||
script: Shell script to execute.
|
||||
cwd: Working directory for the script.
|
||||
timeout_ms: Timeout in milliseconds.
|
||||
|
||||
Raises:
|
||||
WorkspaceHookError: If the hook fails or times out.
|
||||
"""
|
||||
timeout_sec = timeout_ms / 1000.0
|
||||
|
||||
logger.info(f"Running hook in {cwd}")
|
||||
|
||||
try:
|
||||
process = await asyncio.create_subprocess_exec(
|
||||
"bash", "-lc", script,
|
||||
cwd=str(cwd),
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
)
|
||||
|
||||
try:
|
||||
stdout, stderr = await asyncio.wait_for(
|
||||
process.communicate(), timeout=timeout_sec
|
||||
)
|
||||
except asyncio.TimeoutError:
|
||||
process.kill()
|
||||
await process.wait()
|
||||
raise WorkspaceHookError(
|
||||
f"Hook timed out after {timeout_ms}ms"
|
||||
)
|
||||
|
||||
if process.returncode != 0:
|
||||
stderr_text = stderr.decode("utf-8", errors="replace")[:500]
|
||||
raise WorkspaceHookError(
|
||||
f"Hook failed with exit code {process.returncode}: {stderr_text}"
|
||||
)
|
||||
|
||||
except FileNotFoundError as e:
|
||||
raise WorkspaceHookError(f"Hook execution failed: {e}") from e
|
||||
|
||||
|
||||
async def run_hook_best_effort(
|
||||
script: str | None, cwd: Path, timeout_ms: int
|
||||
) -> None:
|
||||
"""Run a hook, logging failures without raising.
|
||||
|
||||
Args:
|
||||
script: Shell script to execute, or None to skip.
|
||||
cwd: Working directory.
|
||||
timeout_ms: Timeout in milliseconds.
|
||||
"""
|
||||
if not script:
|
||||
return
|
||||
|
||||
try:
|
||||
await run_hook(script, cwd, timeout_ms)
|
||||
except WorkspaceHookError as e:
|
||||
logger.warning(f"Hook failed (non-fatal): {e}")
|
||||
|
||||
|
||||
async def cleanup_workspace(
|
||||
config: ServiceConfig, identifier: str
|
||||
) -> None:
|
||||
"""Remove a workspace directory for a terminal issue.
|
||||
|
||||
Args:
|
||||
config: Service configuration.
|
||||
identifier: Issue identifier.
|
||||
"""
|
||||
workspace_path = get_workspace_path(config.workspace.root, identifier)
|
||||
|
||||
if not workspace_path.exists():
|
||||
return
|
||||
|
||||
# Run before_remove hook
|
||||
await run_hook_best_effort(
|
||||
config.hooks.before_remove, workspace_path, config.hooks.timeout_ms
|
||||
)
|
||||
|
||||
# Remove the workspace
|
||||
try:
|
||||
import shutil
|
||||
shutil.rmtree(workspace_path)
|
||||
logger.info(f"Cleaned workspace for {identifier}")
|
||||
except OSError as e:
|
||||
logger.warning(f"Failed to remove workspace for {identifier}: {e}")
|
||||
Reference in New Issue
Block a user