forgexa-cli 1.21.0__tar.gz → 1.21.2__tar.gz
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.
- {forgexa_cli-1.21.0 → forgexa_cli-1.21.2}/PKG-INFO +1 -1
- {forgexa_cli-1.21.0 → forgexa_cli-1.21.2}/forgexa_cli/__init__.py +1 -1
- {forgexa_cli-1.21.0 → forgexa_cli-1.21.2}/forgexa_cli/daemon.py +549 -17
- {forgexa_cli-1.21.0 → forgexa_cli-1.21.2}/forgexa_cli/main.py +81 -3
- {forgexa_cli-1.21.0 → forgexa_cli-1.21.2}/forgexa_cli.egg-info/PKG-INFO +1 -1
- {forgexa_cli-1.21.0 → forgexa_cli-1.21.2}/pyproject.toml +1 -1
- {forgexa_cli-1.21.0 → forgexa_cli-1.21.2}/README.md +0 -0
- {forgexa_cli-1.21.0 → forgexa_cli-1.21.2}/forgexa_cli/_build_config.py +0 -0
- {forgexa_cli-1.21.0 → forgexa_cli-1.21.2}/forgexa_cli/autoupgrade.py +0 -0
- {forgexa_cli-1.21.0 → forgexa_cli-1.21.2}/forgexa_cli/py.typed +0 -0
- {forgexa_cli-1.21.0 → forgexa_cli-1.21.2}/forgexa_cli.egg-info/SOURCES.txt +0 -0
- {forgexa_cli-1.21.0 → forgexa_cli-1.21.2}/forgexa_cli.egg-info/dependency_links.txt +0 -0
- {forgexa_cli-1.21.0 → forgexa_cli-1.21.2}/forgexa_cli.egg-info/entry_points.txt +0 -0
- {forgexa_cli-1.21.0 → forgexa_cli-1.21.2}/forgexa_cli.egg-info/requires.txt +0 -0
- {forgexa_cli-1.21.0 → forgexa_cli-1.21.2}/forgexa_cli.egg-info/top_level.txt +0 -0
- {forgexa_cli-1.21.0 → forgexa_cli-1.21.2}/setup.cfg +0 -0
- {forgexa_cli-1.21.0 → forgexa_cli-1.21.2}/tests/test_auth_and_runtime_commands.py +0 -0
- {forgexa_cli-1.21.0 → forgexa_cli-1.21.2}/tests/test_autoupgrade.py +0 -0
- {forgexa_cli-1.21.0 → forgexa_cli-1.21.2}/tests/test_check_command.py +0 -0
- {forgexa_cli-1.21.0 → forgexa_cli-1.21.2}/tests/test_silent_install.py +0 -0
- {forgexa_cli-1.21.0 → forgexa_cli-1.21.2}/tests/test_upgrade_observability.py +0 -0
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
"""forgexa-cli — Forgexa command-line client."""
|
|
2
|
-
__version__ = "1.21.
|
|
2
|
+
__version__ = "1.21.2"
|
|
@@ -47,7 +47,7 @@ from datetime import datetime, timezone
|
|
|
47
47
|
from pathlib import Path
|
|
48
48
|
from typing import Any, Awaitable, Callable
|
|
49
49
|
from urllib.parse import urlparse
|
|
50
|
-
from uuid import UUID
|
|
50
|
+
from uuid import UUID, uuid4
|
|
51
51
|
|
|
52
52
|
# fcntl is Unix-only; on Windows we skip file locking
|
|
53
53
|
try:
|
|
@@ -555,7 +555,7 @@ except (ImportError, ModuleNotFoundError):
|
|
|
555
555
|
# DAEMON_VERSION is the protocol/logic version of the daemon code.
|
|
556
556
|
# Kept in sync with pyproject.toml version via bump-version.sh.
|
|
557
557
|
# CLIENT_TYPE identifies which packaging/distribution this daemon runs in.
|
|
558
|
-
DAEMON_VERSION = "1.21.
|
|
558
|
+
DAEMON_VERSION = "1.21.2"
|
|
559
559
|
|
|
560
560
|
|
|
561
561
|
def _detect_client_type() -> str:
|
|
@@ -780,8 +780,19 @@ def _prepare_agent_command(command: list[str]) -> list[str]:
|
|
|
780
780
|
|
|
781
781
|
suffix = Path(command[0]).suffix.lower()
|
|
782
782
|
if suffix in {".cmd", ".bat"}:
|
|
783
|
+
comspec = os.environ.get("COMSPEC", "")
|
|
784
|
+
if comspec and Path(comspec).is_file():
|
|
785
|
+
command_processor = comspec
|
|
786
|
+
else:
|
|
787
|
+
system_root = os.environ.get("SystemRoot") or os.environ.get("WINDIR", "")
|
|
788
|
+
system_cmd = Path(system_root) / "System32" / "cmd.exe" if system_root else None
|
|
789
|
+
command_processor = (
|
|
790
|
+
str(system_cmd)
|
|
791
|
+
if system_cmd and system_cmd.is_file()
|
|
792
|
+
else shutil.which("cmd.exe") or comspec or "cmd.exe"
|
|
793
|
+
)
|
|
783
794
|
return [
|
|
784
|
-
|
|
795
|
+
command_processor,
|
|
785
796
|
"/d",
|
|
786
797
|
"/s",
|
|
787
798
|
"/c",
|
|
@@ -800,6 +811,46 @@ def _prepare_agent_command(command: list[str]) -> list[str]:
|
|
|
800
811
|
return command
|
|
801
812
|
|
|
802
813
|
|
|
814
|
+
def _agent_process_group_kwargs() -> dict[str, bool]:
|
|
815
|
+
"""Isolate agent process groups where the platform supports POSIX sessions."""
|
|
816
|
+
# Windows cleanup uses taskkill /T, and starting cmd.exe with a new session
|
|
817
|
+
# differs from the command path proven during agent discovery.
|
|
818
|
+
return {"start_new_session": True} if sys.platform != "win32" else {}
|
|
819
|
+
|
|
820
|
+
|
|
821
|
+
def _resolve_git_command() -> str | None:
|
|
822
|
+
"""Return Git's executable, including common Windows installer locations."""
|
|
823
|
+
resolved = shutil.which("git")
|
|
824
|
+
if resolved or sys.platform != "win32":
|
|
825
|
+
return resolved
|
|
826
|
+
|
|
827
|
+
home = Path.home()
|
|
828
|
+
roots = [
|
|
829
|
+
os.environ.get("ProgramFiles", ""),
|
|
830
|
+
os.environ.get("ProgramW6432", ""),
|
|
831
|
+
os.environ.get("ProgramFiles(x86)", ""),
|
|
832
|
+
str(Path(os.environ.get("LOCALAPPDATA", home / "AppData" / "Local")) / "Programs"),
|
|
833
|
+
]
|
|
834
|
+
git_dirs = [
|
|
835
|
+
Path(root) / "Git" / directory
|
|
836
|
+
for root in roots if root
|
|
837
|
+
for directory in ("cmd", "bin", "usr" / Path("bin"))
|
|
838
|
+
]
|
|
839
|
+
existing_dirs = [directory for directory in git_dirs if directory.is_dir()]
|
|
840
|
+
if existing_dirs:
|
|
841
|
+
current = os.environ.get("PATH", "")
|
|
842
|
+
current_dirs = set(current.split(os.pathsep))
|
|
843
|
+
additions = [str(directory) for directory in existing_dirs if str(directory) not in current_dirs]
|
|
844
|
+
if additions:
|
|
845
|
+
os.environ["PATH"] = os.pathsep.join(additions) + os.pathsep + current
|
|
846
|
+
for directory in existing_dirs:
|
|
847
|
+
candidate = directory / "git.exe"
|
|
848
|
+
if candidate.is_file():
|
|
849
|
+
return str(candidate)
|
|
850
|
+
|
|
851
|
+
return shutil.which("git")
|
|
852
|
+
|
|
853
|
+
|
|
803
854
|
@dataclass
|
|
804
855
|
class TaskInfo:
|
|
805
856
|
task_id: str
|
|
@@ -835,6 +886,8 @@ class TaskResult:
|
|
|
835
886
|
# "all_agents_rate_limited" — daemon tried every installed agent, all
|
|
836
887
|
# hit rate/quota limits. Server must NOT retry on the same runtime.
|
|
837
888
|
failure_code: str = ""
|
|
889
|
+
# Optional structured preflight result for project/runtime preflight failures.
|
|
890
|
+
preflight: dict | None = None
|
|
838
891
|
artifacts: list[dict] = field(default_factory=list)
|
|
839
892
|
observations: list[dict] = field(default_factory=list)
|
|
840
893
|
metrics: dict = field(default_factory=dict)
|
|
@@ -1712,10 +1765,8 @@ class WorkspaceManager:
|
|
|
1712
1765
|
|
|
1713
1766
|
def __init__(self, root: str):
|
|
1714
1767
|
self.root = Path(root).expanduser()
|
|
1715
|
-
self.root.mkdir(parents=True, exist_ok=True)
|
|
1716
1768
|
# Project repos live under projects/ to avoid mixing with other daemon files
|
|
1717
1769
|
self.projects_root = self.root / "projects"
|
|
1718
|
-
self.projects_root.mkdir(parents=True, exist_ok=True)
|
|
1719
1770
|
self._ssh_keys: dict[str, str] = {} # project_key -> PEM key content
|
|
1720
1771
|
self._http_auth: dict[str, dict[str, str]] = {} # project_key -> provider/token/username
|
|
1721
1772
|
|
|
@@ -1725,8 +1776,22 @@ class WorkspaceManager:
|
|
|
1725
1776
|
self._worktree_locks: dict[str, asyncio.Lock] = {}
|
|
1726
1777
|
self._worktree_locks_mu: asyncio.Lock = asyncio.Lock()
|
|
1727
1778
|
|
|
1779
|
+
self._dirs_ready = False
|
|
1780
|
+
|
|
1781
|
+
def _ensure_dirs(self) -> None:
|
|
1782
|
+
"""Lazy initialization of workspace directories.
|
|
1783
|
+
|
|
1784
|
+
Called before first use (prepare_workspace or preflight check).
|
|
1785
|
+
If the root cannot be created (permissions, disk full), the OSError
|
|
1786
|
+
propagates to the caller — the daemon itself does NOT crash at startup.
|
|
1787
|
+
"""
|
|
1788
|
+
if self._dirs_ready:
|
|
1789
|
+
return
|
|
1790
|
+
self.root.mkdir(parents=True, exist_ok=True)
|
|
1791
|
+
self.projects_root.mkdir(parents=True, exist_ok=True)
|
|
1728
1792
|
# One-time migration: move legacy project dirs from root/ to projects/
|
|
1729
1793
|
self._migrate_legacy_project_dirs()
|
|
1794
|
+
self._dirs_ready = True
|
|
1730
1795
|
|
|
1731
1796
|
def set_ssh_key(self, project_key: str, ssh_private_key: str) -> None:
|
|
1732
1797
|
"""Register an SSH private key for a project."""
|
|
@@ -1992,12 +2057,13 @@ class WorkspaceManager:
|
|
|
1992
2057
|
if last_exc is not None:
|
|
1993
2058
|
raise last_exc
|
|
1994
2059
|
|
|
1995
|
-
|
|
1996
|
-
"""
|
|
2060
|
+
def _configure_project_git_auth(self, project: dict) -> tuple[str, str, str]:
|
|
2061
|
+
"""Register SSH key and HTTPS token auth for a project.
|
|
1997
2062
|
|
|
1998
|
-
|
|
1999
|
-
|
|
2000
|
-
|
|
2063
|
+
Extracted from prepare_workspace() so that project-level preflight
|
|
2064
|
+
(ls-remote) can register credentials before worktree creation.
|
|
2065
|
+
|
|
2066
|
+
Returns (project_key, repo_url, default_branch).
|
|
2001
2067
|
"""
|
|
2002
2068
|
project_key = project.get("project_key", "default")
|
|
2003
2069
|
repo_url = project.get("repo_url", "")
|
|
@@ -2016,6 +2082,105 @@ class WorkspaceManager:
|
|
|
2016
2082
|
if git_api_token:
|
|
2017
2083
|
self.set_http_auth(project_key, git_provider, git_api_token, git_username or None)
|
|
2018
2084
|
|
|
2085
|
+
return project_key, repo_url, default_branch
|
|
2086
|
+
|
|
2087
|
+
async def check_remote_access(self, project: dict) -> dict | None:
|
|
2088
|
+
"""Project-level preflight: verify remote repo access via ls-remote.
|
|
2089
|
+
|
|
2090
|
+
Returns None on success, or a structured preflight result dict on failure:
|
|
2091
|
+
|
|
2092
|
+
{"scope": "project", "checks": [{"id": "...", "status": "fail",
|
|
2093
|
+
"failure_class": "...", "detail": "...", "remediation": "..."}]}
|
|
2094
|
+
|
|
2095
|
+
For local-init projects (empty repo_url), returns None (skip — not an error).
|
|
2096
|
+
"""
|
|
2097
|
+
project_key, repo_url, default_branch = self._configure_project_git_auth(project)
|
|
2098
|
+
|
|
2099
|
+
# Empty repo_url is a legal local-init mode — no remote to check.
|
|
2100
|
+
if not repo_url:
|
|
2101
|
+
return None
|
|
2102
|
+
|
|
2103
|
+
ref = f"refs/heads/{default_branch}"
|
|
2104
|
+
try:
|
|
2105
|
+
output = await self._git(
|
|
2106
|
+
"ls-remote", repo_url, ref,
|
|
2107
|
+
project_key=project_key,
|
|
2108
|
+
timeout=30,
|
|
2109
|
+
)
|
|
2110
|
+
if not output.strip():
|
|
2111
|
+
# `git ls-remote` exits 0 even when the ref pattern matches no
|
|
2112
|
+
# refs at all -- a missing branch is NOT reported as a command
|
|
2113
|
+
# failure. Without this check, a typo'd or deleted default
|
|
2114
|
+
# branch would silently pass preflight and only surface later
|
|
2115
|
+
# as a worktree checkout failure.
|
|
2116
|
+
return {
|
|
2117
|
+
"scope": "project",
|
|
2118
|
+
"checks": [
|
|
2119
|
+
{
|
|
2120
|
+
"id": "git_remote_access",
|
|
2121
|
+
"status": "fail",
|
|
2122
|
+
"blocking": True,
|
|
2123
|
+
"failure_class": "default_branch_missing",
|
|
2124
|
+
"detail": f"No ref matching '{ref}' found on remote",
|
|
2125
|
+
"remediation": f"Verify the default branch '{default_branch}' exists on the remote repository",
|
|
2126
|
+
}
|
|
2127
|
+
],
|
|
2128
|
+
}
|
|
2129
|
+
return None # success
|
|
2130
|
+
except Exception as exc:
|
|
2131
|
+
err_lower = str(exc).lower()
|
|
2132
|
+
# Classify into failure_class using error markers (not free-text parsing)
|
|
2133
|
+
if self._is_retryable_git_transport_error(exc):
|
|
2134
|
+
failure_class = "network_transient"
|
|
2135
|
+
remediation = "Check network connectivity, DNS, or firewall rules"
|
|
2136
|
+
elif any(m in err_lower for m in (
|
|
2137
|
+
"permission denied", "authentication failed", "access denied",
|
|
2138
|
+
"could not read from remote", "fatal: could not read",
|
|
2139
|
+
"invalid credentials", "unauthorized",
|
|
2140
|
+
)):
|
|
2141
|
+
failure_class = "auth_error"
|
|
2142
|
+
remediation = "Check SSH key, deploy key, or HTTPS token permissions for this repository"
|
|
2143
|
+
elif any(m in err_lower for m in (
|
|
2144
|
+
"repository not found", "not found", "does not exist",
|
|
2145
|
+
"no such repository",
|
|
2146
|
+
)):
|
|
2147
|
+
failure_class = "not_found"
|
|
2148
|
+
remediation = "Verify the repository URL is correct and accessible"
|
|
2149
|
+
elif any(m in err_lower for m in (
|
|
2150
|
+
"refs/heads/", "remote branch", "no refs", "failed to resolve",
|
|
2151
|
+
)):
|
|
2152
|
+
failure_class = "default_branch_missing"
|
|
2153
|
+
remediation = f"Default branch '{default_branch}' does not exist on remote"
|
|
2154
|
+
else:
|
|
2155
|
+
failure_class = "network_transient"
|
|
2156
|
+
remediation = "Check repository URL, credentials, and network connectivity"
|
|
2157
|
+
|
|
2158
|
+
# Sanitize: strip embedded credentials from detail
|
|
2159
|
+
safe_detail = self._summarize_git_error(exc)
|
|
2160
|
+
return {
|
|
2161
|
+
"scope": "project",
|
|
2162
|
+
"checks": [
|
|
2163
|
+
{
|
|
2164
|
+
"id": "git_remote_access",
|
|
2165
|
+
"status": "fail",
|
|
2166
|
+
"blocking": True,
|
|
2167
|
+
"failure_class": failure_class,
|
|
2168
|
+
"detail": safe_detail[:500],
|
|
2169
|
+
"remediation": remediation,
|
|
2170
|
+
}
|
|
2171
|
+
],
|
|
2172
|
+
}
|
|
2173
|
+
|
|
2174
|
+
async def prepare_workspace(self, project: dict, task: TaskInfo) -> Path:
|
|
2175
|
+
"""Create or reuse a workspace for the given task.
|
|
2176
|
+
|
|
2177
|
+
All nodes in the same execution graph share one workspace directory
|
|
2178
|
+
and git branch so that design→coding→testing→review can see each
|
|
2179
|
+
other's output files.
|
|
2180
|
+
"""
|
|
2181
|
+
self._ensure_dirs()
|
|
2182
|
+
project_key, repo_url, default_branch = self._configure_project_git_auth(project)
|
|
2183
|
+
|
|
2019
2184
|
project_dir = self.projects_root / project_key
|
|
2020
2185
|
project_dir.mkdir(parents=True, exist_ok=True)
|
|
2021
2186
|
|
|
@@ -3311,7 +3476,7 @@ class WorkspaceManager:
|
|
|
3311
3476
|
env = None
|
|
3312
3477
|
git_prefix_args: list[str] = []
|
|
3313
3478
|
# Inject SSH key for commands that touch the remote
|
|
3314
|
-
remote_commands = {"clone", "fetch", "pull", "push"}
|
|
3479
|
+
remote_commands = {"clone", "fetch", "pull", "push", "ls-remote"}
|
|
3315
3480
|
if project_key and args and args[0] in remote_commands:
|
|
3316
3481
|
key_content = self._ssh_keys.get(project_key)
|
|
3317
3482
|
if key_content:
|
|
@@ -3413,6 +3578,15 @@ class WorkspaceManager:
|
|
|
3413
3578
|
git_prefix_args = self._build_http_git_config_args(project_key)
|
|
3414
3579
|
if git_prefix_args:
|
|
3415
3580
|
env = {**(env or os.environ), "GIT_TERMINAL_PROMPT": "0"}
|
|
3581
|
+
elif args[0] == "ls-remote" and len(args) >= 2:
|
|
3582
|
+
# HTTPS auth for ls-remote preflight: URL is args[1].
|
|
3583
|
+
repo_candidate = args[1]
|
|
3584
|
+
if isinstance(repo_candidate, str):
|
|
3585
|
+
parsed = urlparse(repo_candidate)
|
|
3586
|
+
if parsed.scheme in ("http", "https"):
|
|
3587
|
+
git_prefix_args = self._build_http_git_config_args(project_key)
|
|
3588
|
+
if git_prefix_args:
|
|
3589
|
+
env = {**(env or os.environ), "GIT_TERMINAL_PROMPT": "0"}
|
|
3416
3590
|
|
|
3417
3591
|
# Apply optional partial-clone filter for large-repo support.
|
|
3418
3592
|
# Injecting here centralises the setting across all call sites: callers
|
|
@@ -3430,6 +3604,12 @@ class WorkspaceManager:
|
|
|
3430
3604
|
# reject long paths on platforms where it is already enabled).
|
|
3431
3605
|
# On Linux/macOS this is a no-op.
|
|
3432
3606
|
longpath_args = ["-c", "core.longpaths=true"]
|
|
3607
|
+
git_command = _resolve_git_command()
|
|
3608
|
+
if not git_command:
|
|
3609
|
+
raise RuntimeError(
|
|
3610
|
+
"Git executable not found. Install Git for Windows and ensure "
|
|
3611
|
+
"'C:\\Program Files\\Git\\cmd' is accessible to the Forgexa daemon."
|
|
3612
|
+
)
|
|
3433
3613
|
|
|
3434
3614
|
# start_new_session=True puts git in its own process group.
|
|
3435
3615
|
# On timeout we send SIGKILL to the entire group, which includes
|
|
@@ -3438,13 +3618,13 @@ class WorkspaceManager:
|
|
|
3438
3618
|
# Windows note: start_new_session creates a new console process group;
|
|
3439
3619
|
# we use taskkill /T there instead of killpg.
|
|
3440
3620
|
proc = await asyncio.create_subprocess_exec(
|
|
3441
|
-
|
|
3621
|
+
git_command, *longpath_args, *git_prefix_args, *args,
|
|
3442
3622
|
stdin=asyncio.subprocess.DEVNULL,
|
|
3443
3623
|
stdout=asyncio.subprocess.PIPE,
|
|
3444
3624
|
stderr=asyncio.subprocess.PIPE,
|
|
3445
3625
|
cwd=str(cwd) if cwd else None,
|
|
3446
3626
|
env=env,
|
|
3447
|
-
|
|
3627
|
+
**_agent_process_group_kwargs(),
|
|
3448
3628
|
)
|
|
3449
3629
|
try:
|
|
3450
3630
|
stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=timeout)
|
|
@@ -4502,7 +4682,7 @@ class ProcessManager:
|
|
|
4502
4682
|
cwd=str(cwd),
|
|
4503
4683
|
env=env,
|
|
4504
4684
|
limit=100 * 1024 * 1024, # 100MB line buffer for large JSON output from long sessions
|
|
4505
|
-
|
|
4685
|
+
**_agent_process_group_kwargs(),
|
|
4506
4686
|
)
|
|
4507
4687
|
self.active_processes[task_id] = proc
|
|
4508
4688
|
stdout, stderr, returncode = await self._stream_process(
|
|
@@ -4559,6 +4739,16 @@ class ProcessManager:
|
|
|
4559
4739
|
error=f"Claude exited with code {returncode}: {stderr[-500:]}",
|
|
4560
4740
|
metrics=metrics,
|
|
4561
4741
|
)
|
|
4742
|
+
except FileNotFoundError as exc:
|
|
4743
|
+
logger.exception("Claude executable was not found for task %s", task_id)
|
|
4744
|
+
return TaskResult(
|
|
4745
|
+
status="failed",
|
|
4746
|
+
exit_code=-1,
|
|
4747
|
+
stdout="",
|
|
4748
|
+
stderr="",
|
|
4749
|
+
error=f"Claude launch failed: executable not found: {cmd[0]!r} ({exc})",
|
|
4750
|
+
failure_code="agent_launch_not_found",
|
|
4751
|
+
)
|
|
4562
4752
|
except asyncio.TimeoutError as exc:
|
|
4563
4753
|
_kill_proc(self.active_processes.pop(task_id, None) or proc)
|
|
4564
4754
|
_err = (
|
|
@@ -5237,7 +5427,7 @@ class ProcessManager:
|
|
|
5237
5427
|
cwd=str(cwd),
|
|
5238
5428
|
env=env,
|
|
5239
5429
|
limit=100 * 1024 * 1024,
|
|
5240
|
-
|
|
5430
|
+
**_agent_process_group_kwargs(),
|
|
5241
5431
|
**_win_flags,
|
|
5242
5432
|
)
|
|
5243
5433
|
self.active_processes[task_id] = proc
|
|
@@ -5367,6 +5557,19 @@ class ProcessManager:
|
|
|
5367
5557
|
error=failure_error,
|
|
5368
5558
|
metrics=metrics,
|
|
5369
5559
|
)
|
|
5560
|
+
except FileNotFoundError as exc:
|
|
5561
|
+
logger.exception("Copilot executable was not found for task %s", task_id)
|
|
5562
|
+
return TaskResult(
|
|
5563
|
+
status="failed",
|
|
5564
|
+
exit_code=-1,
|
|
5565
|
+
stdout="",
|
|
5566
|
+
stderr="",
|
|
5567
|
+
error=(
|
|
5568
|
+
f"Copilot launch failed: executable not found: {cmd[0]!r}; "
|
|
5569
|
+
f"configured agent path: {agent.command!r} ({exc})"
|
|
5570
|
+
),
|
|
5571
|
+
failure_code="agent_launch_not_found",
|
|
5572
|
+
)
|
|
5370
5573
|
except asyncio.TimeoutError as exc:
|
|
5371
5574
|
_kill_proc(self.active_processes.pop(task_id, None) or proc)
|
|
5372
5575
|
_err = (
|
|
@@ -5428,7 +5631,7 @@ class ProcessManager:
|
|
|
5428
5631
|
cwd=str(cwd),
|
|
5429
5632
|
env=env,
|
|
5430
5633
|
limit=100 * 1024 * 1024, # 100MB line buffer for large agent output
|
|
5431
|
-
|
|
5634
|
+
**_agent_process_group_kwargs(),
|
|
5432
5635
|
)
|
|
5433
5636
|
self.active_processes[task_id] = proc
|
|
5434
5637
|
stdin_bytes = stdin_input.encode() if stdin_input else None
|
|
@@ -5445,6 +5648,16 @@ class ProcessManager:
|
|
|
5445
5648
|
stderr=stderr[-10000:],
|
|
5446
5649
|
error="" if status == "success" else f"Exited with code {returncode}",
|
|
5447
5650
|
)
|
|
5651
|
+
except FileNotFoundError as exc:
|
|
5652
|
+
logger.exception("Agent executable was not found for task %s", task_id)
|
|
5653
|
+
return TaskResult(
|
|
5654
|
+
status="failed",
|
|
5655
|
+
exit_code=-1,
|
|
5656
|
+
stdout="",
|
|
5657
|
+
stderr="",
|
|
5658
|
+
error=f"Agent launch failed: executable not found: {cmd[0]!r} ({exc})",
|
|
5659
|
+
failure_code="agent_launch_not_found",
|
|
5660
|
+
)
|
|
5448
5661
|
except asyncio.TimeoutError as exc:
|
|
5449
5662
|
_kill_proc(self.active_processes.pop(task_id, None) or proc)
|
|
5450
5663
|
_err = (
|
|
@@ -6179,6 +6392,7 @@ class ProgressReporter:
|
|
|
6179
6392
|
"stderr_tail": result.stderr[-5000:] if result.stderr else "",
|
|
6180
6393
|
"error": result.error,
|
|
6181
6394
|
"failure_code": result.failure_code,
|
|
6395
|
+
"preflight": result.preflight,
|
|
6182
6396
|
"files_changed": result.files_changed,
|
|
6183
6397
|
"lines_added": result.lines_added,
|
|
6184
6398
|
"lines_removed": result.lines_removed,
|
|
@@ -6208,6 +6422,285 @@ class ProgressReporter:
|
|
|
6208
6422
|
logger.error("Exhausted %d attempts to report completion for task %s", max_attempts, task_id)
|
|
6209
6423
|
|
|
6210
6424
|
|
|
6425
|
+
# ── Runtime Preflight Monitor ──
|
|
6426
|
+
|
|
6427
|
+
|
|
6428
|
+
class RuntimePreflightMonitor:
|
|
6429
|
+
"""Daemon-level single-flight preflight monitor.
|
|
6430
|
+
|
|
6431
|
+
Runs environment checks (git, workspace, disk) periodically and produces
|
|
6432
|
+
immutable snapshots consumed by heartbeats. Only one check cycle runs at
|
|
6433
|
+
a time; if a cycle is still running when the next is due, the next is
|
|
6434
|
+
skipped (never stacks subprocesses or threads).
|
|
6435
|
+
|
|
6436
|
+
The monitor is owned by ``RuntimeDaemon`` (not per-ServerConnection) so
|
|
6437
|
+
that multi-server daemons only check once regardless of connection count.
|
|
6438
|
+
"""
|
|
6439
|
+
|
|
6440
|
+
def __init__(self, workspace_root: str, interval: int = 15):
|
|
6441
|
+
self._workspace_root = workspace_root
|
|
6442
|
+
self._interval = interval
|
|
6443
|
+
self._session_id = str(uuid4())
|
|
6444
|
+
self._generation = 0
|
|
6445
|
+
self._snapshot: dict | None = None
|
|
6446
|
+
self._in_flight = False
|
|
6447
|
+
self._workspace_probe_in_flight = False
|
|
6448
|
+
self._task: asyncio.Task | None = None
|
|
6449
|
+
|
|
6450
|
+
def get_snapshot(self) -> dict | None:
|
|
6451
|
+
"""Return the last completed preflight snapshot (for heartbeat)."""
|
|
6452
|
+
return self._snapshot
|
|
6453
|
+
|
|
6454
|
+
async def start(self):
|
|
6455
|
+
self._task = asyncio.create_task(self._loop())
|
|
6456
|
+
|
|
6457
|
+
async def stop(self):
|
|
6458
|
+
if self._task:
|
|
6459
|
+
self._task.cancel()
|
|
6460
|
+
try:
|
|
6461
|
+
await self._task
|
|
6462
|
+
except asyncio.CancelledError:
|
|
6463
|
+
pass
|
|
6464
|
+
|
|
6465
|
+
async def _loop(self):
|
|
6466
|
+
# First check immediately so the daemon reports a real status ASAP.
|
|
6467
|
+
await self._run_one_cycle()
|
|
6468
|
+
while True:
|
|
6469
|
+
await asyncio.sleep(self._interval)
|
|
6470
|
+
await self._run_one_cycle()
|
|
6471
|
+
|
|
6472
|
+
async def _run_one_cycle(self):
|
|
6473
|
+
if self._in_flight:
|
|
6474
|
+
return
|
|
6475
|
+
self._in_flight = True
|
|
6476
|
+
try:
|
|
6477
|
+
checks = await self._run_checks()
|
|
6478
|
+
self._generation += 1
|
|
6479
|
+
blocked = any(
|
|
6480
|
+
c.get("status") == "fail" and c.get("blocking")
|
|
6481
|
+
for c in checks
|
|
6482
|
+
)
|
|
6483
|
+
self._snapshot = {
|
|
6484
|
+
"status": "blocked" if blocked else "ready",
|
|
6485
|
+
"daemon_session_id": self._session_id,
|
|
6486
|
+
"generation": self._generation,
|
|
6487
|
+
"checked_at": datetime.now(timezone.utc).isoformat(),
|
|
6488
|
+
"checks": checks,
|
|
6489
|
+
}
|
|
6490
|
+
except Exception as e:
|
|
6491
|
+
logger.warning("Preflight check cycle failed: %s", e)
|
|
6492
|
+
finally:
|
|
6493
|
+
self._in_flight = False
|
|
6494
|
+
|
|
6495
|
+
async def _run_checks(self) -> list[dict]:
|
|
6496
|
+
return [
|
|
6497
|
+
await self._check_git_executable(),
|
|
6498
|
+
await self._check_workspace_root(),
|
|
6499
|
+
await self._check_workspace_disk(),
|
|
6500
|
+
]
|
|
6501
|
+
|
|
6502
|
+
@staticmethod
|
|
6503
|
+
async def _run_command_timeout(cmd: list[str], timeout: float = 5) -> str | None:
|
|
6504
|
+
"""Run a command with a deadline. Returns stdout on success, None on timeout/error.
|
|
6505
|
+
|
|
6506
|
+
On timeout, kills the entire process tree (taskkill /T on Windows,
|
|
6507
|
+
process group SIGKILL on POSIX) and waits for reaping before returning.
|
|
6508
|
+
"""
|
|
6509
|
+
try:
|
|
6510
|
+
proc = await asyncio.create_subprocess_exec(
|
|
6511
|
+
*cmd,
|
|
6512
|
+
stdin=asyncio.subprocess.DEVNULL,
|
|
6513
|
+
stdout=asyncio.subprocess.PIPE,
|
|
6514
|
+
stderr=asyncio.subprocess.PIPE,
|
|
6515
|
+
**_agent_process_group_kwargs(),
|
|
6516
|
+
)
|
|
6517
|
+
except Exception:
|
|
6518
|
+
return None
|
|
6519
|
+
try:
|
|
6520
|
+
stdout, _ = await asyncio.wait_for(proc.communicate(), timeout=timeout)
|
|
6521
|
+
if proc.returncode == 0:
|
|
6522
|
+
return stdout.decode("utf-8", errors="replace").strip()
|
|
6523
|
+
return None
|
|
6524
|
+
except asyncio.TimeoutError:
|
|
6525
|
+
RuntimePreflightMonitor._kill_process_tree(proc)
|
|
6526
|
+
try:
|
|
6527
|
+
await asyncio.wait_for(proc.wait(), timeout=3)
|
|
6528
|
+
except Exception:
|
|
6529
|
+
pass
|
|
6530
|
+
return None
|
|
6531
|
+
|
|
6532
|
+
@staticmethod
|
|
6533
|
+
def _kill_process_tree(proc):
|
|
6534
|
+
"""Best-effort kill of the entire process subtree."""
|
|
6535
|
+
try:
|
|
6536
|
+
if sys.platform == "win32":
|
|
6537
|
+
# Must be a BLOCKING call here: this is a plain (non-async)
|
|
6538
|
+
# staticmethod, so asyncio.create_subprocess_exec(...) would
|
|
6539
|
+
# return an unawaited coroutine that never actually runs --
|
|
6540
|
+
# taskkill would silently never execute, leaking the hung
|
|
6541
|
+
# process. Match the existing synchronous pattern already used
|
|
6542
|
+
# by _kill_proc() and WorkspaceManager._git()'s timeout path.
|
|
6543
|
+
import subprocess as _subprocess
|
|
6544
|
+
_subprocess.run(
|
|
6545
|
+
["taskkill", "/T", "/F", "/PID", str(proc.pid)],
|
|
6546
|
+
capture_output=True,
|
|
6547
|
+
)
|
|
6548
|
+
else:
|
|
6549
|
+
import signal
|
|
6550
|
+
try:
|
|
6551
|
+
os.killpg(os.getpgid(proc.pid), signal.SIGKILL)
|
|
6552
|
+
except ProcessLookupError:
|
|
6553
|
+
pass
|
|
6554
|
+
except Exception:
|
|
6555
|
+
pass
|
|
6556
|
+
|
|
6557
|
+
async def _check_git_executable(self) -> dict:
|
|
6558
|
+
"""Check that git can be resolved and executed."""
|
|
6559
|
+
try:
|
|
6560
|
+
git_cmd = _resolve_git_command()
|
|
6561
|
+
if not git_cmd:
|
|
6562
|
+
return {
|
|
6563
|
+
"id": "git_executable",
|
|
6564
|
+
"status": "fail",
|
|
6565
|
+
"blocking": True,
|
|
6566
|
+
"failure_class": "command_not_found",
|
|
6567
|
+
"detail": "Git executable not found in PATH or standard locations",
|
|
6568
|
+
"remediation": "Install Git for Windows or add Git cmd directory to daemon PATH",
|
|
6569
|
+
}
|
|
6570
|
+
version = await self._run_command_timeout([git_cmd, "--version"], timeout=5)
|
|
6571
|
+
if version is None:
|
|
6572
|
+
return {
|
|
6573
|
+
"id": "git_executable",
|
|
6574
|
+
"status": "unknown",
|
|
6575
|
+
"blocking": True,
|
|
6576
|
+
"failure_class": "command_timeout",
|
|
6577
|
+
"detail": f"git --version timed out or failed at {git_cmd}",
|
|
6578
|
+
"remediation": "Check for filesystem, antivirus, or network-disk stalls",
|
|
6579
|
+
}
|
|
6580
|
+
return {
|
|
6581
|
+
"id": "git_executable",
|
|
6582
|
+
"status": "pass",
|
|
6583
|
+
"blocking": True,
|
|
6584
|
+
"failure_class": "",
|
|
6585
|
+
"detail": f"{version} at {git_cmd}",
|
|
6586
|
+
"remediation": "",
|
|
6587
|
+
}
|
|
6588
|
+
except Exception as e:
|
|
6589
|
+
return {
|
|
6590
|
+
"id": "git_executable",
|
|
6591
|
+
"status": "fail",
|
|
6592
|
+
"blocking": True,
|
|
6593
|
+
"failure_class": "command_not_found",
|
|
6594
|
+
"detail": f"Git check error: {type(e).__name__}",
|
|
6595
|
+
"remediation": "Install Git or check daemon PATH",
|
|
6596
|
+
}
|
|
6597
|
+
|
|
6598
|
+
async def _check_workspace_root(self) -> dict:
|
|
6599
|
+
"""Check that workspace root can be created and is writable.
|
|
6600
|
+
|
|
6601
|
+
The actual filesystem probe is blocking I/O (mkdir/open/write/unlink).
|
|
6602
|
+
It must never run directly on the event loop: a hung network drive or
|
|
6603
|
+
antivirus lock could otherwise freeze heartbeats and task polling for
|
|
6604
|
+
this entire daemon. It runs in a worker thread with a bounded deadline;
|
|
6605
|
+
a single-flight guard prevents queuing a new probe on top of one whose
|
|
6606
|
+
thread is still stuck (threads cannot be forcibly cancelled).
|
|
6607
|
+
"""
|
|
6608
|
+
if self._workspace_probe_in_flight:
|
|
6609
|
+
return {
|
|
6610
|
+
"id": "workspace_root",
|
|
6611
|
+
"status": "unknown",
|
|
6612
|
+
"blocking": True,
|
|
6613
|
+
"failure_class": "command_timeout",
|
|
6614
|
+
"detail": "Previous workspace probe has not returned yet",
|
|
6615
|
+
"remediation": "Check for a hung network drive, antivirus lock, or slow disk",
|
|
6616
|
+
}
|
|
6617
|
+
|
|
6618
|
+
root = Path(self._workspace_root)
|
|
6619
|
+
probe_dir = root / ".preflight"
|
|
6620
|
+
probe_file = probe_dir / f"probe_{os.getpid()}.tmp"
|
|
6621
|
+
|
|
6622
|
+
def _do_probe() -> None:
|
|
6623
|
+
try:
|
|
6624
|
+
root.mkdir(parents=True, exist_ok=True)
|
|
6625
|
+
probe_dir.mkdir(parents=True, exist_ok=True)
|
|
6626
|
+
fd = os.open(str(probe_file), os.O_WRONLY | os.O_CREAT | os.O_EXCL)
|
|
6627
|
+
try:
|
|
6628
|
+
os.write(fd, b"preflight-ok")
|
|
6629
|
+
finally:
|
|
6630
|
+
os.close(fd)
|
|
6631
|
+
os.unlink(str(probe_file))
|
|
6632
|
+
finally:
|
|
6633
|
+
self._workspace_probe_in_flight = False
|
|
6634
|
+
|
|
6635
|
+
self._workspace_probe_in_flight = True
|
|
6636
|
+
try:
|
|
6637
|
+
await asyncio.wait_for(asyncio.to_thread(_do_probe), timeout=5)
|
|
6638
|
+
return {
|
|
6639
|
+
"id": "workspace_root",
|
|
6640
|
+
"status": "pass",
|
|
6641
|
+
"blocking": True,
|
|
6642
|
+
"failure_class": "",
|
|
6643
|
+
"detail": str(root),
|
|
6644
|
+
"remediation": "",
|
|
6645
|
+
}
|
|
6646
|
+
except asyncio.TimeoutError:
|
|
6647
|
+
# Intentionally leave _workspace_probe_in_flight = True here; the
|
|
6648
|
+
# worker thread's own finally block clears it whenever the OS call
|
|
6649
|
+
# eventually returns. A timeout is not proof the filesystem is
|
|
6650
|
+
# broken, so this must not be reported as a hard block.
|
|
6651
|
+
return {
|
|
6652
|
+
"id": "workspace_root",
|
|
6653
|
+
"status": "unknown",
|
|
6654
|
+
"blocking": True,
|
|
6655
|
+
"failure_class": "command_timeout",
|
|
6656
|
+
"detail": f"Workspace probe at {root} did not complete within 5s",
|
|
6657
|
+
"remediation": "Check for a hung network drive, antivirus lock, or slow disk",
|
|
6658
|
+
}
|
|
6659
|
+
except OSError as e:
|
|
6660
|
+
return {
|
|
6661
|
+
"id": "workspace_root",
|
|
6662
|
+
"status": "fail",
|
|
6663
|
+
"blocking": True,
|
|
6664
|
+
"failure_class": "permission_denied",
|
|
6665
|
+
"detail": f"Cannot create/write workspace at {root}: {type(e).__name__}",
|
|
6666
|
+
"remediation": "Check directory permissions, disk status, or security software",
|
|
6667
|
+
}
|
|
6668
|
+
|
|
6669
|
+
async def _check_workspace_disk(self) -> dict:
|
|
6670
|
+
"""Check available disk space at workspace root (non-blocking warning)."""
|
|
6671
|
+
try:
|
|
6672
|
+
import psutil
|
|
6673
|
+
usage = psutil.disk_usage(str(Path(self._workspace_root)))
|
|
6674
|
+
free_gb = usage.free / (1024 ** 3)
|
|
6675
|
+
threshold_gb = 5
|
|
6676
|
+
if free_gb < threshold_gb:
|
|
6677
|
+
return {
|
|
6678
|
+
"id": "workspace_disk",
|
|
6679
|
+
"status": "fail",
|
|
6680
|
+
"blocking": False,
|
|
6681
|
+
"failure_class": "disk_full",
|
|
6682
|
+
"detail": f"{free_gb:.1f} GB free at {self._workspace_root}",
|
|
6683
|
+
"remediation": f"Free at least {threshold_gb} GB for workspace operations",
|
|
6684
|
+
}
|
|
6685
|
+
return {
|
|
6686
|
+
"id": "workspace_disk",
|
|
6687
|
+
"status": "pass",
|
|
6688
|
+
"blocking": False,
|
|
6689
|
+
"failure_class": "",
|
|
6690
|
+
"detail": f"{free_gb:.1f} GB free",
|
|
6691
|
+
"remediation": "",
|
|
6692
|
+
}
|
|
6693
|
+
except Exception:
|
|
6694
|
+
return {
|
|
6695
|
+
"id": "workspace_disk",
|
|
6696
|
+
"status": "unknown",
|
|
6697
|
+
"blocking": False,
|
|
6698
|
+
"failure_class": "",
|
|
6699
|
+
"detail": "disk usage check unavailable",
|
|
6700
|
+
"remediation": "",
|
|
6701
|
+
}
|
|
6702
|
+
|
|
6703
|
+
|
|
6211
6704
|
# ── Heartbeat Service ──
|
|
6212
6705
|
|
|
6213
6706
|
|
|
@@ -6221,6 +6714,7 @@ class HeartbeatService:
|
|
|
6221
6714
|
runtime_id: str,
|
|
6222
6715
|
daemon_id: str,
|
|
6223
6716
|
interval: int = 15,
|
|
6717
|
+
preflight_snapshot_provider: Callable[[], dict | None] | None = None,
|
|
6224
6718
|
):
|
|
6225
6719
|
self.client = client
|
|
6226
6720
|
self.server_url = server_url.rstrip("/")
|
|
@@ -6230,6 +6724,7 @@ class HeartbeatService:
|
|
|
6230
6724
|
self._task: asyncio.Task | None = None
|
|
6231
6725
|
self._active_tasks = 0
|
|
6232
6726
|
self._agents: list[dict] = []
|
|
6727
|
+
self._preflight_snapshot_provider = preflight_snapshot_provider or (lambda: None)
|
|
6233
6728
|
self._on_success = lambda: None # Callbacks set by ServerConnection
|
|
6234
6729
|
self._on_auth_failure = lambda: None
|
|
6235
6730
|
|
|
@@ -6261,6 +6756,7 @@ class HeartbeatService:
|
|
|
6261
6756
|
"os_info": get_os_info(),
|
|
6262
6757
|
"daemon_version": DAEMON_VERSION,
|
|
6263
6758
|
"client_type": _CLIENT_TYPE,
|
|
6759
|
+
"execution_preflight": self._preflight_snapshot_provider(),
|
|
6264
6760
|
},
|
|
6265
6761
|
timeout=10,
|
|
6266
6762
|
)
|
|
@@ -6665,6 +7161,7 @@ class ServerConnection:
|
|
|
6665
7161
|
heartbeat_interval: int,
|
|
6666
7162
|
poll_interval: int,
|
|
6667
7163
|
agents: list[DiscoveredAgent],
|
|
7164
|
+
preflight_snapshot_provider: Callable[[], dict | None] | None = None,
|
|
6668
7165
|
):
|
|
6669
7166
|
"""Initialize heartbeat, poller, and reporter services."""
|
|
6670
7167
|
self._agents = agents
|
|
@@ -6691,6 +7188,7 @@ class ServerConnection:
|
|
|
6691
7188
|
self.heartbeat = HeartbeatService(
|
|
6692
7189
|
self.client, self.server_url, self.runtime_id, self.daemon_id,
|
|
6693
7190
|
heartbeat_interval,
|
|
7191
|
+
preflight_snapshot_provider=preflight_snapshot_provider,
|
|
6694
7192
|
)
|
|
6695
7193
|
self.heartbeat._on_success = _on_auth_success
|
|
6696
7194
|
self.heartbeat._on_auth_failure = _on_auth_failure
|
|
@@ -6930,6 +7428,12 @@ class RuntimeDaemon:
|
|
|
6930
7428
|
self.process_manager = ProcessManager()
|
|
6931
7429
|
self._lock_file = None # File lock to prevent multiple daemon instances
|
|
6932
7430
|
|
|
7431
|
+
# Daemon-level preflight monitor (shared across all ServerConnections).
|
|
7432
|
+
# Single-flight: only one check cycle at a time regardless of server count.
|
|
7433
|
+
self.preflight_monitor = RuntimePreflightMonitor(
|
|
7434
|
+
self.workspaces_root, interval=self.heartbeat_interval,
|
|
7435
|
+
)
|
|
7436
|
+
|
|
6933
7437
|
@staticmethod
|
|
6934
7438
|
def _project_execution_key(project: dict | None, task_hint: str) -> str:
|
|
6935
7439
|
project_info = project or {}
|
|
@@ -7296,6 +7800,10 @@ class RuntimeDaemon:
|
|
|
7296
7800
|
logger.info("Discovered %d agent(s): %s", len(self.agents),
|
|
7297
7801
|
", ".join(a.agent_id for a in self.agents))
|
|
7298
7802
|
|
|
7803
|
+
# 1b. Start preflight monitor (runs first check immediately so the
|
|
7804
|
+
# initial heartbeat already carries a real status snapshot).
|
|
7805
|
+
await self.preflight_monitor.start()
|
|
7806
|
+
|
|
7299
7807
|
# 2. Register with all servers
|
|
7300
7808
|
# 2. Register with all servers (with retry on transient failures)
|
|
7301
7809
|
max_registration_attempts = 5
|
|
@@ -7316,7 +7824,10 @@ class RuntimeDaemon:
|
|
|
7316
7824
|
try:
|
|
7317
7825
|
await conn.register(self.agents, self.max_concurrent)
|
|
7318
7826
|
self.api_token = conn.api_token
|
|
7319
|
-
conn.start_services(
|
|
7827
|
+
conn.start_services(
|
|
7828
|
+
self.heartbeat_interval, self.poll_interval, self.agents,
|
|
7829
|
+
preflight_snapshot_provider=self.preflight_monitor.get_snapshot,
|
|
7830
|
+
)
|
|
7320
7831
|
await conn.start_heartbeat()
|
|
7321
7832
|
self.connections.append(conn)
|
|
7322
7833
|
logger.info("[%s] Connected and ready", conn.label)
|
|
@@ -7610,6 +8121,24 @@ class RuntimeDaemon:
|
|
|
7610
8121
|
# Track which agents we've already tried (for rate-limit fallback)
|
|
7611
8122
|
tried_agents: set[str] = set()
|
|
7612
8123
|
|
|
8124
|
+
# 1b. Project-level preflight: verify remote repo access before
|
|
8125
|
+
# worktree creation. This catches auth errors, wrong default branch,
|
|
8126
|
+
# and network issues BEFORE spending time on clone/checkout.
|
|
8127
|
+
project_preflight = await self.workspace_manager.check_remote_access(
|
|
8128
|
+
task.project,
|
|
8129
|
+
)
|
|
8130
|
+
if project_preflight:
|
|
8131
|
+
_pf_checks = project_preflight.get("checks", [])
|
|
8132
|
+
_pf_detail = _pf_checks[0].get("detail", "Project preflight failed") if _pf_checks else "Project preflight failed"
|
|
8133
|
+
logger.warning("Project preflight failed for task %s: %s", task.task_id, _pf_detail)
|
|
8134
|
+
await reporter.report_complete(task.task_id, TaskResult(
|
|
8135
|
+
status="failed", exit_code=-1, stdout="", stderr="",
|
|
8136
|
+
error=_pf_detail,
|
|
8137
|
+
failure_code="project_preflight_failed",
|
|
8138
|
+
preflight=project_preflight,
|
|
8139
|
+
))
|
|
8140
|
+
return
|
|
8141
|
+
|
|
7613
8142
|
# 2. Prepare workspace
|
|
7614
8143
|
await reporter.report_progress(task.task_id, 5, "preparing_workspace")
|
|
7615
8144
|
workspace_path = await self.workspace_manager.prepare_workspace(
|
|
@@ -11300,6 +11829,9 @@ class RuntimeDaemon:
|
|
|
11300
11829
|
task.cancel()
|
|
11301
11830
|
await self.process_manager.cancel(task_id)
|
|
11302
11831
|
|
|
11832
|
+
# Stop preflight monitor
|
|
11833
|
+
await self.preflight_monitor.stop()
|
|
11834
|
+
|
|
11303
11835
|
# Stop all server connections (heartbeat + unregister)
|
|
11304
11836
|
for conn in self.connections:
|
|
11305
11837
|
await conn.stop()
|
|
@@ -1683,6 +1683,65 @@ def _print_check_report(results: list[dict], quick: bool) -> None:
|
|
|
1683
1683
|
print("Gemini CLI, Kimi Code, or GitHub Copilot CLI), then re-run: forgexa check")
|
|
1684
1684
|
|
|
1685
1685
|
|
|
1686
|
+
def _run_environment_checks(narrate: bool) -> bool:
|
|
1687
|
+
"""Run environment checks (git, workspace, disk) using the daemon's check logic.
|
|
1688
|
+
|
|
1689
|
+
Returns True if any blocking check failed.
|
|
1690
|
+
Prints a flutter-doctor-style listing in table mode.
|
|
1691
|
+
"""
|
|
1692
|
+
import asyncio
|
|
1693
|
+
from forgexa_cli.daemon import RuntimePreflightMonitor, settings
|
|
1694
|
+
|
|
1695
|
+
workspace_root = settings.get_daemon_workspaces_root()
|
|
1696
|
+
|
|
1697
|
+
if narrate:
|
|
1698
|
+
print("Environment Checks (this CLI process scope):")
|
|
1699
|
+
print(f" Workspace root: {workspace_root}")
|
|
1700
|
+
print()
|
|
1701
|
+
|
|
1702
|
+
monitor = RuntimePreflightMonitor(workspace_root, interval=999)
|
|
1703
|
+
try:
|
|
1704
|
+
checks = asyncio.run(monitor._run_checks())
|
|
1705
|
+
except Exception as exc:
|
|
1706
|
+
if narrate:
|
|
1707
|
+
print(f" [ERROR] Environment check failed: {exc}")
|
|
1708
|
+
return True
|
|
1709
|
+
|
|
1710
|
+
blocking_failed = False
|
|
1711
|
+
for check in checks:
|
|
1712
|
+
status = check.get("status", "unknown")
|
|
1713
|
+
check_id = check.get("id", "?")
|
|
1714
|
+
detail = check.get("detail", "")
|
|
1715
|
+
blocking = check.get("blocking", False)
|
|
1716
|
+
remediation = check.get("remediation", "")
|
|
1717
|
+
|
|
1718
|
+
if status == "pass":
|
|
1719
|
+
icon = "[OK]"
|
|
1720
|
+
elif status == "fail":
|
|
1721
|
+
icon = "[FAIL]" if blocking else "[WARN]"
|
|
1722
|
+
if blocking:
|
|
1723
|
+
blocking_failed = True
|
|
1724
|
+
elif status == "unknown":
|
|
1725
|
+
icon = "[?]"
|
|
1726
|
+
else:
|
|
1727
|
+
icon = f"[{status.upper()}]"
|
|
1728
|
+
|
|
1729
|
+
if narrate:
|
|
1730
|
+
print(f" {icon} {check_id}: {detail}")
|
|
1731
|
+
if remediation:
|
|
1732
|
+
print(f" -> {remediation}")
|
|
1733
|
+
print()
|
|
1734
|
+
|
|
1735
|
+
if narrate:
|
|
1736
|
+
if blocking_failed:
|
|
1737
|
+
print("One or more blocking checks failed. Fix them before running tasks.")
|
|
1738
|
+
else:
|
|
1739
|
+
print("All environment checks passed.")
|
|
1740
|
+
print()
|
|
1741
|
+
|
|
1742
|
+
return blocking_failed
|
|
1743
|
+
|
|
1744
|
+
|
|
1686
1745
|
def cmd_check(args: argparse.Namespace) -> None:
|
|
1687
1746
|
"""Verify locally-installed AI agent CLIs are genuinely usable for platform tasks.
|
|
1688
1747
|
|
|
@@ -1698,8 +1757,24 @@ def cmd_check(args: argparse.Namespace) -> None:
|
|
|
1698
1757
|
|
|
1699
1758
|
only_agent = getattr(args, "agent", None)
|
|
1700
1759
|
quick = bool(getattr(args, "quick", False))
|
|
1760
|
+
environment_only = bool(getattr(args, "environment", False))
|
|
1701
1761
|
timeout = getattr(args, "timeout", None) or 90
|
|
1702
1762
|
|
|
1763
|
+
narrate = _fmt() == "table"
|
|
1764
|
+
env_failed = False
|
|
1765
|
+
|
|
1766
|
+
# ── Environment checks (shared with daemon's RuntimePreflightMonitor) ──
|
|
1767
|
+
# Run when --environment or --quick is set. These are free (no API calls,
|
|
1768
|
+
# no network, no LLM tokens) and reuse the exact same check logic the
|
|
1769
|
+
# daemon uses for its periodic preflight.
|
|
1770
|
+
if environment_only or quick:
|
|
1771
|
+
env_failed = _run_environment_checks(narrate)
|
|
1772
|
+
if environment_only:
|
|
1773
|
+
# --environment: only env checks, then exit
|
|
1774
|
+
if env_failed:
|
|
1775
|
+
sys.exit(1)
|
|
1776
|
+
return
|
|
1777
|
+
|
|
1703
1778
|
known_ids = list(AgentDiscovery.AGENT_REGISTRY.keys())
|
|
1704
1779
|
if only_agent:
|
|
1705
1780
|
if only_agent not in known_ids:
|
|
@@ -1709,7 +1784,6 @@ def cmd_check(args: argparse.Namespace) -> None:
|
|
|
1709
1784
|
else:
|
|
1710
1785
|
agent_ids = known_ids
|
|
1711
1786
|
|
|
1712
|
-
narrate = _fmt() == "table"
|
|
1713
1787
|
if narrate:
|
|
1714
1788
|
print("Scanning for installed agent CLIs...")
|
|
1715
1789
|
if not quick:
|
|
@@ -1725,7 +1799,7 @@ def cmd_check(args: argparse.Namespace) -> None:
|
|
|
1725
1799
|
|
|
1726
1800
|
_print_check_report(results, quick=quick)
|
|
1727
1801
|
|
|
1728
|
-
if not any(r["available"] for r in results):
|
|
1802
|
+
if env_failed or not any(r["available"] for r in results):
|
|
1729
1803
|
sys.exit(1)
|
|
1730
1804
|
|
|
1731
1805
|
|
|
@@ -2203,7 +2277,11 @@ def main() -> None:
|
|
|
2203
2277
|
check_p.add_argument("--agent", default=None, help="Check only this agent (e.g. claude, codex, kimi)")
|
|
2204
2278
|
check_p.add_argument(
|
|
2205
2279
|
"--quick", action="store_true",
|
|
2206
|
-
help="Discovery
|
|
2280
|
+
help="Discovery + environment checks — skip the live task execution test (no API calls)",
|
|
2281
|
+
)
|
|
2282
|
+
check_p.add_argument(
|
|
2283
|
+
"--environment", action="store_true",
|
|
2284
|
+
help="Environment checks only (git, workspace, disk) — skip agent discovery and live calls",
|
|
2207
2285
|
)
|
|
2208
2286
|
check_p.add_argument(
|
|
2209
2287
|
"--timeout", type=int, default=90, metavar="SECONDS",
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|