forgexa-cli 1.57.7__tar.gz → 1.58.1__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.57.7 → forgexa_cli-1.58.1}/PKG-INFO +1 -1
- {forgexa_cli-1.57.7 → forgexa_cli-1.58.1}/forgexa_cli/__init__.py +1 -1
- {forgexa_cli-1.57.7 → forgexa_cli-1.58.1}/forgexa_cli/agent_core.py +43 -0
- {forgexa_cli-1.57.7 → forgexa_cli-1.58.1}/forgexa_cli/daemon.py +58 -11
- {forgexa_cli-1.57.7 → forgexa_cli-1.58.1}/forgexa_cli.egg-info/PKG-INFO +1 -1
- {forgexa_cli-1.57.7 → forgexa_cli-1.58.1}/pyproject.toml +1 -1
- {forgexa_cli-1.57.7 → forgexa_cli-1.58.1}/README.md +0 -0
- {forgexa_cli-1.57.7 → forgexa_cli-1.58.1}/forgexa_cli/_build_config.py +0 -0
- {forgexa_cli-1.57.7 → forgexa_cli-1.58.1}/forgexa_cli/_local_bind.py +0 -0
- {forgexa_cli-1.57.7 → forgexa_cli-1.58.1}/forgexa_cli/autoupgrade.py +0 -0
- {forgexa_cli-1.57.7 → forgexa_cli-1.58.1}/forgexa_cli/main.py +0 -0
- {forgexa_cli-1.57.7 → forgexa_cli-1.58.1}/forgexa_cli/py.typed +0 -0
- {forgexa_cli-1.57.7 → forgexa_cli-1.58.1}/forgexa_cli/runtime_evidence.py +0 -0
- {forgexa_cli-1.57.7 → forgexa_cli-1.58.1}/forgexa_cli/zcode_protocol.py +0 -0
- {forgexa_cli-1.57.7 → forgexa_cli-1.58.1}/forgexa_cli.egg-info/SOURCES.txt +0 -0
- {forgexa_cli-1.57.7 → forgexa_cli-1.58.1}/forgexa_cli.egg-info/dependency_links.txt +0 -0
- {forgexa_cli-1.57.7 → forgexa_cli-1.58.1}/forgexa_cli.egg-info/entry_points.txt +0 -0
- {forgexa_cli-1.57.7 → forgexa_cli-1.58.1}/forgexa_cli.egg-info/requires.txt +0 -0
- {forgexa_cli-1.57.7 → forgexa_cli-1.58.1}/forgexa_cli.egg-info/top_level.txt +0 -0
- {forgexa_cli-1.57.7 → forgexa_cli-1.58.1}/setup.cfg +0 -0
- {forgexa_cli-1.57.7 → forgexa_cli-1.58.1}/tests/test_auth_and_runtime_commands.py +0 -0
- {forgexa_cli-1.57.7 → forgexa_cli-1.58.1}/tests/test_autoupgrade.py +0 -0
- {forgexa_cli-1.57.7 → forgexa_cli-1.58.1}/tests/test_check_command.py +0 -0
- {forgexa_cli-1.57.7 → forgexa_cli-1.58.1}/tests/test_expiry_warnings_and_revoke.py +0 -0
- {forgexa_cli-1.57.7 → forgexa_cli-1.58.1}/tests/test_local_bind_commands.py +0 -0
- {forgexa_cli-1.57.7 → forgexa_cli-1.58.1}/tests/test_runtime_credentials.py +0 -0
- {forgexa_cli-1.57.7 → forgexa_cli-1.58.1}/tests/test_session_credentials.py +0 -0
- {forgexa_cli-1.57.7 → forgexa_cli-1.58.1}/tests/test_silent_install.py +0 -0
- {forgexa_cli-1.57.7 → forgexa_cli-1.58.1}/tests/test_upgrade_observability.py +0 -0
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
"""forgexa-cli — Forgexa command-line client."""
|
|
2
|
-
__version__ = "1.
|
|
2
|
+
__version__ = "1.58.1"
|
|
@@ -816,6 +816,30 @@ class AgentDiscovery:
|
|
|
816
816
|
os.environ["PATH"] = os.pathsep.join(additions) + os.pathsep + current
|
|
817
817
|
logger.debug("Expanded PATH with: %s", ", ".join(additions))
|
|
818
818
|
|
|
819
|
+
@staticmethod
|
|
820
|
+
def _wsl_windows_executable(path: str) -> bool:
|
|
821
|
+
"""Reject a Windows-side agent before its version probe can run via WSL interop."""
|
|
822
|
+
if sys.platform != "linux" or not os.environ.get("WSL_DISTRO_NAME"):
|
|
823
|
+
return False
|
|
824
|
+
resolved = Path(path).resolve()
|
|
825
|
+
if resolved.suffix.lower() in {".exe", ".cmd", ".bat"}:
|
|
826
|
+
return True
|
|
827
|
+
parts = resolved.parts
|
|
828
|
+
if len(parts) >= 3 and parts[1] == "mnt" and len(parts[2]) == 1 and parts[2].isalpha():
|
|
829
|
+
return True
|
|
830
|
+
# A custom DrvFs mount need not live under /mnt/<drive>.
|
|
831
|
+
try:
|
|
832
|
+
mounts = Path("/proc/mounts").read_text(encoding="utf-8")
|
|
833
|
+
except OSError:
|
|
834
|
+
return False
|
|
835
|
+
for line in mounts.splitlines():
|
|
836
|
+
fields = line.split()
|
|
837
|
+
if len(fields) >= 3 and fields[2] in {"9p", "drvfs"}:
|
|
838
|
+
mount = Path(fields[1].replace("\\040", " "))
|
|
839
|
+
if resolved == mount or mount in resolved.parents:
|
|
840
|
+
return True
|
|
841
|
+
return False
|
|
842
|
+
|
|
819
843
|
async def discover(self) -> list[DiscoveredAgent]:
|
|
820
844
|
self._expand_path()
|
|
821
845
|
# Probe bwrap support once at discovery time and log a clear warning
|
|
@@ -827,6 +851,9 @@ class AgentDiscovery:
|
|
|
827
851
|
cmd = custom_path or spec["commands"][0]
|
|
828
852
|
resolved = shutil.which(cmd)
|
|
829
853
|
if resolved:
|
|
854
|
+
if self._wsl_windows_executable(resolved):
|
|
855
|
+
logger.warning("Agent %s resolved only to a Windows executable inside WSL; skipping", agent_id)
|
|
856
|
+
continue
|
|
830
857
|
version = await self._get_version(spec["detect"], resolved)
|
|
831
858
|
if version is None and "detect_alt" in spec:
|
|
832
859
|
# Primary detect command failed; try the alternative.
|
|
@@ -877,6 +904,8 @@ class AgentDiscovery:
|
|
|
877
904
|
"binary found but not functional".
|
|
878
905
|
"""
|
|
879
906
|
import re
|
|
907
|
+
probe_dir = None
|
|
908
|
+
proc = None
|
|
880
909
|
try:
|
|
881
910
|
parts = detect_cmd.split()
|
|
882
911
|
if executable:
|
|
@@ -886,11 +915,17 @@ class AgentDiscovery:
|
|
|
886
915
|
if resolved:
|
|
887
916
|
parts[0] = resolved
|
|
888
917
|
parts = _prepare_agent_command(parts, preserve_argv=False)
|
|
918
|
+
env = None
|
|
919
|
+
if detect_cmd in ("opencode --version", "mimo --version"):
|
|
920
|
+
probe_dir = tempfile.TemporaryDirectory(prefix=f"forgexa-{detect_cmd.split()[0]}-probe-")
|
|
921
|
+
env = os.environ.copy()
|
|
922
|
+
env.update(TMPDIR=probe_dir.name, BUN_TMPDIR=probe_dir.name)
|
|
889
923
|
proc = await asyncio.create_subprocess_exec(
|
|
890
924
|
*parts,
|
|
891
925
|
stdin=asyncio.subprocess.DEVNULL,
|
|
892
926
|
stdout=asyncio.subprocess.PIPE,
|
|
893
927
|
stderr=asyncio.subprocess.PIPE,
|
|
928
|
+
env=env,
|
|
894
929
|
**_agent_process_group_kwargs(),
|
|
895
930
|
)
|
|
896
931
|
stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=10)
|
|
@@ -918,11 +953,19 @@ class AgentDiscovery:
|
|
|
918
953
|
)
|
|
919
954
|
return None
|
|
920
955
|
return output
|
|
956
|
+
except asyncio.CancelledError:
|
|
957
|
+
raise
|
|
921
958
|
except Exception as exc:
|
|
922
959
|
logger.warning(
|
|
923
960
|
"Version check for %r raised %s: %s", detect_cmd, type(exc).__name__, exc,
|
|
924
961
|
)
|
|
925
962
|
return None
|
|
963
|
+
finally:
|
|
964
|
+
if proc is not None and proc.returncode is None:
|
|
965
|
+
_kill_proc(proc)
|
|
966
|
+
await proc.wait()
|
|
967
|
+
if probe_dir is not None:
|
|
968
|
+
probe_dir.cleanup()
|
|
926
969
|
|
|
927
970
|
@staticmethod
|
|
928
971
|
async def _probe_bwrap_support() -> None:
|
|
@@ -1150,7 +1150,7 @@ except (ImportError, ModuleNotFoundError):
|
|
|
1150
1150
|
# DAEMON_VERSION is the protocol/logic version of the daemon code.
|
|
1151
1151
|
# Kept in sync with pyproject.toml version via bump-version.sh.
|
|
1152
1152
|
# CLIENT_TYPE identifies which packaging/distribution this daemon runs in.
|
|
1153
|
-
DAEMON_VERSION = "1.
|
|
1153
|
+
DAEMON_VERSION = "1.58.1"
|
|
1154
1154
|
|
|
1155
1155
|
|
|
1156
1156
|
def _detect_client_type() -> str:
|
|
@@ -2899,11 +2899,21 @@ class WorkspaceManager:
|
|
|
2899
2899
|
self._ssh_keys[project_key] = ssh_private_key
|
|
2900
2900
|
|
|
2901
2901
|
@staticmethod
|
|
2902
|
-
def _summarize_git_error(error: Exception) -> str:
|
|
2902
|
+
def _summarize_git_error(error: Exception, *, tail: bool = False) -> str:
|
|
2903
2903
|
"""Return a compact Git error without exposing embedded HTTPS credentials."""
|
|
2904
2904
|
detail = " ".join(str(error).split())
|
|
2905
2905
|
detail = re.sub(r"(https?://)[^/@\s]+@", r"\1***@", detail)
|
|
2906
|
-
return detail[:500]
|
|
2906
|
+
return detail[-500:] if tail else detail[:500]
|
|
2907
|
+
|
|
2908
|
+
@staticmethod
|
|
2909
|
+
def _git_push_diagnostic(error: Exception) -> str:
|
|
2910
|
+
detail = str(error).replace("\r\n", "\n").replace("\r", "\n")
|
|
2911
|
+
detail = re.sub(r"(https?://)[^/@\s]+@", r"\1***@", detail)
|
|
2912
|
+
if len(detail) <= 16_000:
|
|
2913
|
+
return detail
|
|
2914
|
+
beginning, ending = detail[:1000], detail[-14_800:]
|
|
2915
|
+
omitted = len(detail) - len(beginning) - len(ending)
|
|
2916
|
+
return f"{beginning}\n... [{omitted} earlier characters omitted] ...\n{ending}"
|
|
2907
2917
|
|
|
2908
2918
|
def _migrate_legacy_project_dirs(self) -> None:
|
|
2909
2919
|
"""Move project directories from root/ to root/projects/ (one-time migration).
|
|
@@ -6699,6 +6709,7 @@ class ProcessManager:
|
|
|
6699
6709
|
data_home=tmp_data_root,
|
|
6700
6710
|
require_windows_shell=True,
|
|
6701
6711
|
)
|
|
6712
|
+
env.update(TMPDIR=tmp_data_root, BUN_TMPDIR=tmp_data_root)
|
|
6702
6713
|
except RuntimeError as exc:
|
|
6703
6714
|
return TaskResult(
|
|
6704
6715
|
status="failed",
|
|
@@ -6792,6 +6803,7 @@ class ProcessManager:
|
|
|
6792
6803
|
prompt_file=prompt_file,
|
|
6793
6804
|
)
|
|
6794
6805
|
env = agent_core.mimo_base_env(os.environ.copy())
|
|
6806
|
+
env.update(TMPDIR=tmp_root, BUN_TMPDIR=tmp_root)
|
|
6795
6807
|
result = await self._run_cli(
|
|
6796
6808
|
cmd, cwd, timeout, task_id, on_chunk=on_chunk, env=env,
|
|
6797
6809
|
)
|
|
@@ -7900,6 +7912,7 @@ class ProcessManager:
|
|
|
7900
7912
|
env: dict | None = None,
|
|
7901
7913
|
stream_stderr: bool = False,
|
|
7902
7914
|
) -> TaskResult:
|
|
7915
|
+
proc = None
|
|
7903
7916
|
try:
|
|
7904
7917
|
cmd = _prepare_agent_command(cmd)
|
|
7905
7918
|
proc = await asyncio.create_subprocess_exec(
|
|
@@ -7938,7 +7951,8 @@ class ProcessManager:
|
|
|
7938
7951
|
failure_code="agent_launch_not_found",
|
|
7939
7952
|
)
|
|
7940
7953
|
except asyncio.TimeoutError as exc:
|
|
7941
|
-
|
|
7954
|
+
if proc is not None:
|
|
7955
|
+
_kill_proc(proc)
|
|
7942
7956
|
_err = (
|
|
7943
7957
|
f"Agent produced no stdout, stderr, or workspace changes for "
|
|
7944
7958
|
f"{exc.idle_seconds:.0f}s — process terminated. Inspect retained "
|
|
@@ -7954,16 +7968,14 @@ class ProcessManager:
|
|
|
7954
7968
|
)
|
|
7955
7969
|
except Exception as exc:
|
|
7956
7970
|
logger.exception("CLI stream error for task %s", task_id)
|
|
7957
|
-
if task_id in self.active_processes:
|
|
7958
|
-
try:
|
|
7959
|
-
self.active_processes[task_id].kill()
|
|
7960
|
-
except Exception:
|
|
7961
|
-
pass
|
|
7962
7971
|
return TaskResult(
|
|
7963
7972
|
status="failed", exit_code=-1, stdout="", stderr="",
|
|
7964
7973
|
error=f"Stream processing error: {exc}",
|
|
7965
7974
|
)
|
|
7966
7975
|
finally:
|
|
7976
|
+
if proc is not None and proc.returncode is None:
|
|
7977
|
+
_kill_proc(proc)
|
|
7978
|
+
await proc.wait()
|
|
7967
7979
|
self.active_processes.pop(task_id, None)
|
|
7968
7980
|
|
|
7969
7981
|
def _parse_claude_output(self, stdout: str) -> dict:
|
|
@@ -17715,6 +17727,41 @@ class RuntimeDaemon:
|
|
|
17715
17727
|
|
|
17716
17728
|
if remote_ahead:
|
|
17717
17729
|
remote_count = len(remote_ahead.splitlines())
|
|
17730
|
+
if (
|
|
17731
|
+
task is not None
|
|
17732
|
+
and task.workspace_resolution == "cloud_worktree"
|
|
17733
|
+
and task.node_type == "analysis"
|
|
17734
|
+
and task.input_data.get("analysis_mode") == "fresh"
|
|
17735
|
+
and task.requirement_key
|
|
17736
|
+
and branch == f"feature/{task.requirement_key}"
|
|
17737
|
+
):
|
|
17738
|
+
default_branch = task.project.get("default_branch") or "main"
|
|
17739
|
+
try:
|
|
17740
|
+
await git(
|
|
17741
|
+
"merge-base", "--is-ancestor", f"origin/{default_branch}",
|
|
17742
|
+
"HEAD", cwd=workspace_path,
|
|
17743
|
+
)
|
|
17744
|
+
if await self.workspace_manager._branch_has_impl_commits(
|
|
17745
|
+
workspace_path, branch, default_branch,
|
|
17746
|
+
):
|
|
17747
|
+
return (
|
|
17748
|
+
f"Push blocked: remote branch '{branch}' contains "
|
|
17749
|
+
"implementation commits; fresh analysis cannot replace it."
|
|
17750
|
+
)
|
|
17751
|
+
remote_sha = (await git(
|
|
17752
|
+
"rev-parse", "--verify", f"refs/remotes/origin/{branch}",
|
|
17753
|
+
cwd=workspace_path,
|
|
17754
|
+
)).strip()
|
|
17755
|
+
await git(
|
|
17756
|
+
"push", f"--force-with-lease=refs/heads/{branch}:{remote_sha}",
|
|
17757
|
+
"-u", "origin", branch, cwd=workspace_path,
|
|
17758
|
+
project_key=project_key, timeout=settings.GIT_PUSH_TIMEOUT,
|
|
17759
|
+
)
|
|
17760
|
+
logger.info("Pushed fresh analysis branch %s with exact lease", branch)
|
|
17761
|
+
return None
|
|
17762
|
+
except RuntimeError as exc:
|
|
17763
|
+
detail = self.workspace_manager._git_push_diagnostic(exc)
|
|
17764
|
+
return f"Fresh analysis push blocked for branch '{branch}': {detail}"
|
|
17718
17765
|
# Divergence detected — but this is expected after rebase.
|
|
17719
17766
|
# Check if the remote commits are already incorporated
|
|
17720
17767
|
# (i.e., their patches are empty against our branch, meaning
|
|
@@ -17922,8 +17969,8 @@ class RuntimeDaemon:
|
|
|
17922
17969
|
f"Push refused: remote branch '{branch}' has {remote_count} "
|
|
17923
17970
|
f"commit(s) not in local history, rebase (-X theirs) failed, "
|
|
17924
17971
|
f"and force-with-lease fallback also failed. "
|
|
17925
|
-
f"Rebase error
|
|
17926
|
-
f"Force error
|
|
17972
|
+
f"Rebase error:\n{self.workspace_manager._git_push_diagnostic(rebase_exc)}\n"
|
|
17973
|
+
f"Force error:\n{self.workspace_manager._git_push_diagnostic(force_exc)}"
|
|
17927
17974
|
)
|
|
17928
17975
|
|
|
17929
17976
|
logger.info("Found unpushed commits on %s, pushing...", branch)
|
|
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
|
|
File without changes
|
|
File without changes
|