forgexa-cli 1.58.0__tar.gz → 1.58.2__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 (29) hide show
  1. {forgexa_cli-1.58.0 → forgexa_cli-1.58.2}/PKG-INFO +1 -1
  2. {forgexa_cli-1.58.0 → forgexa_cli-1.58.2}/forgexa_cli/__init__.py +1 -1
  3. {forgexa_cli-1.58.0 → forgexa_cli-1.58.2}/forgexa_cli/agent_core.py +27 -0
  4. {forgexa_cli-1.58.0 → forgexa_cli-1.58.2}/forgexa_cli/daemon.py +225 -10
  5. {forgexa_cli-1.58.0 → forgexa_cli-1.58.2}/forgexa_cli.egg-info/PKG-INFO +1 -1
  6. {forgexa_cli-1.58.0 → forgexa_cli-1.58.2}/pyproject.toml +1 -1
  7. {forgexa_cli-1.58.0 → forgexa_cli-1.58.2}/README.md +0 -0
  8. {forgexa_cli-1.58.0 → forgexa_cli-1.58.2}/forgexa_cli/_build_config.py +0 -0
  9. {forgexa_cli-1.58.0 → forgexa_cli-1.58.2}/forgexa_cli/_local_bind.py +0 -0
  10. {forgexa_cli-1.58.0 → forgexa_cli-1.58.2}/forgexa_cli/autoupgrade.py +0 -0
  11. {forgexa_cli-1.58.0 → forgexa_cli-1.58.2}/forgexa_cli/main.py +0 -0
  12. {forgexa_cli-1.58.0 → forgexa_cli-1.58.2}/forgexa_cli/py.typed +0 -0
  13. {forgexa_cli-1.58.0 → forgexa_cli-1.58.2}/forgexa_cli/runtime_evidence.py +0 -0
  14. {forgexa_cli-1.58.0 → forgexa_cli-1.58.2}/forgexa_cli/zcode_protocol.py +0 -0
  15. {forgexa_cli-1.58.0 → forgexa_cli-1.58.2}/forgexa_cli.egg-info/SOURCES.txt +0 -0
  16. {forgexa_cli-1.58.0 → forgexa_cli-1.58.2}/forgexa_cli.egg-info/dependency_links.txt +0 -0
  17. {forgexa_cli-1.58.0 → forgexa_cli-1.58.2}/forgexa_cli.egg-info/entry_points.txt +0 -0
  18. {forgexa_cli-1.58.0 → forgexa_cli-1.58.2}/forgexa_cli.egg-info/requires.txt +0 -0
  19. {forgexa_cli-1.58.0 → forgexa_cli-1.58.2}/forgexa_cli.egg-info/top_level.txt +0 -0
  20. {forgexa_cli-1.58.0 → forgexa_cli-1.58.2}/setup.cfg +0 -0
  21. {forgexa_cli-1.58.0 → forgexa_cli-1.58.2}/tests/test_auth_and_runtime_commands.py +0 -0
  22. {forgexa_cli-1.58.0 → forgexa_cli-1.58.2}/tests/test_autoupgrade.py +0 -0
  23. {forgexa_cli-1.58.0 → forgexa_cli-1.58.2}/tests/test_check_command.py +0 -0
  24. {forgexa_cli-1.58.0 → forgexa_cli-1.58.2}/tests/test_expiry_warnings_and_revoke.py +0 -0
  25. {forgexa_cli-1.58.0 → forgexa_cli-1.58.2}/tests/test_local_bind_commands.py +0 -0
  26. {forgexa_cli-1.58.0 → forgexa_cli-1.58.2}/tests/test_runtime_credentials.py +0 -0
  27. {forgexa_cli-1.58.0 → forgexa_cli-1.58.2}/tests/test_session_credentials.py +0 -0
  28. {forgexa_cli-1.58.0 → forgexa_cli-1.58.2}/tests/test_silent_install.py +0 -0
  29. {forgexa_cli-1.58.0 → forgexa_cli-1.58.2}/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.58.0
3
+ Version: 1.58.2
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.58.0"
2
+ __version__ = "1.58.2"
@@ -816,6 +816,30 @@ class AgentDiscovery:
816
816
  os.environ["PATH"] = os.pathsep.join(additions) + os.pathsep + current
817
817
  logger.debug("Expanded PATH with: %s", ", ".join(additions))
818
818
 
819
+ @staticmethod
820
+ def _wsl_windows_executable(path: str) -> bool:
821
+ """Reject a Windows-side agent before its version probe can run via WSL interop."""
822
+ if sys.platform != "linux" or not os.environ.get("WSL_DISTRO_NAME"):
823
+ return False
824
+ resolved = Path(path).resolve()
825
+ if resolved.suffix.lower() in {".exe", ".cmd", ".bat"}:
826
+ return True
827
+ parts = resolved.parts
828
+ if len(parts) >= 3 and parts[1] == "mnt" and len(parts[2]) == 1 and parts[2].isalpha():
829
+ return True
830
+ # A custom DrvFs mount need not live under /mnt/<drive>.
831
+ try:
832
+ mounts = Path("/proc/mounts").read_text(encoding="utf-8")
833
+ except OSError:
834
+ return False
835
+ for line in mounts.splitlines():
836
+ fields = line.split()
837
+ if len(fields) >= 3 and fields[2] in {"9p", "drvfs"}:
838
+ mount = Path(fields[1].replace("\\040", " "))
839
+ if resolved == mount or mount in resolved.parents:
840
+ return True
841
+ return False
842
+
819
843
  async def discover(self) -> list[DiscoveredAgent]:
820
844
  self._expand_path()
821
845
  # Probe bwrap support once at discovery time and log a clear warning
@@ -827,6 +851,9 @@ class AgentDiscovery:
827
851
  cmd = custom_path or spec["commands"][0]
828
852
  resolved = shutil.which(cmd)
829
853
  if resolved:
854
+ if self._wsl_windows_executable(resolved):
855
+ logger.warning("Agent %s resolved only to a Windows executable inside WSL; skipping", agent_id)
856
+ continue
830
857
  version = await self._get_version(spec["detect"], resolved)
831
858
  if version is None and "detect_alt" in spec:
832
859
  # Primary detect command failed; try the alternative.
@@ -1096,6 +1096,26 @@ except (ImportError, ModuleNotFoundError):
1096
1096
  return int(env_clone)
1097
1097
  return 1800
1098
1098
 
1099
+ @property
1100
+ def GIT_INDEX_LOCK_WAIT(self) -> int:
1101
+ """Seconds to wait for a concurrently-held git index.lock (SI-1509).
1102
+
1103
+ A live git process (huge add/status on a slow disk) legitimately
1104
+ holds the lock for minutes; failing the node sync after seconds of
1105
+ retries just burns a requeue cycle on the same contention.
1106
+ """
1107
+ return int(os.environ.get("GIT_INDEX_LOCK_WAIT", "300"))
1108
+
1109
+ @property
1110
+ def GIT_STALE_LOCK_AGE(self) -> int:
1111
+ """Age (seconds) after which an index.lock is orphaned (SI-1509).
1112
+
1113
+ A lock older than this with the sync still failing is left behind
1114
+ by a SIGKILL'd git process (daemon timeout kills the whole process
1115
+ group) and is removed, as git's own error message recommends.
1116
+ """
1117
+ return int(os.environ.get("GIT_STALE_LOCK_AGE", "180"))
1118
+
1099
1119
  @property
1100
1120
  def GIT_CLONE_FILTER(self) -> str:
1101
1121
  """Optional partial-clone filter, e.g. 'blob:none' for blobless clones.
@@ -1150,7 +1170,7 @@ except (ImportError, ModuleNotFoundError):
1150
1170
  # DAEMON_VERSION is the protocol/logic version of the daemon code.
1151
1171
  # Kept in sync with pyproject.toml version via bump-version.sh.
1152
1172
  # CLIENT_TYPE identifies which packaging/distribution this daemon runs in.
1153
- DAEMON_VERSION = "1.58.0"
1173
+ DAEMON_VERSION = "1.58.2"
1154
1174
 
1155
1175
 
1156
1176
  def _detect_client_type() -> str:
@@ -3419,7 +3439,10 @@ class WorkspaceManager:
3419
3439
  else:
3420
3440
  try:
3421
3441
  # Ensure we're on the correct branch
3422
- await self._git("checkout", branch_name, cwd=ws_path)
3442
+ await self._git_index_op(
3443
+ "checkout", branch_name, cwd=ws_path,
3444
+ context=f"final sync checkout {branch_name} in {ws_path}",
3445
+ )
3423
3446
  except RuntimeError as exc:
3424
3447
  sync_error = f"checkout branch failed: {exc}"
3425
3448
  else:
@@ -3427,9 +3450,11 @@ class WorkspaceManager:
3427
3450
  # branch has diverged (force-pushed by prior phase),
3428
3451
  # reset --hard is used below.
3429
3452
  try:
3430
- await self._git(
3453
+ await self._git_index_op(
3431
3454
  "pull", "--ff-only", "origin", branch_name,
3432
3455
  cwd=ws_path, project_key=project_key,
3456
+ timeout=settings.GIT_FETCH_TIMEOUT,
3457
+ context=f"final sync pull --ff-only {branch_name} in {ws_path}",
3433
3458
  )
3434
3459
  sync_success = True
3435
3460
  except RuntimeError as exc:
@@ -3439,9 +3464,10 @@ class WorkspaceManager:
3439
3464
  # ff-only failed (diverged or remote not yet created) — try
3440
3465
  # reset --hard to force-sync to whatever the remote has.
3441
3466
  try:
3442
- await self._git(
3467
+ await self._git_index_op(
3443
3468
  "reset", "--hard", f"origin/{branch_name}",
3444
3469
  cwd=ws_path,
3470
+ context=f"final sync reset to origin/{branch_name} in {ws_path}",
3445
3471
  )
3446
3472
  sync_success = True
3447
3473
  except RuntimeError as exc:
@@ -3826,6 +3852,157 @@ class WorkspaceManager:
3826
3852
  backup_tag,
3827
3853
  )
3828
3854
 
3855
+ # ── git index.lock contention recovery (SI-1509) ─────────────────────
3856
+ # A git command that writes the index (checkout, reset, pull, add,
3857
+ # commit, status-refresh) refuses to run while the worktree's
3858
+ # index.lock exists. Two very different situations produce that file:
3859
+ # 1. A LIVE concurrent git process (e.g. a huge `git add`/`git status`
3860
+ # on a slow disk) — must be waited out; removing the lock mid-op
3861
+ # corrupts the index.
3862
+ # 2. A STALE lock orphaned by a SIGKILL'd process (the daemon kills
3863
+ # agent process trees on timeout) — never disappears on its own
3864
+ # and must be removed; git's own error message says exactly this.
3865
+ # Before SI-1509 neither case was handled: sync failed after ~6 s of
3866
+ # blind retries with a misleading "fetch timed out or credentials
3867
+ # missing/invalid" message, and the node was requeued to repeat the
3868
+ # same failure (the real checkout error text was dropped).
3869
+
3870
+ _INDEX_LOCK_RE = re.compile(r"'([^']*index\.lock)'")
3871
+
3872
+ @classmethod
3873
+ def _extract_index_lock_path(cls, err_text: str) -> Path | None:
3874
+ """Parse the index.lock path git reports when it refuses to proceed.
3875
+
3876
+ Git prints the lock path in single quotes in every locale, e.g.
3877
+ fatal: Unable to create '/w/…/_main/.git/worktrees/<id>/index.lock': File exists.
3878
+ fatal: 无法创建 '/w/…/index.lock':文件已存在。
3879
+ """
3880
+ m = cls._INDEX_LOCK_RE.search(err_text)
3881
+ if not m:
3882
+ return None
3883
+ raw = m.group(1)
3884
+ # On Windows git may print an MSYS-style path (/c/…); leave those
3885
+ # alone — only recover locks whose path we can act on directly.
3886
+ p = Path(raw)
3887
+ return p if p.is_absolute() else None
3888
+
3889
+ @staticmethod
3890
+ def _linked_worktree_index_lock_path(
3891
+ ws_path: Path, main_repo: Path | None,
3892
+ ) -> Path | None:
3893
+ """Return the index.lock path of a structurally-valid linked worktree.
3894
+
3895
+ Unlike `_is_healthy_worktree` this isolates the *lock* condition:
3896
+ if this returns a path, the worktree is fine apart from being
3897
+ locked, so lock recovery alone can restore it without the
3898
+ destructive remove-and-recreate path.
3899
+ """
3900
+ git_file = ws_path / ".git"
3901
+ if not git_file.exists() or git_file.is_dir():
3902
+ return None
3903
+ try:
3904
+ content = git_file.read_text().strip()
3905
+ except OSError:
3906
+ return None
3907
+ if not content.startswith("gitdir: "):
3908
+ return None
3909
+ gitdir = Path(content.split("gitdir: ", 1)[1].strip())
3910
+ if main_repo is not None:
3911
+ try:
3912
+ gitdir.resolve().relative_to((main_repo / ".git" / "worktrees").resolve())
3913
+ except (ValueError, OSError):
3914
+ return None
3915
+ lock = gitdir / "index.lock"
3916
+ return lock if lock.exists() else None
3917
+
3918
+ async def _wait_or_remove_index_lock(
3919
+ self, lock_path: Path, *, max_wait: float, stale_age: float,
3920
+ context: str, poll_interval: float = 5.0,
3921
+ ) -> bool:
3922
+ """Wait out a live index.lock holder; remove the lock once stale.
3923
+
3924
+ Policy:
3925
+ • Poll every 5 s; the holder finishing removes the lock itself.
3926
+ • If the lock's mtime age exceeds ``stale_age`` seconds, the
3927
+ holder is almost certainly gone (SIGKILL leaves exactly this
3928
+ state) — unlink it as git's error message recommends.
3929
+ • Give up after ``max_wait`` seconds and return False so callers
3930
+ can fall back to their existing failure handling.
3931
+ """
3932
+ deadline = time.monotonic() + max_wait
3933
+ last_age = -1.0
3934
+ while True:
3935
+ if not lock_path.exists():
3936
+ if last_age >= 0:
3937
+ logger.info(
3938
+ "%s: index.lock %s disappeared — holder finished; retrying",
3939
+ context, lock_path,
3940
+ )
3941
+ return True
3942
+ try:
3943
+ age = time.time() - lock_path.stat().st_mtime
3944
+ except FileNotFoundError:
3945
+ continue # lock vanished between exists() and stat()
3946
+ except OSError:
3947
+ age = 0.0 # unreadable mtime (permissions) — treat as fresh
3948
+ if age > stale_age:
3949
+ try:
3950
+ lock_path.unlink()
3951
+ except FileNotFoundError:
3952
+ return True
3953
+ except OSError as rm_exc:
3954
+ logger.warning(
3955
+ "%s: could not remove stale index.lock %s (%s) — retrying",
3956
+ context, lock_path, rm_exc,
3957
+ )
3958
+ else:
3959
+ logger.warning(
3960
+ "%s: removed stale index.lock %s (age %.0fs > %.0fs; "
3961
+ "left behind by an interrupted git process)",
3962
+ context, lock_path, age, stale_age,
3963
+ )
3964
+ return True
3965
+ if time.monotonic() >= deadline:
3966
+ logger.warning(
3967
+ "%s: index.lock %s still held after %.0fs — giving up",
3968
+ context, lock_path, max_wait,
3969
+ )
3970
+ return False
3971
+ if last_age < 0:
3972
+ logger.info(
3973
+ "%s: waiting up to %.0fs for concurrent git process holding %s",
3974
+ context, max_wait, lock_path,
3975
+ )
3976
+ last_age = age
3977
+ await asyncio.sleep(poll_interval)
3978
+
3979
+ async def _git_index_op(
3980
+ self, *args: str, cwd: Path | str, context: str,
3981
+ project_key: str | None = None, timeout: int = 120,
3982
+ ) -> str:
3983
+ """Run a git command that writes the worktree index, with lock recovery.
3984
+
3985
+ On index.lock contention (live or stale) this waits out or clears
3986
+ the lock and retries once instead of failing the whole node sync —
3987
+ see the SI-1509 note above the helpers.
3988
+ """
3989
+ try:
3990
+ return await self._git(*args, cwd=cwd, timeout=timeout, project_key=project_key)
3991
+ except RuntimeError as exc:
3992
+ lock_path = self._extract_index_lock_path(str(exc))
3993
+ if lock_path is None:
3994
+ raise
3995
+ recovered = await self._wait_or_remove_index_lock(
3996
+ lock_path,
3997
+ max_wait=settings.GIT_INDEX_LOCK_WAIT,
3998
+ stale_age=settings.GIT_STALE_LOCK_AGE,
3999
+ context=context,
4000
+ )
4001
+ if not recovered:
4002
+ raise
4003
+ logger.info("%s: index.lock cleared — retrying git %s", context, args[0])
4004
+ return await self._git(*args, cwd=cwd, timeout=timeout, project_key=project_key)
4005
+
3829
4006
  async def _is_healthy_worktree(
3830
4007
  self,
3831
4008
  ws_path: Path,
@@ -4426,11 +4603,35 @@ class WorkspaceManager:
4426
4603
  # valid main repo and has no stale Git index lock. Worktrees break
4427
4604
  # when directories are moved or an interrupted Git process leaves
4428
4605
  # its index locked.
4429
- if not await self._is_healthy_worktree(
4606
+ _worktree_healthy = await self._is_healthy_worktree(
4430
4607
  ws_path,
4431
4608
  main_repo=main_repo,
4432
4609
  require_unlocked=True,
4433
- ):
4610
+ )
4611
+ if not _worktree_healthy:
4612
+ # SI-1509: when the only problem is an index.lock (live
4613
+ # concurrent git process, or a stale one left by a SIGKILL'd
4614
+ # process), recovering just the lock keeps the worktree and
4615
+ # any uncommitted state intact — no need to nuke and re-clone.
4616
+ _lock_path = self._linked_worktree_index_lock_path(ws_path, main_repo)
4617
+ if _lock_path is not None:
4618
+ logger.warning(
4619
+ "Worktree %s is locked by %s — attempting lock recovery before "
4620
+ "falling back to worktree recreation",
4621
+ ws_path, _lock_path,
4622
+ )
4623
+ if await self._wait_or_remove_index_lock(
4624
+ _lock_path,
4625
+ max_wait=settings.GIT_INDEX_LOCK_WAIT,
4626
+ stale_age=settings.GIT_STALE_LOCK_AGE,
4627
+ context=f"worktree health check for {ws_path}",
4628
+ ):
4629
+ _worktree_healthy = await self._is_healthy_worktree(
4630
+ ws_path,
4631
+ main_repo=main_repo,
4632
+ require_unlocked=True,
4633
+ )
4634
+ if not _worktree_healthy:
4434
4635
  logger.warning("Broken worktree detected at %s — removing and recreating", ws_path)
4435
4636
  await self._remove_broken_worktree(main_repo, ws_path, workspace_key)
4436
4637
  else:
@@ -4660,15 +4861,25 @@ class WorkspaceManager:
4660
4861
  sync_success = False
4661
4862
  for _sync_attempt in range(3):
4662
4863
  try:
4663
- await self._git("checkout", branch_name, cwd=ws_path)
4864
+ await self._git_index_op(
4865
+ "checkout", branch_name, cwd=ws_path,
4866
+ context=f"sync checkout {branch_name} in {ws_path}",
4867
+ )
4664
4868
  except RuntimeError:
4665
4869
  # Branch might not exist locally yet — create tracking branch
4666
4870
  try:
4667
- await self._git(
4871
+ await self._git_index_op(
4668
4872
  "checkout", "-B", branch_name, f"origin/{branch_name}",
4669
4873
  cwd=ws_path,
4874
+ context=f"sync checkout -B {branch_name} in {ws_path}",
4670
4875
  )
4671
4876
  except RuntimeError as exc:
4877
+ # SI-1509: record the REAL checkout error (lock
4878
+ # contention, bad ref, …) — without this the
4879
+ # raised message fell back to a misleading
4880
+ # "fetch timed out or credentials missing/invalid"
4881
+ # even though every fetch had succeeded.
4882
+ _last_sync_err = str(exc)[:300]
4672
4883
  if _sync_attempt < 2:
4673
4884
  logger.info(
4674
4885
  "Branch %s not available yet (attempt %d/3), re-fetching...",
@@ -4693,13 +4904,15 @@ class WorkspaceManager:
4693
4904
  logger.warning("Failed to checkout %s after retries: %s", branch_name, exc)
4694
4905
  # Reset working tree to match remote branch (fast-forward)
4695
4906
  try:
4696
- await self._git(
4907
+ await self._git_index_op(
4697
4908
  "reset", "--hard", f"origin/{branch_name}",
4698
4909
  cwd=ws_path,
4910
+ context=f"sync reset to origin/{branch_name} in {ws_path}",
4699
4911
  )
4700
4912
  sync_success = True
4701
4913
  break
4702
4914
  except RuntimeError as exc:
4915
+ _last_sync_err = str(exc)[:300]
4703
4916
  if _sync_attempt < 2:
4704
4917
  logger.info(
4705
4918
  "Could not reset to origin/%s (attempt %d/3), re-fetching...",
@@ -4761,7 +4974,9 @@ class WorkspaceManager:
4761
4974
  logger.warning("Could not remove stale worktree %s: %s", ws_path, _rm_exc)
4762
4975
  _err_detail = (
4763
4976
  f"Git error: {_last_sync_err}" if _last_sync_err
4764
- else "fetch timed out or credentials missing/invalid"
4977
+ else "checkout/reset failed repeatedly with no captured "
4978
+ "git error (possible stale index.lock or interrupted "
4979
+ "git process on the daemon host)"
4765
4980
  )
4766
4981
  raise RuntimeError(
4767
4982
  f"Failed to sync branch '{branch_name}' from remote after 3 attempts "
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: forgexa-cli
3
- Version: 1.58.0
3
+ Version: 1.58.2
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.58.0"
3
+ version = "1.58.2"
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