forgexa-cli 1.14.5__tar.gz → 1.14.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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: forgexa-cli
3
- Version: 1.14.5
3
+ Version: 1.14.6
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: MIT
@@ -1,2 +1,2 @@
1
1
  """forgexa-cli — Forgexa command-line client."""
2
- __version__ = "1.14.5"
2
+ __version__ = "1.14.6"
@@ -523,7 +523,7 @@ except (ImportError, ModuleNotFoundError):
523
523
  # DAEMON_VERSION is the protocol/logic version of the daemon code.
524
524
  # Kept in sync with pyproject.toml version via bump-version.sh.
525
525
  # CLIENT_TYPE identifies which packaging/distribution this daemon runs in.
526
- DAEMON_VERSION = "1.14.5"
526
+ DAEMON_VERSION = "1.14.6"
527
527
 
528
528
 
529
529
  def _detect_client_type() -> str:
@@ -1709,6 +1709,35 @@ class WorkspaceManager:
1709
1709
  )
1710
1710
  return any(marker in err for marker in retry_markers)
1711
1711
 
1712
+ @staticmethod
1713
+ def _safe_rmtree(path: Path) -> None:
1714
+ """Remove a directory tree, handling read-only files on Windows.
1715
+
1716
+ On Windows, git object files (.git/objects/**/*) are stored read-only
1717
+ (mode 0444). Plain shutil.rmtree(path, ignore_errors=True) silently
1718
+ skips those files, leaving the directory intact. A subsequent
1719
+ git clone then fails with 'destination path already exists and is
1720
+ not an empty directory'.
1721
+
1722
+ This method uses the onerror callback to chmod each entry writable
1723
+ before retrying — the established pattern for handling git repos on
1724
+ Windows.
1725
+ """
1726
+ import shutil
1727
+ import stat as _stat
1728
+
1729
+ if not path.exists():
1730
+ return
1731
+
1732
+ def _handle_readonly(func, entry_path, excinfo):
1733
+ try:
1734
+ os.chmod(entry_path, _stat.S_IWRITE | _stat.S_IREAD | _stat.S_IRWXU)
1735
+ func(entry_path)
1736
+ except Exception:
1737
+ pass # Best-effort: if even chmod fails, ignore
1738
+
1739
+ shutil.rmtree(path, onerror=_handle_readonly)
1740
+
1712
1741
  async def _clone_repo(
1713
1742
  self,
1714
1743
  repo_url: str,
@@ -1718,8 +1747,6 @@ class WorkspaceManager:
1718
1747
  project_key: str = "default",
1719
1748
  max_attempts: int = 3,
1720
1749
  ) -> None:
1721
- import shutil
1722
-
1723
1750
  last_exc: RuntimeError | None = None
1724
1751
  for attempt in range(1, max_attempts + 1):
1725
1752
  try:
@@ -1737,7 +1764,8 @@ class WorkspaceManager:
1737
1764
  # Clean up any partial directory git may have created before giving up.
1738
1765
  # If not cleaned up now, _is_healthy_worktree will detect it as broken
1739
1766
  # on the next call and remove it, but cleaning here gives a faster path.
1740
- shutil.rmtree(dest, ignore_errors=True)
1767
+ # Use _safe_rmtree to handle read-only git objects on Windows.
1768
+ self._safe_rmtree(dest)
1741
1769
  raise
1742
1770
 
1743
1771
  logger.warning(
@@ -1748,7 +1776,9 @@ class WorkspaceManager:
1748
1776
  dest,
1749
1777
  exc,
1750
1778
  )
1751
- shutil.rmtree(dest, ignore_errors=True)
1779
+ # Use _safe_rmtree so that read-only git objects on Windows are
1780
+ # properly removed before the next clone attempt.
1781
+ self._safe_rmtree(dest)
1752
1782
  await asyncio.sleep(attempt * 5)
1753
1783
 
1754
1784
  if last_exc is not None:
@@ -1959,7 +1989,6 @@ class WorkspaceManager:
1959
1989
 
1960
1990
  async def _remove_broken_worktree(self, main_repo: Path, ws_path: Path, workspace_key: str):
1961
1991
  """Remove a broken worktree directory and clean up stale worktree refs."""
1962
- import shutil
1963
1992
  # Try to prune from main repo first
1964
1993
  if main_repo.exists():
1965
1994
  try:
@@ -1969,9 +1998,10 @@ class WorkspaceManager:
1969
1998
  # Also remove the worktree ref if it still exists
1970
1999
  wt_ref = main_repo / ".git" / "worktrees" / workspace_key
1971
2000
  if wt_ref.exists():
1972
- shutil.rmtree(wt_ref, ignore_errors=True)
1973
- # Remove the broken worktree directory
1974
- shutil.rmtree(ws_path, ignore_errors=True)
2001
+ self._safe_rmtree(wt_ref)
2002
+ # Remove the broken worktree directory using _safe_rmtree so that
2003
+ # read-only git objects on Windows are properly handled.
2004
+ self._safe_rmtree(ws_path)
1975
2005
 
1976
2006
  async def _safe_rmtree_main(self, main_repo: Path) -> None:
1977
2007
  """Remove _main repo, first evicting all linked worktrees to prevent orphans.
@@ -1979,16 +2009,15 @@ class WorkspaceManager:
1979
2009
  A linked worktree's .git file contains «gitdir: <main>/.git/worktrees/<n>».
1980
2010
  Deleting _main without first removing linked worktrees leaves those
1981
2011
  directories with a dangling .git pointer — `_is_healthy_worktree` then
1982
- flags them broken and `_remove_broken_worktree` calls shutil.rmtree on
2012
+ flags them broken and `_remove_broken_worktree` calls _safe_rmtree on
1983
2013
  any active workspace, silently discarding uncommitted work.
1984
2014
 
1985
2015
  Strategy:
1986
2016
  1. Use git worktree list --porcelain to enumerate linked worktrees.
1987
- 2. shutil.rmtree each linked worktree directory (excluding _main itself).
1988
- 3. shutil.rmtree _main.
2017
+ 2. _safe_rmtree each linked worktree directory (excluding _main itself).
2018
+ 3. _safe_rmtree _main.
1989
2019
  If git is unavailable (already corrupted), fall back to a directory scan.
1990
2020
  """
1991
- import shutil as _shutil
1992
2021
  if not main_repo.exists():
1993
2022
  return
1994
2023
  linked: list[Path] = []
@@ -2011,8 +2040,10 @@ class WorkspaceManager:
2011
2040
  linked.append(child)
2012
2041
  for wt in linked:
2013
2042
  logger.info("Evicting linked worktree %s before deleting _main", wt)
2014
- _shutil.rmtree(wt, ignore_errors=True)
2015
- _shutil.rmtree(main_repo, ignore_errors=True)
2043
+ self._safe_rmtree(wt)
2044
+ # Use _safe_rmtree so that read-only git objects on Windows
2045
+ # (e.g. .git/objects/**/* stored as 0444) are properly removed.
2046
+ self._safe_rmtree(main_repo)
2016
2047
 
2017
2048
  async def _find_existing_worktree_for_branch(
2018
2049
  self,
@@ -2364,13 +2395,52 @@ class WorkspaceManager:
2364
2395
 
2365
2396
  if branch_has_impl:
2366
2397
  logger.warning(
2367
- "Fresh start requested for %s but remote branch has implementation "
2368
- "commit(s) ahead of %s — switching to safe sync to avoid "
2369
- "destroying prior implementation work",
2398
+ "Fresh start requested for %s: remote branch has implementation "
2399
+ "commit(s) ahead of %s — deleting remote branch to ensure "
2400
+ "a clean analysis environment (user explicitly chose fresh analysis)",
2370
2401
  branch_name, default_branch,
2371
2402
  )
2372
- # Override: fall through to the non-fresh sync path
2373
- fresh_start = False
2403
+ # When the user explicitly chooses "fresh analysis", they want a clean
2404
+ # slate. Deleting the remote branch achieves this: the agent runs on
2405
+ # a new branch forked from default, completely free of prior
2406
+ # implementation commits that may have been wrong or irrelevant.
2407
+ # This is intentional and safe because:
2408
+ # 1. The user selected fresh analysis (not refine) — they accept
2409
+ # that prior work on this branch will be discarded.
2410
+ # 2. Keeping the old implementation commits would contaminate the
2411
+ # fresh analysis with incorrect context (the original bug report).
2412
+ # 3. "Refine" mode preserves the branch — fresh mode does not.
2413
+ try:
2414
+ await self._git(
2415
+ "push", "origin", "--delete", branch_name,
2416
+ cwd=ws_path, project_key=project_key,
2417
+ )
2418
+ logger.info(
2419
+ "Deleted remote branch %s for fresh analysis restart",
2420
+ branch_name,
2421
+ )
2422
+ # Also remove local branch reference so checkout -B below starts clean
2423
+ try:
2424
+ await self._git("branch", "-D", branch_name, cwd=ws_path)
2425
+ except RuntimeError:
2426
+ pass # Local branch may not exist; not an error
2427
+ # Remove the remote-tracking ref so git doesn't think the
2428
+ # branch still exists on the remote
2429
+ try:
2430
+ await self._git(
2431
+ "update-ref", "-d", f"refs/remotes/origin/{branch_name}",
2432
+ cwd=ws_path,
2433
+ )
2434
+ except RuntimeError:
2435
+ pass
2436
+ except RuntimeError as _del_err:
2437
+ logger.warning(
2438
+ "Failed to delete remote branch %s: %s — "
2439
+ "proceeding with reset to origin/%s instead",
2440
+ branch_name, _del_err, default_branch,
2441
+ )
2442
+ # fresh_start remains True: checkout -B below will recreate
2443
+ # the branch from origin/default_branch
2374
2444
  elif ahead_count > 0:
2375
2445
  # Branch has commits but they are all analysis-only (safe to reset).
2376
2446
  logger.info(
@@ -2565,6 +2635,23 @@ class WorkspaceManager:
2565
2635
  main_repo,
2566
2636
  )
2567
2637
  await self._safe_rmtree_main(main_repo)
2638
+ # Verify deletion succeeded (on Windows, rmtree can silently fail
2639
+ # on read-only git objects — _safe_rmtree should handle this, but
2640
+ # add a fallback guard to provide a clear error if it still exists).
2641
+ if main_repo.exists():
2642
+ logger.warning(
2643
+ "_main at %s still exists after rmtree (files may be locked). "
2644
+ "Attempting one more forced removal before re-clone.",
2645
+ main_repo,
2646
+ )
2647
+ self._safe_rmtree(main_repo)
2648
+ if main_repo.exists():
2649
+ raise RuntimeError(
2650
+ f"Cannot re-clone: {main_repo} could not be fully removed "
2651
+ f"(files may be locked by another process on Windows). "
2652
+ f"Please stop the daemon, manually delete '{main_repo}', "
2653
+ f"then restart."
2654
+ )
2568
2655
  try:
2569
2656
  await self._clone_repo(
2570
2657
  repo_url,
@@ -2573,7 +2660,7 @@ class WorkspaceManager:
2573
2660
  project_key=project_key,
2574
2661
  )
2575
2662
  except Exception:
2576
- shutil.rmtree(main_repo, ignore_errors=True)
2663
+ self._safe_rmtree(main_repo)
2577
2664
  raise
2578
2665
  # Use targeted fetch instead of --all to avoid pulling every branch/tag
2579
2666
  # from potentially large repos. Use GIT_FETCH_TIMEOUT (default 1800 s)
@@ -2607,23 +2694,26 @@ class WorkspaceManager:
2607
2694
  project_key=project_key,
2608
2695
  )
2609
2696
  except Exception:
2610
- shutil.rmtree(main_repo, ignore_errors=True)
2697
+ self._safe_rmtree(main_repo)
2611
2698
  raise
2612
2699
  else:
2613
2700
  raise
2614
2701
 
2615
2702
  # --single-branch clone only fetches the default branch.
2616
- # Explicitly fetch the feature branch so origin/{branch_name}
2617
- # is available for worktree creation and checkout.
2618
- if not fresh_start:
2619
- try:
2620
- await self._git(
2621
- "fetch", "origin",
2622
- f"{branch_name}:refs/remotes/origin/{branch_name}",
2623
- cwd=main_repo, timeout=60, project_key=project_key,
2624
- )
2625
- except RuntimeError:
2626
- pass # Branch may not exist on remote yet (first analysis)
2703
+ # Always explicitly fetch the feature branch so origin/{branch_name}
2704
+ # is available for:
2705
+ # - refine mode: we need the existing commits to check out
2706
+ # - fresh mode: we need to detect if the branch exists and has
2707
+ # implementation commits (to delete it before recreating)
2708
+ # A missing branch (first-ever analysis) is silently ignored.
2709
+ try:
2710
+ await self._git(
2711
+ "fetch", "origin",
2712
+ f"{branch_name}:refs/remotes/origin/{branch_name}",
2713
+ cwd=main_repo, timeout=60, project_key=project_key,
2714
+ )
2715
+ except RuntimeError:
2716
+ pass # Branch may not exist on remote yet (first analysis)
2627
2717
 
2628
2718
  # Prune stale worktree references (e.g. directories deleted externally
2629
2719
  # when simulating cross-runtime or after disk cleanup). Without this,
@@ -2681,6 +2771,67 @@ class WorkspaceManager:
2681
2771
  except RuntimeError:
2682
2772
  pass
2683
2773
 
2774
+ # Fresh analysis with an existing remote branch that has implementation commits:
2775
+ # delete the remote branch so the agent starts from a clean slate.
2776
+ # We must do this check here (new worktree path) as well as in the existing
2777
+ # worktree path above, because the daemon that runs a fresh re-analysis may
2778
+ # never have processed this requirement before (no ws_path yet).
2779
+ if fresh_start and branch_exists_remote:
2780
+ # Check for implementation commits — same logic as in the existing worktree path.
2781
+ _new_branch_has_impl = False
2782
+ try:
2783
+ _new_ahead_count_raw = await self._git(
2784
+ "rev-list", "--count",
2785
+ f"origin/{default_branch}..origin/{branch_name}",
2786
+ cwd=main_repo,
2787
+ )
2788
+ _new_ahead_count = int(_new_ahead_count_raw.strip()) if _new_ahead_count_raw.strip() else 0
2789
+ except (RuntimeError, ValueError):
2790
+ _new_ahead_count = 0
2791
+
2792
+ if _new_ahead_count > 0:
2793
+ _new_branch_has_impl = await self._branch_has_impl_commits(
2794
+ main_repo, branch_name, default_branch,
2795
+ )
2796
+
2797
+ if _new_branch_has_impl:
2798
+ logger.warning(
2799
+ "Fresh analysis (new worktree) for %s: remote branch has implementation "
2800
+ "commit(s) — deleting remote branch for a clean analysis environment",
2801
+ branch_name,
2802
+ )
2803
+ try:
2804
+ await self._git(
2805
+ "push", "origin", "--delete", branch_name,
2806
+ cwd=main_repo, project_key=project_key,
2807
+ )
2808
+ logger.info(
2809
+ "Deleted remote branch %s for fresh analysis restart (new worktree path)",
2810
+ branch_name,
2811
+ )
2812
+ # Remove local branch ref and remote-tracking ref
2813
+ try:
2814
+ await self._git("branch", "-D", branch_name, cwd=main_repo)
2815
+ except RuntimeError:
2816
+ pass
2817
+ try:
2818
+ await self._git(
2819
+ "update-ref", "-d", f"refs/remotes/origin/{branch_name}",
2820
+ cwd=main_repo,
2821
+ )
2822
+ except RuntimeError:
2823
+ pass
2824
+ # Branch no longer exists on remote
2825
+ branch_exists_remote = False
2826
+ except RuntimeError as _del_err2:
2827
+ logger.warning(
2828
+ "Failed to delete remote branch %s (new worktree path): %s — "
2829
+ "proceeding with worktree add from default branch",
2830
+ branch_name, _del_err2,
2831
+ )
2832
+ # Treat as if branch doesn't exist to force fresh worktree
2833
+ branch_exists_remote = False
2834
+
2684
2835
  if branch_exists_remote and not fresh_start:
2685
2836
  # Branch exists on remote and we want to preserve it (refine mode)
2686
2837
  # First, update local branch ref to match remote (in case it's stale)
@@ -2835,9 +2986,18 @@ class WorkspaceManager:
2835
2986
  # passing -o StrictModes=no to the SSH client is invalid and causes:
2836
2987
  # "command-line: line 0: Bad configuration option: strictmodes"
2837
2988
  # RC2 (Windows): /dev/null doesn't exist on Windows native OpenSSH
2838
- # (C:\Windows\System32\OpenSSH\ssh.exe). Use NUL instead.
2989
+ # (C:\Windows\System32\OpenSSH\ssh.exe). Use a temp file next
2990
+ # to the SSH key instead of bare "NUL": git-for-windows MSYS2
2991
+ # SSH treats "NUL" as a relative path and may write accepted
2992
+ # host keys to a file named NUL in the git worktree CWD,
2993
+ # which pollutes `git status` output and confuses agents.
2839
2994
  if sys.platform == "win32":
2840
- _known_hosts_null = "NUL"
2995
+ _kh_temp = key_path + ".known_hosts"
2996
+ try:
2997
+ open(_kh_temp, "a").close()
2998
+ _known_hosts_null = _kh_temp.replace("\\", "/")
2999
+ except OSError:
3000
+ _known_hosts_null = "NUL" # fallback to null device
2841
3001
  try:
2842
3002
  import subprocess as _subp
2843
3003
  _username = (
@@ -2969,6 +3129,11 @@ class WorkspaceManager:
2969
3129
  os.unlink(key_file)
2970
3130
  except OSError:
2971
3131
  pass
3132
+ # Clean up the known_hosts temp file created alongside the key
3133
+ try:
3134
+ os.unlink(key_file + ".known_hosts")
3135
+ except OSError:
3136
+ pass
2972
3137
  if proc.returncode != 0:
2973
3138
  raise RuntimeError(f"git {' '.join(args)} failed:\n{stderr.decode()}")
2974
3139
  return stdout.decode()
@@ -4362,6 +4527,7 @@ class ProcessManager:
4362
4527
  on_chunk: Any = None,
4363
4528
  *,
4364
4529
  _retry_without_effort: bool = False,
4530
+ _isolated_copilot_home: str | None = None,
4365
4531
  ) -> TaskResult:
4366
4532
  """Run GitHub Copilot CLI in non-interactive JSON-streaming mode.
4367
4533
 
@@ -4383,10 +4549,41 @@ class ProcessManager:
4383
4549
  makes the CLI return exitCode=1 in the JSONL result event immediately
4384
4550
  without calling any tools. Making it opt-in avoids this incompatibility
4385
4551
  while still allowing operators to tune effort on supported versions.
4552
+
4553
+ Session isolation:
4554
+ Each invocation creates a fresh per-task COPILOT_HOME (temp dir) so
4555
+ the coding agent always starts with an empty session-state database.
4556
+ Without this, a retry for the same workspace finds the completed todos
4557
+ from the prior attempt and reports "no tracked work items", producing
4558
+ no output files. Mirrors the _run_opencode XDG_DATA_HOME pattern.
4386
4559
  """
4560
+ # Create per-task isolated COPILOT_HOME to prevent stale session state
4561
+ # from a previous run causing "no tracked work items" on retry.
4562
+ # The Copilot coding agent keys sessions by workspace path; if
4563
+ # session-state persists from the prior attempt all todos are already
4564
+ # "done" and the agent exits immediately without producing any output.
4565
+ _is_outer_call = _isolated_copilot_home is None
4566
+ if _is_outer_call:
4567
+ master_home = self._prepare_copilot_home()
4568
+ _isolated_copilot_home = tempfile.mkdtemp(prefix=f"copilot-{task_id[:8]}-")
4569
+ try:
4570
+ for child in Path(master_home).iterdir():
4571
+ if child.name in {"session-state", "logs"}:
4572
+ continue
4573
+ dest = Path(_isolated_copilot_home) / child.name
4574
+ try:
4575
+ if child.is_dir():
4576
+ shutil.copytree(str(child), str(dest), dirs_exist_ok=True)
4577
+ elif child.is_file():
4578
+ shutil.copy2(str(child), str(dest))
4579
+ except Exception as _cp_exc:
4580
+ logger.debug("Copilot home copy %s -> %s: %s", child, dest, _cp_exc)
4581
+ except Exception as _iter_exc:
4582
+ logger.debug("Copilot home isolation warning: %s", _iter_exc)
4583
+
4387
4584
  env = os.environ.copy()
4388
4585
  env["TERM"] = "dumb" # suppress TTY-detection that suspends the process
4389
- env["COPILOT_HOME"] = self._prepare_copilot_home()
4586
+ env["COPILOT_HOME"] = _isolated_copilot_home
4390
4587
 
4391
4588
  model_override = os.environ.get("FACTORY_COPILOT_MODEL")
4392
4589
  # FACTORY_COPILOT_REASONING is opt-in: only pass --effort when the env
@@ -4510,6 +4707,7 @@ class ProcessManager:
4510
4707
  return await self._run_copilot(
4511
4708
  agent, prompt, cwd, timeout, task_id, on_chunk,
4512
4709
  _retry_without_effort=True,
4710
+ _isolated_copilot_home=_isolated_copilot_home,
4513
4711
  )
4514
4712
  # Exhausted retries — emit an actionable diagnostic hint
4515
4713
  if effective_rc == 1 and _no_tools and not copilot_specific_error:
@@ -4555,6 +4753,8 @@ class ProcessManager:
4555
4753
  )
4556
4754
  finally:
4557
4755
  self.active_processes.pop(task_id, None)
4756
+ if _is_outer_call:
4757
+ shutil.rmtree(_isolated_copilot_home, ignore_errors=True)
4558
4758
 
4559
4759
  async def _run_generic(
4560
4760
  self, agent: DiscoveredAgent, prompt: str, cwd: Path, timeout: int, task_id: str,
@@ -9441,12 +9641,20 @@ class RuntimeDaemon:
9441
9641
  if line.startswith("+ ")
9442
9642
  ]
9443
9643
  if not new_shas:
9444
- # Nothing genuinely new remote is already
9445
- # up-to-date, treat as success.
9644
+ # Remote already has equivalent content under
9645
+ # different SHAs. Align local HEAD to the
9646
+ # canonical remote tip before reporting
9647
+ # success so later git metadata does not point
9648
+ # at a local-only commit.
9649
+ await git(
9650
+ "reset", "--hard", f"origin/{branch}",
9651
+ cwd=workspace_path,
9652
+ )
9446
9653
  logger.info(
9447
9654
  "Recovery: no truly new commits on %s — "
9448
- "remote already has equivalent content",
9449
- branch,
9655
+ "remote already has equivalent content; "
9656
+ "reset local branch to origin/%s",
9657
+ branch, branch,
9450
9658
  )
9451
9659
  return None
9452
9660
  # Reset local branch to match remote exactly,
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: forgexa-cli
3
- Version: 1.14.5
3
+ Version: 1.14.6
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: MIT
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "forgexa-cli"
3
- version = "1.14.5"
3
+ version = "1.14.6"
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 = { text = "MIT" }
File without changes
File without changes