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.
- {forgexa_cli-1.43.2 → forgexa_cli-1.43.3}/PKG-INFO +1 -1
- {forgexa_cli-1.43.2 → forgexa_cli-1.43.3}/forgexa_cli/__init__.py +1 -1
- {forgexa_cli-1.43.2 → forgexa_cli-1.43.3}/forgexa_cli/agent_core.py +109 -0
- {forgexa_cli-1.43.2 → forgexa_cli-1.43.3}/forgexa_cli/daemon.py +181 -27
- {forgexa_cli-1.43.2 → forgexa_cli-1.43.3}/forgexa_cli.egg-info/PKG-INFO +1 -1
- {forgexa_cli-1.43.2 → forgexa_cli-1.43.3}/pyproject.toml +1 -1
- {forgexa_cli-1.43.2 → forgexa_cli-1.43.3}/README.md +0 -0
- {forgexa_cli-1.43.2 → forgexa_cli-1.43.3}/forgexa_cli/_build_config.py +0 -0
- {forgexa_cli-1.43.2 → forgexa_cli-1.43.3}/forgexa_cli/_local_bind.py +0 -0
- {forgexa_cli-1.43.2 → forgexa_cli-1.43.3}/forgexa_cli/autoupgrade.py +0 -0
- {forgexa_cli-1.43.2 → forgexa_cli-1.43.3}/forgexa_cli/main.py +0 -0
- {forgexa_cli-1.43.2 → forgexa_cli-1.43.3}/forgexa_cli/py.typed +0 -0
- {forgexa_cli-1.43.2 → forgexa_cli-1.43.3}/forgexa_cli.egg-info/SOURCES.txt +0 -0
- {forgexa_cli-1.43.2 → forgexa_cli-1.43.3}/forgexa_cli.egg-info/dependency_links.txt +0 -0
- {forgexa_cli-1.43.2 → forgexa_cli-1.43.3}/forgexa_cli.egg-info/entry_points.txt +0 -0
- {forgexa_cli-1.43.2 → forgexa_cli-1.43.3}/forgexa_cli.egg-info/requires.txt +0 -0
- {forgexa_cli-1.43.2 → forgexa_cli-1.43.3}/forgexa_cli.egg-info/top_level.txt +0 -0
- {forgexa_cli-1.43.2 → forgexa_cli-1.43.3}/setup.cfg +0 -0
- {forgexa_cli-1.43.2 → forgexa_cli-1.43.3}/tests/test_auth_and_runtime_commands.py +0 -0
- {forgexa_cli-1.43.2 → forgexa_cli-1.43.3}/tests/test_autoupgrade.py +0 -0
- {forgexa_cli-1.43.2 → forgexa_cli-1.43.3}/tests/test_check_command.py +0 -0
- {forgexa_cli-1.43.2 → forgexa_cli-1.43.3}/tests/test_expiry_warnings_and_revoke.py +0 -0
- {forgexa_cli-1.43.2 → forgexa_cli-1.43.3}/tests/test_local_bind_commands.py +0 -0
- {forgexa_cli-1.43.2 → forgexa_cli-1.43.3}/tests/test_runtime_credentials.py +0 -0
- {forgexa_cli-1.43.2 → forgexa_cli-1.43.3}/tests/test_session_credentials.py +0 -0
- {forgexa_cli-1.43.2 → forgexa_cli-1.43.3}/tests/test_silent_install.py +0 -0
- {forgexa_cli-1.43.2 → forgexa_cli-1.43.3}/tests/test_upgrade_observability.py +0 -0
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
"""forgexa-cli — Forgexa command-line client."""
|
|
2
|
-
__version__ = "1.43.
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
|
14090
|
-
|
|
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
|
-
|
|
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()
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|