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:
2026-06-30 20:39:52 +02:00
parent 2f2b295531
commit a5f8fb49ab
1717 changed files with 447332 additions and 0 deletions
@@ -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}")