forgexa-cli 1.22.2__tar.gz → 1.22.4__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.
Files changed (21) hide show
  1. {forgexa_cli-1.22.2 → forgexa_cli-1.22.4}/PKG-INFO +1 -1
  2. {forgexa_cli-1.22.2 → forgexa_cli-1.22.4}/forgexa_cli/__init__.py +1 -1
  3. {forgexa_cli-1.22.2 → forgexa_cli-1.22.4}/forgexa_cli/daemon.py +224 -23
  4. {forgexa_cli-1.22.2 → forgexa_cli-1.22.4}/forgexa_cli.egg-info/PKG-INFO +1 -1
  5. {forgexa_cli-1.22.2 → forgexa_cli-1.22.4}/pyproject.toml +1 -1
  6. {forgexa_cli-1.22.2 → forgexa_cli-1.22.4}/README.md +0 -0
  7. {forgexa_cli-1.22.2 → forgexa_cli-1.22.4}/forgexa_cli/_build_config.py +0 -0
  8. {forgexa_cli-1.22.2 → forgexa_cli-1.22.4}/forgexa_cli/autoupgrade.py +0 -0
  9. {forgexa_cli-1.22.2 → forgexa_cli-1.22.4}/forgexa_cli/main.py +0 -0
  10. {forgexa_cli-1.22.2 → forgexa_cli-1.22.4}/forgexa_cli/py.typed +0 -0
  11. {forgexa_cli-1.22.2 → forgexa_cli-1.22.4}/forgexa_cli.egg-info/SOURCES.txt +0 -0
  12. {forgexa_cli-1.22.2 → forgexa_cli-1.22.4}/forgexa_cli.egg-info/dependency_links.txt +0 -0
  13. {forgexa_cli-1.22.2 → forgexa_cli-1.22.4}/forgexa_cli.egg-info/entry_points.txt +0 -0
  14. {forgexa_cli-1.22.2 → forgexa_cli-1.22.4}/forgexa_cli.egg-info/requires.txt +0 -0
  15. {forgexa_cli-1.22.2 → forgexa_cli-1.22.4}/forgexa_cli.egg-info/top_level.txt +0 -0
  16. {forgexa_cli-1.22.2 → forgexa_cli-1.22.4}/setup.cfg +0 -0
  17. {forgexa_cli-1.22.2 → forgexa_cli-1.22.4}/tests/test_auth_and_runtime_commands.py +0 -0
  18. {forgexa_cli-1.22.2 → forgexa_cli-1.22.4}/tests/test_autoupgrade.py +0 -0
  19. {forgexa_cli-1.22.2 → forgexa_cli-1.22.4}/tests/test_check_command.py +0 -0
  20. {forgexa_cli-1.22.2 → forgexa_cli-1.22.4}/tests/test_silent_install.py +0 -0
  21. {forgexa_cli-1.22.2 → forgexa_cli-1.22.4}/tests/test_upgrade_observability.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: forgexa-cli
3
- Version: 1.22.2
3
+ Version: 1.22.4
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.22.2"
2
+ __version__ = "1.22.4"
@@ -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.22.2"
558
+ DAEMON_VERSION = "1.22.4"
559
559
 
560
560
 
561
561
  def _detect_client_type() -> str:
@@ -1012,6 +1012,8 @@ class TaskResult:
1012
1012
  # Key values:
1013
1013
  # "all_agents_rate_limited" — daemon tried every installed agent, all
1014
1014
  # hit rate/quota limits. Server must NOT retry on the same runtime.
1015
+ # "validation_retry_exhausted" — required-output validation still failed
1016
+ # after the allowed repair agents. Server must not auto-retry it.
1015
1017
  failure_code: str = ""
1016
1018
  # Optional structured preflight result for project/runtime preflight failures.
1017
1019
  preflight: dict | None = None
@@ -2615,7 +2617,10 @@ class WorkspaceManager:
2615
2617
 
2616
2618
  async def _remove_broken_worktree(self, main_repo: Path, ws_path: Path, workspace_key: str):
2617
2619
  """Remove a broken worktree directory and clean up stale worktree refs."""
2618
- # Try to prune from main repo first
2620
+ # `git worktree prune` keeps an entry while its directory exists, even
2621
+ # when that directory's .git pointer is corrupt.
2622
+ self._safe_rmtree(ws_path)
2623
+
2619
2624
  if main_repo.exists():
2620
2625
  try:
2621
2626
  await self._git("worktree", "prune", cwd=main_repo)
@@ -2625,9 +2630,6 @@ class WorkspaceManager:
2625
2630
  wt_ref = main_repo / ".git" / "worktrees" / workspace_key
2626
2631
  if wt_ref.exists():
2627
2632
  self._safe_rmtree(wt_ref)
2628
- # Remove the broken worktree directory using _safe_rmtree so that
2629
- # read-only git objects on Windows are properly handled.
2630
- self._safe_rmtree(ws_path)
2631
2633
 
2632
2634
  async def _safe_rmtree_main(self, main_repo: Path) -> None:
2633
2635
  """Remove _main repo, first evicting all linked worktrees to prevent orphans.
@@ -2675,8 +2677,10 @@ class WorkspaceManager:
2675
2677
  self,
2676
2678
  main_repo: Path,
2677
2679
  branch_name: str,
2680
+ *,
2681
+ healthy_only: bool = True,
2678
2682
  ) -> Path | None:
2679
- """Return an existing healthy linked worktree already on branch_name."""
2683
+ """Return a linked worktree already on branch_name."""
2680
2684
  try:
2681
2685
  raw = await self._git("worktree", "list", "--porcelain", cwd=main_repo)
2682
2686
  except RuntimeError:
@@ -2702,7 +2706,9 @@ class WorkspaceManager:
2702
2706
  for candidate_path, candidate_branch in entries:
2703
2707
  if candidate_path == main_repo or candidate_branch != target_ref:
2704
2708
  continue
2705
- if candidate_path.exists() and await self._is_healthy_worktree(candidate_path, main_repo=main_repo):
2709
+ if not candidate_path.exists():
2710
+ continue
2711
+ if not healthy_only or await self._is_healthy_worktree(candidate_path, main_repo=main_repo):
2706
2712
  return candidate_path
2707
2713
 
2708
2714
  return None
@@ -3825,6 +3831,43 @@ class WorkspaceManager:
3825
3831
  cwd=main_repo,
3826
3832
  )
3827
3833
  except Exception as exc:
3834
+ existing_branch_worktree = await self._find_existing_worktree_for_branch(
3835
+ main_repo, branch_name,
3836
+ )
3837
+ if existing_branch_worktree is not None:
3838
+ logger.info(
3839
+ "Fresh start reusing worktree %s after resetting %s to origin/%s",
3840
+ existing_branch_worktree, branch_name, default_branch,
3841
+ )
3842
+ await self._git(
3843
+ "checkout", "-B", branch_name, f"origin/{default_branch}",
3844
+ cwd=existing_branch_worktree,
3845
+ )
3846
+ return existing_branch_worktree
3847
+
3848
+ stale_branch_worktree = await self._find_existing_worktree_for_branch(
3849
+ main_repo, branch_name, healthy_only=False,
3850
+ )
3851
+ if stale_branch_worktree is not None:
3852
+ logger.warning(
3853
+ "Fresh start found broken worktree %s holding branch %s; removing it before retrying",
3854
+ stale_branch_worktree, branch_name,
3855
+ )
3856
+ try:
3857
+ await self._remove_broken_worktree(
3858
+ main_repo, stale_branch_worktree, stale_branch_worktree.name,
3859
+ )
3860
+ await self._git(
3861
+ "branch", "-f", branch_name, f"origin/{default_branch}",
3862
+ cwd=main_repo,
3863
+ )
3864
+ await self._git(
3865
+ "worktree", "add", str(ws_path), branch_name,
3866
+ cwd=main_repo,
3867
+ )
3868
+ return ws_path
3869
+ except Exception as recovery_exc:
3870
+ exc = recovery_exc
3828
3871
  self._safe_rmtree(ws_path)
3829
3872
  raise RuntimeError(
3830
3873
  f"Failed to create clean fresh worktree for branch '{branch_name}'. "
@@ -7692,6 +7735,22 @@ def _extract_testing_artifacts(workspace_path: Path, output_dir: str) -> dict |
7692
7735
  return None
7693
7736
 
7694
7737
 
7738
+ def _case_evidence_entries(value: Any) -> list[dict[str, Any]]:
7739
+ """Normalize list and keyed-object case_evidence forms into entries."""
7740
+ if isinstance(value, list):
7741
+ return [entry for entry in value if isinstance(entry, dict)]
7742
+ if isinstance(value, dict):
7743
+ entries: list[dict[str, Any]] = []
7744
+ for case_id, entry in value.items():
7745
+ if not isinstance(entry, dict):
7746
+ continue
7747
+ normalized = dict(entry)
7748
+ normalized.setdefault("test_case_id", str(case_id))
7749
+ entries.append(normalized)
7750
+ return entries
7751
+ return []
7752
+
7753
+
7695
7754
  def _validate_test_evidence(
7696
7755
  base: Path, workspace_path: Path, e2e_case_ids: list[str], max_anchor_checks: int = 50
7697
7756
  ) -> list[str]:
@@ -7725,12 +7784,13 @@ def _validate_test_evidence(
7725
7784
  "must ground their selectors/routes in source-file evidence."
7726
7785
  )
7727
7786
 
7728
- case_evidence = ev.get("case_evidence") or []
7729
- covered_ids = {
7730
- str(entry.get("test_case_id"))
7731
- for entry in case_evidence
7732
- if isinstance(entry, dict) and entry.get("test_case_id")
7733
- }
7787
+ case_evidence = _case_evidence_entries(ev.get("case_evidence"))
7788
+ entries_by_case_id: dict[str, list[dict[str, Any]]] = {}
7789
+ for entry in case_evidence:
7790
+ case_id = str(entry.get("test_case_id") or "").strip()
7791
+ if case_id:
7792
+ entries_by_case_id.setdefault(case_id, []).append(entry)
7793
+ covered_ids = set(entries_by_case_id)
7734
7794
  missing_cases = [cid for cid in e2e_case_ids if cid not in covered_ids]
7735
7795
  if missing_cases:
7736
7796
  issues.append(
@@ -7739,6 +7799,54 @@ def _validate_test_evidence(
7739
7799
  f"with route_refs/locator_refs."
7740
7800
  )
7741
7801
 
7802
+ route_ids = {
7803
+ str(route.get("id"))
7804
+ for route in routes
7805
+ if isinstance(route, dict) and route.get("id")
7806
+ }
7807
+ locator_ids = {
7808
+ str(locator.get("id"))
7809
+ for locator in locators
7810
+ if isinstance(locator, dict) and locator.get("id")
7811
+ }
7812
+ for case_id in e2e_case_ids:
7813
+ entries = entries_by_case_id.get(case_id, [])
7814
+ if not entries:
7815
+ continue
7816
+ if len(entries) > 1:
7817
+ issues.append(
7818
+ f"test-evidence.json has multiple case_evidence entries for e2e test case "
7819
+ f"{case_id}. Keep exactly one entry per test case."
7820
+ )
7821
+ continue
7822
+ entry = entries[0]
7823
+ route_refs = entry.get("route_refs")
7824
+ locator_refs = entry.get("locator_refs")
7825
+ if not isinstance(route_refs, list) or not isinstance(locator_refs, list):
7826
+ issues.append(
7827
+ f"test-evidence.json case_evidence for {case_id} must define "
7828
+ "route_refs and locator_refs as arrays."
7829
+ )
7830
+ continue
7831
+ if not route_refs and not locator_refs:
7832
+ issues.append(
7833
+ f"test-evidence.json case_evidence for {case_id} must reference at least "
7834
+ "one route or locator."
7835
+ )
7836
+ continue
7837
+ unknown_routes = [str(ref) for ref in route_refs if str(ref) not in route_ids]
7838
+ unknown_locators = [str(ref) for ref in locator_refs if str(ref) not in locator_ids]
7839
+ if unknown_routes:
7840
+ issues.append(
7841
+ f"test-evidence.json case_evidence for {case_id} references unknown "
7842
+ f"route_refs: {unknown_routes[:10]}."
7843
+ )
7844
+ if unknown_locators:
7845
+ issues.append(
7846
+ f"test-evidence.json case_evidence for {case_id} references unknown "
7847
+ f"locator_refs: {unknown_locators[:10]}."
7848
+ )
7849
+
7742
7850
  checked = 0
7743
7851
  for item in list(locators) + list(routes):
7744
7852
  if checked >= max_anchor_checks:
@@ -8969,9 +9077,9 @@ class RuntimeDaemon:
8969
9077
  # 4.5 Layer 2: Validation gate — check outputs before committing
8970
9078
  if result.status == "success":
8971
9079
  try:
8972
- result = await self._validate_and_retry(
9080
+ result, agent = await self._validate_with_agent_fallback(
8973
9081
  agent, task, workspace_path, result,
8974
- reporter, on_output_chunk, max_retries=2,
9082
+ reporter, on_output_chunk, tried_agents=tried_agents,
8975
9083
  default_branch=default_branch, before_sha=node_before_sha,
8976
9084
  )
8977
9085
  # Re-collect git info if validation triggered retries
@@ -9023,9 +9131,9 @@ class RuntimeDaemon:
9023
9131
  # failure) — re-run the validation gate and update git state.
9024
9132
  if result.status == "success":
9025
9133
  try:
9026
- result = await self._validate_and_retry(
9134
+ result, agent = await self._validate_with_agent_fallback(
9027
9135
  agent, task, workspace_path, result,
9028
- reporter, on_output_chunk, max_retries=2,
9136
+ reporter, on_output_chunk, tried_agents=tried_agents,
9029
9137
  default_branch=default_branch, before_sha=node_before_sha,
9030
9138
  )
9031
9139
  pre_commit_git = await self.process_manager._collect_git_info(workspace_path)
@@ -9230,7 +9338,11 @@ class RuntimeDaemon:
9230
9338
  result.metrics["actual_agent"] = agent.agent_id
9231
9339
  if agent.agent_id != task.agent_type:
9232
9340
  result.metrics["original_agent"] = task.agent_type
9233
- result.metrics["fallback_reason"] = fallback_reason
9341
+ result.metrics["fallback_reason"] = (
9342
+ result.metrics.get("fallback_reason")
9343
+ or fallback_reason
9344
+ or "validation_retry_exhausted"
9345
+ )
9234
9346
  await reporter.report_progress(task.task_id, 100, "completed" if result.status == "success" else "failed")
9235
9347
  await reporter.report_complete(task.task_id, result)
9236
9348
 
@@ -10069,6 +10181,78 @@ class RuntimeDaemon:
10069
10181
 
10070
10182
  return info
10071
10183
 
10184
+ async def _validate_with_agent_fallback(
10185
+ self,
10186
+ agent: "DiscoveredAgent",
10187
+ task: TaskInfo,
10188
+ workspace_path: Path,
10189
+ result: TaskResult,
10190
+ reporter: "ProgressReporter",
10191
+ on_chunk: Any,
10192
+ tried_agents: set[str],
10193
+ default_branch: str = "",
10194
+ before_sha: str = "",
10195
+ ) -> tuple[TaskResult, "DiscoveredAgent"]:
10196
+ """Repair blocking validation failures without repeating an agent blindly."""
10197
+ tried_agents.add(agent.agent_id)
10198
+ result = await self._validate_and_retry(
10199
+ agent,
10200
+ task,
10201
+ workspace_path,
10202
+ result,
10203
+ reporter,
10204
+ on_chunk,
10205
+ max_retries=1,
10206
+ default_branch=default_branch,
10207
+ before_sha=before_sha,
10208
+ )
10209
+ if result.status != "failed" or result.failure_code != "validation_retry_exhausted":
10210
+ return result, agent
10211
+
10212
+ fallback_agent = self._select_fallback_agent(
10213
+ agent.agent_id,
10214
+ task.fallback_chain,
10215
+ tried_agents,
10216
+ )
10217
+ if not fallback_agent:
10218
+ result.metrics["validation_agents_tried"] = sorted(tried_agents)
10219
+ result.metrics["validation_agent_fallback_available"] = False
10220
+ result.error = (
10221
+ f"{result.error} No alternate agent is available on this runtime; "
10222
+ "retry after selecting a different agent or runtime."
10223
+ )
10224
+ return result, agent
10225
+
10226
+ tried_agents.add(fallback_agent.agent_id)
10227
+ await reporter.report_progress(
10228
+ task.task_id,
10229
+ 92,
10230
+ f"validation_agent_fallback:{fallback_agent.agent_id}",
10231
+ output_lines=[
10232
+ "[daemon] Required-output validation still failed after the first repair, "
10233
+ f"switching from {agent.agent_id} to {fallback_agent.agent_id}.",
10234
+ ],
10235
+ )
10236
+ previous_retries = int(result.metrics.get("validation_retries_used", 0) or 0)
10237
+ result = await self._validate_and_retry(
10238
+ fallback_agent,
10239
+ task,
10240
+ workspace_path,
10241
+ result,
10242
+ reporter,
10243
+ on_chunk,
10244
+ max_retries=1,
10245
+ default_branch=default_branch,
10246
+ before_sha=before_sha,
10247
+ )
10248
+ result.metrics["validation_agents_tried"] = sorted(tried_agents)
10249
+ result.metrics["validation_agent_fallback_available"] = True
10250
+ result.metrics["validation_retries_used"] = previous_retries + int(
10251
+ result.metrics.get("validation_retries_used", 0) or 0
10252
+ )
10253
+ result.metrics["fallback_reason"] = "validation_retry_exhausted"
10254
+ return result, fallback_agent
10255
+
10072
10256
  async def _validate_and_retry(
10073
10257
  self,
10074
10258
  agent: "DiscoveredAgent",
@@ -10085,8 +10269,10 @@ class RuntimeDaemon:
10085
10269
 
10086
10270
  Layer 2 of the reflection mechanism. Runs deterministic checks
10087
10271
  (file existence, syntax, JSON validity) after agent completion but
10088
- before git commit. If issues are found, builds a fix prompt listing
10089
- all problems and re-invokes the same agent.
10272
+ before git commit. If issues are found, builds a fix prompt listing all
10273
+ problems and gives the current agent one focused repair attempt. The
10274
+ caller routes a still-blocking failure to a different agent when one is
10275
+ available.
10090
10276
 
10091
10277
  Returns the (possibly updated) TaskResult.
10092
10278
  """
@@ -10204,13 +10390,27 @@ class RuntimeDaemon:
10204
10390
  )
10205
10391
  remaining = self._validate_outputs(workspace_path, task, result)
10206
10392
  if remaining:
10207
- # Distinguish critical issues (no output produced) from minor ones (syntax)
10393
+ # Distinguish blocking artifact-contract failures from advisory syntax
10394
+ # warnings. Evidence coverage and references are release-blocking even
10395
+ # when the file itself exists.
10208
10396
  scope_violations = [
10209
10397
  issue
10210
10398
  for issue in remaining
10211
10399
  if issue.startswith("Verification work item modified requirement analysis assets:")
10212
10400
  ]
10213
- critical_patterns = ("missing", "not found", "is empty")
10401
+ critical_patterns = (
10402
+ "missing",
10403
+ "not found",
10404
+ "is empty",
10405
+ "not valid json",
10406
+ "case_evidence",
10407
+ "test-evidence.json has no",
10408
+ "evidence item",
10409
+ "anchor not found",
10410
+ "uncovered acceptance criteria",
10411
+ "no p0 priority test cases",
10412
+ "contains no test cases",
10413
+ )
10214
10414
  critical_issues = [
10215
10415
  iss for iss in remaining
10216
10416
  if any(p in iss.lower() for p in critical_patterns)
@@ -10228,8 +10428,9 @@ class RuntimeDaemon:
10228
10428
  result.failure_code = "verification_work_item_scope_violation"
10229
10429
  result.error = "; ".join(scope_violations[:3])
10230
10430
  else:
10431
+ result.failure_code = "validation_retry_exhausted"
10231
10432
  result.error = (
10232
- f"Agent failed to produce required output after {max_retries} retries: "
10433
+ "Agent failed to satisfy required output validation: "
10233
10434
  + "; ".join(critical_issues[:3])
10234
10435
  )
10235
10436
  else:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: forgexa-cli
3
- Version: 1.22.2
3
+ Version: 1.22.4
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.22.2"
3
+ version = "1.22.4"
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