forgexa-cli 1.43.2__tar.gz → 1.43.3__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 (27) hide show
  1. {forgexa_cli-1.43.2 → forgexa_cli-1.43.3}/PKG-INFO +1 -1
  2. {forgexa_cli-1.43.2 → forgexa_cli-1.43.3}/forgexa_cli/__init__.py +1 -1
  3. {forgexa_cli-1.43.2 → forgexa_cli-1.43.3}/forgexa_cli/agent_core.py +109 -0
  4. {forgexa_cli-1.43.2 → forgexa_cli-1.43.3}/forgexa_cli/daemon.py +181 -27
  5. {forgexa_cli-1.43.2 → forgexa_cli-1.43.3}/forgexa_cli.egg-info/PKG-INFO +1 -1
  6. {forgexa_cli-1.43.2 → forgexa_cli-1.43.3}/pyproject.toml +1 -1
  7. {forgexa_cli-1.43.2 → forgexa_cli-1.43.3}/README.md +0 -0
  8. {forgexa_cli-1.43.2 → forgexa_cli-1.43.3}/forgexa_cli/_build_config.py +0 -0
  9. {forgexa_cli-1.43.2 → forgexa_cli-1.43.3}/forgexa_cli/_local_bind.py +0 -0
  10. {forgexa_cli-1.43.2 → forgexa_cli-1.43.3}/forgexa_cli/autoupgrade.py +0 -0
  11. {forgexa_cli-1.43.2 → forgexa_cli-1.43.3}/forgexa_cli/main.py +0 -0
  12. {forgexa_cli-1.43.2 → forgexa_cli-1.43.3}/forgexa_cli/py.typed +0 -0
  13. {forgexa_cli-1.43.2 → forgexa_cli-1.43.3}/forgexa_cli.egg-info/SOURCES.txt +0 -0
  14. {forgexa_cli-1.43.2 → forgexa_cli-1.43.3}/forgexa_cli.egg-info/dependency_links.txt +0 -0
  15. {forgexa_cli-1.43.2 → forgexa_cli-1.43.3}/forgexa_cli.egg-info/entry_points.txt +0 -0
  16. {forgexa_cli-1.43.2 → forgexa_cli-1.43.3}/forgexa_cli.egg-info/requires.txt +0 -0
  17. {forgexa_cli-1.43.2 → forgexa_cli-1.43.3}/forgexa_cli.egg-info/top_level.txt +0 -0
  18. {forgexa_cli-1.43.2 → forgexa_cli-1.43.3}/setup.cfg +0 -0
  19. {forgexa_cli-1.43.2 → forgexa_cli-1.43.3}/tests/test_auth_and_runtime_commands.py +0 -0
  20. {forgexa_cli-1.43.2 → forgexa_cli-1.43.3}/tests/test_autoupgrade.py +0 -0
  21. {forgexa_cli-1.43.2 → forgexa_cli-1.43.3}/tests/test_check_command.py +0 -0
  22. {forgexa_cli-1.43.2 → forgexa_cli-1.43.3}/tests/test_expiry_warnings_and_revoke.py +0 -0
  23. {forgexa_cli-1.43.2 → forgexa_cli-1.43.3}/tests/test_local_bind_commands.py +0 -0
  24. {forgexa_cli-1.43.2 → forgexa_cli-1.43.3}/tests/test_runtime_credentials.py +0 -0
  25. {forgexa_cli-1.43.2 → forgexa_cli-1.43.3}/tests/test_session_credentials.py +0 -0
  26. {forgexa_cli-1.43.2 → forgexa_cli-1.43.3}/tests/test_silent_install.py +0 -0
  27. {forgexa_cli-1.43.2 → forgexa_cli-1.43.3}/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.43.2
3
+ Version: 1.43.3
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.43.2"
2
+ __version__ = "1.43.3"
@@ -1766,6 +1766,8 @@ def copilot_base_env(env: dict[str, str], *, copilot_home: str | Path) -> dict[s
1766
1766
  env = dict(env)
1767
1767
  env["TERM"] = "dumb" # suppress TTY-detection that suspends the process
1768
1768
  env["COPILOT_HOME"] = str(copilot_home)
1769
+ env.setdefault("TOKIO_WORKER_THREADS", "2")
1770
+ env["FORGEXA_AGENT_PROCESS"] = "1"
1769
1771
  return env
1770
1772
 
1771
1773
 
@@ -1808,6 +1810,113 @@ def is_copilot_model_discovery_auth_error(*messages: str | None) -> bool:
1808
1810
  )
1809
1811
 
1810
1812
 
1813
+ def is_runtime_resource_exhaustion_error(*messages: str | None) -> bool:
1814
+ """Return whether the OS refused to create an agent process or thread."""
1815
+ detail = " ".join(str(message or "").lower() for message in messages)
1816
+ return any(
1817
+ pattern in detail
1818
+ for pattern in (
1819
+ "can't spawn worker thread",
1820
+ "failed to load ca certificates off thread",
1821
+ "resource temporarily unavailable (os error 11)",
1822
+ "pthread_create failed",
1823
+ )
1824
+ )
1825
+
1826
+
1827
+ def collect_runtime_resource_metrics(
1828
+ *,
1829
+ proc_root: str | Path = "/proc",
1830
+ cgroup_root: str | Path = "/sys/fs/cgroup",
1831
+ ) -> dict[str, Any]:
1832
+ """Collect Linux process/thread headroom without optional dependencies."""
1833
+ metrics: dict[str, Any] = {}
1834
+ if not sys.platform.startswith("linux"):
1835
+ return metrics
1836
+
1837
+ proc_path = Path(proc_root)
1838
+ cgroup_path = Path(cgroup_root)
1839
+ constraints: list[tuple[str, int, int]] = []
1840
+
1841
+ try:
1842
+ for line in (proc_path / "self" / "cgroup").read_text().splitlines():
1843
+ hierarchy, controllers, relative_path = line.split(":", 2)
1844
+ relative = relative_path.lstrip("/")
1845
+ if hierarchy == "0" and not controllers:
1846
+ pid_dir = cgroup_path / relative
1847
+ elif "pids" in controllers.split(","):
1848
+ pid_dir = cgroup_path / "pids" / relative
1849
+ else:
1850
+ continue
1851
+ current_text = (pid_dir / "pids.current").read_text().strip()
1852
+ maximum_text = (pid_dir / "pids.max").read_text().strip()
1853
+ if maximum_text != "max":
1854
+ constraints.append(("cgroup", int(current_text), int(maximum_text)))
1855
+ break
1856
+ except (OSError, ValueError):
1857
+ pass
1858
+
1859
+ try:
1860
+ import resource
1861
+
1862
+ soft_limit, _ = resource.getrlimit(resource.RLIMIT_NPROC)
1863
+ if soft_limit != resource.RLIM_INFINITY:
1864
+ current_uid = os.getuid()
1865
+ user_threads = 0
1866
+ for status_path in proc_path.glob("[0-9]*/status"):
1867
+ try:
1868
+ real_uid: int | None = None
1869
+ threads = 1
1870
+ for line in status_path.read_text().splitlines():
1871
+ if line.startswith("Uid:"):
1872
+ real_uid = int(line.split()[1])
1873
+ elif line.startswith("Threads:"):
1874
+ threads = int(line.split()[1])
1875
+ if real_uid == current_uid:
1876
+ user_threads += threads
1877
+ except (OSError, ValueError, IndexError):
1878
+ continue
1879
+ constraints.append(("rlimit_nproc", user_threads, int(soft_limit)))
1880
+ except (ImportError, OSError, ValueError):
1881
+ pass
1882
+
1883
+ if constraints:
1884
+ source, current, limit = min(
1885
+ constraints,
1886
+ key=lambda item: item[2] - item[1],
1887
+ )
1888
+ metrics.update({
1889
+ "process_limit_source": source,
1890
+ "process_current": current,
1891
+ "process_limit": limit,
1892
+ "process_remaining": max(0, limit - current),
1893
+ })
1894
+
1895
+ try:
1896
+ for line in (proc_path / "meminfo").read_text().splitlines():
1897
+ if line.startswith("MemAvailable:"):
1898
+ metrics["memory_available_mb"] = int(line.split()[1]) // 1024
1899
+ break
1900
+ except (OSError, ValueError, IndexError):
1901
+ pass
1902
+ return metrics
1903
+
1904
+
1905
+ def runtime_resource_exhaustion_reason(metrics: dict[str, Any]) -> str | None:
1906
+ """Return a blocking reason when an agent lacks safe process headroom."""
1907
+ process_remaining = metrics.get("process_remaining")
1908
+ if isinstance(process_remaining, int) and process_remaining < 32:
1909
+ return (
1910
+ f"Only {process_remaining} process/thread slots remain "
1911
+ f"({metrics.get('process_current')}/{metrics.get('process_limit')} used via "
1912
+ f"{metrics.get('process_limit_source')})"
1913
+ )
1914
+ memory_available_mb = metrics.get("memory_available_mb")
1915
+ if isinstance(memory_available_mb, int) and memory_available_mb < 256:
1916
+ return f"Only {memory_available_mb} MiB memory is available"
1917
+ return None
1918
+
1919
+
1811
1920
  def copilot_auth_state_signature(copilot_home: str | Path) -> str | None:
1812
1921
  """Return a content signature for Copilot's persisted authentication state."""
1813
1922
  config_file = Path(copilot_home) / "config.json"
@@ -910,7 +910,7 @@ except (ImportError, ModuleNotFoundError):
910
910
  # DAEMON_VERSION is the protocol/logic version of the daemon code.
911
911
  # Kept in sync with pyproject.toml version via bump-version.sh.
912
912
  # CLIENT_TYPE identifies which packaging/distribution this daemon runs in.
913
- DAEMON_VERSION = "1.43.2"
913
+ DAEMON_VERSION = "1.43.3"
914
914
 
915
915
 
916
916
  def _detect_client_type() -> str:
@@ -4783,6 +4783,10 @@ class ProcessManager:
4783
4783
  "websocket receive failed",
4784
4784
  "misdirected request",
4785
4785
  "could not retrieve the list of available models",
4786
+ "can't spawn worker thread",
4787
+ "failed to load ca certificates off thread",
4788
+ "resource temporarily unavailable (os error 11)",
4789
+ "pthread_create failed",
4786
4790
  "failed to initialize mcp client",
4787
4791
  # "api error" removed: too broad — matches agent-generated code/output
4788
4792
  # discussing API errors. Real API transport errors are covered by the
@@ -5077,6 +5081,38 @@ class ProcessManager:
5077
5081
  on_chunk: Any = None,
5078
5082
  ) -> TaskResult:
5079
5083
  """Execute an agent CLI and collect results."""
5084
+ resource_metrics = agent_core.collect_runtime_resource_metrics()
5085
+ resource_reason = agent_core.runtime_resource_exhaustion_reason(
5086
+ resource_metrics,
5087
+ )
5088
+ if resource_reason:
5089
+ check = {
5090
+ "id": "process_headroom",
5091
+ "status": "fail",
5092
+ "blocking": True,
5093
+ "failure_class": "resource_exhausted",
5094
+ "detail": resource_reason,
5095
+ "remediation": (
5096
+ "Stop orphaned agent processes or raise the user/cgroup PID limit, "
5097
+ "then retry"
5098
+ ),
5099
+ }
5100
+ logger.error(
5101
+ "Refusing to launch agent '%s' for task %s: %s",
5102
+ agent.agent_id,
5103
+ task.task_id,
5104
+ resource_reason,
5105
+ )
5106
+ return TaskResult(
5107
+ status="failed",
5108
+ exit_code=-1,
5109
+ stdout="",
5110
+ stderr="",
5111
+ error=f"Runtime resource preflight failed: {resource_reason}",
5112
+ failure_code="runtime_preflight_failed",
5113
+ preflight={"scope": "runtime", "checks": [check]},
5114
+ )
5115
+
5080
5116
  # Centralized, agent-agnostic version gate: AgentDiscovery.discover()
5081
5117
  # already computed min_version_ok/version_warning against
5082
5118
  # AGENT_REGISTRY[agent_id]["min_version"] (see _check_agent_min_version).
@@ -6399,6 +6435,14 @@ class ProcessManager:
6399
6435
  stdout=stdout[-settings.AGENT_MAX_OUTPUT_SIZE:],
6400
6436
  stderr=stderr[-10000:],
6401
6437
  error=failure_error,
6438
+ failure_code=(
6439
+ "runtime_resource_exhausted"
6440
+ if agent_core.is_runtime_resource_exhaustion_error(
6441
+ failure_error,
6442
+ stderr,
6443
+ )
6444
+ else ""
6445
+ ),
6402
6446
  metrics=metrics,
6403
6447
  )
6404
6448
  except asyncio.CancelledError:
@@ -6748,26 +6792,11 @@ class ProcessManager:
6748
6792
  async def cancel(self, task_id: str):
6749
6793
  proc = self.active_processes.pop(task_id, None)
6750
6794
  if proc:
6795
+ _kill_proc(proc)
6751
6796
  try:
6752
- if sys.platform != "win32":
6753
- import signal as _signal
6754
- try:
6755
- os.killpg(os.getpgid(proc.pid), _signal.SIGKILL)
6756
- except (ProcessLookupError, PermissionError, OSError):
6757
- pass
6758
- else:
6759
- import subprocess as _subprocess
6760
- _subprocess.run(
6761
- ["taskkill", "/F", "/T", "/PID", str(proc.pid)],
6762
- capture_output=True,
6763
- )
6797
+ await asyncio.wait_for(proc.wait(), timeout=5)
6764
6798
  except Exception:
6765
- pass
6766
- finally:
6767
- try:
6768
- proc.kill()
6769
- except Exception:
6770
- pass
6799
+ logger.warning("Timed out reaping agent process for task %s", task_id)
6771
6800
 
6772
6801
 
6773
6802
  # ── Progress Reporter ──
@@ -6967,8 +6996,48 @@ class RuntimePreflightMonitor:
6967
6996
  await self._check_git_executable(),
6968
6997
  await self._check_workspace_root(),
6969
6998
  await self._check_workspace_disk(),
6999
+ await self._check_process_headroom(),
6970
7000
  ]
6971
7001
 
7002
+ @staticmethod
7003
+ async def _check_process_headroom() -> dict:
7004
+ metrics = agent_core.collect_runtime_resource_metrics()
7005
+ reason = agent_core.runtime_resource_exhaustion_reason(metrics)
7006
+ if reason:
7007
+ return {
7008
+ "id": "process_headroom",
7009
+ "status": "fail",
7010
+ "blocking": True,
7011
+ "failure_class": "resource_exhausted",
7012
+ "detail": reason,
7013
+ "remediation": (
7014
+ "Stop orphaned agent processes or raise the user/cgroup PID limit, "
7015
+ "then restart the daemon"
7016
+ ),
7017
+ }
7018
+ if metrics:
7019
+ detail = (
7020
+ f"{metrics['process_remaining']} process/thread slots remain"
7021
+ if "process_remaining" in metrics
7022
+ else f"{metrics.get('memory_available_mb', 'unknown')} MiB memory available"
7023
+ )
7024
+ return {
7025
+ "id": "process_headroom",
7026
+ "status": "pass",
7027
+ "blocking": True,
7028
+ "failure_class": "",
7029
+ "detail": detail,
7030
+ "remediation": "",
7031
+ }
7032
+ return {
7033
+ "id": "process_headroom",
7034
+ "status": "unknown",
7035
+ "blocking": False,
7036
+ "failure_class": "",
7037
+ "detail": "Process/thread headroom is unavailable on this platform",
7038
+ "remediation": "",
7039
+ }
7040
+
6972
7041
  @staticmethod
6973
7042
  async def _run_command_timeout(cmd: list[str], timeout: float = 5) -> str | None:
6974
7043
  """Run a command with a deadline. Returns stdout on success, None on timeout/error.
@@ -7273,15 +7342,17 @@ class HeartbeatService:
7273
7342
 
7274
7343
  def _collect_system_metrics(self) -> dict:
7275
7344
  """Basic system metrics."""
7345
+ metrics = agent_core.collect_runtime_resource_metrics()
7276
7346
  try:
7277
7347
  import psutil
7278
- return {
7348
+ metrics.update({
7279
7349
  "cpu_percent": psutil.cpu_percent(),
7280
7350
  "memory_percent": psutil.virtual_memory().percent,
7281
7351
  "disk_percent": psutil.disk_usage("/").percent,
7282
- }
7352
+ })
7283
7353
  except ImportError:
7284
- return {}
7354
+ pass
7355
+ return metrics
7285
7356
 
7286
7357
 
7287
7358
  # ── Log Uploader ──
@@ -8619,6 +8690,12 @@ class RuntimeDaemon:
8619
8690
  """Main entry point."""
8620
8691
  # Prevent multiple daemon instances on the same machine
8621
8692
  self._acquire_lock()
8693
+ cleaned_processes = self._cleanup_stale_agent_processes()
8694
+ if cleaned_processes:
8695
+ logger.warning(
8696
+ "Cleaned up %d orphaned Forgexa agent process group(s)",
8697
+ cleaned_processes,
8698
+ )
8622
8699
 
8623
8700
  # Mint a local-dev token if no token is available yet
8624
8701
  if not self.api_token and not self._has_stored_credentials():
@@ -9328,7 +9405,15 @@ class RuntimeDaemon:
9328
9405
  # Guard: if the agent already produced file changes in the workspace, it DID
9329
9406
  # meaningful work — don't trigger fallback even if it crashed after completing.
9330
9407
  # Let the recovery logic (step 4.1) handle non-zero exit with committed work.
9331
- agent_failure_kind = ProcessManager.agent_failure_kind(result)
9408
+ runtime_resource_failure = result.failure_code in {
9409
+ "runtime_preflight_failed",
9410
+ "runtime_resource_exhausted",
9411
+ }
9412
+ agent_failure_kind = (
9413
+ None
9414
+ if runtime_resource_failure
9415
+ else ProcessManager.agent_failure_kind(result)
9416
+ )
9332
9417
  is_agent_fallback_eligible = agent_failure_kind is not None
9333
9418
  is_silent_idle_timeout = self.process_manager.is_silent_idle_timeout(result)
9334
9419
  fallback_reason = (
@@ -11465,7 +11550,13 @@ class RuntimeDaemon:
11465
11550
  # try the next available agent before giving up. An explicit
11466
11551
  # override is deliberately a single-agent attempt.
11467
11552
  fallback_scope = "override" if agent_override else "auto"
11468
- allow_fallback = not bool(agent_override)
11553
+ allow_fallback = (
11554
+ not bool(agent_override)
11555
+ and result.failure_code not in {
11556
+ "runtime_preflight_failed",
11557
+ "runtime_resource_exhausted",
11558
+ }
11559
+ )
11469
11560
  generation_attempts: list[dict] = []
11470
11561
  agent_failure_kind = ProcessManager.agent_failure_kind(result)
11471
11562
  agent_failure_label = (
@@ -14081,15 +14172,78 @@ class RuntimeDaemon:
14081
14172
  except Exception as e:
14082
14173
  logger.debug("Could not clean temp dir %s: %s", d, e)
14083
14174
 
14175
+ @staticmethod
14176
+ def _cleanup_stale_agent_processes(
14177
+ *,
14178
+ proc_root: str | Path = "/proc",
14179
+ temp_root: str | Path | None = None,
14180
+ ) -> int:
14181
+ """Kill orphaned agent groups identifiable as Forgexa-owned on Linux."""
14182
+ if not sys.platform.startswith("linux"):
14183
+ return 0
14184
+ import signal as _signal
14185
+ import tempfile
14186
+
14187
+ root = Path(proc_root)
14188
+ temp = Path(temp_root or tempfile.gettempdir()).resolve()
14189
+ current_pid = os.getpid()
14190
+ current_group = os.getpgrp()
14191
+ cleaned = 0
14192
+ try:
14193
+ process_dirs = sorted(
14194
+ (path for path in root.iterdir() if path.name.isdigit()),
14195
+ key=lambda path: int(path.name),
14196
+ )
14197
+ except OSError:
14198
+ return 0
14199
+
14200
+ for process_dir in process_dirs:
14201
+ pid = int(process_dir.name)
14202
+ if pid == current_pid:
14203
+ continue
14204
+ try:
14205
+ entries = (process_dir / "environ").read_bytes().split(b"\0")
14206
+ environment = {}
14207
+ for entry in entries:
14208
+ if b"=" not in entry:
14209
+ continue
14210
+ key, value = entry.split(b"=", 1)
14211
+ environment[key.decode(errors="ignore")] = value.decode(errors="ignore")
14212
+ marked = environment.get("FORGEXA_AGENT_PROCESS") == "1"
14213
+ isolated_home = environment.get("COPILOT_HOME", "")
14214
+ legacy_copilot = False
14215
+ if isolated_home:
14216
+ isolated_path = Path(isolated_home).resolve()
14217
+ legacy_copilot = (
14218
+ isolated_path.parent == temp
14219
+ and isolated_path.name.startswith("copilot-")
14220
+ )
14221
+ if not (marked or legacy_copilot):
14222
+ continue
14223
+ process_group = os.getpgid(pid)
14224
+ if process_group == current_group:
14225
+ continue
14226
+ os.killpg(process_group, _signal.SIGKILL)
14227
+ cleaned += 1
14228
+ except (OSError, ValueError):
14229
+ continue
14230
+ return cleaned
14231
+
14084
14232
  async def _shutdown_gracefully(self):
14085
14233
  """Graceful shutdown."""
14086
14234
  logger.info("Shutting down daemon...")
14087
14235
  self._shutdown = True
14088
14236
 
14089
- # Cancel active tasks
14090
- for task_id, task in self.active_tasks.items():
14237
+ # Cancel task coroutines, then terminate and reap every tracked process.
14238
+ tasks = list(self.active_tasks.values())
14239
+ for task in tasks:
14091
14240
  task.cancel()
14092
- await self.process_manager.cancel(task_id)
14241
+ for process_id in list(self.process_manager.active_processes):
14242
+ await self.process_manager.cancel(process_id)
14243
+ if tasks:
14244
+ await asyncio.gather(*tasks, return_exceptions=True)
14245
+ self.active_tasks.clear()
14246
+ self._task_connections.clear()
14093
14247
 
14094
14248
  # Stop preflight monitor
14095
14249
  await self.preflight_monitor.stop()
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: forgexa-cli
3
- Version: 1.43.2
3
+ Version: 1.43.3
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.43.2"
3
+ version = "1.43.3"
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