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.
- {forgexa_cli-1.14.5 → forgexa_cli-1.14.6}/PKG-INFO +1 -1
- {forgexa_cli-1.14.5 → forgexa_cli-1.14.6}/forgexa_cli/__init__.py +1 -1
- {forgexa_cli-1.14.5 → forgexa_cli-1.14.6}/forgexa_cli/daemon.py +248 -40
- {forgexa_cli-1.14.5 → forgexa_cli-1.14.6}/forgexa_cli.egg-info/PKG-INFO +1 -1
- {forgexa_cli-1.14.5 → forgexa_cli-1.14.6}/pyproject.toml +1 -1
- {forgexa_cli-1.14.5 → forgexa_cli-1.14.6}/README.md +0 -0
- {forgexa_cli-1.14.5 → forgexa_cli-1.14.6}/forgexa_cli/_build_config.py +0 -0
- {forgexa_cli-1.14.5 → forgexa_cli-1.14.6}/forgexa_cli/main.py +0 -0
- {forgexa_cli-1.14.5 → forgexa_cli-1.14.6}/forgexa_cli/py.typed +0 -0
- {forgexa_cli-1.14.5 → forgexa_cli-1.14.6}/forgexa_cli.egg-info/SOURCES.txt +0 -0
- {forgexa_cli-1.14.5 → forgexa_cli-1.14.6}/forgexa_cli.egg-info/dependency_links.txt +0 -0
- {forgexa_cli-1.14.5 → forgexa_cli-1.14.6}/forgexa_cli.egg-info/entry_points.txt +0 -0
- {forgexa_cli-1.14.5 → forgexa_cli-1.14.6}/forgexa_cli.egg-info/requires.txt +0 -0
- {forgexa_cli-1.14.5 → forgexa_cli-1.14.6}/forgexa_cli.egg-info/top_level.txt +0 -0
- {forgexa_cli-1.14.5 → forgexa_cli-1.14.6}/setup.cfg +0 -0
- {forgexa_cli-1.14.5 → 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,
|
|
@@ -9441,12 +9641,20 @@ class RuntimeDaemon:
|
|
|
9441
9641
|
if line.startswith("+ ")
|
|
9442
9642
|
]
|
|
9443
9643
|
if not new_shas:
|
|
9444
|
-
#
|
|
9445
|
-
#
|
|
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,
|
|
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
|