forgexa-cli 1.18.9__tar.gz → 1.18.11__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.9
3
+ Version: 1.18.11
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.9"
2
+ __version__ = "1.18.11"
@@ -555,7 +555,7 @@ except (ImportError, ModuleNotFoundError):
555
555
  # DAEMON_VERSION is the protocol/logic version of the daemon code.
556
556
  # Kept in sync with pyproject.toml version via bump-version.sh.
557
557
  # CLIENT_TYPE identifies which packaging/distribution this daemon runs in.
558
- DAEMON_VERSION = "1.18.9"
558
+ DAEMON_VERSION = "1.18.11"
559
559
 
560
560
 
561
561
  def _detect_client_type() -> str:
@@ -1716,6 +1716,13 @@ class WorkspaceManager:
1716
1716
  """Register an SSH private key for a project."""
1717
1717
  self._ssh_keys[project_key] = ssh_private_key
1718
1718
 
1719
+ @staticmethod
1720
+ def _summarize_git_error(error: Exception) -> str:
1721
+ """Return a compact Git error without exposing embedded HTTPS credentials."""
1722
+ detail = " ".join(str(error).split())
1723
+ detail = re.sub(r"(https?://)[^/@\s]+@", r"\1***@", detail)
1724
+ return detail[:500]
1725
+
1719
1726
  def _migrate_legacy_project_dirs(self) -> None:
1720
1727
  """Move project directories from root/ to root/projects/ (one-time migration).
1721
1728
 
@@ -3042,6 +3049,7 @@ class WorkspaceManager:
3042
3049
  # Retry up to 2 times with short delay for cross-runtime timing issues
3043
3050
  # (analysis daemon may have just pushed the branch).
3044
3051
  branch_exists_remote = False
3052
+ last_branch_fetch_error: str | None = None
3045
3053
  for _check_attempt in range(3 if not fresh_start else 1):
3046
3054
  try:
3047
3055
  check_proc = await asyncio.create_subprocess_exec(
@@ -3070,8 +3078,8 @@ class WorkspaceManager:
3070
3078
  timeout=settings.GIT_FETCH_TIMEOUT,
3071
3079
  project_key=project_key,
3072
3080
  )
3073
- except RuntimeError:
3074
- pass
3081
+ except RuntimeError as exc:
3082
+ last_branch_fetch_error = self._summarize_git_error(exc)
3075
3083
 
3076
3084
  # Fresh analysis with an existing remote branch that has implementation commits:
3077
3085
  # delete the remote branch so the agent starts from a clean slate.
@@ -3138,10 +3146,16 @@ class WorkspaceManager:
3138
3146
 
3139
3147
  if expect_branch and not fresh_start and not branch_exists_remote:
3140
3148
  self._safe_rmtree(ws_path)
3149
+ fetch_detail = (
3150
+ f" Last branch fetch error: {last_branch_fetch_error}"
3151
+ if last_branch_fetch_error
3152
+ else ""
3153
+ )
3141
3154
  raise RuntimeError(
3142
3155
  f"Expected remote branch '{branch_name}' to exist, but it could not be "
3143
3156
  "fetched or resolved. Refusing to create a continuation workspace from "
3144
3157
  f"origin/{default_branch} because that risks overwriting requirement-scoped history."
3158
+ f"{fetch_detail}"
3145
3159
  )
3146
3160
 
3147
3161
  if branch_exists_remote and not fresh_start:
@@ -7292,6 +7306,43 @@ class RuntimeDaemon:
7292
7306
  result.metrics["recovered_from_timeout_uncommitted_changes"] = True
7293
7307
  return True
7294
7308
 
7309
+ async def _discard_verification_scope_changes(
7310
+ self,
7311
+ workspace_path: Path,
7312
+ task: TaskInfo,
7313
+ result: TaskResult,
7314
+ before_sha: str,
7315
+ ) -> None:
7316
+ """Discard protected analysis changes from a failed verification task."""
7317
+ if not bool((task.input_data or {}).get("verification_work_item")):
7318
+ return
7319
+
7320
+ forbidden_paths = sorted(
7321
+ path
7322
+ for path in ProcessManager._normalize_repo_paths(result.files_changed)
7323
+ if _is_requirement_analysis_path(path)
7324
+ )
7325
+ if not forbidden_paths:
7326
+ return
7327
+
7328
+ try:
7329
+ await self.workspace_manager._git(
7330
+ "reset", "--hard", before_sha or "HEAD", cwd=workspace_path,
7331
+ )
7332
+ await self.workspace_manager._git(
7333
+ "clean", "-f", "--", *forbidden_paths, cwd=workspace_path,
7334
+ )
7335
+ logger.warning(
7336
+ "Discarded verification scope-violation changes for task %s: %s",
7337
+ task.task_id,
7338
+ forbidden_paths,
7339
+ )
7340
+ except Exception:
7341
+ logger.exception(
7342
+ "Failed to discard verification scope-violation changes for task %s",
7343
+ task.task_id,
7344
+ )
7345
+
7295
7346
  async def _execute_task(self, task: TaskInfo, conn: ServerConnection):
7296
7347
  """Execute a single task, reporting to the originating server connection."""
7297
7348
  reporter = conn.reporter
@@ -7769,6 +7820,7 @@ class RuntimeDaemon:
7769
7820
  result = await self._validate_and_retry(
7770
7821
  agent, task, workspace_path, result,
7771
7822
  reporter, on_output_chunk, max_retries=2,
7823
+ default_branch=default_branch, before_sha=node_before_sha,
7772
7824
  )
7773
7825
  # Re-collect git info if validation triggered retries
7774
7826
  if result.metrics.get("validation_issues") is not None or True:
@@ -7776,6 +7828,11 @@ class RuntimeDaemon:
7776
7828
  except Exception:
7777
7829
  logger.exception("Validation gate error for task %s (proceeding anyway)", task.task_id)
7778
7830
 
7831
+ if result.failure_code == "verification_work_item_scope_violation":
7832
+ await self._discard_verification_scope_changes(
7833
+ workspace_path, task, result, node_before_sha,
7834
+ )
7835
+
7779
7836
  # 4.6 Post-validation rate-limit fallback.
7780
7837
  # _validate_and_retry returns early (preserving the rate-limit error)
7781
7838
  # when the agent hits a quota wall mid-retry. The initial-run fallback
@@ -7817,6 +7874,7 @@ class RuntimeDaemon:
7817
7874
  result = await self._validate_and_retry(
7818
7875
  agent, task, workspace_path, result,
7819
7876
  reporter, on_output_chunk, max_retries=2,
7877
+ default_branch=default_branch, before_sha=node_before_sha,
7820
7878
  )
7821
7879
  pre_commit_git = await self.process_manager._collect_git_info(workspace_path)
7822
7880
  except Exception:
@@ -8779,6 +8837,8 @@ class RuntimeDaemon:
8779
8837
  reporter: "ProgressReporter",
8780
8838
  on_chunk: Any,
8781
8839
  max_retries: int = 2,
8840
+ default_branch: str = "",
8841
+ before_sha: str = "",
8782
8842
  ) -> TaskResult:
8783
8843
  """Validate agent output and retry with fix prompt if issues found.
8784
8844
 
@@ -8790,6 +8850,16 @@ class RuntimeDaemon:
8790
8850
  Returns the (possibly updated) TaskResult.
8791
8851
  """
8792
8852
  for attempt in range(max_retries):
8853
+ if before_sha and bool((task.input_data or {}).get("verification_work_item")):
8854
+ committed_git = await self.process_manager._collect_git_info_vs_parent(
8855
+ workspace_path,
8856
+ default_branch=default_branch,
8857
+ before_sha=before_sha,
8858
+ )
8859
+ result.files_changed = sorted(
8860
+ set(result.files_changed)
8861
+ | set(committed_git.get("files_changed") or [])
8862
+ )
8793
8863
  issues = self._validate_outputs(workspace_path, task, result)
8794
8864
  if not issues:
8795
8865
  return result # All validations passed
@@ -8878,27 +8948,46 @@ class RuntimeDaemon:
8878
8948
  return result
8879
8949
 
8880
8950
  # Final check after all retries
8951
+ if before_sha and bool((task.input_data or {}).get("verification_work_item")):
8952
+ committed_git = await self.process_manager._collect_git_info_vs_parent(
8953
+ workspace_path,
8954
+ default_branch=default_branch,
8955
+ before_sha=before_sha,
8956
+ )
8957
+ result.files_changed = sorted(
8958
+ set(result.files_changed)
8959
+ | set(committed_git.get("files_changed") or [])
8960
+ )
8881
8961
  remaining = self._validate_outputs(workspace_path, task, result)
8882
8962
  if remaining:
8883
8963
  # Distinguish critical issues (no output produced) from minor ones (syntax)
8964
+ scope_violations = [
8965
+ issue
8966
+ for issue in remaining
8967
+ if issue.startswith("Verification work item modified requirement analysis assets:")
8968
+ ]
8884
8969
  critical_patterns = ("missing", "not found", "is empty")
8885
8970
  critical_issues = [
8886
8971
  iss for iss in remaining
8887
8972
  if any(p in iss.lower() for p in critical_patterns)
8888
8973
  ]
8889
- if critical_issues:
8974
+ if critical_issues or scope_violations:
8890
8975
  # Agent didn't produce required output — mark as failed
8891
8976
  logger.warning(
8892
8977
  "Validation gate: %d critical issues remain after %d retries for task %s — "
8893
8978
  "marking as failed:\n%s",
8894
- len(critical_issues), max_retries, task.task_id,
8895
- "\n".join(f" - {iss}" for iss in critical_issues),
8979
+ len(critical_issues) + len(scope_violations), max_retries, task.task_id,
8980
+ "\n".join(f" - {iss}" for iss in critical_issues + scope_violations),
8896
8981
  )
8897
8982
  result.status = "failed"
8898
- result.error = (
8899
- f"Agent failed to produce required output after {max_retries} retries: "
8900
- + "; ".join(critical_issues[:3])
8901
- )
8983
+ if scope_violations:
8984
+ result.failure_code = "verification_work_item_scope_violation"
8985
+ result.error = "; ".join(scope_violations[:3])
8986
+ else:
8987
+ result.error = (
8988
+ f"Agent failed to produce required output after {max_retries} retries: "
8989
+ + "; ".join(critical_issues[:3])
8990
+ )
8902
8991
  else:
8903
8992
  # Non-critical issues (syntax warnings) — proceed with warning
8904
8993
  logger.warning(
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: forgexa-cli
3
- Version: 1.18.9
3
+ Version: 1.18.11
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.9"
3
+ version = "1.18.11"
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"
@@ -5,7 +5,7 @@ import io
5
5
  import json
6
6
  from pathlib import Path
7
7
  from types import SimpleNamespace
8
- from unittest.mock import MagicMock
8
+ from unittest.mock import AsyncMock, MagicMock
9
9
 
10
10
  import httpx
11
11
  import pytest
@@ -196,6 +196,49 @@ def test_httpx_recovery_hints_use_platform_specific_upgrade_commands() -> None:
196
196
  assert linux_hints[0] == "python3 -m pip install --upgrade forgexa-cli"
197
197
 
198
198
 
199
+ @pytest.mark.asyncio
200
+ async def test_workspace_reports_last_expected_branch_fetch_error(
201
+ tmp_path: Path,
202
+ monkeypatch: pytest.MonkeyPatch,
203
+ ) -> None:
204
+ manager = daemon.WorkspaceManager(str(tmp_path / "daemon-root"))
205
+ project_dir = tmp_path / "project"
206
+ (project_dir / "_main").mkdir(parents=True)
207
+
208
+ class DummyProc:
209
+ returncode = 1
210
+
211
+ async def communicate(self):
212
+ return b"", b""
213
+
214
+ async def fake_git(*args, **kwargs):
215
+ if args[:2] == ("fetch", "origin") and args[2].startswith("feature/SI-647:"):
216
+ raise RuntimeError("Permission denied (publickey).")
217
+ return ""
218
+
219
+ manager._git = AsyncMock(side_effect=fake_git)
220
+ monkeypatch.setattr(
221
+ daemon.asyncio,
222
+ "create_subprocess_exec",
223
+ AsyncMock(return_value=DummyProc()),
224
+ )
225
+ monkeypatch.setattr(daemon.asyncio, "sleep", AsyncMock())
226
+
227
+ with pytest.raises(
228
+ RuntimeError,
229
+ match="Last branch fetch error: Permission denied \\(publickey\\)\\.",
230
+ ):
231
+ await manager._create_worktree_impl(
232
+ project_dir,
233
+ "ssh://git@example.com/repo.git",
234
+ "develop",
235
+ "workflow-keep-branch",
236
+ "feature/SI-647",
237
+ project_key="SI",
238
+ expect_branch=True,
239
+ )
240
+
241
+
199
242
  def test_normalize_target_version_accepts_plain_and_v_prefixed_tags() -> None:
200
243
  assert main._normalize_target_version("1.12.3") == "1.12.3"
201
244
  assert main._normalize_target_version("v1.12.3") == "1.12.3"
File without changes
File without changes