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.
- {forgexa_cli-1.14.4 → forgexa_cli-1.14.6}/PKG-INFO +1 -1
- {forgexa_cli-1.14.4 → forgexa_cli-1.14.6}/forgexa_cli/__init__.py +1 -1
- {forgexa_cli-1.14.4 → forgexa_cli-1.14.6}/forgexa_cli/daemon.py +326 -62
- {forgexa_cli-1.14.4 → forgexa_cli-1.14.6}/forgexa_cli.egg-info/PKG-INFO +1 -1
- {forgexa_cli-1.14.4 → forgexa_cli-1.14.6}/pyproject.toml +1 -1
- {forgexa_cli-1.14.4 → forgexa_cli-1.14.6}/README.md +0 -0
- {forgexa_cli-1.14.4 → forgexa_cli-1.14.6}/forgexa_cli/_build_config.py +0 -0
- {forgexa_cli-1.14.4 → forgexa_cli-1.14.6}/forgexa_cli/main.py +0 -0
- {forgexa_cli-1.14.4 → forgexa_cli-1.14.6}/forgexa_cli/py.typed +0 -0
- {forgexa_cli-1.14.4 → forgexa_cli-1.14.6}/forgexa_cli.egg-info/SOURCES.txt +0 -0
- {forgexa_cli-1.14.4 → forgexa_cli-1.14.6}/forgexa_cli.egg-info/dependency_links.txt +0 -0
- {forgexa_cli-1.14.4 → forgexa_cli-1.14.6}/forgexa_cli.egg-info/entry_points.txt +0 -0
- {forgexa_cli-1.14.4 → forgexa_cli-1.14.6}/forgexa_cli.egg-info/requires.txt +0 -0
- {forgexa_cli-1.14.4 → forgexa_cli-1.14.6}/forgexa_cli.egg-info/top_level.txt +0 -0
- {forgexa_cli-1.14.4 → forgexa_cli-1.14.6}/setup.cfg +0 -0
- {forgexa_cli-1.14.4 → forgexa_cli-1.14.6}/tests/test_auth_and_runtime_commands.py +0 -0
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
"""forgexa-cli — Forgexa command-line client."""
|
|
2
|
-
__version__ = "1.14.
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1973
|
-
# Remove the broken worktree directory
|
|
1974
|
-
|
|
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
|
|
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.
|
|
1988
|
-
3.
|
|
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
|
-
|
|
2015
|
-
|
|
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
|
|
2368
|
-
"commit(s) ahead of %s —
|
|
2369
|
-
"
|
|
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
|
-
#
|
|
2373
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
2617
|
-
# is available for
|
|
2618
|
-
|
|
2619
|
-
|
|
2620
|
-
|
|
2621
|
-
|
|
2622
|
-
|
|
2623
|
-
|
|
2624
|
-
|
|
2625
|
-
|
|
2626
|
-
|
|
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
|
|
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
|
-
|
|
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"] =
|
|
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(
|
|
5110
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
#
|
|
5119
|
-
|
|
5120
|
-
|
|
5121
|
-
|
|
5122
|
-
|
|
5123
|
-
|
|
5124
|
-
|
|
5125
|
-
|
|
5126
|
-
|
|
5127
|
-
|
|
5128
|
-
|
|
5129
|
-
|
|
5130
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
-
#
|
|
9389
|
-
#
|
|
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,
|
|
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
|