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.
- {forgexa_cli-1.22.5 → forgexa_cli-1.22.6}/PKG-INFO +1 -1
- {forgexa_cli-1.22.5 → forgexa_cli-1.22.6}/forgexa_cli/__init__.py +1 -1
- {forgexa_cli-1.22.5 → forgexa_cli-1.22.6}/forgexa_cli/daemon.py +158 -18
- {forgexa_cli-1.22.5 → forgexa_cli-1.22.6}/forgexa_cli.egg-info/PKG-INFO +1 -1
- {forgexa_cli-1.22.5 → forgexa_cli-1.22.6}/pyproject.toml +1 -1
- {forgexa_cli-1.22.5 → forgexa_cli-1.22.6}/README.md +0 -0
- {forgexa_cli-1.22.5 → forgexa_cli-1.22.6}/forgexa_cli/_build_config.py +0 -0
- {forgexa_cli-1.22.5 → forgexa_cli-1.22.6}/forgexa_cli/autoupgrade.py +0 -0
- {forgexa_cli-1.22.5 → forgexa_cli-1.22.6}/forgexa_cli/main.py +0 -0
- {forgexa_cli-1.22.5 → forgexa_cli-1.22.6}/forgexa_cli/py.typed +0 -0
- {forgexa_cli-1.22.5 → forgexa_cli-1.22.6}/forgexa_cli.egg-info/SOURCES.txt +0 -0
- {forgexa_cli-1.22.5 → forgexa_cli-1.22.6}/forgexa_cli.egg-info/dependency_links.txt +0 -0
- {forgexa_cli-1.22.5 → forgexa_cli-1.22.6}/forgexa_cli.egg-info/entry_points.txt +0 -0
- {forgexa_cli-1.22.5 → forgexa_cli-1.22.6}/forgexa_cli.egg-info/requires.txt +0 -0
- {forgexa_cli-1.22.5 → forgexa_cli-1.22.6}/forgexa_cli.egg-info/top_level.txt +0 -0
- {forgexa_cli-1.22.5 → forgexa_cli-1.22.6}/setup.cfg +0 -0
- {forgexa_cli-1.22.5 → forgexa_cli-1.22.6}/tests/test_auth_and_runtime_commands.py +0 -0
- {forgexa_cli-1.22.5 → forgexa_cli-1.22.6}/tests/test_autoupgrade.py +0 -0
- {forgexa_cli-1.22.5 → forgexa_cli-1.22.6}/tests/test_check_command.py +0 -0
- {forgexa_cli-1.22.5 → forgexa_cli-1.22.6}/tests/test_silent_install.py +0 -0
- {forgexa_cli-1.22.5 → forgexa_cli-1.22.6}/tests/test_upgrade_observability.py +0 -0
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
"""forgexa-cli — Forgexa command-line client."""
|
|
2
|
-
__version__ = "1.22.
|
|
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.
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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
|
-
|
|
3104
|
-
|
|
3105
|
-
|
|
3106
|
-
|
|
3107
|
-
|
|
3108
|
-
|
|
3109
|
-
|
|
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(
|
|
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:
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|