monoco-toolkit 0.3.12__py3-none-any.whl → 0.4.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- monoco/core/automation/__init__.py +0 -11
- monoco/core/automation/handlers.py +108 -26
- monoco/core/config.py +28 -10
- monoco/core/daemon/__init__.py +5 -0
- monoco/core/daemon/pid.py +290 -0
- monoco/core/injection.py +86 -8
- monoco/core/integrations.py +0 -24
- monoco/core/router/__init__.py +1 -39
- monoco/core/router/action.py +3 -142
- monoco/core/scheduler/events.py +28 -2
- monoco/core/setup.py +9 -0
- monoco/core/sync.py +199 -4
- monoco/core/watcher/__init__.py +6 -0
- monoco/core/watcher/base.py +18 -1
- monoco/core/watcher/im.py +460 -0
- monoco/core/watcher/memo.py +40 -48
- monoco/daemon/app.py +3 -60
- monoco/daemon/commands.py +459 -25
- monoco/daemon/scheduler.py +1 -16
- monoco/daemon/services.py +15 -0
- monoco/features/agent/resources/en/AGENTS.md +14 -14
- monoco/features/agent/resources/en/skills/monoco_role_engineer/SKILL.md +101 -0
- monoco/features/agent/resources/en/skills/monoco_role_manager/SKILL.md +95 -0
- monoco/features/agent/resources/en/skills/monoco_role_planner/SKILL.md +177 -0
- monoco/features/agent/resources/en/skills/monoco_role_reviewer/SKILL.md +139 -0
- monoco/features/agent/resources/zh/skills/monoco_role_engineer/SKILL.md +101 -0
- monoco/features/agent/resources/zh/skills/monoco_role_manager/SKILL.md +95 -0
- monoco/features/agent/resources/zh/skills/monoco_role_planner/SKILL.md +177 -0
- monoco/features/agent/resources/zh/skills/monoco_role_reviewer/SKILL.md +139 -0
- monoco/features/hooks/__init__.py +61 -6
- monoco/features/hooks/commands.py +281 -271
- monoco/features/hooks/dispatchers/__init__.py +23 -0
- monoco/features/hooks/dispatchers/agent_dispatcher.py +486 -0
- monoco/features/hooks/dispatchers/git_dispatcher.py +478 -0
- monoco/features/hooks/manager.py +357 -0
- monoco/features/hooks/models.py +262 -0
- monoco/features/hooks/parser.py +322 -0
- monoco/features/hooks/universal_interceptor.py +503 -0
- monoco/features/im/__init__.py +67 -0
- monoco/features/im/core.py +782 -0
- monoco/features/im/models.py +311 -0
- monoco/features/issue/commands.py +65 -50
- monoco/features/issue/core.py +199 -99
- monoco/features/issue/domain_commands.py +0 -19
- monoco/features/issue/resources/en/AGENTS.md +17 -122
- monoco/features/issue/resources/hooks/agent/before-tool.sh +102 -0
- monoco/features/issue/resources/hooks/agent/session-start.sh +88 -0
- monoco/features/issue/resources/hooks/{post-checkout.sh → git/git-post-checkout.sh} +10 -9
- monoco/features/issue/resources/hooks/git/git-pre-commit.sh +31 -0
- monoco/features/issue/resources/hooks/{pre-push.sh → git/git-pre-push.sh} +7 -13
- monoco/features/issue/resources/zh/AGENTS.md +18 -123
- monoco/features/memo/cli.py +15 -64
- monoco/features/memo/core.py +6 -34
- monoco/features/memo/models.py +24 -15
- monoco/features/memo/resources/en/AGENTS.md +31 -0
- monoco/features/memo/resources/zh/AGENTS.md +28 -5
- monoco/main.py +5 -3
- {monoco_toolkit-0.3.12.dist-info → monoco_toolkit-0.4.0.dist-info}/METADATA +1 -1
- monoco_toolkit-0.4.0.dist-info/RECORD +170 -0
- monoco/core/automation/config.py +0 -338
- monoco/core/execution.py +0 -67
- monoco/core/executor/__init__.py +0 -38
- monoco/core/executor/agent_action.py +0 -254
- monoco/core/executor/git_action.py +0 -303
- monoco/core/executor/im_action.py +0 -309
- monoco/core/executor/pytest_action.py +0 -218
- monoco/core/router/router.py +0 -392
- monoco/features/agent/resources/atoms/atom-code-dev.yaml +0 -61
- monoco/features/agent/resources/atoms/atom-issue-lifecycle.yaml +0 -73
- monoco/features/agent/resources/atoms/atom-knowledge.yaml +0 -55
- monoco/features/agent/resources/atoms/atom-review.yaml +0 -60
- monoco/features/agent/resources/en/skills/monoco_atom_core/SKILL.md +0 -99
- monoco/features/agent/resources/en/skills/monoco_workflow_agent_engineer/SKILL.md +0 -94
- monoco/features/agent/resources/en/skills/monoco_workflow_agent_manager/SKILL.md +0 -93
- monoco/features/agent/resources/en/skills/monoco_workflow_agent_planner/SKILL.md +0 -85
- monoco/features/agent/resources/en/skills/monoco_workflow_agent_reviewer/SKILL.md +0 -114
- monoco/features/agent/resources/workflows/workflow-dev.yaml +0 -83
- monoco/features/agent/resources/workflows/workflow-issue-create.yaml +0 -72
- monoco/features/agent/resources/workflows/workflow-review.yaml +0 -94
- monoco/features/agent/resources/zh/roles/monoco_role_engineer.yaml +0 -49
- monoco/features/agent/resources/zh/roles/monoco_role_manager.yaml +0 -46
- monoco/features/agent/resources/zh/roles/monoco_role_planner.yaml +0 -46
- monoco/features/agent/resources/zh/roles/monoco_role_reviewer.yaml +0 -47
- monoco/features/agent/resources/zh/skills/monoco_atom_core/SKILL.md +0 -99
- monoco/features/agent/resources/zh/skills/monoco_workflow_agent_engineer/SKILL.md +0 -94
- monoco/features/agent/resources/zh/skills/monoco_workflow_agent_manager/SKILL.md +0 -88
- monoco/features/agent/resources/zh/skills/monoco_workflow_agent_planner/SKILL.md +0 -259
- monoco/features/agent/resources/zh/skills/monoco_workflow_agent_reviewer/SKILL.md +0 -137
- monoco/features/artifact/resources/zh/skills/monoco_atom_artifact/SKILL.md +0 -278
- monoco/features/glossary/resources/en/skills/monoco_atom_glossary/SKILL.md +0 -35
- monoco/features/glossary/resources/zh/skills/monoco_atom_glossary/SKILL.md +0 -35
- monoco/features/hooks/adapter.py +0 -67
- monoco/features/hooks/core.py +0 -441
- monoco/features/i18n/resources/en/skills/monoco_atom_i18n/SKILL.md +0 -96
- monoco/features/i18n/resources/en/skills/monoco_workflow_i18n_scan/SKILL.md +0 -105
- monoco/features/i18n/resources/zh/skills/monoco_atom_i18n/SKILL.md +0 -96
- monoco/features/i18n/resources/zh/skills/monoco_workflow_i18n_scan/SKILL.md +0 -105
- monoco/features/issue/resources/en/skills/monoco_atom_issue/SKILL.md +0 -165
- monoco/features/issue/resources/en/skills/monoco_workflow_issue_creation/SKILL.md +0 -167
- monoco/features/issue/resources/en/skills/monoco_workflow_issue_development/SKILL.md +0 -224
- monoco/features/issue/resources/en/skills/monoco_workflow_issue_management/SKILL.md +0 -159
- monoco/features/issue/resources/en/skills/monoco_workflow_issue_refinement/SKILL.md +0 -203
- monoco/features/issue/resources/hooks/pre-commit.sh +0 -41
- monoco/features/issue/resources/zh/skills/monoco_atom_issue_lifecycle/SKILL.md +0 -190
- monoco/features/issue/resources/zh/skills/monoco_workflow_issue_creation/SKILL.md +0 -167
- monoco/features/issue/resources/zh/skills/monoco_workflow_issue_development/SKILL.md +0 -224
- monoco/features/issue/resources/zh/skills/monoco_workflow_issue_management/SKILL.md +0 -159
- monoco/features/issue/resources/zh/skills/monoco_workflow_issue_refinement/SKILL.md +0 -203
- monoco/features/memo/resources/en/skills/monoco_atom_memo/SKILL.md +0 -77
- monoco/features/memo/resources/en/skills/monoco_workflow_note_processing/SKILL.md +0 -140
- monoco/features/memo/resources/zh/skills/monoco_atom_memo/SKILL.md +0 -77
- monoco/features/memo/resources/zh/skills/monoco_workflow_note_processing/SKILL.md +0 -140
- monoco/features/spike/resources/en/skills/monoco_atom_spike/SKILL.md +0 -76
- monoco/features/spike/resources/en/skills/monoco_workflow_research/SKILL.md +0 -121
- monoco/features/spike/resources/zh/skills/monoco_atom_spike/SKILL.md +0 -76
- monoco/features/spike/resources/zh/skills/monoco_workflow_research/SKILL.md +0 -121
- monoco_toolkit-0.3.12.dist-info/RECORD +0 -202
- {monoco_toolkit-0.3.12.dist-info → monoco_toolkit-0.4.0.dist-info}/WHEEL +0 -0
- {monoco_toolkit-0.3.12.dist-info → monoco_toolkit-0.4.0.dist-info}/entry_points.txt +0 -0
- {monoco_toolkit-0.3.12.dist-info → monoco_toolkit-0.4.0.dist-info}/licenses/LICENSE +0 -0
monoco/daemon/app.py
CHANGED
|
@@ -5,10 +5,10 @@ from sse_starlette.sse import EventSourceResponse
|
|
|
5
5
|
import asyncio
|
|
6
6
|
import logging
|
|
7
7
|
import os
|
|
8
|
-
from typing import Optional,
|
|
8
|
+
from typing import Optional, Dict
|
|
9
9
|
from monoco.daemon.services import Broadcaster, ProjectManager
|
|
10
10
|
from monoco.core.git import GitMonitor
|
|
11
|
-
from monoco.core.config import get_config
|
|
11
|
+
from monoco.core.config import get_config
|
|
12
12
|
from monoco.daemon.scheduler import SchedulerService
|
|
13
13
|
from monoco.daemon.mailroom_service import MailroomService
|
|
14
14
|
|
|
@@ -18,11 +18,6 @@ logger = logging.getLogger("monoco.daemon")
|
|
|
18
18
|
from pathlib import Path
|
|
19
19
|
from monoco.core.config import get_config
|
|
20
20
|
from monoco.features.issue.core import list_issues
|
|
21
|
-
from monoco.core.execution import (
|
|
22
|
-
scan_execution_profiles,
|
|
23
|
-
get_profile_detail,
|
|
24
|
-
ExecutionProfile,
|
|
25
|
-
)
|
|
26
21
|
|
|
27
22
|
description = """
|
|
28
23
|
Monoco Daemon Process
|
|
@@ -34,7 +29,6 @@ Monoco Daemon Process
|
|
|
34
29
|
# Service Instances
|
|
35
30
|
broadcaster = Broadcaster()
|
|
36
31
|
git_monitor: GitMonitor | None = None
|
|
37
|
-
config_monitors: List[ConfigMonitor] = []
|
|
38
32
|
project_manager: ProjectManager | None = None
|
|
39
33
|
scheduler_service: SchedulerService | None = None
|
|
40
34
|
mailroom_service: MailroomService | None = None
|
|
@@ -45,7 +39,7 @@ async def lifespan(app: FastAPI):
|
|
|
45
39
|
# Startup
|
|
46
40
|
logger.info("Starting Monoco Daemon services...")
|
|
47
41
|
|
|
48
|
-
global project_manager, git_monitor,
|
|
42
|
+
global project_manager, git_monitor, scheduler_service, mailroom_service
|
|
49
43
|
# Use MONOCO_SERVER_ROOT if set, otherwise CWD
|
|
50
44
|
env_root = os.getenv("MONOCO_SERVER_ROOT")
|
|
51
45
|
workspace_root = Path(env_root) if env_root else Path.cwd()
|
|
@@ -55,26 +49,8 @@ async def lifespan(app: FastAPI):
|
|
|
55
49
|
async def on_git_change(new_hash: str):
|
|
56
50
|
await broadcaster.broadcast("HEAD_UPDATED", {"ref": "HEAD", "hash": new_hash})
|
|
57
51
|
|
|
58
|
-
async def on_config_change(path: str):
|
|
59
|
-
logger.info(f"Config file changed: {path}, broadcasting update...")
|
|
60
|
-
await broadcaster.broadcast(
|
|
61
|
-
"CONFIG_UPDATED", {"scope": "workspace", "path": path}
|
|
62
|
-
)
|
|
63
|
-
|
|
64
52
|
git_monitor = GitMonitor(workspace_root, on_git_change)
|
|
65
53
|
|
|
66
|
-
project_config_path = get_config_path(ConfigScope.PROJECT, str(workspace_root))
|
|
67
|
-
workspace_config_path = get_config_path(ConfigScope.WORKSPACE, str(workspace_root))
|
|
68
|
-
|
|
69
|
-
config_monitors = [
|
|
70
|
-
ConfigMonitor(
|
|
71
|
-
project_config_path, lambda: on_config_change(str(project_config_path))
|
|
72
|
-
),
|
|
73
|
-
ConfigMonitor(
|
|
74
|
-
workspace_config_path, lambda: on_config_change(str(workspace_config_path))
|
|
75
|
-
),
|
|
76
|
-
]
|
|
77
|
-
|
|
78
54
|
await project_manager.start_all()
|
|
79
55
|
# Start Scheduler
|
|
80
56
|
scheduler_service = SchedulerService(project_manager)
|
|
@@ -88,15 +64,12 @@ async def lifespan(app: FastAPI):
|
|
|
88
64
|
await mailroom_service.start()
|
|
89
65
|
|
|
90
66
|
git_task = asyncio.create_task(git_monitor.start())
|
|
91
|
-
config_tasks = [asyncio.create_task(m.start()) for m in config_monitors]
|
|
92
67
|
|
|
93
68
|
yield
|
|
94
69
|
# Shutdown
|
|
95
70
|
logger.info("Shutting down Monoco Daemon services...")
|
|
96
71
|
if git_monitor:
|
|
97
72
|
git_monitor.stop()
|
|
98
|
-
for m in config_monitors:
|
|
99
|
-
m.stop()
|
|
100
73
|
if project_manager:
|
|
101
74
|
project_manager.stop_all()
|
|
102
75
|
if scheduler_service:
|
|
@@ -105,7 +78,6 @@ async def lifespan(app: FastAPI):
|
|
|
105
78
|
await mailroom_service.stop()
|
|
106
79
|
|
|
107
80
|
await git_task
|
|
108
|
-
await asyncio.gather(*config_tasks)
|
|
109
81
|
|
|
110
82
|
|
|
111
83
|
app = FastAPI(
|
|
@@ -463,35 +435,6 @@ async def refresh_monitor():
|
|
|
463
435
|
return {"status": "refreshed", "head": current_hash}
|
|
464
436
|
|
|
465
437
|
|
|
466
|
-
# --- Execution Profiles ---
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
@app.get("/api/v1/execution/profiles", response_model=List[ExecutionProfile])
|
|
470
|
-
async def get_execution_profiles(project_id: Optional[str] = None):
|
|
471
|
-
"""
|
|
472
|
-
List all execution profiles available for the project/workspace.
|
|
473
|
-
"""
|
|
474
|
-
project = None
|
|
475
|
-
if project_id:
|
|
476
|
-
project = get_project_or_404(project_id)
|
|
477
|
-
elif project_manager and project_manager.projects:
|
|
478
|
-
# Fallback to first project if none specified
|
|
479
|
-
project = list(project_manager.projects.values())[0]
|
|
480
|
-
|
|
481
|
-
return scan_execution_profiles(project.path if project else None)
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
@app.get("/api/v1/execution/profiles/detail", response_model=ExecutionProfile)
|
|
485
|
-
async def get_execution_profile_detail(path: str):
|
|
486
|
-
"""
|
|
487
|
-
Get full content of an execution profile.
|
|
488
|
-
"""
|
|
489
|
-
profile = get_profile_detail(path)
|
|
490
|
-
if not profile:
|
|
491
|
-
raise HTTPException(status_code=404, detail="Profile not found")
|
|
492
|
-
return profile
|
|
493
|
-
|
|
494
|
-
|
|
495
438
|
# --- Workspace State Management ---
|
|
496
439
|
from monoco.core.state import WorkspaceState
|
|
497
440
|
|
monoco/daemon/commands.py
CHANGED
|
@@ -1,47 +1,481 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
1
|
+
"""Monoco Daemon CLI commands for process management and service governance."""
|
|
2
|
+
|
|
3
3
|
import os
|
|
4
|
+
import signal
|
|
5
|
+
import subprocess
|
|
6
|
+
import sys
|
|
7
|
+
import time
|
|
8
|
+
from datetime import datetime
|
|
9
|
+
from pathlib import Path
|
|
4
10
|
from typing import Optional
|
|
5
|
-
|
|
11
|
+
|
|
12
|
+
import typer
|
|
13
|
+
import uvicorn
|
|
14
|
+
from rich.console import Console
|
|
15
|
+
from rich.table import Table
|
|
16
|
+
|
|
6
17
|
from monoco.core.config import get_config
|
|
18
|
+
from monoco.core.daemon.pid import PIDManager, PortManager, PIDFileError
|
|
19
|
+
from monoco.core.output import print_output
|
|
7
20
|
|
|
8
|
-
|
|
21
|
+
console = Console()
|
|
22
|
+
|
|
23
|
+
# Create serve subcommand app
|
|
24
|
+
serve_app = typer.Typer(
|
|
25
|
+
name="serve",
|
|
26
|
+
help="Monoco Daemon server management",
|
|
27
|
+
no_args_is_help=True,
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _get_workspace_root(root: Optional[str] = None) -> Path:
|
|
32
|
+
"""Get workspace root path."""
|
|
33
|
+
if root:
|
|
34
|
+
return Path(root).resolve()
|
|
35
|
+
env_root = os.getenv("MONOCO_SERVER_ROOT")
|
|
36
|
+
if env_root:
|
|
37
|
+
return Path(env_root)
|
|
38
|
+
return Path.cwd()
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _setup_signal_handlers(pid_manager: PIDManager):
|
|
42
|
+
"""Setup signal handlers for graceful shutdown.
|
|
43
|
+
|
|
44
|
+
Note: We don't call sys.exit() here to allow uvicorn's graceful shutdown
|
|
45
|
+
to complete, which will execute the lifespan shutdown code and properly
|
|
46
|
+
stop all services (watchers, scheduler, etc.).
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
def signal_handler(signum, frame):
|
|
50
|
+
console.print(f"\n[yellow]Received signal {signum}, shutting down gracefully...[/yellow]")
|
|
51
|
+
# Only remove PID file here; let uvicorn handle the rest
|
|
52
|
+
# The lifespan shutdown in app.py will stop all services
|
|
53
|
+
pid_manager.remove_pid_file()
|
|
54
|
+
# Don't call sys.exit() - let uvicorn's signal handler continue
|
|
55
|
+
# to execute the shutdown sequence properly
|
|
56
|
+
|
|
57
|
+
signal.signal(signal.SIGTERM, signal_handler)
|
|
58
|
+
signal.signal(signal.SIGINT, signal_handler)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _daemonize(
|
|
62
|
+
workspace_root: Path,
|
|
63
|
+
host: str,
|
|
64
|
+
port: int,
|
|
65
|
+
log_file: Optional[Path] = None,
|
|
66
|
+
) -> int:
|
|
67
|
+
"""Daemonize the current process using double-fork technique.
|
|
68
|
+
|
|
69
|
+
Args:
|
|
70
|
+
workspace_root: Workspace root path
|
|
71
|
+
host: Host to bind
|
|
72
|
+
port: Port to bind
|
|
73
|
+
log_file: Optional log file path (defaults to .monoco/log/daemon.log)
|
|
74
|
+
|
|
75
|
+
Returns:
|
|
76
|
+
Parent process returns child PID, child process returns 0
|
|
77
|
+
"""
|
|
78
|
+
if log_file is None:
|
|
79
|
+
log_dir = workspace_root / ".monoco" / "log"
|
|
80
|
+
log_dir.mkdir(parents=True, exist_ok=True)
|
|
81
|
+
log_file = log_dir / "daemon.log"
|
|
82
|
+
|
|
83
|
+
# First fork
|
|
84
|
+
try:
|
|
85
|
+
pid = os.fork()
|
|
86
|
+
if pid > 0:
|
|
87
|
+
# Parent process: wait a moment to check if child started successfully
|
|
88
|
+
time.sleep(0.5)
|
|
89
|
+
return pid
|
|
90
|
+
except OSError as e:
|
|
91
|
+
console.print(f"[red]Fork #1 failed: {e}[/red]")
|
|
92
|
+
sys.exit(1)
|
|
93
|
+
|
|
94
|
+
# Child process continues
|
|
95
|
+
os.chdir(str(workspace_root))
|
|
96
|
+
os.setsid() # Create new session, detach from terminal
|
|
97
|
+
|
|
98
|
+
# Second fork
|
|
99
|
+
try:
|
|
100
|
+
pid = os.fork()
|
|
101
|
+
if pid > 0:
|
|
102
|
+
# First child exits
|
|
103
|
+
sys.exit(0)
|
|
104
|
+
except OSError as e:
|
|
105
|
+
console.print(f"[red]Fork #2 failed: {e}[/red]")
|
|
106
|
+
sys.exit(1)
|
|
107
|
+
|
|
108
|
+
# Grandchild process continues
|
|
109
|
+
# Redirect stdout/stderr to log file
|
|
110
|
+
sys.stdout.flush()
|
|
111
|
+
sys.stderr.flush()
|
|
112
|
+
|
|
113
|
+
with open(log_file, "a+") as log:
|
|
114
|
+
os.dup2(log.fileno(), sys.stdout.fileno())
|
|
115
|
+
os.dup2(log.fileno(), sys.stderr.fileno())
|
|
116
|
+
|
|
117
|
+
return 0
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def serve_start(
|
|
121
|
+
host: str = typer.Option("127.0.0.1", "--host", "-h", help="Bind host"),
|
|
122
|
+
port: int = typer.Option(8642, "--port", "-p", help="Bind port"),
|
|
123
|
+
daemon: bool = typer.Option(
|
|
124
|
+
False, "--daemon", "-d", help="Run as background daemon"
|
|
125
|
+
),
|
|
126
|
+
root: Optional[str] = typer.Option(
|
|
127
|
+
None, "--root", help="Workspace root directory"
|
|
128
|
+
),
|
|
129
|
+
max_agents: Optional[int] = typer.Option(
|
|
130
|
+
None, "--max-agents", help="Override global maximum concurrent agents"
|
|
131
|
+
),
|
|
132
|
+
auto_port: bool = typer.Option(
|
|
133
|
+
True, "--auto-port/--no-auto-port", help="Automatically find available port if default is in use"
|
|
134
|
+
),
|
|
135
|
+
):
|
|
136
|
+
"""Start the Monoco Daemon server."""
|
|
137
|
+
workspace_root = _get_workspace_root(root)
|
|
138
|
+
pid_manager = PIDManager(workspace_root)
|
|
139
|
+
|
|
140
|
+
# Check if already running
|
|
141
|
+
existing = pid_manager.get_daemon_info()
|
|
142
|
+
if existing:
|
|
143
|
+
console.print(
|
|
144
|
+
f"[yellow]Daemon already running (PID: {existing['pid']}, "
|
|
145
|
+
f"http://{existing['host']}:{existing['port']})[/yellow]"
|
|
146
|
+
)
|
|
147
|
+
raise typer.Exit(code=0)
|
|
148
|
+
|
|
149
|
+
# Handle port selection
|
|
150
|
+
try:
|
|
151
|
+
if auto_port and PortManager.is_port_in_use(port, host):
|
|
152
|
+
new_port = PortManager.find_available_port(port + 1, host)
|
|
153
|
+
console.print(
|
|
154
|
+
f"[yellow]Port {port} is in use, using port {new_port} instead[/yellow]"
|
|
155
|
+
)
|
|
156
|
+
port = new_port
|
|
157
|
+
elif not auto_port and PortManager.is_port_in_use(port, host):
|
|
158
|
+
console.print(f"[red]Error: Port {port} is already in use[/red]")
|
|
159
|
+
raise typer.Exit(code=1)
|
|
160
|
+
except PIDFileError as e:
|
|
161
|
+
console.print(f"[red]Error: {e}[/red]")
|
|
162
|
+
raise typer.Exit(code=1)
|
|
163
|
+
|
|
164
|
+
# Set environment variables
|
|
165
|
+
os.environ["MONOCO_SERVER_ROOT"] = str(workspace_root)
|
|
166
|
+
if max_agents is not None:
|
|
167
|
+
os.environ["MONOCO_MAX_AGENTS"] = str(max_agents)
|
|
168
|
+
|
|
169
|
+
if daemon:
|
|
170
|
+
# Daemonize
|
|
171
|
+
log_dir = workspace_root / ".monoco" / "log"
|
|
172
|
+
log_dir.mkdir(parents=True, exist_ok=True)
|
|
173
|
+
log_file = log_dir / "daemon.log"
|
|
174
|
+
|
|
175
|
+
pid = _daemonize(workspace_root, host, port, log_file)
|
|
176
|
+
if pid > 0:
|
|
177
|
+
# Parent process: show success message and exit
|
|
178
|
+
console.print(f"[green]Daemon started (PID: {pid})[/green]")
|
|
179
|
+
console.print(f"[dim]Logs: {log_file}[/dim]")
|
|
180
|
+
console.print(f"[dim]URL: http://{host}:{port}[/dim]")
|
|
181
|
+
raise typer.Exit(code=0)
|
|
182
|
+
|
|
183
|
+
# Child process: continue to start server
|
|
184
|
+
|
|
185
|
+
# Create PID file before starting server
|
|
186
|
+
try:
|
|
187
|
+
pid_file = pid_manager.create_pid_file(host, port)
|
|
188
|
+
except PIDFileError as e:
|
|
189
|
+
console.print(f"[red]Error: {e}[/red]")
|
|
190
|
+
raise typer.Exit(code=1)
|
|
191
|
+
|
|
192
|
+
# Setup signal handlers for graceful shutdown in both modes
|
|
193
|
+
# - Foreground: Ctrl+C (SIGINT) or SIGTERM
|
|
194
|
+
# - Daemon: SIGTERM from `serve stop` command
|
|
195
|
+
_setup_signal_handlers(pid_manager)
|
|
196
|
+
|
|
197
|
+
try:
|
|
198
|
+
console.print(
|
|
199
|
+
f"[green]Starting Monoco Daemon on http://{host}:{port}[/green]"
|
|
200
|
+
)
|
|
201
|
+
if daemon:
|
|
202
|
+
print(
|
|
203
|
+
f"[{datetime.now().isoformat()}] Daemon started on {host}:{port}",
|
|
204
|
+
flush=True,
|
|
205
|
+
)
|
|
206
|
+
|
|
207
|
+
app_str = "monoco.daemon.app:app"
|
|
208
|
+
uvicorn.run(
|
|
209
|
+
app_str,
|
|
210
|
+
host=host,
|
|
211
|
+
port=port,
|
|
212
|
+
reload=False,
|
|
213
|
+
log_level="info",
|
|
214
|
+
)
|
|
215
|
+
finally:
|
|
216
|
+
pid_manager.remove_pid_file()
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def serve_stop(
|
|
220
|
+
root: Optional[str] = typer.Option(None, "--root", help="Workspace root directory"),
|
|
221
|
+
force: bool = typer.Option(False, "--force", "-f", help="Force kill the daemon"),
|
|
222
|
+
):
|
|
223
|
+
"""Stop the running Monoco Daemon."""
|
|
224
|
+
workspace_root = _get_workspace_root(root)
|
|
225
|
+
pid_manager = PIDManager(workspace_root)
|
|
226
|
+
|
|
227
|
+
daemon_info = pid_manager.get_daemon_info()
|
|
228
|
+
if not daemon_info:
|
|
229
|
+
console.print("[yellow]Daemon is not running[/yellow]")
|
|
230
|
+
raise typer.Exit(code=0)
|
|
231
|
+
|
|
232
|
+
pid = daemon_info["pid"]
|
|
233
|
+
console.print(f"Stopping daemon (PID: {pid})...")
|
|
234
|
+
|
|
235
|
+
if force:
|
|
236
|
+
success = pid_manager.send_signal(signal.SIGKILL)
|
|
237
|
+
else:
|
|
238
|
+
success = pid_manager.terminate(timeout=5)
|
|
239
|
+
|
|
240
|
+
if success:
|
|
241
|
+
console.print("[green]Daemon stopped successfully[/green]")
|
|
242
|
+
else:
|
|
243
|
+
console.print("[red]Failed to stop daemon[/red]")
|
|
244
|
+
raise typer.Exit(code=1)
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def serve_status(
|
|
248
|
+
root: Optional[str] = typer.Option(None, "--root", help="Workspace root directory"),
|
|
249
|
+
):
|
|
250
|
+
"""Show the status of the Monoco Daemon."""
|
|
251
|
+
workspace_root = _get_workspace_root(root)
|
|
252
|
+
pid_manager = PIDManager(workspace_root)
|
|
253
|
+
|
|
254
|
+
daemon_info = pid_manager.get_daemon_info()
|
|
255
|
+
|
|
256
|
+
if not daemon_info:
|
|
257
|
+
console.print("[yellow]Daemon is not running[/yellow]")
|
|
258
|
+
console.print(f"Workspace: {workspace_root}")
|
|
259
|
+
raise typer.Exit(code=0)
|
|
260
|
+
|
|
261
|
+
# Calculate uptime
|
|
262
|
+
started_at = datetime.fromisoformat(daemon_info["started_at"])
|
|
263
|
+
uptime = datetime.now() - started_at
|
|
264
|
+
uptime_str = str(uptime).split(".")[0] # Remove microseconds
|
|
265
|
+
|
|
266
|
+
table = Table(title="Monoco Daemon Status")
|
|
267
|
+
table.add_column("Property", style="cyan")
|
|
268
|
+
table.add_column("Value", style="green")
|
|
269
|
+
|
|
270
|
+
table.add_row("Status", "Running")
|
|
271
|
+
table.add_row("PID", str(daemon_info["pid"]))
|
|
272
|
+
table.add_row("Host", daemon_info["host"])
|
|
273
|
+
table.add_row("Port", str(daemon_info["port"]))
|
|
274
|
+
table.add_row("URL", f"http://{daemon_info['host']}:{daemon_info['port']}")
|
|
275
|
+
table.add_row("Version", daemon_info.get("version", "unknown"))
|
|
276
|
+
table.add_row("Started At", daemon_info["started_at"])
|
|
277
|
+
table.add_row("Uptime", uptime_str)
|
|
278
|
+
table.add_row("Workspace", str(workspace_root))
|
|
9
279
|
|
|
280
|
+
console.print(table)
|
|
10
281
|
|
|
11
|
-
|
|
282
|
+
|
|
283
|
+
def serve_restart(
|
|
284
|
+
host: str = typer.Option("127.0.0.1", "--host", "-h", help="Bind host"),
|
|
285
|
+
port: int = typer.Option(8642, "--port", "-p", help="Bind port"),
|
|
286
|
+
daemon: bool = typer.Option(
|
|
287
|
+
False, "--daemon", "-d", help="Run as background daemon"
|
|
288
|
+
),
|
|
289
|
+
root: Optional[str] = typer.Option(
|
|
290
|
+
None, "--root", help="Workspace root directory"
|
|
291
|
+
),
|
|
292
|
+
max_agents: Optional[int] = typer.Option(
|
|
293
|
+
None, "--max-agents", help="Override global maximum concurrent agents"
|
|
294
|
+
),
|
|
295
|
+
auto_port: bool = typer.Option(
|
|
296
|
+
True, "--auto-port/--no-auto-port", help="Automatically find available port if default is in use"
|
|
297
|
+
),
|
|
298
|
+
):
|
|
299
|
+
"""Restart the Monoco Daemon."""
|
|
300
|
+
workspace_root = _get_workspace_root(root)
|
|
301
|
+
pid_manager = PIDManager(workspace_root)
|
|
302
|
+
|
|
303
|
+
# Stop if running
|
|
304
|
+
if pid_manager.get_daemon_info():
|
|
305
|
+
console.print("Stopping existing daemon...")
|
|
306
|
+
serve_stop(root=root)
|
|
307
|
+
time.sleep(1) # Wait for process to fully terminate
|
|
308
|
+
|
|
309
|
+
# Start new daemon
|
|
310
|
+
serve_start(
|
|
311
|
+
host=host,
|
|
312
|
+
port=port,
|
|
313
|
+
daemon=daemon,
|
|
314
|
+
root=root,
|
|
315
|
+
max_agents=max_agents,
|
|
316
|
+
auto_port=auto_port,
|
|
317
|
+
)
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
def serve_cleanup(
|
|
321
|
+
root: Optional[str] = typer.Option(None, "--root", help="Workspace root directory"),
|
|
322
|
+
port: int = typer.Option(8642, "--port", "-p", help="Default port to check for orphans"),
|
|
323
|
+
dry_run: bool = typer.Option(
|
|
324
|
+
False, "--dry-run", help="Show what would be cleaned without actually doing it"
|
|
325
|
+
),
|
|
326
|
+
):
|
|
327
|
+
"""Clean up orphaned daemon processes.
|
|
328
|
+
|
|
329
|
+
Scans for and terminates orphaned uvicorn processes that may have been
|
|
330
|
+
left behind when terminals were closed without proper shutdown.
|
|
331
|
+
"""
|
|
332
|
+
workspace_root = _get_workspace_root(root)
|
|
333
|
+
pid_manager = PIDManager(workspace_root)
|
|
334
|
+
|
|
335
|
+
cleaned = []
|
|
336
|
+
errors = []
|
|
337
|
+
|
|
338
|
+
# Check PID file first
|
|
339
|
+
pid_data = pid_manager.read_pid_file()
|
|
340
|
+
if pid_data:
|
|
341
|
+
pid = pid_data["pid"]
|
|
342
|
+
if not PIDManager.is_process_alive(pid):
|
|
343
|
+
if not dry_run:
|
|
344
|
+
pid_manager.remove_pid_file()
|
|
345
|
+
cleaned.append(f"Stale PID file (PID: {pid})")
|
|
346
|
+
|
|
347
|
+
# Find uvicorn processes
|
|
348
|
+
try:
|
|
349
|
+
result = subprocess.run(
|
|
350
|
+
["ps", "aux"],
|
|
351
|
+
capture_output=True,
|
|
352
|
+
text=True,
|
|
353
|
+
check=True,
|
|
354
|
+
)
|
|
355
|
+
|
|
356
|
+
for line in result.stdout.splitlines():
|
|
357
|
+
if "uvicorn" in line.lower() and "monoco.daemon.app" in line:
|
|
358
|
+
parts = line.split()
|
|
359
|
+
if len(parts) >= 2:
|
|
360
|
+
try:
|
|
361
|
+
uvicorn_pid = int(parts[1])
|
|
362
|
+
# Check if this is the current valid daemon
|
|
363
|
+
if pid_data and uvicorn_pid == pid_data["pid"]:
|
|
364
|
+
continue
|
|
365
|
+
|
|
366
|
+
proc_info = " ".join(parts[10:]) if len(parts) > 10 else line
|
|
367
|
+
|
|
368
|
+
if not dry_run:
|
|
369
|
+
try:
|
|
370
|
+
os.kill(uvicorn_pid, signal.SIGTERM)
|
|
371
|
+
# Wait briefly for graceful termination
|
|
372
|
+
time.sleep(0.5)
|
|
373
|
+
if PIDManager.is_process_alive(uvicorn_pid):
|
|
374
|
+
os.kill(uvicorn_pid, signal.SIGKILL)
|
|
375
|
+
cleaned.append(f"Orphan uvicorn (PID: {uvicorn_pid}): {proc_info[:50]}...")
|
|
376
|
+
except (OSError, ProcessLookupError) as e:
|
|
377
|
+
errors.append(f"Failed to kill PID {uvicorn_pid}: {e}")
|
|
378
|
+
else:
|
|
379
|
+
cleaned.append(f"[DRY RUN] Orphan uvicorn (PID: {uvicorn_pid}): {proc_info[:50]}...")
|
|
380
|
+
except (ValueError, IndexError):
|
|
381
|
+
continue
|
|
382
|
+
|
|
383
|
+
except subprocess.SubprocessError as e:
|
|
384
|
+
console.print(f"[red]Error scanning processes: {e}[/red]")
|
|
385
|
+
raise typer.Exit(code=1)
|
|
386
|
+
|
|
387
|
+
# Check for port conflicts
|
|
388
|
+
for check_port in range(port, port + 10):
|
|
389
|
+
if PortManager.is_port_in_use(check_port, "127.0.0.1"):
|
|
390
|
+
# Try to find which process is using this port
|
|
391
|
+
try:
|
|
392
|
+
result = subprocess.run(
|
|
393
|
+
["lsof", "-ti", f":{check_port}"],
|
|
394
|
+
capture_output=True,
|
|
395
|
+
text=True,
|
|
396
|
+
)
|
|
397
|
+
if result.returncode == 0 and result.stdout.strip():
|
|
398
|
+
pids = result.stdout.strip().split("\n")
|
|
399
|
+
for pid_str in pids:
|
|
400
|
+
try:
|
|
401
|
+
pid = int(pid_str.strip())
|
|
402
|
+
# Skip if it's our known daemon
|
|
403
|
+
if pid_data and pid == pid_data["pid"]:
|
|
404
|
+
continue
|
|
405
|
+
|
|
406
|
+
if not dry_run:
|
|
407
|
+
try:
|
|
408
|
+
os.kill(pid, signal.SIGTERM)
|
|
409
|
+
cleaned.append(f"Process on port {check_port} (PID: {pid})")
|
|
410
|
+
except (OSError, ProcessLookupError):
|
|
411
|
+
pass
|
|
412
|
+
else:
|
|
413
|
+
cleaned.append(f"[DRY RUN] Process on port {check_port} (PID: {pid})")
|
|
414
|
+
except ValueError:
|
|
415
|
+
continue
|
|
416
|
+
except (subprocess.SubprocessError, FileNotFoundError):
|
|
417
|
+
# lsof might not be available
|
|
418
|
+
pass
|
|
419
|
+
|
|
420
|
+
# Report results
|
|
421
|
+
if cleaned:
|
|
422
|
+
console.print(f"[green]Cleaned {len(cleaned)} item(s):[/green]")
|
|
423
|
+
for item in cleaned:
|
|
424
|
+
console.print(f" - {item}")
|
|
425
|
+
else:
|
|
426
|
+
console.print("[green]No orphaned processes found[/green]")
|
|
427
|
+
|
|
428
|
+
if errors:
|
|
429
|
+
console.print(f"[yellow]Errors ({len(errors)}):[/yellow]")
|
|
430
|
+
for error in errors:
|
|
431
|
+
console.print(f" - {error}")
|
|
432
|
+
|
|
433
|
+
|
|
434
|
+
def serve_legacy(
|
|
12
435
|
host: str = typer.Option("127.0.0.1", "--host", "-h", help="Bind host"),
|
|
13
436
|
port: int = typer.Option(8642, "--port", "-p", help="Bind port"),
|
|
14
437
|
reload: bool = typer.Option(
|
|
15
438
|
False, "--reload", "-r", help="Enable auto-reload for dev"
|
|
16
439
|
),
|
|
17
|
-
root: Optional[str] = typer.Option(
|
|
440
|
+
root: Optional[str] = typer.Option(
|
|
441
|
+
None, "--root", help="Workspace root directory"
|
|
442
|
+
),
|
|
18
443
|
max_agents: Optional[int] = typer.Option(
|
|
19
444
|
None, "--max-agents", help="Override global maximum concurrent agents (default: 3)"
|
|
20
445
|
),
|
|
21
446
|
):
|
|
22
|
-
"""
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
447
|
+
"""Start the Monoco Daemon server (legacy command, same as 'serve start')."""
|
|
448
|
+
# For backward compatibility, --reload implies foreground mode
|
|
449
|
+
if reload:
|
|
450
|
+
workspace_root = _get_workspace_root(root)
|
|
451
|
+
os.environ["MONOCO_SERVER_ROOT"] = str(workspace_root)
|
|
452
|
+
if max_agents is not None:
|
|
453
|
+
os.environ["MONOCO_MAX_AGENTS"] = str(max_agents)
|
|
26
454
|
|
|
27
|
-
if root:
|
|
28
|
-
os.environ["MONOCO_SERVER_ROOT"] = str(Path(root).resolve())
|
|
29
455
|
print_output(
|
|
30
|
-
f"
|
|
456
|
+
f"Starting Monoco Daemon on http://{host}:{port}", title="Monoco Serve"
|
|
31
457
|
)
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
458
|
+
|
|
459
|
+
app_str = "monoco.daemon.app:app"
|
|
460
|
+
uvicorn.run(app_str, host=host, port=port, reload=reload, log_level="info")
|
|
461
|
+
else:
|
|
462
|
+
# Without --reload, use the new start command
|
|
463
|
+
serve_start(
|
|
464
|
+
host=host,
|
|
465
|
+
port=port,
|
|
466
|
+
daemon=False,
|
|
467
|
+
root=root,
|
|
468
|
+
max_agents=max_agents,
|
|
469
|
+
auto_port=True,
|
|
38
470
|
)
|
|
39
471
|
|
|
40
|
-
print_output(
|
|
41
|
-
f"Starting Monoco Daemon on http://{host}:{port}", title="Monoco Serve"
|
|
42
|
-
)
|
|
43
472
|
|
|
44
|
-
|
|
45
|
-
|
|
473
|
+
# Register subcommands
|
|
474
|
+
serve_app.command(name="start")(serve_start)
|
|
475
|
+
serve_app.command(name="stop")(serve_stop)
|
|
476
|
+
serve_app.command(name="status")(serve_status)
|
|
477
|
+
serve_app.command(name="restart")(serve_restart)
|
|
478
|
+
serve_app.command(name="cleanup")(serve_cleanup)
|
|
46
479
|
|
|
47
|
-
|
|
480
|
+
# Keep 'serve' as alias for 'serve start' for backward compatibility
|
|
481
|
+
serve = serve_legacy
|