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.
- {forgexa_cli-1.58.0 → forgexa_cli-1.58.2}/PKG-INFO +1 -1
- {forgexa_cli-1.58.0 → forgexa_cli-1.58.2}/forgexa_cli/__init__.py +1 -1
- {forgexa_cli-1.58.0 → forgexa_cli-1.58.2}/forgexa_cli/agent_core.py +27 -0
- {forgexa_cli-1.58.0 → forgexa_cli-1.58.2}/forgexa_cli/daemon.py +225 -10
- {forgexa_cli-1.58.0 → forgexa_cli-1.58.2}/forgexa_cli.egg-info/PKG-INFO +1 -1
- {forgexa_cli-1.58.0 → forgexa_cli-1.58.2}/pyproject.toml +1 -1
- {forgexa_cli-1.58.0 → forgexa_cli-1.58.2}/README.md +0 -0
- {forgexa_cli-1.58.0 → forgexa_cli-1.58.2}/forgexa_cli/_build_config.py +0 -0
- {forgexa_cli-1.58.0 → forgexa_cli-1.58.2}/forgexa_cli/_local_bind.py +0 -0
- {forgexa_cli-1.58.0 → forgexa_cli-1.58.2}/forgexa_cli/autoupgrade.py +0 -0
- {forgexa_cli-1.58.0 → forgexa_cli-1.58.2}/forgexa_cli/main.py +0 -0
- {forgexa_cli-1.58.0 → forgexa_cli-1.58.2}/forgexa_cli/py.typed +0 -0
- {forgexa_cli-1.58.0 → forgexa_cli-1.58.2}/forgexa_cli/runtime_evidence.py +0 -0
- {forgexa_cli-1.58.0 → forgexa_cli-1.58.2}/forgexa_cli/zcode_protocol.py +0 -0
- {forgexa_cli-1.58.0 → forgexa_cli-1.58.2}/forgexa_cli.egg-info/SOURCES.txt +0 -0
- {forgexa_cli-1.58.0 → forgexa_cli-1.58.2}/forgexa_cli.egg-info/dependency_links.txt +0 -0
- {forgexa_cli-1.58.0 → forgexa_cli-1.58.2}/forgexa_cli.egg-info/entry_points.txt +0 -0
- {forgexa_cli-1.58.0 → forgexa_cli-1.58.2}/forgexa_cli.egg-info/requires.txt +0 -0
- {forgexa_cli-1.58.0 → forgexa_cli-1.58.2}/forgexa_cli.egg-info/top_level.txt +0 -0
- {forgexa_cli-1.58.0 → forgexa_cli-1.58.2}/setup.cfg +0 -0
- {forgexa_cli-1.58.0 → forgexa_cli-1.58.2}/tests/test_auth_and_runtime_commands.py +0 -0
- {forgexa_cli-1.58.0 → forgexa_cli-1.58.2}/tests/test_autoupgrade.py +0 -0
- {forgexa_cli-1.58.0 → forgexa_cli-1.58.2}/tests/test_check_command.py +0 -0
- {forgexa_cli-1.58.0 → forgexa_cli-1.58.2}/tests/test_expiry_warnings_and_revoke.py +0 -0
- {forgexa_cli-1.58.0 → forgexa_cli-1.58.2}/tests/test_local_bind_commands.py +0 -0
- {forgexa_cli-1.58.0 → forgexa_cli-1.58.2}/tests/test_runtime_credentials.py +0 -0
- {forgexa_cli-1.58.0 → forgexa_cli-1.58.2}/tests/test_session_credentials.py +0 -0
- {forgexa_cli-1.58.0 → forgexa_cli-1.58.2}/tests/test_silent_install.py +0 -0
- {forgexa_cli-1.58.0 → forgexa_cli-1.58.2}/tests/test_upgrade_observability.py +0 -0
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
"""forgexa-cli — Forgexa command-line client."""
|
|
2
|
-
__version__ = "1.58.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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 "
|
|
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 "
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|