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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: forgexa-cli
3
- Version: 1.18.2
3
+ Version: 1.18.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.18.2"
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.2"
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
- commit_result = await self._auto_commit(workspace_path, task)
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 = pre_commit_git
7855
- if not result.files_changed and pre_commit_git.get("files_changed"):
7856
- result.files_changed = pre_commit_git["files_changed"]
7857
- result.lines_added = pre_commit_git.get("lines_added", 0)
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 _auto_commit(self, workspace_path: Path, task: TaskInfo) -> dict:
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:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: forgexa-cli
3
- Version: 1.18.2
3
+ Version: 1.18.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.18.2"
3
+ version = "1.18.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