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.
- {forgexa_cli-1.18.9 → forgexa_cli-1.18.11}/PKG-INFO +1 -1
- {forgexa_cli-1.18.9 → forgexa_cli-1.18.11}/forgexa_cli/__init__.py +1 -1
- {forgexa_cli-1.18.9 → forgexa_cli-1.18.11}/forgexa_cli/daemon.py +99 -10
- {forgexa_cli-1.18.9 → forgexa_cli-1.18.11}/forgexa_cli.egg-info/PKG-INFO +1 -1
- {forgexa_cli-1.18.9 → forgexa_cli-1.18.11}/pyproject.toml +1 -1
- {forgexa_cli-1.18.9 → forgexa_cli-1.18.11}/tests/test_auth_and_runtime_commands.py +44 -1
- {forgexa_cli-1.18.9 → forgexa_cli-1.18.11}/README.md +0 -0
- {forgexa_cli-1.18.9 → forgexa_cli-1.18.11}/forgexa_cli/_build_config.py +0 -0
- {forgexa_cli-1.18.9 → forgexa_cli-1.18.11}/forgexa_cli/main.py +0 -0
- {forgexa_cli-1.18.9 → forgexa_cli-1.18.11}/forgexa_cli/py.typed +0 -0
- {forgexa_cli-1.18.9 → forgexa_cli-1.18.11}/forgexa_cli.egg-info/SOURCES.txt +0 -0
- {forgexa_cli-1.18.9 → forgexa_cli-1.18.11}/forgexa_cli.egg-info/dependency_links.txt +0 -0
- {forgexa_cli-1.18.9 → forgexa_cli-1.18.11}/forgexa_cli.egg-info/entry_points.txt +0 -0
- {forgexa_cli-1.18.9 → forgexa_cli-1.18.11}/forgexa_cli.egg-info/requires.txt +0 -0
- {forgexa_cli-1.18.9 → forgexa_cli-1.18.11}/forgexa_cli.egg-info/top_level.txt +0 -0
- {forgexa_cli-1.18.9 → forgexa_cli-1.18.11}/setup.cfg +0 -0
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
"""forgexa-cli — Forgexa command-line client."""
|
|
2
|
-
__version__ = "1.18.
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
8899
|
-
|
|
8900
|
-
|
|
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(
|
|
@@ -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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|