forgexa-cli 1.22.5__tar.gz → 1.23.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.22.5 → forgexa_cli-1.23.0}/PKG-INFO +1 -1
  2. {forgexa_cli-1.22.5 → forgexa_cli-1.23.0}/forgexa_cli/__init__.py +1 -1
  3. {forgexa_cli-1.22.5 → forgexa_cli-1.23.0}/forgexa_cli/daemon.py +795 -18
  4. {forgexa_cli-1.22.5 → forgexa_cli-1.23.0}/forgexa_cli.egg-info/PKG-INFO +1 -1
  5. {forgexa_cli-1.22.5 → forgexa_cli-1.23.0}/pyproject.toml +1 -1
  6. {forgexa_cli-1.22.5 → forgexa_cli-1.23.0}/README.md +0 -0
  7. {forgexa_cli-1.22.5 → forgexa_cli-1.23.0}/forgexa_cli/_build_config.py +0 -0
  8. {forgexa_cli-1.22.5 → forgexa_cli-1.23.0}/forgexa_cli/autoupgrade.py +0 -0
  9. {forgexa_cli-1.22.5 → forgexa_cli-1.23.0}/forgexa_cli/main.py +0 -0
  10. {forgexa_cli-1.22.5 → forgexa_cli-1.23.0}/forgexa_cli/py.typed +0 -0
  11. {forgexa_cli-1.22.5 → forgexa_cli-1.23.0}/forgexa_cli.egg-info/SOURCES.txt +0 -0
  12. {forgexa_cli-1.22.5 → forgexa_cli-1.23.0}/forgexa_cli.egg-info/dependency_links.txt +0 -0
  13. {forgexa_cli-1.22.5 → forgexa_cli-1.23.0}/forgexa_cli.egg-info/entry_points.txt +0 -0
  14. {forgexa_cli-1.22.5 → forgexa_cli-1.23.0}/forgexa_cli.egg-info/requires.txt +0 -0
  15. {forgexa_cli-1.22.5 → forgexa_cli-1.23.0}/forgexa_cli.egg-info/top_level.txt +0 -0
  16. {forgexa_cli-1.22.5 → forgexa_cli-1.23.0}/setup.cfg +0 -0
  17. {forgexa_cli-1.22.5 → forgexa_cli-1.23.0}/tests/test_auth_and_runtime_commands.py +0 -0
  18. {forgexa_cli-1.22.5 → forgexa_cli-1.23.0}/tests/test_autoupgrade.py +0 -0
  19. {forgexa_cli-1.22.5 → forgexa_cli-1.23.0}/tests/test_check_command.py +0 -0
  20. {forgexa_cli-1.22.5 → forgexa_cli-1.23.0}/tests/test_silent_install.py +0 -0
  21. {forgexa_cli-1.22.5 → forgexa_cli-1.23.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.22.5
3
+ Version: 1.23.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.22.5"
2
+ __version__ = "1.23.0"
@@ -29,6 +29,8 @@ if sys.version_info < (3, 9):
29
29
 
30
30
  import asyncio
31
31
  import base64
32
+ import errno
33
+ import fnmatch
32
34
  import hashlib
33
35
  import json
34
36
  import logging
@@ -555,7 +557,7 @@ except (ImportError, ModuleNotFoundError):
555
557
  # DAEMON_VERSION is the protocol/logic version of the daemon code.
556
558
  # Kept in sync with pyproject.toml version via bump-version.sh.
557
559
  # CLIENT_TYPE identifies which packaging/distribution this daemon runs in.
558
- DAEMON_VERSION = "1.22.5"
560
+ DAEMON_VERSION = "1.23.0"
559
561
 
560
562
 
561
563
  def _detect_client_type() -> str:
@@ -1948,6 +1950,66 @@ class AgentDiscovery:
1948
1950
  logger.warning("bwrap probe error: %s", exc)
1949
1951
 
1950
1952
 
1953
+ class _WorkspaceFileLock:
1954
+ """Advisory lock shared by daemon processes using one workspace root."""
1955
+
1956
+ def __init__(self, path: Path):
1957
+ self.path = path
1958
+ self._handle: Any | None = None
1959
+
1960
+ async def acquire(self) -> None:
1961
+ self.path.parent.mkdir(parents=True, exist_ok=True)
1962
+ self._handle = open(self.path, "a+b")
1963
+ waiting = False
1964
+
1965
+ try:
1966
+ if sys.platform == "win32":
1967
+ self._handle.seek(0)
1968
+ if not self._handle.read(1):
1969
+ self._handle.write(b"\0")
1970
+ self._handle.flush()
1971
+
1972
+ while True:
1973
+ try:
1974
+ self._handle.seek(0)
1975
+ if sys.platform == "win32":
1976
+ import msvcrt
1977
+
1978
+ msvcrt.locking(self._handle.fileno(), msvcrt.LK_NBLCK, 1)
1979
+ elif fcntl is not None:
1980
+ fcntl.flock(self._handle.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
1981
+ else:
1982
+ raise RuntimeError("Workspace file locking is unavailable on this platform")
1983
+ return
1984
+ except OSError as exc:
1985
+ if exc.errno not in (errno.EACCES, errno.EAGAIN):
1986
+ raise
1987
+ if not waiting:
1988
+ logger.info("Waiting for workspace lock at %s", self.path)
1989
+ waiting = True
1990
+ await asyncio.sleep(0.1)
1991
+ except BaseException:
1992
+ self.release()
1993
+ raise
1994
+
1995
+ def release(self) -> None:
1996
+ handle, self._handle = self._handle, None
1997
+ if handle is None:
1998
+ return
1999
+ try:
2000
+ handle.seek(0)
2001
+ if sys.platform == "win32":
2002
+ import msvcrt
2003
+
2004
+ msvcrt.locking(handle.fileno(), msvcrt.LK_UNLCK, 1)
2005
+ elif fcntl is not None:
2006
+ fcntl.flock(handle.fileno(), fcntl.LOCK_UN)
2007
+ except OSError:
2008
+ pass
2009
+ finally:
2010
+ handle.close()
2011
+
2012
+
1951
2013
  class WorkspaceManager:
1952
2014
  """Manages isolated workspaces per task using git worktrees."""
1953
2015
 
@@ -1966,6 +2028,32 @@ class WorkspaceManager:
1966
2028
 
1967
2029
  self._dirs_ready = False
1968
2030
 
2031
+ @staticmethod
2032
+ def _workspace_key(task: TaskInfo) -> str:
2033
+ if task.requirement_workflow_id:
2034
+ return re.sub(r"[^a-zA-Z0-9_-]", "_", task.requirement_workflow_id)
2035
+ return re.sub(r"[^a-zA-Z0-9_-]", "_", str(task.graph_id))
2036
+
2037
+ async def _acquire_file_lock(self, lock_path: Path) -> _WorkspaceFileLock:
2038
+ lock = _WorkspaceFileLock(lock_path)
2039
+ await lock.acquire()
2040
+ return lock
2041
+
2042
+ async def _acquire_project_worktree_lock(self, project_dir: Path) -> _WorkspaceFileLock:
2043
+ return await self._acquire_file_lock(project_dir / ".forgexa-worktree.lock")
2044
+
2045
+ async def acquire_workspace_execution_lock(
2046
+ self,
2047
+ project: dict,
2048
+ task: TaskInfo,
2049
+ ) -> _WorkspaceFileLock:
2050
+ self._ensure_dirs()
2051
+ project_key = str(project.get("project_key") or "default")
2052
+ project_dir = self.projects_root / project_key
2053
+ return await self._acquire_file_lock(
2054
+ project_dir / ".forgexa-workspace-locks" / f"{self._workspace_key(task)}.lock"
2055
+ )
2056
+
1969
2057
  def _ensure_dirs(self) -> None:
1970
2058
  """Lazy initialization of workspace directories.
1971
2059
 
@@ -2376,10 +2464,7 @@ class WorkspaceManager:
2376
2464
  # analysis, execution, and fix graphs for the same requirement share
2377
2465
  # one directory and git worktree (C-2 / D-1 workspace reuse).
2378
2466
  # 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))
2467
+ workspace_key = self._workspace_key(task)
2383
2468
 
2384
2469
  # Use human-readable branch name feature/{requirement_key}
2385
2470
  # when requirement_key is available; fallback to feature/{workspace_key}
@@ -2554,7 +2639,13 @@ class WorkspaceManager:
2554
2639
  await self._git("config", "core.filemode", "false", cwd=ws_path)
2555
2640
  return ws_path
2556
2641
 
2557
- async def _is_healthy_worktree(self, ws_path: Path, main_repo: Path | None = None) -> bool:
2642
+ async def _is_healthy_worktree(
2643
+ self,
2644
+ ws_path: Path,
2645
+ main_repo: Path | None = None,
2646
+ *,
2647
+ require_unlocked: bool = False,
2648
+ ) -> bool:
2558
2649
  """Check if a git worktree directory is a valid *linked* worktree.
2559
2650
 
2560
2651
  A linked git worktree has .git as a FILE containing a gitdir pointer to
@@ -2610,6 +2701,8 @@ class WorkspaceManager:
2610
2701
  gitdir.resolve().relative_to(expected_wt_root)
2611
2702
  except ValueError:
2612
2703
  return False
2704
+ if require_unlocked and (gitdir / "index.lock").exists():
2705
+ return False
2613
2706
  return True
2614
2707
  except OSError:
2615
2708
  pass
@@ -2686,6 +2779,7 @@ class WorkspaceManager:
2686
2779
  branch_name: str,
2687
2780
  *,
2688
2781
  healthy_only: bool = True,
2782
+ require_unlocked: bool = False,
2689
2783
  ) -> Path | None:
2690
2784
  """Return a linked worktree already on branch_name."""
2691
2785
  try:
@@ -2715,7 +2809,11 @@ class WorkspaceManager:
2715
2809
  continue
2716
2810
  if not healthy_only:
2717
2811
  return candidate_path
2718
- if candidate_path.exists() and await self._is_healthy_worktree(candidate_path, main_repo=main_repo):
2812
+ if candidate_path.exists() and await self._is_healthy_worktree(
2813
+ candidate_path,
2814
+ main_repo=main_repo,
2815
+ require_unlocked=require_unlocked,
2816
+ ):
2719
2817
  return candidate_path
2720
2818
 
2721
2819
  return None
@@ -3100,14 +3198,18 @@ class WorkspaceManager:
3100
3198
  # concurrent tasks from racing on _main (shutil.rmtree / git clone / worktree add).
3101
3199
  project_lock = await self._get_project_lock(project_dir)
3102
3200
  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
- )
3201
+ file_lock = await self._acquire_project_worktree_lock(project_dir)
3202
+ try:
3203
+ return await self._create_worktree_impl(
3204
+ project_dir, repo_url, default_branch,
3205
+ workspace_key, branch_name,
3206
+ fresh_start=fresh_start,
3207
+ project_key=project_key,
3208
+ expect_branch=expect_branch,
3209
+ detach_head=detach_head,
3210
+ )
3211
+ finally:
3212
+ file_lock.release()
3111
3213
 
3112
3214
  async def _create_worktree_impl(
3113
3215
  self, project_dir: Path, repo_url: str, default_branch: str,
@@ -3130,7 +3232,11 @@ class WorkspaceManager:
3130
3232
  if ws_path.exists():
3131
3233
  # Validate that this worktree is healthy (its .git file points to a
3132
3234
  # valid main repo). Worktrees break when directories are moved.
3133
- if not await self._is_healthy_worktree(ws_path, main_repo=main_repo):
3235
+ if not await self._is_healthy_worktree(
3236
+ ws_path,
3237
+ main_repo=main_repo,
3238
+ require_unlocked=fresh_start,
3239
+ ):
3134
3240
  logger.warning("Broken worktree detected at %s — removing and recreating", ws_path)
3135
3241
  await self._remove_broken_worktree(main_repo, ws_path, workspace_key)
3136
3242
  else:
@@ -3605,6 +3711,19 @@ class WorkspaceManager:
3605
3711
  # that `git worktree add ... {default_branch}` uses latest code.
3606
3712
  # We fetch first, then update the local ref directly (avoids
3607
3713
  # needing to checkout default_branch which may conflict with worktrees).
3714
+ try:
3715
+ await self._git(
3716
+ "fetch", "origin",
3717
+ f"{default_branch}:refs/remotes/origin/{default_branch}",
3718
+ cwd=main_repo,
3719
+ timeout=settings.GIT_FETCH_TIMEOUT,
3720
+ project_key=project_key,
3721
+ )
3722
+ except RuntimeError as exc:
3723
+ raise RuntimeError(
3724
+ f"Failed to fetch default branch '{default_branch}' from remote: {exc}"
3725
+ ) from exc
3726
+
3608
3727
  try:
3609
3728
  await self._git(
3610
3729
  "update-ref", f"refs/heads/{default_branch}",
@@ -3815,7 +3934,7 @@ class WorkspaceManager:
3815
3934
  # feature ref behind. Attaching that ref would resurrect stale
3816
3935
  # requirement history, so reset it explicitly.
3817
3936
  existing_branch_worktree = await self._find_existing_worktree_for_branch(
3818
- main_repo, branch_name,
3937
+ main_repo, branch_name, require_unlocked=True,
3819
3938
  )
3820
3939
  if existing_branch_worktree is not None:
3821
3940
  logger.info(
@@ -3839,7 +3958,7 @@ class WorkspaceManager:
3839
3958
  )
3840
3959
  except Exception as exc:
3841
3960
  existing_branch_worktree = await self._find_existing_worktree_for_branch(
3842
- main_repo, branch_name,
3961
+ main_repo, branch_name, require_unlocked=True,
3843
3962
  )
3844
3963
  if existing_branch_worktree is not None:
3845
3964
  logger.info(
@@ -4087,6 +4206,13 @@ class WorkspaceManager:
4087
4206
  )
4088
4207
  try:
4089
4208
  stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=timeout)
4209
+ except asyncio.CancelledError:
4210
+ _kill_proc(proc)
4211
+ try:
4212
+ await asyncio.shield(asyncio.wait_for(proc.wait(), timeout=5.0))
4213
+ except Exception:
4214
+ pass
4215
+ raise
4090
4216
  except (TimeoutError, asyncio.TimeoutError):
4091
4217
  # Kill the entire process group so that SSH (and any other
4092
4218
  # child processes git may have spawned) are also terminated.
@@ -7928,6 +8054,10 @@ class RuntimeDaemon:
7928
8054
  self._project_execution_locks_mu: asyncio.Lock = asyncio.Lock()
7929
8055
  self._shutdown = False
7930
8056
 
8057
+ # Deployment merge worktree GC: runs at most every 6 hours.
8058
+ self._last_merge_gc: float = 0.0
8059
+ self._MERGE_GC_INTERVAL = 6 * 3600 # seconds
8060
+
7931
8061
  self.connections: list[ServerConnection] = []
7932
8062
  self.agent_discovery = AgentDiscovery()
7933
8063
  self.workspace_manager = WorkspaceManager(self.workspaces_root)
@@ -8390,6 +8520,14 @@ class RuntimeDaemon:
8390
8520
  """Poll for tasks from all connected servers and execute them."""
8391
8521
  had_work = False
8392
8522
 
8523
+ # Opportunistic GC: remove old deploy-merge worktrees (throttled internally).
8524
+ try:
8525
+ await self._cleanup_old_merge_worktrees(
8526
+ self.connections[0] if self.connections else None
8527
+ )
8528
+ except Exception:
8529
+ pass # GC failure must never block the poll loop
8530
+
8393
8531
  def _cancel_server_requested_ai_jobs(conn: ServerConnection) -> None:
8394
8532
  for job_id in conn.poller.cancelled_ai_job_ids:
8395
8533
  ai_task_key = f"aijob_{job_id}"
@@ -8601,6 +8739,7 @@ class RuntimeDaemon:
8601
8739
  project_lock_key,
8602
8740
  )
8603
8741
  await project_lock.acquire()
8742
+ workspace_execution_lock: _WorkspaceFileLock | None = None
8604
8743
  try:
8605
8744
  # 1. Find the right agent
8606
8745
  agent = self._select_agent(task.agent_type, task.fallback_chain)
@@ -8624,6 +8763,11 @@ class RuntimeDaemon:
8624
8763
  ))
8625
8764
  return
8626
8765
 
8766
+ workspace_execution_lock = await self.workspace_manager.acquire_workspace_execution_lock(
8767
+ task.project,
8768
+ task,
8769
+ )
8770
+
8627
8771
  # Track which agents we've already tried (for rate-limit fallback)
8628
8772
  tried_agents: set[str] = set()
8629
8773
 
@@ -9362,6 +9506,8 @@ class RuntimeDaemon:
9362
9506
  error=str(e),
9363
9507
  ))
9364
9508
  finally:
9509
+ if workspace_execution_lock is not None:
9510
+ workspace_execution_lock.release()
9365
9511
  project_lock.release()
9366
9512
 
9367
9513
  def _select_fallback_agent(
@@ -9840,6 +9986,626 @@ class RuntimeDaemon:
9840
9986
 
9841
9987
  return issues
9842
9988
 
9989
+ def _merge_job_index_path(self) -> Path:
9990
+ return Path(self.workspaces_root) / "deploy-merge-jobs.json"
9991
+
9992
+ def _load_merge_job_index(self) -> dict:
9993
+ """Load the merge_job_id → project_id index recorded by merge jobs."""
9994
+ try:
9995
+ p = self._merge_job_index_path()
9996
+ if p.exists():
9997
+ data = json.loads(p.read_text())
9998
+ if isinstance(data, dict):
9999
+ return data
10000
+ except Exception:
10001
+ pass
10002
+ return {}
10003
+
10004
+ def _record_merge_job(self, merge_job_id: str, project_id: str) -> None:
10005
+ """Persist merge_job_id → project_id so GC can query eligibility later."""
10006
+ if not merge_job_id or not project_id:
10007
+ return
10008
+ try:
10009
+ index = self._load_merge_job_index()
10010
+ index[merge_job_id] = {"project_id": project_id, "recorded_at": time.time()}
10011
+ # Bound the index size by dropping the oldest entries.
10012
+ if len(index) > 500:
10013
+ oldest = sorted(index, key=lambda k: index[k].get("recorded_at", 0))
10014
+ for k in oldest[: len(index) - 500]:
10015
+ index.pop(k, None)
10016
+ self._merge_job_index_path().write_text(json.dumps(index))
10017
+ except Exception as exc:
10018
+ logger.debug("deployment_merge: failed to record merge job index: %s", exc)
10019
+
10020
+ async def _cleanup_old_merge_worktrees(self, conn: "ServerConnection | None" = None) -> int:
10021
+ """Remove deploy-merge-* worktrees the server marks as GC-eligible.
10022
+
10023
+ Retention policy (design 5.6): worktrees of terminal jobs (succeeded /
10024
+ failed / cancelled / superseded) become eligible after 7 days, while
10025
+ conflict_needs_human / precheck_failed jobs are retained permanently
10026
+ until a human finishes handling them. The daemon never decides
10027
+ eligibility from mtime alone — it asks the server which job IDs are
10028
+ safe to collect. If the server is unreachable the whole round is
10029
+ skipped and nothing is deleted. A 30-day orphan fallback guards
10030
+ against disk leaks from worktrees the server no longer knows about.
10031
+ """
10032
+ import time as _time
10033
+ now = _time.time()
10034
+ if now - self._last_merge_gc < self._MERGE_GC_INTERVAL:
10035
+ return 0
10036
+ self._last_merge_gc = now
10037
+
10038
+ # Discover candidate worktrees: {workspaces_root}/{project}/deploy-merge-{job_id}
10039
+ candidates: list[tuple[Path, Path, str, float]] = [] # (project_dir, wt_dir, job_id, mtime)
10040
+ try:
10041
+ ws_root = Path(self.workspaces_root)
10042
+ if not ws_root.exists():
10043
+ return 0
10044
+ for project_dir in ws_root.iterdir():
10045
+ if not project_dir.is_dir():
10046
+ continue
10047
+ for wt_dir in project_dir.iterdir():
10048
+ if not wt_dir.is_dir():
10049
+ continue
10050
+ name = wt_dir.name
10051
+ # Match deploy-merge-{job_id} pattern
10052
+ if not name.startswith("deploy-merge-"):
10053
+ continue
10054
+ try:
10055
+ mtime = wt_dir.stat().st_mtime
10056
+ except Exception:
10057
+ mtime = now
10058
+ candidates.append((project_dir, wt_dir, name[len("deploy-merge-"):], mtime))
10059
+ except Exception as exc:
10060
+ logger.warning("deployment_merge GC: scan failed: %s", exc)
10061
+ return 0
10062
+ if not candidates:
10063
+ return 0
10064
+
10065
+ if conn is None:
10066
+ logger.warning("deployment_merge GC: no server connection — skipping this round")
10067
+ return 0
10068
+
10069
+ # Ask the server which jobs are safe to collect. Any request failure
10070
+ # aborts the whole round: never delete while eligibility is unknown.
10071
+ index = self._load_merge_job_index()
10072
+ project_ids = {
10073
+ str(index[jid].get("project_id"))
10074
+ for _, _, jid, _ in candidates
10075
+ if jid in index and index[jid].get("project_id")
10076
+ }
10077
+ eligible: set[str] = set()
10078
+ for pid in sorted(project_ids):
10079
+ try:
10080
+ resp = await conn.client.get(
10081
+ f"{conn.server_url}/api/v1/projects/{pid}/deployment-trains/jobs/gc-eligible",
10082
+ timeout=10,
10083
+ )
10084
+ if resp.status_code != 200:
10085
+ logger.warning(
10086
+ "deployment_merge GC: gc-eligible query for project %s returned %s — skipping this round",
10087
+ pid, resp.status_code,
10088
+ )
10089
+ return 0
10090
+ eligible.update(str(j) for j in resp.json().get("eligible_job_ids", []))
10091
+ except Exception as exc:
10092
+ logger.warning(
10093
+ "deployment_merge GC: server unreachable (%s) — skipping this round", exc,
10094
+ )
10095
+ return 0
10096
+
10097
+ orphan_cutoff = now - (30 * 24 * 3600) # 30 days ago
10098
+ removed = 0
10099
+ for project_dir, wt_dir, job_id, mtime in candidates:
10100
+ is_eligible = job_id in eligible
10101
+ is_orphan = not is_eligible and mtime < orphan_cutoff
10102
+ if not is_eligible and not is_orphan:
10103
+ continue
10104
+ main_repo = project_dir / "_main"
10105
+ try:
10106
+ removed_ok = False
10107
+ if main_repo.exists():
10108
+ try:
10109
+ await self.workspace_manager._git(
10110
+ "worktree", "remove", "--force", str(wt_dir), cwd=main_repo,
10111
+ )
10112
+ removed_ok = True
10113
+ except RuntimeError:
10114
+ removed_ok = False
10115
+ if not removed_ok:
10116
+ shutil.rmtree(wt_dir, ignore_errors=True)
10117
+ if main_repo.exists():
10118
+ try:
10119
+ await self.workspace_manager._git("worktree", "prune", cwd=main_repo)
10120
+ except RuntimeError:
10121
+ pass
10122
+ removed += 1
10123
+ index.pop(job_id, None)
10124
+ logger.info(
10125
+ "deployment_merge GC: removed worktree %s (eligible=%s, orphan=%s, age=%.0f days)",
10126
+ wt_dir, is_eligible, is_orphan, (now - mtime) / 86400,
10127
+ )
10128
+ except Exception as exc:
10129
+ logger.debug("deployment_merge GC: skip %s: %s", wt_dir, exc)
10130
+ # Best-effort remote candidate branch cleanup (non-fatal).
10131
+ if main_repo.exists():
10132
+ try:
10133
+ await self.workspace_manager._git(
10134
+ "push", "origin", "--delete", f"merge-candidate/{job_id}", cwd=main_repo,
10135
+ )
10136
+ except RuntimeError as exc:
10137
+ logger.debug(
10138
+ "deployment_merge GC: remote branch delete failed for %s: %s", job_id, exc,
10139
+ )
10140
+ if removed:
10141
+ try:
10142
+ self._merge_job_index_path().write_text(json.dumps(index))
10143
+ except Exception:
10144
+ pass
10145
+ logger.info("deployment_merge GC: removed %d worktree(s)", removed)
10146
+ return removed
10147
+
10148
+ async def _execute_deployment_merge_job(self, aj: dict, conn: "ServerConnection"):
10149
+ """Execute a deployment_merge AIJob: git merge + conflict resolution.
10150
+
10151
+ Phase 2: merges the requirement's frozen source SHA onto the integration
10152
+ branch candidate ref, detects conflicts, and attempts batched AI
10153
+ conflict resolution (10 files per batch, up to 3 batches). Conflicts
10154
+ touching sensitive paths, or left unresolved after all batches, are
10155
+ aborted and reported as conflict_needs_human. Resolved/clean candidates
10156
+ pass a deterministic precheck (no residual conflict markers, clean
10157
+ working tree), are pushed to origin as merge-candidate/{merge_job_id},
10158
+ and the structured result is reported back.
10159
+ """
10160
+ job_id = aj.get("job_id", "")
10161
+ input_ctx = aj.get("input_context", {}) or {}
10162
+ project_info = aj.get("project", {}) or {}
10163
+ reporter_url = f"{conn.server_url.rstrip('/')}/api/v1/runtimes/{conn.runtime_id}/ai-jobs/{job_id}"
10164
+
10165
+ merge_job_id = input_ctx.get("deployment_merge_job_id", "")
10166
+ integration_branch = input_ctx.get("integration_branch", "")
10167
+ source_commit_sha = input_ctx.get("source_commit_sha", "")
10168
+ source_branch = input_ctx.get("source_branch", "")
10169
+ base_integration_sha = input_ctx.get("base_integration_sha")
10170
+ default_branch = input_ctx.get("default_branch", "main")
10171
+ agent_type = input_ctx.get("agent_type", "claude")
10172
+ fallback_chain = input_ctx.get("fallback_chain", [])
10173
+ timeout_seconds = min(int(input_ctx.get("timeout_seconds", 300)), 300)
10174
+ sensitive_patterns = input_ctx.get("sensitive_path_patterns") or []
10175
+ candidate_ref = f"refs/heads/merge-candidate/{merge_job_id}"
10176
+
10177
+ # Record merge_job_id → project_id so GC can query eligibility later.
10178
+ self._record_merge_job(merge_job_id, str(input_ctx.get("project_id") or ""))
10179
+
10180
+ async def _report(result: dict, status: str = "completed", error: str = ""):
10181
+ payload = {"status": status, "output_result": result}
10182
+ if error:
10183
+ payload["error"] = error[:1000]
10184
+ payload["failure_code"] = result.get("merge_outcome", "merge_failed")
10185
+ try:
10186
+ await conn.client.post(f"{reporter_url}/complete", json=payload, timeout=15)
10187
+ except Exception:
10188
+ logger.warning("deployment_merge: failed to report result for job %s", job_id)
10189
+
10190
+ await conn.client.post(
10191
+ f"{reporter_url}/progress",
10192
+ json={"current_phase": "preparing", "current_step": "Preparing merge workspace...", "progress_pct": 5},
10193
+ timeout=10,
10194
+ )
10195
+
10196
+ fake_task = TaskInfo(
10197
+ task_id=job_id,
10198
+ graph_id=f"deploy-merge-{merge_job_id}",
10199
+ node_type="deployment_merge",
10200
+ agent_type=agent_type,
10201
+ input_prompt="",
10202
+ input_data={},
10203
+ timeout_seconds=timeout_seconds,
10204
+ max_retries=0,
10205
+ retry_count=0,
10206
+ project=project_info,
10207
+ work_item={},
10208
+ fallback_chain=fallback_chain or [],
10209
+ )
10210
+
10211
+ _proj_key = project_info.get("project_key") or str(project_info.get("id") or "default")
10212
+ project_lock = await self._get_project_execution_lock(f"{_proj_key}:deploy-merge-{merge_job_id}")
10213
+ await project_lock.acquire()
10214
+
10215
+ try:
10216
+ workspace_path = await self.workspace_manager.prepare_workspace(project_info, fake_task)
10217
+ git = self.workspace_manager._git
10218
+ _git_name, _git_email = _resolve_git_author(project_info)
10219
+
10220
+ await conn.client.post(
10221
+ f"{reporter_url}/progress",
10222
+ json={"current_phase": "merging", "current_step": f"Merging into {integration_branch}...", "progress_pct": 25},
10223
+ timeout=10,
10224
+ )
10225
+
10226
+ # Fetch latest refs.
10227
+ await git("fetch", "origin", cwd=workspace_path)
10228
+
10229
+ # Determine base SHA: use recorded base, else default branch HEAD.
10230
+ if base_integration_sha:
10231
+ base_ref = base_integration_sha
10232
+ else:
10233
+ try:
10234
+ base_ref = (await git("rev-parse", f"origin/{default_branch}", cwd=workspace_path)).strip()
10235
+ except RuntimeError:
10236
+ base_ref = "HEAD"
10237
+
10238
+ # Detach HEAD to the base, creating a clean starting point for the candidate.
10239
+ await git("checkout", "--detach", base_ref, cwd=workspace_path)
10240
+
10241
+ # Resolve source SHA from branch name if not already a SHA.
10242
+ if not source_commit_sha and source_branch:
10243
+ try:
10244
+ source_commit_sha = (await git("rev-parse", f"origin/{source_branch}", cwd=workspace_path)).strip()
10245
+ except RuntimeError:
10246
+ pass
10247
+ if not source_commit_sha:
10248
+ await _report({"merge_outcome": "conflict_needs_human"}, status="failed", error="Cannot resolve source commit SHA")
10249
+ return
10250
+
10251
+ # Attempt the merge.
10252
+ try:
10253
+ await git(
10254
+ "-c", f"user.name={_git_name}",
10255
+ "-c", f"user.email={_git_email}",
10256
+ "merge", "--no-ff", "--no-edit", "-m", f"deploy-merge: {source_branch or source_commit_sha[:8]}",
10257
+ source_commit_sha,
10258
+ cwd=workspace_path,
10259
+ )
10260
+ merge_conflicted = False
10261
+ except RuntimeError:
10262
+ merge_conflicted = True
10263
+
10264
+ # Check for unresolved conflicts.
10265
+ try:
10266
+ diff_u = await git("diff", "--name-only", "--diff-filter=U", cwd=workspace_path)
10267
+ except RuntimeError:
10268
+ diff_u = ""
10269
+ conflicted_files = [f.strip() for f in diff_u.strip().splitlines() if f.strip()]
10270
+
10271
+ resolved_agent_id: str | None = None
10272
+ if merge_conflicted or conflicted_files:
10273
+ # Sensitive path policy (design 5.5.3): any conflicted file
10274
+ # matching a sensitive path pattern must go to a human — AI
10275
+ # resolution is disallowed by project policy.
10276
+ sensitive_hits = [
10277
+ (f, p)
10278
+ for f in conflicted_files
10279
+ for p in sensitive_patterns
10280
+ if fnmatch.fnmatchcase(f, p)
10281
+ ]
10282
+ if sensitive_hits:
10283
+ hit_notes = "; ".join(f"{f} matches '{p}'" for f, p in sensitive_hits[:20])
10284
+ logger.warning(
10285
+ "deployment_merge: job %s hit sensitive path(s) — forcing human review: %s",
10286
+ job_id, hit_notes,
10287
+ )
10288
+ try:
10289
+ await git("merge", "--abort", cwd=workspace_path)
10290
+ except RuntimeError:
10291
+ await git("reset", "--hard", "HEAD", cwd=workspace_path)
10292
+ await _report({
10293
+ "merge_outcome": "conflict_needs_human",
10294
+ "conflicted_files": conflicted_files[:30],
10295
+ "candidate_ref": candidate_ref,
10296
+ "base_integration_sha": base_ref,
10297
+ "ai_resolution_notes": (
10298
+ "sensitive path(s) matched — AI resolution disallowed "
10299
+ f"by project policy: {hit_notes}"
10300
+ ),
10301
+ })
10302
+ return
10303
+
10304
+ # Conflicts detected — attempt AI resolution before falling back to human.
10305
+ logger.info(
10306
+ "deployment_merge: job %s has %d conflict(s), attempting AI resolution: %s",
10307
+ job_id, len(conflicted_files), ", ".join(conflicted_files[:10]),
10308
+ )
10309
+
10310
+ # Requirement context prefix shared by all batch prompts
10311
+ # (design 5.4.2 contract (d)).
10312
+ req_context_lines: list[str] = []
10313
+ req_key = input_ctx.get("requirement_key") or ""
10314
+ req_title = input_ctx.get("requirement_title") or ""
10315
+ req_summary = input_ctx.get("requirement_summary") or ""
10316
+ if req_key or req_title:
10317
+ req_context_lines.append(
10318
+ f"Source requirement: {req_key} — {req_title}".rstrip(" —")
10319
+ )
10320
+ if req_summary:
10321
+ req_context_lines.append(f"Requirement summary: {req_summary}")
10322
+ req_context_lines.append(
10323
+ f"Candidate baseline SHA: {base_integration_sha or base_ref}"
10324
+ )
10325
+ member_ledger = input_ctx.get("member_ledger") or []
10326
+ if member_ledger:
10327
+ req_context_lines.append(
10328
+ "Independent requirements already on the integration branch:\n"
10329
+ + "\n".join(f"- {m}" for m in member_ledger)
10330
+ )
10331
+ req_context = "\n".join(req_context_lines) + "\n\n"
10332
+
10333
+ # Batched AI resolution: up to 3 batches of 10 files each,
10334
+ # re-checking remaining conflicts after every batch.
10335
+ MAX_SNIPPET_CHARS = 3000
10336
+ MAX_BATCH_FILES = 10
10337
+ MAX_BATCHES = 3
10338
+ remaining = list(conflicted_files)
10339
+ for batch_no in range(1, MAX_BATCHES + 1):
10340
+ if not remaining:
10341
+ break
10342
+
10343
+ await conn.client.post(
10344
+ f"{reporter_url}/progress",
10345
+ json={
10346
+ "current_phase": "ai_resolving",
10347
+ "current_step": (
10348
+ f"AI resolving conflicts (batch {batch_no}/{MAX_BATCHES}, "
10349
+ f"{len(remaining)} remaining)..."
10350
+ ),
10351
+ "progress_pct": 40,
10352
+ },
10353
+ timeout=10,
10354
+ )
10355
+
10356
+ # Read conflict file contents for this batch (3000 chars each).
10357
+ files_to_read = remaining[:MAX_BATCH_FILES]
10358
+ conflict_snippets: list[str] = []
10359
+ for fpath in files_to_read:
10360
+ full_path = workspace_path / fpath
10361
+ if full_path.exists():
10362
+ try:
10363
+ content = full_path.read_text(errors="replace")
10364
+ if len(content) > MAX_SNIPPET_CHARS:
10365
+ content = content[:MAX_SNIPPET_CHARS] + "\n... (truncated)"
10366
+ conflict_snippets.append(f"### {fpath}\n```\n{content}\n```")
10367
+ except Exception:
10368
+ conflict_snippets.append(f"### {fpath}\n(could not read)")
10369
+ else:
10370
+ conflict_snippets.append(f"### {fpath}\n(file not found)")
10371
+
10372
+ # Build the deployment-merge conflict resolution prompt.
10373
+ # Key difference from the existing _ai_resolve_conflicts prompt:
10374
+ # this is NOT "your branch behind main" — both sides are equally
10375
+ # valid independent requirements that must be composed, not chosen.
10376
+ resolve_prompt = (
10377
+ req_context
10378
+ + f"CONTEXT: You are merging a frozen change for an independent "
10379
+ f"requirement (source: {source_branch or source_commit_sha[:8]}) "
10380
+ f"into a candidate integration version that already contains "
10381
+ f"other independent requirements. Both sides are equally valid "
10382
+ f"product changes.\n\n"
10383
+ f"GOAL: Preserve and correctly compose both requirements. "
10384
+ f"Do NOT choose one side merely because it is newer or shorter. "
10385
+ f"Do not add unrelated features. Remove ALL conflict markers "
10386
+ f"(<<<<<<< HEAD, =======, >>>>>>>).\n\n"
10387
+ f"FAILURE CONTRACT: If the intents cannot safely coexist, leave "
10388
+ f"the Git conflict unresolved. Do not replace a conflict with a "
10389
+ f"TODO, comment, stub, or guessed behavior.\n\n"
10390
+ f"Conflicted files (batch {batch_no}, "
10391
+ f"{len(files_to_read)} of {len(remaining)}):\n"
10392
+ + "\n".join(f"- {f}" for f in files_to_read)
10393
+ + "\n\n"
10394
+ + "\n\n".join(conflict_snippets)
10395
+ )
10396
+
10397
+ # Select agent and run conflict resolution for this batch.
10398
+ cr_agent = self._select_agent(agent_type, fallback_chain)
10399
+ if not cr_agent:
10400
+ logger.warning(
10401
+ "deployment_merge: no agent available for conflict resolution (job %s)",
10402
+ job_id,
10403
+ )
10404
+ break
10405
+ _cr_tried: set[str] = {cr_agent.agent_id}
10406
+ _cr_task = TaskInfo(
10407
+ task_id=f"{job_id}-conflict-resolve-{batch_no}",
10408
+ graph_id=f"deploy-merge-{merge_job_id}",
10409
+ node_type="conflict_resolution",
10410
+ agent_type=cr_agent.agent_id,
10411
+ input_prompt=resolve_prompt,
10412
+ input_data={},
10413
+ timeout_seconds=timeout_seconds,
10414
+ max_retries=0,
10415
+ retry_count=0,
10416
+ project=project_info,
10417
+ work_item={},
10418
+ fallback_chain=fallback_chain or [],
10419
+ )
10420
+ try:
10421
+ cr_result = await self.process_manager.run_agent(
10422
+ cr_agent, _cr_task, workspace_path,
10423
+ )
10424
+ # Rate-limit fallback loop.
10425
+ while self.process_manager.is_rate_limited(cr_result):
10426
+ _cr_fallback = self._select_fallback_agent(
10427
+ cr_agent.agent_id, fallback_chain, _cr_tried,
10428
+ )
10429
+ if not _cr_fallback:
10430
+ logger.warning(
10431
+ "deployment_merge: all agents rate-limited for job %s",
10432
+ job_id,
10433
+ )
10434
+ break
10435
+ logger.warning(
10436
+ "deployment_merge: agent '%s' rate-limited, falling back to '%s'",
10437
+ cr_agent.agent_id, _cr_fallback.agent_id,
10438
+ )
10439
+ cr_agent = _cr_fallback
10440
+ _cr_tried.add(cr_agent.agent_id)
10441
+ _cr_task.agent_type = cr_agent.agent_id
10442
+ cr_result = await self.process_manager.run_agent(
10443
+ cr_agent, _cr_task, workspace_path,
10444
+ )
10445
+ if not self.process_manager.is_rate_limited(cr_result):
10446
+ # Remember the last agent that actually ran to completion.
10447
+ resolved_agent_id = cr_agent.agent_id
10448
+ except Exception as exc:
10449
+ logger.warning(
10450
+ "deployment_merge: AI resolution exception for job %s: %s",
10451
+ job_id, exc,
10452
+ )
10453
+ break
10454
+
10455
+ # Re-check remaining conflicts after this batch.
10456
+ try:
10457
+ diff_u = await git("diff", "--name-only", "--diff-filter=U", cwd=workspace_path)
10458
+ remaining = [
10459
+ f.strip() for f in diff_u.strip().splitlines() if f.strip()
10460
+ ]
10461
+ except RuntimeError:
10462
+ pass # keep the previous remaining list
10463
+
10464
+ resolution_succeeded = not remaining
10465
+ if not resolution_succeeded:
10466
+ logger.warning(
10467
+ "deployment_merge: AI could not resolve all conflicts "
10468
+ "(%d remaining after %d batch(es)) for job %s",
10469
+ len(remaining), MAX_BATCHES, job_id,
10470
+ )
10471
+ # AI failed or unavailable — abort and report for human.
10472
+ try:
10473
+ await git("merge", "--abort", cwd=workspace_path)
10474
+ except RuntimeError:
10475
+ await git("reset", "--hard", "HEAD", cwd=workspace_path)
10476
+ await _report({
10477
+ "merge_outcome": "conflict_needs_human",
10478
+ "conflicted_files": (remaining or conflicted_files)[:30],
10479
+ "candidate_ref": candidate_ref,
10480
+ "base_integration_sha": base_ref,
10481
+ })
10482
+ return
10483
+
10484
+ # All conflicts resolved — stage resolved files and complete the merge.
10485
+ await git("add", "-A", cwd=workspace_path)
10486
+ try:
10487
+ await git(
10488
+ "-c", f"user.name={_git_name}",
10489
+ "-c", f"user.email={_git_email}",
10490
+ "commit", "--no-edit",
10491
+ cwd=workspace_path,
10492
+ )
10493
+ except RuntimeError:
10494
+ pass # merge may have already committed
10495
+ logger.info(
10496
+ "deployment_merge: AI resolution succeeded for job %s via %s",
10497
+ job_id, resolved_agent_id,
10498
+ )
10499
+
10500
+ # AI resolution succeeded — falls through to the shared
10501
+ # deterministic precheck + reporting block below.
10502
+
10503
+ # Deterministic precheck: scan for residual conflict markers.
10504
+ # Applies to both clean merges and AI-resolved merges.
10505
+ candidate_sha = (await git("rev-parse", "HEAD", cwd=workspace_path)).strip()
10506
+ marker_proc = await asyncio.create_subprocess_exec(
10507
+ "git", "grep", "-l", "-E", r"^(<<<<<<<|=======|>>>>>>>)",
10508
+ cwd=str(workspace_path),
10509
+ stdout=asyncio.subprocess.PIPE,
10510
+ stderr=asyncio.subprocess.DEVNULL,
10511
+ )
10512
+ marker_stdout, _ = await marker_proc.communicate()
10513
+ marker_files = [f.strip() for f in marker_stdout.decode().strip().splitlines() if f.strip()]
10514
+
10515
+ if marker_files:
10516
+ logger.warning(
10517
+ "deployment_merge: job %s precheck failed — residual markers in %d file(s)",
10518
+ job_id, len(marker_files),
10519
+ )
10520
+ await _report({
10521
+ "merge_outcome": "precheck_failed",
10522
+ "conflicted_files": marker_files[:30],
10523
+ "candidate_commit_sha": candidate_sha,
10524
+ })
10525
+ return
10526
+
10527
+ # Deterministic precheck: the working tree must be clean (no
10528
+ # unstaged/staged leftovers) after merge/commit. Untracked files
10529
+ # are ignored — worktrees may contain unrelated artifacts.
10530
+ try:
10531
+ status_out = await git(
10532
+ "status", "--porcelain", "--untracked-files=no", cwd=workspace_path,
10533
+ )
10534
+ except RuntimeError:
10535
+ status_out = ""
10536
+ dirty_files = [
10537
+ (line[3:].strip() if len(line) > 3 else line.strip())
10538
+ for line in status_out.strip().splitlines()
10539
+ if line.strip()
10540
+ ]
10541
+ if dirty_files:
10542
+ logger.warning(
10543
+ "deployment_merge: job %s precheck failed — working tree not clean (%d file(s))",
10544
+ job_id, len(dirty_files),
10545
+ )
10546
+ await _report({
10547
+ "merge_outcome": "precheck_failed",
10548
+ "conflicted_files": dirty_files[:30],
10549
+ "candidate_commit_sha": candidate_sha,
10550
+ })
10551
+ return
10552
+
10553
+ # Push the candidate commit to origin so external CI can check it
10554
+ # out and subsequent merges can fetch it as a base.
10555
+ try:
10556
+ await git("push", "origin", f"HEAD:{candidate_ref}", cwd=workspace_path)
10557
+ except RuntimeError as exc:
10558
+ logger.warning(
10559
+ "deployment_merge: job %s candidate push failed: %s", job_id, exc,
10560
+ )
10561
+ await _report(
10562
+ {
10563
+ "merge_outcome": "precheck_failed",
10564
+ "candidate_commit_sha": candidate_sha,
10565
+ "candidate_ref": candidate_ref,
10566
+ },
10567
+ status="failed",
10568
+ error=f"candidate push failed: {exc}",
10569
+ )
10570
+ return
10571
+
10572
+ # Success — determine outcome label.
10573
+ if resolved_agent_id:
10574
+ outcome = "ai_resolved"
10575
+ step_msg = f"AI resolved merge via {resolved_agent_id}"
10576
+ else:
10577
+ outcome = "clean_merged"
10578
+ step_msg = "Merge completed cleanly"
10579
+
10580
+ await conn.client.post(
10581
+ f"{reporter_url}/progress",
10582
+ json={"current_phase": "completed", "current_step": step_msg, "progress_pct": 100},
10583
+ timeout=10,
10584
+ )
10585
+ logger.info(
10586
+ "deployment_merge: job %s %s → %s",
10587
+ job_id, outcome, candidate_sha[:12],
10588
+ )
10589
+ await _report({
10590
+ "merge_outcome": outcome,
10591
+ "resolved_agent": resolved_agent_id,
10592
+ "candidate_commit_sha": candidate_sha,
10593
+ "candidate_ref": candidate_ref,
10594
+ "merge_commit_sha": candidate_sha,
10595
+ "base_integration_sha": base_ref,
10596
+ "source_commit_sha": source_commit_sha,
10597
+ })
10598
+
10599
+ except Exception as exc:
10600
+ logger.error("deployment_merge: job %s exception: %s", job_id, exc)
10601
+ await _report(
10602
+ {"merge_outcome": "conflict_needs_human"},
10603
+ status="failed",
10604
+ error=str(exc),
10605
+ )
10606
+ finally:
10607
+ project_lock.release()
10608
+
9843
10609
  async def _execute_ai_job(self, aj: dict, conn: "ServerConnection"):
9844
10610
  """Execute an AIJob in daemon workspace and report results back.
9845
10611
 
@@ -9848,6 +10614,10 @@ class RuntimeDaemon:
9848
10614
  """
9849
10615
  job_id = aj.get("job_id", "")
9850
10616
  task_type = aj.get("task_type", "unknown")
10617
+ # Deployment merge has a specialized flow (git merge + conflict detection).
10618
+ if task_type == "deployment_merge":
10619
+ await self._execute_deployment_merge_job(aj, conn)
10620
+ return
9851
10621
  project_info = aj.get("project", {})
9852
10622
  requirement_key = aj.get("requirement_key")
9853
10623
  agent_override = aj.get("agent_override")
@@ -9876,6 +10646,7 @@ class RuntimeDaemon:
9876
10646
  project_lock_key,
9877
10647
  )
9878
10648
  await project_lock.acquire()
10649
+ workspace_execution_lock: _WorkspaceFileLock | None = None
9879
10650
 
9880
10651
  try:
9881
10652
  # Report progress: starting
@@ -9940,6 +10711,10 @@ class RuntimeDaemon:
9940
10711
  workspace_source_mode=qa_ws_source.get("mode"),
9941
10712
  workspace_branch=qa_ws_source.get("branch"),
9942
10713
  )
10714
+ workspace_execution_lock = await self.workspace_manager.acquire_workspace_execution_lock(
10715
+ project_info,
10716
+ fake_task,
10717
+ )
9943
10718
  workspace_path = await self.workspace_manager.prepare_workspace(
9944
10719
  project_info, fake_task,
9945
10720
  )
@@ -10163,6 +10938,8 @@ class RuntimeDaemon:
10163
10938
  except Exception:
10164
10939
  pass
10165
10940
  finally:
10941
+ if workspace_execution_lock is not None:
10942
+ workspace_execution_lock.release()
10166
10943
  project_lock.release()
10167
10944
 
10168
10945
  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.23.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.22.5"
3
+ version = "1.23.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