forgexa-cli 1.14.4__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.4
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.4"
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.4"
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,
@@ -5106,28 +5306,47 @@ class ProcessManager:
5106
5306
 
5107
5307
  return info
5108
5308
 
5109
- async def _collect_git_info_vs_parent(self, cwd: Path) -> dict:
5110
- """Collect git diff stats comparing HEAD vs merge-base with default branch.
5309
+ async def _collect_git_info_vs_parent(
5310
+ self,
5311
+ cwd: Path,
5312
+ *,
5313
+ default_branch: str = "",
5314
+ before_sha: str = "",
5315
+ ) -> dict:
5316
+ """Collect git diff stats comparing HEAD vs merge-base with the base branch.
5111
5317
 
5112
- Uses merge-base to capture ALL changes on the feature branch, not just
5318
+ Uses merge-base to capture ALL changes on the current branch, not just
5113
5319
  the last commit (agents like claude may create multiple commits).
5114
- Falls back to HEAD~1 if merge-base detection fails.
5320
+ Prefers the project's configured default branch, then falls back to the
5321
+ conventional `main` / `master` refs. Falls back to HEAD~1 if merge-base
5322
+ detection fails.
5115
5323
  """
5116
5324
  info: dict[str, Any] = {}
5117
5325
  try:
5118
- # Try to find merge-base with origin's default branch
5119
- base_ref = "HEAD~1"
5120
- for candidate in ("origin/master", "origin/main"):
5121
- proc_mb = await asyncio.create_subprocess_exec(
5122
- "git", "merge-base", candidate, "HEAD",
5123
- stdout=asyncio.subprocess.PIPE,
5124
- stderr=asyncio.subprocess.PIPE,
5125
- cwd=str(cwd),
5126
- )
5127
- mb_stdout, _ = await asyncio.wait_for(proc_mb.communicate(), timeout=10)
5128
- if proc_mb.returncode == 0 and mb_stdout.strip():
5129
- base_ref = mb_stdout.decode().strip()
5130
- break
5326
+ # Prefer the exact pre-node HEAD when available so the diff is
5327
+ # scoped to this node's work, not the whole requirement branch.
5328
+ base_ref = str(before_sha or "").strip() or "HEAD~1"
5329
+ if not str(before_sha or "").strip():
5330
+ candidate_refs: list[str] = []
5331
+ for candidate in (default_branch, "main", "master"):
5332
+ candidate = str(candidate or "").strip()
5333
+ if not candidate:
5334
+ continue
5335
+ ref = candidate if candidate.startswith("origin/") else f"origin/{candidate}"
5336
+ if ref not in candidate_refs:
5337
+ candidate_refs.append(ref)
5338
+
5339
+ for candidate in candidate_refs:
5340
+ proc_mb = await asyncio.create_subprocess_exec(
5341
+ "git", "merge-base", candidate, "HEAD",
5342
+ stdout=asyncio.subprocess.PIPE,
5343
+ stderr=asyncio.subprocess.PIPE,
5344
+ cwd=str(cwd),
5345
+ )
5346
+ mb_stdout, _ = await asyncio.wait_for(proc_mb.communicate(), timeout=10)
5347
+ if proc_mb.returncode == 0 and mb_stdout.strip():
5348
+ base_ref = mb_stdout.decode().strip()
5349
+ break
5131
5350
 
5132
5351
  # Changed files since branch point
5133
5352
  proc = await asyncio.create_subprocess_exec(
@@ -5173,6 +5392,8 @@ class ProcessManager:
5173
5392
  )
5174
5393
  stdout4, _ = await asyncio.wait_for(proc4.communicate(), timeout=10)
5175
5394
  info["commit_sha"] = stdout4.decode().strip()
5395
+ info["before_sha"] = base_ref
5396
+ info["after_sha"] = info["commit_sha"]
5176
5397
 
5177
5398
  except Exception as e:
5178
5399
  logger.debug("Git info (vs parent) collection failed: %s", e)
@@ -6364,6 +6585,8 @@ class RuntimeDaemon:
6364
6585
  async def _execute_task(self, task: TaskInfo, conn: ServerConnection):
6365
6586
  """Execute a single task, reporting to the originating server connection."""
6366
6587
  reporter = conn.reporter
6588
+ default_branch = str((task.project or {}).get("default_branch") or "").strip()
6589
+ node_before_sha = ""
6367
6590
  # Use workspace-level granularity (requirement_workflow_id or graph_id) so that
6368
6591
  # tasks for DIFFERENT requirements can run in parallel on the same daemon, while
6369
6592
  # tasks for the SAME requirement (e.g. analysis + retry) are still serialized to
@@ -6543,6 +6766,19 @@ class RuntimeDaemon:
6543
6766
  output_dir_norm, task.task_id,
6544
6767
  )
6545
6768
 
6769
+ try:
6770
+ node_before_sha = (
6771
+ await self.workspace_manager._git(
6772
+ "rev-parse", "HEAD", cwd=workspace_path, timeout=30,
6773
+ )
6774
+ ).strip()
6775
+ except Exception as _before_sha_err:
6776
+ logger.debug(
6777
+ "Could not resolve pre-node HEAD for task %s: %s",
6778
+ task.task_id,
6779
+ _before_sha_err,
6780
+ )
6781
+
6546
6782
  # 3. Run agent with real-time output streaming + periodic progress heartbeat
6547
6783
  await reporter.report_progress(task.task_id, 10, "running_agent")
6548
6784
 
@@ -6615,7 +6851,11 @@ class RuntimeDaemon:
6615
6851
  _skip_fallback = False
6616
6852
  if self.process_manager.is_rate_limited(result):
6617
6853
  _pre_fallback_git = await self.process_manager._collect_git_info(workspace_path)
6618
- _pre_fallback_committed = await self.process_manager._collect_git_info_vs_parent(workspace_path)
6854
+ _pre_fallback_committed = await self.process_manager._collect_git_info_vs_parent(
6855
+ workspace_path,
6856
+ default_branch=default_branch,
6857
+ before_sha=node_before_sha,
6858
+ )
6619
6859
  has_workspace_changes = (
6620
6860
  bool(_pre_fallback_git.get("files_changed"))
6621
6861
  or bool(_pre_fallback_committed.get("files_changed"))
@@ -6726,7 +6966,11 @@ class RuntimeDaemon:
6726
6966
  # (e.g., after rate-limit fallback, or API errors not caught by output parsing).
6727
6967
  if result.status == "success" and task.node_type in ("coding", "fix", "testing"):
6728
6968
  has_uncommitted = bool(pre_commit_git.get("files_changed"))
6729
- committed_git = await self.process_manager._collect_git_info_vs_parent(workspace_path)
6969
+ committed_git = await self.process_manager._collect_git_info_vs_parent(
6970
+ workspace_path,
6971
+ default_branch=default_branch,
6972
+ before_sha=node_before_sha,
6973
+ )
6730
6974
  has_committed = bool(committed_git.get("files_changed"))
6731
6975
  has_tokens = (
6732
6976
  int(result.metrics.get("token_input", 0) or 0)
@@ -6764,7 +7008,11 @@ class RuntimeDaemon:
6764
7008
  )
6765
7009
  if can_attempt_recovery:
6766
7010
  original_exit_code = result.exit_code
6767
- committed_git = await self.process_manager._collect_git_info_vs_parent(workspace_path)
7011
+ committed_git = await self.process_manager._collect_git_info_vs_parent(
7012
+ workspace_path,
7013
+ default_branch=default_branch,
7014
+ before_sha=node_before_sha,
7015
+ )
6768
7016
  has_committed_changes = bool(committed_git.get("files_changed"))
6769
7017
  has_uncommitted_changes = bool(pre_commit_git.get("files_changed"))
6770
7018
  has_no_uncommitted = not has_uncommitted_changes
@@ -6888,7 +7136,11 @@ class RuntimeDaemon:
6888
7136
  # 4.55 Analysis/design/fix nodes must update their deliverables in THIS run.
6889
7137
  # Existing files from a prior iteration are not sufficient evidence.
6890
7138
  if result.status == "success" and task.node_type in ("analysis", "design", "fix"):
6891
- committed_git = await self.process_manager._collect_git_info_vs_parent(workspace_path)
7139
+ committed_git = await self.process_manager._collect_git_info_vs_parent(
7140
+ workspace_path,
7141
+ default_branch=default_branch,
7142
+ before_sha=node_before_sha,
7143
+ )
6892
7144
  git_check_passed = self.process_manager._has_required_deliverable_updates(
6893
7145
  task,
6894
7146
  pre_commit_git.get("files_changed"),
@@ -7002,7 +7254,11 @@ class RuntimeDaemon:
7002
7254
  )
7003
7255
  result.error = f"Git push failed: {push_err}"
7004
7256
  # Re-collect git info after commit (compare with parent)
7005
- post_commit_git = await self.process_manager._collect_git_info_vs_parent(workspace_path)
7257
+ post_commit_git = await self.process_manager._collect_git_info_vs_parent(
7258
+ workspace_path,
7259
+ default_branch=default_branch,
7260
+ before_sha=node_before_sha,
7261
+ )
7006
7262
  # Merge: use the pre-commit file list if post-commit is empty
7007
7263
  result.git = post_commit_git
7008
7264
  if not result.git.get("files_changed") and pre_commit_git.get("files_changed"):
@@ -9385,12 +9641,20 @@ class RuntimeDaemon:
9385
9641
  if line.startswith("+ ")
9386
9642
  ]
9387
9643
  if not new_shas:
9388
- # Nothing genuinely new remote is already
9389
- # 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
+ )
9390
9653
  logger.info(
9391
9654
  "Recovery: no truly new commits on %s — "
9392
- "remote already has equivalent content",
9393
- branch,
9655
+ "remote already has equivalent content; "
9656
+ "reset local branch to origin/%s",
9657
+ branch, branch,
9394
9658
  )
9395
9659
  return None
9396
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.4
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.4"
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