forgexa-cli 1.21.8__tar.gz → 1.22.0__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.21.8 → forgexa_cli-1.22.0}/PKG-INFO +1 -1
  2. {forgexa_cli-1.21.8 → forgexa_cli-1.22.0}/forgexa_cli/__init__.py +1 -1
  3. {forgexa_cli-1.21.8 → forgexa_cli-1.22.0}/forgexa_cli/daemon.py +212 -7
  4. {forgexa_cli-1.21.8 → forgexa_cli-1.22.0}/forgexa_cli.egg-info/PKG-INFO +1 -1
  5. {forgexa_cli-1.21.8 → forgexa_cli-1.22.0}/pyproject.toml +1 -1
  6. {forgexa_cli-1.21.8 → forgexa_cli-1.22.0}/README.md +0 -0
  7. {forgexa_cli-1.21.8 → forgexa_cli-1.22.0}/forgexa_cli/_build_config.py +0 -0
  8. {forgexa_cli-1.21.8 → forgexa_cli-1.22.0}/forgexa_cli/autoupgrade.py +0 -0
  9. {forgexa_cli-1.21.8 → forgexa_cli-1.22.0}/forgexa_cli/main.py +0 -0
  10. {forgexa_cli-1.21.8 → forgexa_cli-1.22.0}/forgexa_cli/py.typed +0 -0
  11. {forgexa_cli-1.21.8 → forgexa_cli-1.22.0}/forgexa_cli.egg-info/SOURCES.txt +0 -0
  12. {forgexa_cli-1.21.8 → forgexa_cli-1.22.0}/forgexa_cli.egg-info/dependency_links.txt +0 -0
  13. {forgexa_cli-1.21.8 → forgexa_cli-1.22.0}/forgexa_cli.egg-info/entry_points.txt +0 -0
  14. {forgexa_cli-1.21.8 → forgexa_cli-1.22.0}/forgexa_cli.egg-info/requires.txt +0 -0
  15. {forgexa_cli-1.21.8 → forgexa_cli-1.22.0}/forgexa_cli.egg-info/top_level.txt +0 -0
  16. {forgexa_cli-1.21.8 → forgexa_cli-1.22.0}/setup.cfg +0 -0
  17. {forgexa_cli-1.21.8 → forgexa_cli-1.22.0}/tests/test_auth_and_runtime_commands.py +0 -0
  18. {forgexa_cli-1.21.8 → forgexa_cli-1.22.0}/tests/test_autoupgrade.py +0 -0
  19. {forgexa_cli-1.21.8 → forgexa_cli-1.22.0}/tests/test_check_command.py +0 -0
  20. {forgexa_cli-1.21.8 → forgexa_cli-1.22.0}/tests/test_silent_install.py +0 -0
  21. {forgexa_cli-1.21.8 → forgexa_cli-1.22.0}/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.21.8
3
+ Version: 1.22.0
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.21.8"
2
+ __version__ = "1.22.0"
@@ -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.21.8"
558
+ DAEMON_VERSION = "1.22.0"
559
559
 
560
560
 
561
561
  def _detect_client_type() -> str:
@@ -990,6 +990,12 @@ class TaskInfo:
990
990
  requirement_key: str | None = None
991
991
  graph_type: str = "execution"
992
992
  analysis_branch: str | None = None
993
+ # Explicit workspace baseline resolved server-side (SI-887). When set,
994
+ # these override the legacy requirement_key-based branch derivation:
995
+ # "requirement_branch" -> checkout workspace_branch, fail closed
996
+ # "default_branch" -> detached HEAD at origin/workspace_branch, never push
997
+ workspace_source_mode: str | None = None
998
+ workspace_branch: str | None = None
993
999
 
994
1000
 
995
1001
  @dataclass
@@ -2375,7 +2381,22 @@ class WorkspaceManager:
2375
2381
 
2376
2382
  # Use human-readable branch name feature/{requirement_key}
2377
2383
  # when requirement_key is available; fallback to feature/{workspace_key}
2378
- if task.requirement_key:
2384
+ #
2385
+ # Explicit server-resolved baseline (SI-887) takes precedence over the
2386
+ # legacy requirement_key derivation:
2387
+ # - "requirement_branch": checkout the given branch, fail closed.
2388
+ # - "default_branch": detached HEAD at origin/<branch> (QA read-only
2389
+ # context) — never probe feature/{key}, never push.
2390
+ ws_source_mode = task.workspace_source_mode
2391
+ detach_head = ws_source_mode == "default_branch"
2392
+ if ws_source_mode == "requirement_branch":
2393
+ branch_name = task.workspace_branch or (
2394
+ f"feature/{task.requirement_key}" if task.requirement_key
2395
+ else f"feature/{workspace_key}"
2396
+ )
2397
+ elif detach_head:
2398
+ branch_name = task.workspace_branch or default_branch
2399
+ elif task.requirement_key:
2379
2400
  branch_name = f"feature/{task.requirement_key}"
2380
2401
  else:
2381
2402
  branch_name = f"feature/{workspace_key}"
@@ -2408,16 +2429,25 @@ class WorkspaceManager:
2408
2429
  # reflects the branch from a *previous* analysis run (e.g. a prior Copilot
2409
2430
  # analysis) which may have been deleted as part of the fresh restart.
2410
2431
  # Only refine/continuation tasks require the branch to already exist.
2411
- expect_branch = (not is_fresh_start) and (
2412
- bool(task.analysis_branch) or (
2413
- bool(task.requirement_key) and task.node_type != "analysis"
2432
+ if ws_source_mode == "requirement_branch":
2433
+ # Server-resolved requirement baseline: the branch must exist.
2434
+ expect_branch = True
2435
+ elif detach_head:
2436
+ # Server-resolved default-branch baseline: read-only QA context,
2437
+ # never fail closed on the delivery branch protection.
2438
+ expect_branch = False
2439
+ else:
2440
+ expect_branch = (not is_fresh_start) and (
2441
+ bool(task.analysis_branch) or (
2442
+ bool(task.requirement_key) and task.node_type != "analysis"
2443
+ )
2414
2444
  )
2415
- )
2416
2445
  ws_path = await self._create_worktree(
2417
2446
  project_dir, repo_url, default_branch, workspace_key, branch_name,
2418
2447
  fresh_start=is_fresh_start,
2419
2448
  project_key=project_key,
2420
2449
  expect_branch=expect_branch,
2450
+ detach_head=detach_head,
2421
2451
  )
2422
2452
  # After workspace creation, perform a final branch-specific fetch + reset
2423
2453
  # to ensure the working tree is at the absolute latest remote state.
@@ -2427,7 +2457,9 @@ class WorkspaceManager:
2427
2457
  # may have pushed commits while this runtime's agent was executing.
2428
2458
  # A final sync here keeps the workspace current so the agent works
2429
2459
  # on the latest codebase and avoids non-fast-forward push failures.
2430
- if not is_fresh_start:
2460
+ # Skipped for detach_head (default-branch QA baseline): the detached
2461
+ # worktree path already fetched and pinned HEAD to origin/<branch>.
2462
+ if not is_fresh_start and not detach_head:
2431
2463
  sync_success = False
2432
2464
  sync_error = ""
2433
2465
  try:
@@ -2893,10 +2925,163 @@ class WorkspaceManager:
2893
2925
  self._worktree_locks[key] = asyncio.Lock()
2894
2926
  return self._worktree_locks[key]
2895
2927
 
2928
+ async def _refresh_or_invalidate_detached_worktree(
2929
+ self, main_repo: Path, ws_path: Path, branch_name: str, *, project_key: str,
2930
+ ) -> bool:
2931
+ """Refresh an existing healthy detached-mode worktree onto origin/<branch_name>.
2932
+
2933
+ Returns True when the worktree was successfully refreshed/re-pinned and
2934
+ the caller should reuse it as-is. Returns False when it was removed and
2935
+ the caller must fall through to full recreation.
2936
+
2937
+ SI-887 review fix: a worktree still checked out on a real local branch
2938
+ (not yet detached) is a leftover requirement_branch-mode checkout from
2939
+ before a baseline switch. That content must never be silently reused
2940
+ just because the refresh fetch below happens to fail — doing so would
2941
+ run the job against the wrong baseline entirely, not merely stale
2942
+ content. Only an already-detached worktree (same-mode refresh) may
2943
+ tolerate a fetch failure, matching the daemon's existing tolerance of
2944
+ transient fetch errors elsewhere when there is no baseline-identity
2945
+ risk.
2946
+ """
2947
+ stale_branch_checkout = False
2948
+ try:
2949
+ await self._git("symbolic-ref", "-q", "HEAD", cwd=ws_path)
2950
+ stale_branch_checkout = True
2951
+ except RuntimeError:
2952
+ pass # already detached
2953
+
2954
+ try:
2955
+ await self._git(
2956
+ "fetch", "origin",
2957
+ f"{branch_name}:refs/remotes/origin/{branch_name}",
2958
+ cwd=ws_path, timeout=settings.GIT_FETCH_TIMEOUT,
2959
+ project_key=project_key,
2960
+ )
2961
+ except RuntimeError as exc:
2962
+ if not stale_branch_checkout:
2963
+ logger.warning(
2964
+ "fetch origin/%s failed for QA workspace %s: %s — reusing current checkout",
2965
+ branch_name, ws_path, exc,
2966
+ )
2967
+ return True
2968
+ logger.warning(
2969
+ "fetch origin/%s failed for QA workspace %s while switching off a "
2970
+ "requirement-branch baseline: %s — discarding the stale checkout "
2971
+ "instead of running this job against the wrong baseline",
2972
+ branch_name, ws_path, exc,
2973
+ )
2974
+ await self._remove_broken_worktree(main_repo, ws_path, ws_path.name)
2975
+ return False
2976
+
2977
+ try:
2978
+ await self._git(
2979
+ "checkout", "-f", "--detach", f"origin/{branch_name}",
2980
+ cwd=ws_path,
2981
+ )
2982
+ except RuntimeError as exc:
2983
+ logger.warning(
2984
+ "Failed to detach QA workspace %s onto origin/%s: %s — recreating",
2985
+ ws_path, branch_name, exc,
2986
+ )
2987
+ await self._remove_broken_worktree(main_repo, ws_path, ws_path.name)
2988
+ return False
2989
+ return True
2990
+
2991
+ async def _create_detached_worktree(
2992
+ self, repo_url: str, main_repo: Path, ws_path: Path, branch_name: str,
2993
+ *, default_branch: str, project_key: str = "default",
2994
+ ) -> Path:
2995
+ """Create or refresh a read-only worktree pinned to origin/<branch_name>.
2996
+
2997
+ Used for server-resolved default-branch QA baselines (SI-887): the
2998
+ worktree is checked out at a DETACHED HEAD so it never collides with
2999
+ the default branch held by _main, never creates/tracks a local branch,
3000
+ and is never pushed. AI job results flow back via the API only.
3001
+ """
3002
+ if ws_path.exists():
3003
+ if not await self._is_healthy_worktree(ws_path, main_repo=main_repo):
3004
+ logger.warning("Broken worktree detected at %s — removing and recreating", ws_path)
3005
+ await self._remove_broken_worktree(main_repo, ws_path, ws_path.name)
3006
+ else:
3007
+ # Reuse: refresh origin/<branch_name> and re-pin HEAD onto it (or
3008
+ # discard and fall through to full recreation below) — see
3009
+ # _refresh_or_invalidate_detached_worktree for the baseline-switch
3010
+ # safety rationale.
3011
+ if await self._refresh_or_invalidate_detached_worktree(
3012
+ main_repo, ws_path, branch_name, project_key=project_key,
3013
+ ):
3014
+ return ws_path
3015
+
3016
+ # Ensure _main exists (single-branch clone of the default branch).
3017
+ if not main_repo.exists():
3018
+ await self._clone_repo(
3019
+ repo_url, main_repo, branch=default_branch, project_key=project_key,
3020
+ )
3021
+ else:
3022
+ # Same interrupted-clone guard as the feature-branch path: without a
3023
+ # valid HEAD / readable tree, worktree add would always fail.
3024
+ try:
3025
+ await self._git("rev-parse", "HEAD", cwd=main_repo)
3026
+ await self._git("rev-parse", "HEAD^{tree}", cwd=main_repo)
3027
+ except RuntimeError:
3028
+ logger.warning(
3029
+ "_main repo at %s has no valid HEAD (likely interrupted initial clone) "
3030
+ "— deleting and re-cloning from scratch",
3031
+ main_repo,
3032
+ )
3033
+ await self._safe_rmtree_main(main_repo)
3034
+ if main_repo.exists():
3035
+ raise RuntimeError(
3036
+ f"Cannot re-clone: {main_repo} could not be fully removed "
3037
+ "(files may be locked by another process). "
3038
+ "Please stop the daemon, manually delete it, then restart."
3039
+ )
3040
+ await self._clone_repo(
3041
+ repo_url, main_repo, branch=default_branch, project_key=project_key,
3042
+ )
3043
+
3044
+ # Refresh origin/<branch_name>. _main is a single-branch clone of the
3045
+ # default branch and branch_name is the server-resolved baseline
3046
+ # (normally that same default branch); fetch the explicit refspec so
3047
+ # the pin below always has a fresh ref.
3048
+ try:
3049
+ await self._git(
3050
+ "fetch", "origin",
3051
+ f"{branch_name}:refs/remotes/origin/{branch_name}",
3052
+ cwd=main_repo, timeout=settings.GIT_FETCH_TIMEOUT,
3053
+ project_key=project_key,
3054
+ )
3055
+ except RuntimeError as exc:
3056
+ raise RuntimeError(
3057
+ f"Failed to fetch QA baseline branch '{branch_name}' from remote: {exc}"
3058
+ ) from exc
3059
+
3060
+ try:
3061
+ await self._git(
3062
+ "worktree", "add", "--detach", str(ws_path), f"origin/{branch_name}",
3063
+ cwd=main_repo,
3064
+ )
3065
+ except RuntimeError:
3066
+ # A stale worktree registration may linger after a manual delete.
3067
+ await self._git("worktree", "prune", cwd=main_repo)
3068
+ try:
3069
+ await self._git(
3070
+ "worktree", "add", "--detach", str(ws_path), f"origin/{branch_name}",
3071
+ cwd=main_repo,
3072
+ )
3073
+ except RuntimeError as exc:
3074
+ self._safe_rmtree(ws_path)
3075
+ raise RuntimeError(
3076
+ f"Failed to create detached QA workspace at origin/{branch_name}: {exc}"
3077
+ ) from exc
3078
+ return ws_path
3079
+
2896
3080
  async def _create_worktree(
2897
3081
  self, project_dir: Path, repo_url: str, default_branch: str,
2898
3082
  workspace_key: str, branch_name: str, *, fresh_start: bool = False,
2899
3083
  project_key: str = "default", expect_branch: bool = False,
3084
+ detach_head: bool = False,
2900
3085
  ) -> Path:
2901
3086
  # Serialize all _create_worktree calls for the same project to prevent
2902
3087
  # concurrent tasks from racing on _main (shutil.rmtree / git clone / worktree add).
@@ -2908,16 +3093,27 @@ class WorkspaceManager:
2908
3093
  fresh_start=fresh_start,
2909
3094
  project_key=project_key,
2910
3095
  expect_branch=expect_branch,
3096
+ detach_head=detach_head,
2911
3097
  )
2912
3098
 
2913
3099
  async def _create_worktree_impl(
2914
3100
  self, project_dir: Path, repo_url: str, default_branch: str,
2915
3101
  workspace_key: str, branch_name: str, *, fresh_start: bool = False,
2916
3102
  project_key: str = "default", expect_branch: bool = False,
3103
+ detach_head: bool = False,
2917
3104
  ) -> Path:
2918
3105
  main_repo = project_dir / "_main"
2919
3106
  ws_path = project_dir / workspace_key
2920
3107
 
3108
+ if detach_head:
3109
+ # Server-resolved default-branch baseline (SI-887 QA context):
3110
+ # dedicated path that never touches feature/* refs and pins the
3111
+ # worktree to a detached HEAD at origin/<branch_name>.
3112
+ return await self._create_detached_worktree(
3113
+ repo_url, main_repo, ws_path, branch_name,
3114
+ default_branch=default_branch, project_key=project_key,
3115
+ )
3116
+
2921
3117
  if ws_path.exists():
2922
3118
  # Validate that this worktree is healthy (its .git file points to a
2923
3119
  # valid main repo). Worktrees break when directories are moved.
@@ -9540,6 +9736,13 @@ class RuntimeDaemon:
9540
9736
  user_prompt = aj.get("user_prompt", "")
9541
9737
  input_ctx = aj.get("input_context", {})
9542
9738
  ai_workspace_scope = _resolve_ai_job_workspace_scope(aj)
9739
+ # Server-resolved QA workspace baseline (SI-887). Top-level field wins;
9740
+ # the input_context copy is the fallback for older payload shapes.
9741
+ # When absent, prepare_workspace keeps its legacy requirement_key
9742
+ # derivation so this daemon stays compatible with older servers.
9743
+ qa_ws_source = aj.get("qa_workspace_source") or (
9744
+ input_ctx.get("qa_workspace_source") if isinstance(input_ctx, dict) else None
9745
+ ) or {}
9543
9746
 
9544
9747
  reporter_url = f"{conn.server_url.rstrip('/')}/api/v1/runtimes/{conn.runtime_id}/ai-jobs/{job_id}"
9545
9748
  # Use workspace-level key (project:requirement or project:job_id) so AI jobs
@@ -9615,6 +9818,8 @@ class RuntimeDaemon:
9615
9818
  requirement_workflow_id=None,
9616
9819
  requirement_key=requirement_key,
9617
9820
  graph_type="ai_job",
9821
+ workspace_source_mode=qa_ws_source.get("mode"),
9822
+ workspace_branch=qa_ws_source.get("branch"),
9618
9823
  )
9619
9824
  workspace_path = await self.workspace_manager.prepare_workspace(
9620
9825
  project_info, fake_task,
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: forgexa-cli
3
- Version: 1.21.8
3
+ Version: 1.22.0
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.21.8"
3
+ version = "1.22.0"
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