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.
Files changed (21) hide show
  1. {forgexa_cli-1.21.0 → forgexa_cli-1.21.2}/PKG-INFO +1 -1
  2. {forgexa_cli-1.21.0 → forgexa_cli-1.21.2}/forgexa_cli/__init__.py +1 -1
  3. {forgexa_cli-1.21.0 → forgexa_cli-1.21.2}/forgexa_cli/daemon.py +549 -17
  4. {forgexa_cli-1.21.0 → forgexa_cli-1.21.2}/forgexa_cli/main.py +81 -3
  5. {forgexa_cli-1.21.0 → forgexa_cli-1.21.2}/forgexa_cli.egg-info/PKG-INFO +1 -1
  6. {forgexa_cli-1.21.0 → forgexa_cli-1.21.2}/pyproject.toml +1 -1
  7. {forgexa_cli-1.21.0 → forgexa_cli-1.21.2}/README.md +0 -0
  8. {forgexa_cli-1.21.0 → forgexa_cli-1.21.2}/forgexa_cli/_build_config.py +0 -0
  9. {forgexa_cli-1.21.0 → forgexa_cli-1.21.2}/forgexa_cli/autoupgrade.py +0 -0
  10. {forgexa_cli-1.21.0 → forgexa_cli-1.21.2}/forgexa_cli/py.typed +0 -0
  11. {forgexa_cli-1.21.0 → forgexa_cli-1.21.2}/forgexa_cli.egg-info/SOURCES.txt +0 -0
  12. {forgexa_cli-1.21.0 → forgexa_cli-1.21.2}/forgexa_cli.egg-info/dependency_links.txt +0 -0
  13. {forgexa_cli-1.21.0 → forgexa_cli-1.21.2}/forgexa_cli.egg-info/entry_points.txt +0 -0
  14. {forgexa_cli-1.21.0 → forgexa_cli-1.21.2}/forgexa_cli.egg-info/requires.txt +0 -0
  15. {forgexa_cli-1.21.0 → forgexa_cli-1.21.2}/forgexa_cli.egg-info/top_level.txt +0 -0
  16. {forgexa_cli-1.21.0 → forgexa_cli-1.21.2}/setup.cfg +0 -0
  17. {forgexa_cli-1.21.0 → forgexa_cli-1.21.2}/tests/test_auth_and_runtime_commands.py +0 -0
  18. {forgexa_cli-1.21.0 → forgexa_cli-1.21.2}/tests/test_autoupgrade.py +0 -0
  19. {forgexa_cli-1.21.0 → forgexa_cli-1.21.2}/tests/test_check_command.py +0 -0
  20. {forgexa_cli-1.21.0 → forgexa_cli-1.21.2}/tests/test_silent_install.py +0 -0
  21. {forgexa_cli-1.21.0 → forgexa_cli-1.21.2}/tests/test_upgrade_observability.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: forgexa-cli
3
- Version: 1.21.0
3
+ Version: 1.21.2
4
4
  Summary: Forgexa CLI — command-line client and AI agent runtime for the Forgexa platform
5
5
  Author-email: Jason Sun <dev.winds@gmail.com>
6
6
  License-Expression: MIT
@@ -1,2 +1,2 @@
1
1
  """forgexa-cli — Forgexa command-line client."""
2
- __version__ = "1.21.0"
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.0"
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
- os.environ.get("COMSPEC", "cmd.exe"),
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
- async def prepare_workspace(self, project: dict, task: TaskInfo) -> Path:
1996
- """Create or reuse a workspace for the given task.
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
- All nodes in the same execution graph share one workspace directory
1999
- and git branch so that design→coding→testing→review can see each
2000
- other's output files.
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
- "git", *longpath_args, *git_prefix_args, *args,
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
- start_new_session=True,
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
- start_new_session=True, # own process group → killpg on timeout kills all children
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
- start_new_session=True,
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
- start_new_session=True, # own process group → killpg on timeout kills all children
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(self.heartbeat_interval, self.poll_interval, self.agents)
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 only — skip the live task execution test (no API calls)",
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",
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: forgexa-cli
3
- Version: 1.21.0
3
+ Version: 1.21.2
4
4
  Summary: Forgexa CLI — command-line client and AI agent runtime for the Forgexa platform
5
5
  Author-email: Jason Sun <dev.winds@gmail.com>
6
6
  License-Expression: MIT
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "forgexa-cli"
3
- version = "1.21.0"
3
+ version = "1.21.2"
4
4
  description = "Forgexa CLI — command-line client and AI agent runtime for the Forgexa platform"
5
5
  requires-python = ">=3.9"
6
6
  license = "MIT"
File without changes
File without changes