forgexa-cli 1.22.4__tar.gz → 1.22.6__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.4 → forgexa_cli-1.22.6}/PKG-INFO +1 -1
  2. {forgexa_cli-1.22.4 → forgexa_cli-1.22.6}/forgexa_cli/__init__.py +1 -1
  3. {forgexa_cli-1.22.4 → forgexa_cli-1.22.6}/forgexa_cli/daemon.py +167 -20
  4. {forgexa_cli-1.22.4 → forgexa_cli-1.22.6}/forgexa_cli.egg-info/PKG-INFO +1 -1
  5. {forgexa_cli-1.22.4 → forgexa_cli-1.22.6}/pyproject.toml +1 -1
  6. {forgexa_cli-1.22.4 → forgexa_cli-1.22.6}/README.md +0 -0
  7. {forgexa_cli-1.22.4 → forgexa_cli-1.22.6}/forgexa_cli/_build_config.py +0 -0
  8. {forgexa_cli-1.22.4 → forgexa_cli-1.22.6}/forgexa_cli/autoupgrade.py +0 -0
  9. {forgexa_cli-1.22.4 → forgexa_cli-1.22.6}/forgexa_cli/main.py +0 -0
  10. {forgexa_cli-1.22.4 → forgexa_cli-1.22.6}/forgexa_cli/py.typed +0 -0
  11. {forgexa_cli-1.22.4 → forgexa_cli-1.22.6}/forgexa_cli.egg-info/SOURCES.txt +0 -0
  12. {forgexa_cli-1.22.4 → forgexa_cli-1.22.6}/forgexa_cli.egg-info/dependency_links.txt +0 -0
  13. {forgexa_cli-1.22.4 → forgexa_cli-1.22.6}/forgexa_cli.egg-info/entry_points.txt +0 -0
  14. {forgexa_cli-1.22.4 → forgexa_cli-1.22.6}/forgexa_cli.egg-info/requires.txt +0 -0
  15. {forgexa_cli-1.22.4 → forgexa_cli-1.22.6}/forgexa_cli.egg-info/top_level.txt +0 -0
  16. {forgexa_cli-1.22.4 → forgexa_cli-1.22.6}/setup.cfg +0 -0
  17. {forgexa_cli-1.22.4 → forgexa_cli-1.22.6}/tests/test_auth_and_runtime_commands.py +0 -0
  18. {forgexa_cli-1.22.4 → forgexa_cli-1.22.6}/tests/test_autoupgrade.py +0 -0
  19. {forgexa_cli-1.22.4 → forgexa_cli-1.22.6}/tests/test_check_command.py +0 -0
  20. {forgexa_cli-1.22.4 → forgexa_cli-1.22.6}/tests/test_silent_install.py +0 -0
  21. {forgexa_cli-1.22.4 → forgexa_cli-1.22.6}/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.4
3
+ Version: 1.22.6
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.4"
2
+ __version__ = "1.22.6"
@@ -29,6 +29,7 @@ if sys.version_info < (3, 9):
29
29
 
30
30
  import asyncio
31
31
  import base64
32
+ import errno
32
33
  import hashlib
33
34
  import json
34
35
  import logging
@@ -555,7 +556,7 @@ except (ImportError, ModuleNotFoundError):
555
556
  # DAEMON_VERSION is the protocol/logic version of the daemon code.
556
557
  # Kept in sync with pyproject.toml version via bump-version.sh.
557
558
  # CLIENT_TYPE identifies which packaging/distribution this daemon runs in.
558
- DAEMON_VERSION = "1.22.4"
559
+ DAEMON_VERSION = "1.22.6"
559
560
 
560
561
 
561
562
  def _detect_client_type() -> str:
@@ -1948,6 +1949,66 @@ class AgentDiscovery:
1948
1949
  logger.warning("bwrap probe error: %s", exc)
1949
1950
 
1950
1951
 
1952
+ class _WorkspaceFileLock:
1953
+ """Advisory lock shared by daemon processes using one workspace root."""
1954
+
1955
+ def __init__(self, path: Path):
1956
+ self.path = path
1957
+ self._handle: Any | None = None
1958
+
1959
+ async def acquire(self) -> None:
1960
+ self.path.parent.mkdir(parents=True, exist_ok=True)
1961
+ self._handle = open(self.path, "a+b")
1962
+ waiting = False
1963
+
1964
+ try:
1965
+ if sys.platform == "win32":
1966
+ self._handle.seek(0)
1967
+ if not self._handle.read(1):
1968
+ self._handle.write(b"\0")
1969
+ self._handle.flush()
1970
+
1971
+ while True:
1972
+ try:
1973
+ self._handle.seek(0)
1974
+ if sys.platform == "win32":
1975
+ import msvcrt
1976
+
1977
+ msvcrt.locking(self._handle.fileno(), msvcrt.LK_NBLCK, 1)
1978
+ elif fcntl is not None:
1979
+ fcntl.flock(self._handle.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
1980
+ else:
1981
+ raise RuntimeError("Workspace file locking is unavailable on this platform")
1982
+ return
1983
+ except OSError as exc:
1984
+ if exc.errno not in (errno.EACCES, errno.EAGAIN):
1985
+ raise
1986
+ if not waiting:
1987
+ logger.info("Waiting for workspace lock at %s", self.path)
1988
+ waiting = True
1989
+ await asyncio.sleep(0.1)
1990
+ except BaseException:
1991
+ self.release()
1992
+ raise
1993
+
1994
+ def release(self) -> None:
1995
+ handle, self._handle = self._handle, None
1996
+ if handle is None:
1997
+ return
1998
+ try:
1999
+ handle.seek(0)
2000
+ if sys.platform == "win32":
2001
+ import msvcrt
2002
+
2003
+ msvcrt.locking(handle.fileno(), msvcrt.LK_UNLCK, 1)
2004
+ elif fcntl is not None:
2005
+ fcntl.flock(handle.fileno(), fcntl.LOCK_UN)
2006
+ except OSError:
2007
+ pass
2008
+ finally:
2009
+ handle.close()
2010
+
2011
+
1951
2012
  class WorkspaceManager:
1952
2013
  """Manages isolated workspaces per task using git worktrees."""
1953
2014
 
@@ -1966,6 +2027,32 @@ class WorkspaceManager:
1966
2027
 
1967
2028
  self._dirs_ready = False
1968
2029
 
2030
+ @staticmethod
2031
+ def _workspace_key(task: TaskInfo) -> str:
2032
+ if task.requirement_workflow_id:
2033
+ return re.sub(r"[^a-zA-Z0-9_-]", "_", task.requirement_workflow_id)
2034
+ return re.sub(r"[^a-zA-Z0-9_-]", "_", str(task.graph_id))
2035
+
2036
+ async def _acquire_file_lock(self, lock_path: Path) -> _WorkspaceFileLock:
2037
+ lock = _WorkspaceFileLock(lock_path)
2038
+ await lock.acquire()
2039
+ return lock
2040
+
2041
+ async def _acquire_project_worktree_lock(self, project_dir: Path) -> _WorkspaceFileLock:
2042
+ return await self._acquire_file_lock(project_dir / ".forgexa-worktree.lock")
2043
+
2044
+ async def acquire_workspace_execution_lock(
2045
+ self,
2046
+ project: dict,
2047
+ task: TaskInfo,
2048
+ ) -> _WorkspaceFileLock:
2049
+ self._ensure_dirs()
2050
+ project_key = str(project.get("project_key") or "default")
2051
+ project_dir = self.projects_root / project_key
2052
+ return await self._acquire_file_lock(
2053
+ project_dir / ".forgexa-workspace-locks" / f"{self._workspace_key(task)}.lock"
2054
+ )
2055
+
1969
2056
  def _ensure_dirs(self) -> None:
1970
2057
  """Lazy initialization of workspace directories.
1971
2058
 
@@ -2376,10 +2463,7 @@ class WorkspaceManager:
2376
2463
  # analysis, execution, and fix graphs for the same requirement share
2377
2464
  # one directory and git worktree (C-2 / D-1 workspace reuse).
2378
2465
  # Fall back to graph_id for backward compatibility.
2379
- if task.requirement_workflow_id:
2380
- workspace_key = re.sub(r"[^a-zA-Z0-9_-]", "_", task.requirement_workflow_id)
2381
- else:
2382
- workspace_key = re.sub(r"[^a-zA-Z0-9_-]", "_", str(task.graph_id))
2466
+ workspace_key = self._workspace_key(task)
2383
2467
 
2384
2468
  # Use human-readable branch name feature/{requirement_key}
2385
2469
  # when requirement_key is available; fallback to feature/{workspace_key}
@@ -2554,7 +2638,13 @@ class WorkspaceManager:
2554
2638
  await self._git("config", "core.filemode", "false", cwd=ws_path)
2555
2639
  return ws_path
2556
2640
 
2557
- async def _is_healthy_worktree(self, ws_path: Path, main_repo: Path | None = None) -> bool:
2641
+ async def _is_healthy_worktree(
2642
+ self,
2643
+ ws_path: Path,
2644
+ main_repo: Path | None = None,
2645
+ *,
2646
+ require_unlocked: bool = False,
2647
+ ) -> bool:
2558
2648
  """Check if a git worktree directory is a valid *linked* worktree.
2559
2649
 
2560
2650
  A linked git worktree has .git as a FILE containing a gitdir pointer to
@@ -2610,6 +2700,8 @@ class WorkspaceManager:
2610
2700
  gitdir.resolve().relative_to(expected_wt_root)
2611
2701
  except ValueError:
2612
2702
  return False
2703
+ if require_unlocked and (gitdir / "index.lock").exists():
2704
+ return False
2613
2705
  return True
2614
2706
  except OSError:
2615
2707
  pass
@@ -2622,6 +2714,13 @@ class WorkspaceManager:
2622
2714
  self._safe_rmtree(ws_path)
2623
2715
 
2624
2716
  if main_repo.exists():
2717
+ # Prune also preserves explicitly locked worktrees, even after
2718
+ # their directories disappear. Unlock before pruning so a stale
2719
+ # registration cannot keep its branch ref occupied forever.
2720
+ try:
2721
+ await self._git("worktree", "unlock", str(ws_path), cwd=main_repo)
2722
+ except RuntimeError:
2723
+ pass
2625
2724
  try:
2626
2725
  await self._git("worktree", "prune", cwd=main_repo)
2627
2726
  except RuntimeError:
@@ -2679,6 +2778,7 @@ class WorkspaceManager:
2679
2778
  branch_name: str,
2680
2779
  *,
2681
2780
  healthy_only: bool = True,
2781
+ require_unlocked: bool = False,
2682
2782
  ) -> Path | None:
2683
2783
  """Return a linked worktree already on branch_name."""
2684
2784
  try:
@@ -2706,9 +2806,13 @@ class WorkspaceManager:
2706
2806
  for candidate_path, candidate_branch in entries:
2707
2807
  if candidate_path == main_repo or candidate_branch != target_ref:
2708
2808
  continue
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):
2809
+ if not healthy_only:
2810
+ return candidate_path
2811
+ if candidate_path.exists() and await self._is_healthy_worktree(
2812
+ candidate_path,
2813
+ main_repo=main_repo,
2814
+ require_unlocked=require_unlocked,
2815
+ ):
2712
2816
  return candidate_path
2713
2817
 
2714
2818
  return None
@@ -3093,14 +3197,18 @@ class WorkspaceManager:
3093
3197
  # concurrent tasks from racing on _main (shutil.rmtree / git clone / worktree add).
3094
3198
  project_lock = await self._get_project_lock(project_dir)
3095
3199
  async with project_lock:
3096
- return await self._create_worktree_impl(
3097
- project_dir, repo_url, default_branch,
3098
- workspace_key, branch_name,
3099
- fresh_start=fresh_start,
3100
- project_key=project_key,
3101
- expect_branch=expect_branch,
3102
- detach_head=detach_head,
3103
- )
3200
+ file_lock = await self._acquire_project_worktree_lock(project_dir)
3201
+ try:
3202
+ return await self._create_worktree_impl(
3203
+ project_dir, repo_url, default_branch,
3204
+ workspace_key, branch_name,
3205
+ fresh_start=fresh_start,
3206
+ project_key=project_key,
3207
+ expect_branch=expect_branch,
3208
+ detach_head=detach_head,
3209
+ )
3210
+ finally:
3211
+ file_lock.release()
3104
3212
 
3105
3213
  async def _create_worktree_impl(
3106
3214
  self, project_dir: Path, repo_url: str, default_branch: str,
@@ -3123,7 +3231,11 @@ class WorkspaceManager:
3123
3231
  if ws_path.exists():
3124
3232
  # Validate that this worktree is healthy (its .git file points to a
3125
3233
  # valid main repo). Worktrees break when directories are moved.
3126
- if not await self._is_healthy_worktree(ws_path, main_repo=main_repo):
3234
+ if not await self._is_healthy_worktree(
3235
+ ws_path,
3236
+ main_repo=main_repo,
3237
+ require_unlocked=fresh_start,
3238
+ ):
3127
3239
  logger.warning("Broken worktree detected at %s — removing and recreating", ws_path)
3128
3240
  await self._remove_broken_worktree(main_repo, ws_path, workspace_key)
3129
3241
  else:
@@ -3598,6 +3710,19 @@ class WorkspaceManager:
3598
3710
  # that `git worktree add ... {default_branch}` uses latest code.
3599
3711
  # We fetch first, then update the local ref directly (avoids
3600
3712
  # needing to checkout default_branch which may conflict with worktrees).
3713
+ try:
3714
+ await self._git(
3715
+ "fetch", "origin",
3716
+ f"{default_branch}:refs/remotes/origin/{default_branch}",
3717
+ cwd=main_repo,
3718
+ timeout=settings.GIT_FETCH_TIMEOUT,
3719
+ project_key=project_key,
3720
+ )
3721
+ except RuntimeError as exc:
3722
+ raise RuntimeError(
3723
+ f"Failed to fetch default branch '{default_branch}' from remote: {exc}"
3724
+ ) from exc
3725
+
3601
3726
  try:
3602
3727
  await self._git(
3603
3728
  "update-ref", f"refs/heads/{default_branch}",
@@ -3808,7 +3933,7 @@ class WorkspaceManager:
3808
3933
  # feature ref behind. Attaching that ref would resurrect stale
3809
3934
  # requirement history, so reset it explicitly.
3810
3935
  existing_branch_worktree = await self._find_existing_worktree_for_branch(
3811
- main_repo, branch_name,
3936
+ main_repo, branch_name, require_unlocked=True,
3812
3937
  )
3813
3938
  if existing_branch_worktree is not None:
3814
3939
  logger.info(
@@ -3832,7 +3957,7 @@ class WorkspaceManager:
3832
3957
  )
3833
3958
  except Exception as exc:
3834
3959
  existing_branch_worktree = await self._find_existing_worktree_for_branch(
3835
- main_repo, branch_name,
3960
+ main_repo, branch_name, require_unlocked=True,
3836
3961
  )
3837
3962
  if existing_branch_worktree is not None:
3838
3963
  logger.info(
@@ -4080,6 +4205,13 @@ class WorkspaceManager:
4080
4205
  )
4081
4206
  try:
4082
4207
  stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=timeout)
4208
+ except asyncio.CancelledError:
4209
+ _kill_proc(proc)
4210
+ try:
4211
+ await asyncio.shield(asyncio.wait_for(proc.wait(), timeout=5.0))
4212
+ except Exception:
4213
+ pass
4214
+ raise
4083
4215
  except (TimeoutError, asyncio.TimeoutError):
4084
4216
  # Kill the entire process group so that SSH (and any other
4085
4217
  # child processes git may have spawned) are also terminated.
@@ -8594,6 +8726,7 @@ class RuntimeDaemon:
8594
8726
  project_lock_key,
8595
8727
  )
8596
8728
  await project_lock.acquire()
8729
+ workspace_execution_lock: _WorkspaceFileLock | None = None
8597
8730
  try:
8598
8731
  # 1. Find the right agent
8599
8732
  agent = self._select_agent(task.agent_type, task.fallback_chain)
@@ -8617,6 +8750,11 @@ class RuntimeDaemon:
8617
8750
  ))
8618
8751
  return
8619
8752
 
8753
+ workspace_execution_lock = await self.workspace_manager.acquire_workspace_execution_lock(
8754
+ task.project,
8755
+ task,
8756
+ )
8757
+
8620
8758
  # Track which agents we've already tried (for rate-limit fallback)
8621
8759
  tried_agents: set[str] = set()
8622
8760
 
@@ -9355,6 +9493,8 @@ class RuntimeDaemon:
9355
9493
  error=str(e),
9356
9494
  ))
9357
9495
  finally:
9496
+ if workspace_execution_lock is not None:
9497
+ workspace_execution_lock.release()
9358
9498
  project_lock.release()
9359
9499
 
9360
9500
  def _select_fallback_agent(
@@ -9869,6 +10009,7 @@ class RuntimeDaemon:
9869
10009
  project_lock_key,
9870
10010
  )
9871
10011
  await project_lock.acquire()
10012
+ workspace_execution_lock: _WorkspaceFileLock | None = None
9872
10013
 
9873
10014
  try:
9874
10015
  # Report progress: starting
@@ -9933,6 +10074,10 @@ class RuntimeDaemon:
9933
10074
  workspace_source_mode=qa_ws_source.get("mode"),
9934
10075
  workspace_branch=qa_ws_source.get("branch"),
9935
10076
  )
10077
+ workspace_execution_lock = await self.workspace_manager.acquire_workspace_execution_lock(
10078
+ project_info,
10079
+ fake_task,
10080
+ )
9936
10081
  workspace_path = await self.workspace_manager.prepare_workspace(
9937
10082
  project_info, fake_task,
9938
10083
  )
@@ -10156,6 +10301,8 @@ class RuntimeDaemon:
10156
10301
  except Exception:
10157
10302
  pass
10158
10303
  finally:
10304
+ if workspace_execution_lock is not None:
10305
+ workspace_execution_lock.release()
10159
10306
  project_lock.release()
10160
10307
 
10161
10308
  async def _collect_workspace_git_info(self, workspace_path: Path) -> dict:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: forgexa-cli
3
- Version: 1.22.4
3
+ Version: 1.22.6
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.4"
3
+ version = "1.22.6"
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