forgexa-cli 1.18.2__tar.gz → 1.18.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.18.2 → forgexa_cli-1.18.3}/PKG-INFO +1 -1
- {forgexa_cli-1.18.2 → forgexa_cli-1.18.3}/forgexa_cli/__init__.py +1 -1
- {forgexa_cli-1.18.2 → forgexa_cli-1.18.3}/forgexa_cli/daemon.py +135 -15
- {forgexa_cli-1.18.2 → forgexa_cli-1.18.3}/forgexa_cli.egg-info/PKG-INFO +1 -1
- {forgexa_cli-1.18.2 → forgexa_cli-1.18.3}/pyproject.toml +1 -1
- {forgexa_cli-1.18.2 → forgexa_cli-1.18.3}/README.md +0 -0
- {forgexa_cli-1.18.2 → forgexa_cli-1.18.3}/forgexa_cli/_build_config.py +0 -0
- {forgexa_cli-1.18.2 → forgexa_cli-1.18.3}/forgexa_cli/main.py +0 -0
- {forgexa_cli-1.18.2 → forgexa_cli-1.18.3}/forgexa_cli/py.typed +0 -0
- {forgexa_cli-1.18.2 → forgexa_cli-1.18.3}/forgexa_cli.egg-info/SOURCES.txt +0 -0
- {forgexa_cli-1.18.2 → forgexa_cli-1.18.3}/forgexa_cli.egg-info/dependency_links.txt +0 -0
- {forgexa_cli-1.18.2 → forgexa_cli-1.18.3}/forgexa_cli.egg-info/entry_points.txt +0 -0
- {forgexa_cli-1.18.2 → forgexa_cli-1.18.3}/forgexa_cli.egg-info/requires.txt +0 -0
- {forgexa_cli-1.18.2 → forgexa_cli-1.18.3}/forgexa_cli.egg-info/top_level.txt +0 -0
- {forgexa_cli-1.18.2 → forgexa_cli-1.18.3}/setup.cfg +0 -0
- {forgexa_cli-1.18.2 → forgexa_cli-1.18.3}/tests/test_auth_and_runtime_commands.py +0 -0
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
"""forgexa-cli — Forgexa command-line client."""
|
|
2
|
-
__version__ = "1.18.
|
|
2
|
+
__version__ = "1.18.3"
|
|
@@ -527,7 +527,7 @@ except (ImportError, ModuleNotFoundError):
|
|
|
527
527
|
# DAEMON_VERSION is the protocol/logic version of the daemon code.
|
|
528
528
|
# Kept in sync with pyproject.toml version via bump-version.sh.
|
|
529
529
|
# CLIENT_TYPE identifies which packaging/distribution this daemon runs in.
|
|
530
|
-
DAEMON_VERSION = "1.18.
|
|
530
|
+
DAEMON_VERSION = "1.18.3"
|
|
531
531
|
|
|
532
532
|
|
|
533
533
|
def _detect_client_type() -> str:
|
|
@@ -2087,6 +2087,7 @@ class WorkspaceManager:
|
|
|
2087
2087
|
f"({sync_error or 'unknown sync error'}). Refusing to continue on a "
|
|
2088
2088
|
"workspace that may be based on the default branch."
|
|
2089
2089
|
)
|
|
2090
|
+
await self._git("config", "core.filemode", "false", cwd=ws_path)
|
|
2090
2091
|
return ws_path
|
|
2091
2092
|
else:
|
|
2092
2093
|
# No repo — create a directory with git init for change tracking
|
|
@@ -2109,6 +2110,7 @@ class WorkspaceManager:
|
|
|
2109
2110
|
)
|
|
2110
2111
|
# Create a working branch (V5.4: use human-readable name)
|
|
2111
2112
|
await self._git("checkout", "-b", branch_name, cwd=ws_path)
|
|
2113
|
+
await self._git("config", "core.filemode", "false", cwd=ws_path)
|
|
2112
2114
|
return ws_path
|
|
2113
2115
|
|
|
2114
2116
|
async def _is_healthy_worktree(self, ws_path: Path, main_repo: Path | None = None) -> bool:
|
|
@@ -7110,6 +7112,44 @@ class RuntimeDaemon:
|
|
|
7110
7112
|
|
|
7111
7113
|
return had_work
|
|
7112
7114
|
|
|
7115
|
+
async def _workspace_checkout_health_error(self, workspace_path: Path) -> str | None:
|
|
7116
|
+
"""Return an error when a tracked worktree has been catastrophically emptied."""
|
|
7117
|
+
try:
|
|
7118
|
+
tracked_output = await self.workspace_manager._git(
|
|
7119
|
+
"ls-files", "-z", "--cached", "--", ".",
|
|
7120
|
+
cwd=workspace_path,
|
|
7121
|
+
timeout=30,
|
|
7122
|
+
)
|
|
7123
|
+
tracked_count = len([path for path in tracked_output.split("\0") if path])
|
|
7124
|
+
if tracked_count <= 500:
|
|
7125
|
+
return None
|
|
7126
|
+
|
|
7127
|
+
physical_count = sum(
|
|
7128
|
+
1
|
|
7129
|
+
for path in workspace_path.rglob("*")
|
|
7130
|
+
if path.is_file() and ".git" not in path.parts
|
|
7131
|
+
)
|
|
7132
|
+
except Exception as exc:
|
|
7133
|
+
logger.warning("Workspace health check failed to inspect %s: %s", workspace_path, exc)
|
|
7134
|
+
return None
|
|
7135
|
+
|
|
7136
|
+
if physical_count / tracked_count >= 0.20:
|
|
7137
|
+
return None
|
|
7138
|
+
|
|
7139
|
+
longpath_hint = (
|
|
7140
|
+
" Enable Windows long-path support: run "
|
|
7141
|
+
"`git config --global core.longpaths true` and enable "
|
|
7142
|
+
"LongPathsEnabled in Windows Group Policy / Registry "
|
|
7143
|
+
"(HKLM\\SYSTEM\\CurrentControlSet\\Control\\FileSystem\\LongPathsEnabled=1)."
|
|
7144
|
+
if sys.platform == "win32" else ""
|
|
7145
|
+
)
|
|
7146
|
+
return (
|
|
7147
|
+
f"Workspace health check failed: only {physical_count}/{tracked_count} "
|
|
7148
|
+
f"tracked files exist on disk ({physical_count / tracked_count:.0%}). "
|
|
7149
|
+
"The git checkout was likely damaged during execution."
|
|
7150
|
+
f"{longpath_hint}"
|
|
7151
|
+
)
|
|
7152
|
+
|
|
7113
7153
|
def _recover_timeout_from_workspace_changes(
|
|
7114
7154
|
self,
|
|
7115
7155
|
task: TaskInfo,
|
|
@@ -7797,7 +7837,24 @@ class RuntimeDaemon:
|
|
|
7797
7837
|
|
|
7798
7838
|
# 5. Auto-commit and push if changes exist
|
|
7799
7839
|
if result.status == "success":
|
|
7800
|
-
|
|
7840
|
+
workspace_health_error = await self._workspace_checkout_health_error(
|
|
7841
|
+
workspace_path
|
|
7842
|
+
)
|
|
7843
|
+
if workspace_health_error:
|
|
7844
|
+
result.status = "failed"
|
|
7845
|
+
result.failure_code = "workspace_checkout_failed"
|
|
7846
|
+
result.error = workspace_health_error
|
|
7847
|
+
result.files_changed = []
|
|
7848
|
+
result.lines_added = 0
|
|
7849
|
+
result.lines_removed = 0
|
|
7850
|
+
result.git = {}
|
|
7851
|
+
|
|
7852
|
+
if result.status == "success":
|
|
7853
|
+
commit_result = await self._auto_commit(
|
|
7854
|
+
workspace_path,
|
|
7855
|
+
task,
|
|
7856
|
+
before_sha=node_before_sha,
|
|
7857
|
+
)
|
|
7801
7858
|
if commit_result:
|
|
7802
7859
|
# Propagate push/commit errors in metrics so they're visible
|
|
7803
7860
|
result.metrics.update(commit_result)
|
|
@@ -7841,21 +7898,15 @@ class RuntimeDaemon:
|
|
|
7841
7898
|
default_branch=default_branch,
|
|
7842
7899
|
before_sha=node_before_sha,
|
|
7843
7900
|
)
|
|
7844
|
-
# Merge: use the pre-commit file list if post-commit is empty
|
|
7845
7901
|
result.git = post_commit_git
|
|
7846
|
-
if not result.git.get("files_changed") and pre_commit_git.get("files_changed"):
|
|
7847
|
-
result.git["files_changed"] = pre_commit_git["files_changed"]
|
|
7848
|
-
result.git["lines_added"] = pre_commit_git.get("lines_added", 0)
|
|
7849
|
-
result.git["lines_removed"] = pre_commit_git.get("lines_removed", 0)
|
|
7850
7902
|
result.files_changed = result.git.get("files_changed", [])
|
|
7851
7903
|
result.lines_added = result.git.get("lines_added", 0)
|
|
7852
7904
|
result.lines_removed = result.git.get("lines_removed", 0)
|
|
7853
7905
|
else:
|
|
7854
|
-
result.git =
|
|
7855
|
-
|
|
7856
|
-
|
|
7857
|
-
|
|
7858
|
-
result.lines_removed = pre_commit_git.get("lines_removed", 0)
|
|
7906
|
+
result.git = {}
|
|
7907
|
+
result.files_changed = []
|
|
7908
|
+
result.lines_added = 0
|
|
7909
|
+
result.lines_removed = 0
|
|
7859
7910
|
|
|
7860
7911
|
# 6. Report completion (include actual agent used if different from requested)
|
|
7861
7912
|
result.metrics["actual_agent"] = agent.agent_id
|
|
@@ -8922,7 +8973,57 @@ class RuntimeDaemon:
|
|
|
8922
8973
|
", ".join(removed),
|
|
8923
8974
|
)
|
|
8924
8975
|
|
|
8925
|
-
async def
|
|
8976
|
+
async def _pure_file_mode_change_error(
|
|
8977
|
+
self,
|
|
8978
|
+
workspace_path: Path,
|
|
8979
|
+
*diff_args: str,
|
|
8980
|
+
) -> str | None:
|
|
8981
|
+
"""Reject Git changes that alter only file modes, not file contents."""
|
|
8982
|
+
proc = await asyncio.create_subprocess_exec(
|
|
8983
|
+
"git", "diff", "--raw", "--no-abbrev", "--no-renames", "-z", *diff_args,
|
|
8984
|
+
cwd=str(workspace_path),
|
|
8985
|
+
stdout=asyncio.subprocess.PIPE,
|
|
8986
|
+
stderr=asyncio.subprocess.PIPE,
|
|
8987
|
+
)
|
|
8988
|
+
stdout, stderr = await proc.communicate()
|
|
8989
|
+
if proc.returncode != 0:
|
|
8990
|
+
detail = stderr.decode(errors="replace").strip()
|
|
8991
|
+
return (
|
|
8992
|
+
"Auto-commit blocked: could not validate file mode changes"
|
|
8993
|
+
f"{f': {detail}' if detail else ''}"
|
|
8994
|
+
)
|
|
8995
|
+
|
|
8996
|
+
mode_only_paths: list[str] = []
|
|
8997
|
+
entries = stdout.decode(errors="surrogateescape").split("\0")
|
|
8998
|
+
for index in range(0, len(entries) - 1, 2):
|
|
8999
|
+
metadata, path = entries[index], entries[index + 1]
|
|
9000
|
+
fields = metadata.split()
|
|
9001
|
+
if len(fields) < 4 or not fields[0].startswith(":"):
|
|
9002
|
+
continue
|
|
9003
|
+
old_mode = fields[0][1:]
|
|
9004
|
+
new_mode, old_object, new_object = fields[1:4]
|
|
9005
|
+
if old_object == new_object and old_mode != new_mode:
|
|
9006
|
+
mode_only_paths.append(path)
|
|
9007
|
+
|
|
9008
|
+
if not mode_only_paths:
|
|
9009
|
+
return None
|
|
9010
|
+
|
|
9011
|
+
sample = ", ".join(mode_only_paths[:5])
|
|
9012
|
+
remainder = "" if len(mode_only_paths) <= 5 else ", ..."
|
|
9013
|
+
return (
|
|
9014
|
+
"Auto-commit blocked: detected "
|
|
9015
|
+
f"{len(mode_only_paths)} permission-only file change(s) with unchanged content "
|
|
9016
|
+
f"({sample}{remainder}). Restore the original modes before retrying; "
|
|
9017
|
+
"Forgexa does not auto-commit permission-only changes."
|
|
9018
|
+
)
|
|
9019
|
+
|
|
9020
|
+
async def _auto_commit(
|
|
9021
|
+
self,
|
|
9022
|
+
workspace_path: Path,
|
|
9023
|
+
task: TaskInfo,
|
|
9024
|
+
*,
|
|
9025
|
+
before_sha: str = "",
|
|
9026
|
+
) -> dict:
|
|
8926
9027
|
"""Auto-commit and push agent changes.
|
|
8927
9028
|
|
|
8928
9029
|
Some agents (e.g. claude) commit changes internally, so we must
|
|
@@ -8943,6 +9044,13 @@ class RuntimeDaemon:
|
|
|
8943
9044
|
try:
|
|
8944
9045
|
has_new_commit = False
|
|
8945
9046
|
|
|
9047
|
+
if before_sha:
|
|
9048
|
+
mode_change_error = await self._pure_file_mode_change_error(
|
|
9049
|
+
workspace_path, before_sha, "HEAD"
|
|
9050
|
+
)
|
|
9051
|
+
if mode_change_error:
|
|
9052
|
+
return {"commit_error": mode_change_error}
|
|
9053
|
+
|
|
8946
9054
|
# Check for uncommitted changes
|
|
8947
9055
|
proc = await asyncio.create_subprocess_exec(
|
|
8948
9056
|
"git", "status", "--porcelain",
|
|
@@ -8968,7 +9076,16 @@ class RuntimeDaemon:
|
|
|
8968
9076
|
stdout=asyncio.subprocess.PIPE,
|
|
8969
9077
|
stderr=asyncio.subprocess.PIPE,
|
|
8970
9078
|
)
|
|
8971
|
-
await proc.communicate()
|
|
9079
|
+
add_stdout, add_stderr = await proc.communicate()
|
|
9080
|
+
if proc.returncode != 0:
|
|
9081
|
+
detail = (add_stderr or add_stdout).decode(errors="replace").strip()
|
|
9082
|
+
return {"commit_error": f"git add failed: {detail or 'unknown error'}"}
|
|
9083
|
+
|
|
9084
|
+
mode_change_error = await self._pure_file_mode_change_error(
|
|
9085
|
+
workspace_path, "--cached"
|
|
9086
|
+
)
|
|
9087
|
+
if mode_change_error:
|
|
9088
|
+
return {"commit_error": mode_change_error}
|
|
8972
9089
|
|
|
8973
9090
|
# Collect staged diff stats for rich commit message
|
|
8974
9091
|
change_summary = await self._collect_staged_diff_stats(workspace_path)
|
|
@@ -9010,7 +9127,10 @@ class RuntimeDaemon:
|
|
|
9010
9127
|
"GIT_AUTHOR_NAME": _git_name, "GIT_AUTHOR_EMAIL": _git_email,
|
|
9011
9128
|
"GIT_COMMITTER_NAME": _git_name, "GIT_COMMITTER_EMAIL": _git_email},
|
|
9012
9129
|
)
|
|
9013
|
-
await proc.communicate()
|
|
9130
|
+
commit_stdout, commit_stderr = await proc.communicate()
|
|
9131
|
+
if proc.returncode != 0:
|
|
9132
|
+
detail = (commit_stderr or commit_stdout).decode(errors="replace").strip()
|
|
9133
|
+
return {"commit_error": f"git commit failed: {detail or 'unknown error'}"}
|
|
9014
9134
|
has_new_commit = True
|
|
9015
9135
|
logger.info("Auto-committed changes in %s", workspace_path)
|
|
9016
9136
|
else:
|
|
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
|