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.
- {forgexa_cli-1.22.4 → forgexa_cli-1.22.6}/PKG-INFO +1 -1
- {forgexa_cli-1.22.4 → forgexa_cli-1.22.6}/forgexa_cli/__init__.py +1 -1
- {forgexa_cli-1.22.4 → forgexa_cli-1.22.6}/forgexa_cli/daemon.py +167 -20
- {forgexa_cli-1.22.4 → forgexa_cli-1.22.6}/forgexa_cli.egg-info/PKG-INFO +1 -1
- {forgexa_cli-1.22.4 → forgexa_cli-1.22.6}/pyproject.toml +1 -1
- {forgexa_cli-1.22.4 → forgexa_cli-1.22.6}/README.md +0 -0
- {forgexa_cli-1.22.4 → forgexa_cli-1.22.6}/forgexa_cli/_build_config.py +0 -0
- {forgexa_cli-1.22.4 → forgexa_cli-1.22.6}/forgexa_cli/autoupgrade.py +0 -0
- {forgexa_cli-1.22.4 → forgexa_cli-1.22.6}/forgexa_cli/main.py +0 -0
- {forgexa_cli-1.22.4 → forgexa_cli-1.22.6}/forgexa_cli/py.typed +0 -0
- {forgexa_cli-1.22.4 → forgexa_cli-1.22.6}/forgexa_cli.egg-info/SOURCES.txt +0 -0
- {forgexa_cli-1.22.4 → forgexa_cli-1.22.6}/forgexa_cli.egg-info/dependency_links.txt +0 -0
- {forgexa_cli-1.22.4 → forgexa_cli-1.22.6}/forgexa_cli.egg-info/entry_points.txt +0 -0
- {forgexa_cli-1.22.4 → forgexa_cli-1.22.6}/forgexa_cli.egg-info/requires.txt +0 -0
- {forgexa_cli-1.22.4 → forgexa_cli-1.22.6}/forgexa_cli.egg-info/top_level.txt +0 -0
- {forgexa_cli-1.22.4 → forgexa_cli-1.22.6}/setup.cfg +0 -0
- {forgexa_cli-1.22.4 → forgexa_cli-1.22.6}/tests/test_auth_and_runtime_commands.py +0 -0
- {forgexa_cli-1.22.4 → forgexa_cli-1.22.6}/tests/test_autoupgrade.py +0 -0
- {forgexa_cli-1.22.4 → forgexa_cli-1.22.6}/tests/test_check_command.py +0 -0
- {forgexa_cli-1.22.4 → forgexa_cli-1.22.6}/tests/test_silent_install.py +0 -0
- {forgexa_cli-1.22.4 → 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
|
|
@@ -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
|
|
2710
|
-
|
|
2711
|
-
if
|
|
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
|
-
|
|
3097
|
-
|
|
3098
|
-
|
|
3099
|
-
|
|
3100
|
-
|
|
3101
|
-
|
|
3102
|
-
|
|
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(
|
|
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:
|
|
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
|