forgexa-cli 1.22.5__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.5 → forgexa_cli-1.22.6}/PKG-INFO +1 -1
  2. {forgexa_cli-1.22.5 → forgexa_cli-1.22.6}/forgexa_cli/__init__.py +1 -1
  3. {forgexa_cli-1.22.5 → forgexa_cli-1.22.6}/forgexa_cli/daemon.py +158 -18
  4. {forgexa_cli-1.22.5 → forgexa_cli-1.22.6}/forgexa_cli.egg-info/PKG-INFO +1 -1
  5. {forgexa_cli-1.22.5 → forgexa_cli-1.22.6}/pyproject.toml +1 -1
  6. {forgexa_cli-1.22.5 → forgexa_cli-1.22.6}/README.md +0 -0
  7. {forgexa_cli-1.22.5 → forgexa_cli-1.22.6}/forgexa_cli/_build_config.py +0 -0
  8. {forgexa_cli-1.22.5 → forgexa_cli-1.22.6}/forgexa_cli/autoupgrade.py +0 -0
  9. {forgexa_cli-1.22.5 → forgexa_cli-1.22.6}/forgexa_cli/main.py +0 -0
  10. {forgexa_cli-1.22.5 → forgexa_cli-1.22.6}/forgexa_cli/py.typed +0 -0
  11. {forgexa_cli-1.22.5 → forgexa_cli-1.22.6}/forgexa_cli.egg-info/SOURCES.txt +0 -0
  12. {forgexa_cli-1.22.5 → forgexa_cli-1.22.6}/forgexa_cli.egg-info/dependency_links.txt +0 -0
  13. {forgexa_cli-1.22.5 → forgexa_cli-1.22.6}/forgexa_cli.egg-info/entry_points.txt +0 -0
  14. {forgexa_cli-1.22.5 → forgexa_cli-1.22.6}/forgexa_cli.egg-info/requires.txt +0 -0
  15. {forgexa_cli-1.22.5 → forgexa_cli-1.22.6}/forgexa_cli.egg-info/top_level.txt +0 -0
  16. {forgexa_cli-1.22.5 → forgexa_cli-1.22.6}/setup.cfg +0 -0
  17. {forgexa_cli-1.22.5 → forgexa_cli-1.22.6}/tests/test_auth_and_runtime_commands.py +0 -0
  18. {forgexa_cli-1.22.5 → forgexa_cli-1.22.6}/tests/test_autoupgrade.py +0 -0
  19. {forgexa_cli-1.22.5 → forgexa_cli-1.22.6}/tests/test_check_command.py +0 -0
  20. {forgexa_cli-1.22.5 → forgexa_cli-1.22.6}/tests/test_silent_install.py +0 -0
  21. {forgexa_cli-1.22.5 → 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.5
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.5"
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.5"
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
@@ -2686,6 +2778,7 @@ class WorkspaceManager:
2686
2778
  branch_name: str,
2687
2779
  *,
2688
2780
  healthy_only: bool = True,
2781
+ require_unlocked: bool = False,
2689
2782
  ) -> Path | None:
2690
2783
  """Return a linked worktree already on branch_name."""
2691
2784
  try:
@@ -2715,7 +2808,11 @@ class WorkspaceManager:
2715
2808
  continue
2716
2809
  if not healthy_only:
2717
2810
  return candidate_path
2718
- if candidate_path.exists() and await self._is_healthy_worktree(candidate_path, main_repo=main_repo):
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
+ ):
2719
2816
  return candidate_path
2720
2817
 
2721
2818
  return None
@@ -3100,14 +3197,18 @@ class WorkspaceManager:
3100
3197
  # concurrent tasks from racing on _main (shutil.rmtree / git clone / worktree add).
3101
3198
  project_lock = await self._get_project_lock(project_dir)
3102
3199
  async with project_lock:
3103
- return await self._create_worktree_impl(
3104
- project_dir, repo_url, default_branch,
3105
- workspace_key, branch_name,
3106
- fresh_start=fresh_start,
3107
- project_key=project_key,
3108
- expect_branch=expect_branch,
3109
- detach_head=detach_head,
3110
- )
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()
3111
3212
 
3112
3213
  async def _create_worktree_impl(
3113
3214
  self, project_dir: Path, repo_url: str, default_branch: str,
@@ -3130,7 +3231,11 @@ class WorkspaceManager:
3130
3231
  if ws_path.exists():
3131
3232
  # Validate that this worktree is healthy (its .git file points to a
3132
3233
  # valid main repo). Worktrees break when directories are moved.
3133
- 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
+ ):
3134
3239
  logger.warning("Broken worktree detected at %s — removing and recreating", ws_path)
3135
3240
  await self._remove_broken_worktree(main_repo, ws_path, workspace_key)
3136
3241
  else:
@@ -3605,6 +3710,19 @@ class WorkspaceManager:
3605
3710
  # that `git worktree add ... {default_branch}` uses latest code.
3606
3711
  # We fetch first, then update the local ref directly (avoids
3607
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
+
3608
3726
  try:
3609
3727
  await self._git(
3610
3728
  "update-ref", f"refs/heads/{default_branch}",
@@ -3815,7 +3933,7 @@ class WorkspaceManager:
3815
3933
  # feature ref behind. Attaching that ref would resurrect stale
3816
3934
  # requirement history, so reset it explicitly.
3817
3935
  existing_branch_worktree = await self._find_existing_worktree_for_branch(
3818
- main_repo, branch_name,
3936
+ main_repo, branch_name, require_unlocked=True,
3819
3937
  )
3820
3938
  if existing_branch_worktree is not None:
3821
3939
  logger.info(
@@ -3839,7 +3957,7 @@ class WorkspaceManager:
3839
3957
  )
3840
3958
  except Exception as exc:
3841
3959
  existing_branch_worktree = await self._find_existing_worktree_for_branch(
3842
- main_repo, branch_name,
3960
+ main_repo, branch_name, require_unlocked=True,
3843
3961
  )
3844
3962
  if existing_branch_worktree is not None:
3845
3963
  logger.info(
@@ -4087,6 +4205,13 @@ class WorkspaceManager:
4087
4205
  )
4088
4206
  try:
4089
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
4090
4215
  except (TimeoutError, asyncio.TimeoutError):
4091
4216
  # Kill the entire process group so that SSH (and any other
4092
4217
  # child processes git may have spawned) are also terminated.
@@ -8601,6 +8726,7 @@ class RuntimeDaemon:
8601
8726
  project_lock_key,
8602
8727
  )
8603
8728
  await project_lock.acquire()
8729
+ workspace_execution_lock: _WorkspaceFileLock | None = None
8604
8730
  try:
8605
8731
  # 1. Find the right agent
8606
8732
  agent = self._select_agent(task.agent_type, task.fallback_chain)
@@ -8624,6 +8750,11 @@ class RuntimeDaemon:
8624
8750
  ))
8625
8751
  return
8626
8752
 
8753
+ workspace_execution_lock = await self.workspace_manager.acquire_workspace_execution_lock(
8754
+ task.project,
8755
+ task,
8756
+ )
8757
+
8627
8758
  # Track which agents we've already tried (for rate-limit fallback)
8628
8759
  tried_agents: set[str] = set()
8629
8760
 
@@ -9362,6 +9493,8 @@ class RuntimeDaemon:
9362
9493
  error=str(e),
9363
9494
  ))
9364
9495
  finally:
9496
+ if workspace_execution_lock is not None:
9497
+ workspace_execution_lock.release()
9365
9498
  project_lock.release()
9366
9499
 
9367
9500
  def _select_fallback_agent(
@@ -9876,6 +10009,7 @@ class RuntimeDaemon:
9876
10009
  project_lock_key,
9877
10010
  )
9878
10011
  await project_lock.acquire()
10012
+ workspace_execution_lock: _WorkspaceFileLock | None = None
9879
10013
 
9880
10014
  try:
9881
10015
  # Report progress: starting
@@ -9940,6 +10074,10 @@ class RuntimeDaemon:
9940
10074
  workspace_source_mode=qa_ws_source.get("mode"),
9941
10075
  workspace_branch=qa_ws_source.get("branch"),
9942
10076
  )
10077
+ workspace_execution_lock = await self.workspace_manager.acquire_workspace_execution_lock(
10078
+ project_info,
10079
+ fake_task,
10080
+ )
9943
10081
  workspace_path = await self.workspace_manager.prepare_workspace(
9944
10082
  project_info, fake_task,
9945
10083
  )
@@ -10163,6 +10301,8 @@ class RuntimeDaemon:
10163
10301
  except Exception:
10164
10302
  pass
10165
10303
  finally:
10304
+ if workspace_execution_lock is not None:
10305
+ workspace_execution_lock.release()
10166
10306
  project_lock.release()
10167
10307
 
10168
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.5
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.5"
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