forgexa-cli 1.42.0__tar.gz → 1.43.2__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {forgexa_cli-1.42.0 → forgexa_cli-1.43.2}/PKG-INFO +1 -1
- {forgexa_cli-1.42.0 → forgexa_cli-1.43.2}/forgexa_cli/__init__.py +1 -1
- {forgexa_cli-1.42.0 → forgexa_cli-1.43.2}/forgexa_cli/agent_core.py +367 -89
- {forgexa_cli-1.42.0 → forgexa_cli-1.43.2}/forgexa_cli/daemon.py +162 -51
- {forgexa_cli-1.42.0 → forgexa_cli-1.43.2}/forgexa_cli.egg-info/PKG-INFO +1 -1
- {forgexa_cli-1.42.0 → forgexa_cli-1.43.2}/pyproject.toml +1 -1
- {forgexa_cli-1.42.0 → forgexa_cli-1.43.2}/README.md +0 -0
- {forgexa_cli-1.42.0 → forgexa_cli-1.43.2}/forgexa_cli/_build_config.py +0 -0
- {forgexa_cli-1.42.0 → forgexa_cli-1.43.2}/forgexa_cli/_local_bind.py +0 -0
- {forgexa_cli-1.42.0 → forgexa_cli-1.43.2}/forgexa_cli/autoupgrade.py +0 -0
- {forgexa_cli-1.42.0 → forgexa_cli-1.43.2}/forgexa_cli/main.py +0 -0
- {forgexa_cli-1.42.0 → forgexa_cli-1.43.2}/forgexa_cli/py.typed +0 -0
- {forgexa_cli-1.42.0 → forgexa_cli-1.43.2}/forgexa_cli.egg-info/SOURCES.txt +0 -0
- {forgexa_cli-1.42.0 → forgexa_cli-1.43.2}/forgexa_cli.egg-info/dependency_links.txt +0 -0
- {forgexa_cli-1.42.0 → forgexa_cli-1.43.2}/forgexa_cli.egg-info/entry_points.txt +0 -0
- {forgexa_cli-1.42.0 → forgexa_cli-1.43.2}/forgexa_cli.egg-info/requires.txt +0 -0
- {forgexa_cli-1.42.0 → forgexa_cli-1.43.2}/forgexa_cli.egg-info/top_level.txt +0 -0
- {forgexa_cli-1.42.0 → forgexa_cli-1.43.2}/setup.cfg +0 -0
- {forgexa_cli-1.42.0 → forgexa_cli-1.43.2}/tests/test_auth_and_runtime_commands.py +0 -0
- {forgexa_cli-1.42.0 → forgexa_cli-1.43.2}/tests/test_autoupgrade.py +0 -0
- {forgexa_cli-1.42.0 → forgexa_cli-1.43.2}/tests/test_check_command.py +0 -0
- {forgexa_cli-1.42.0 → forgexa_cli-1.43.2}/tests/test_expiry_warnings_and_revoke.py +0 -0
- {forgexa_cli-1.42.0 → forgexa_cli-1.43.2}/tests/test_local_bind_commands.py +0 -0
- {forgexa_cli-1.42.0 → forgexa_cli-1.43.2}/tests/test_runtime_credentials.py +0 -0
- {forgexa_cli-1.42.0 → forgexa_cli-1.43.2}/tests/test_session_credentials.py +0 -0
- {forgexa_cli-1.42.0 → forgexa_cli-1.43.2}/tests/test_silent_install.py +0 -0
- {forgexa_cli-1.42.0 → forgexa_cli-1.43.2}/tests/test_upgrade_observability.py +0 -0
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
"""forgexa-cli — Forgexa command-line client."""
|
|
2
|
-
__version__ = "1.
|
|
2
|
+
__version__ = "1.43.2"
|
|
@@ -41,11 +41,15 @@ if sys.version_info < (3, 9): # noqa: UP036 - gate guards 3.9+ runtime on end-u
|
|
|
41
41
|
|
|
42
42
|
import asyncio
|
|
43
43
|
import base64
|
|
44
|
+
import errno
|
|
45
|
+
import hashlib
|
|
44
46
|
import json
|
|
45
47
|
import logging
|
|
46
48
|
import os
|
|
47
49
|
import shutil
|
|
48
50
|
import subprocess
|
|
51
|
+
import tempfile
|
|
52
|
+
import time
|
|
49
53
|
from dataclasses import dataclass
|
|
50
54
|
from pathlib import Path
|
|
51
55
|
from typing import Any
|
|
@@ -223,14 +227,14 @@ def _agent_process_group_kwargs() -> dict[str, int | bool]:
|
|
|
223
227
|
def _parse_semver(v: str) -> tuple[int, int, int] | None:
|
|
224
228
|
"""Parse a semver string into a comparable (major, minor, patch) tuple.
|
|
225
229
|
|
|
226
|
-
|
|
227
|
-
'1.
|
|
230
|
+
Finds an embedded version and ignores pre-release/build suffixes so that
|
|
231
|
+
'GitHub Copilot CLI 1.0.81.' and 'v1.4.0-beta.1' are both supported.
|
|
228
232
|
Returns None when the string cannot be parsed (e.g. 'unknown', 'latest'),
|
|
229
233
|
so callers can skip the version check rather than erroneously rejecting
|
|
230
234
|
a valid agent whose version string is non-standard.
|
|
231
235
|
"""
|
|
232
236
|
import re as _re
|
|
233
|
-
m = _re.
|
|
237
|
+
m = _re.search(r"(?<!\d)v?(\d+)\.(\d+)\.(\d+)", v.strip())
|
|
234
238
|
if m:
|
|
235
239
|
return (int(m.group(1)), int(m.group(2)), int(m.group(3)))
|
|
236
240
|
return None
|
|
@@ -241,10 +245,9 @@ def _check_agent_min_version(agent_id: str, version: str, spec: dict) -> tuple[b
|
|
|
241
245
|
|
|
242
246
|
Returns (min_version_ok, version_warning). version_warning is empty
|
|
243
247
|
whenever there's nothing to warn about: no minimum is defined for this
|
|
244
|
-
agent
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
the installed version already meets the minimum.
|
|
248
|
+
agent, an optional minimum has an unparseable version, or the installed
|
|
249
|
+
version meets the minimum. ``strict_min_version`` makes an unparseable
|
|
250
|
+
version a hard failure for integrations with known incompatible releases.
|
|
248
251
|
|
|
249
252
|
This is the single source of truth for per-agent minimum-version gating,
|
|
250
253
|
consumed by both AgentDiscovery.discover() (so `forgexa check` and the
|
|
@@ -257,6 +260,14 @@ def _check_agent_min_version(agent_id: str, version: str, spec: dict) -> tuple[b
|
|
|
257
260
|
return True, ""
|
|
258
261
|
parsed = _parse_semver(version)
|
|
259
262
|
if parsed is None:
|
|
263
|
+
if spec.get("strict_min_version"):
|
|
264
|
+
min_str = ".".join(str(x) for x in min_version)
|
|
265
|
+
upgrade_hint = spec.get("upgrade_hint", f"upgrade the {agent_id} CLI")
|
|
266
|
+
return (
|
|
267
|
+
False,
|
|
268
|
+
f"Could not verify that {agent_id} meets the minimum supported "
|
|
269
|
+
f"version v{min_str}+. Fix: {upgrade_hint}.",
|
|
270
|
+
)
|
|
260
271
|
return True, ""
|
|
261
272
|
if parsed >= tuple(min_version):
|
|
262
273
|
return True, ""
|
|
@@ -337,6 +348,13 @@ class AgentDiscovery:
|
|
|
337
348
|
"invoke_modes": ["cli"],
|
|
338
349
|
"env_path_override": "FACTORY_COPILOT_PATH",
|
|
339
350
|
"compatibility_level": "L3",
|
|
351
|
+
"min_version": (1, 0, 70),
|
|
352
|
+
"strict_min_version": True,
|
|
353
|
+
"min_version_reason": (
|
|
354
|
+
"Older releases have known authentication persistence bugs in "
|
|
355
|
+
"one-shot prompt sessions and across runtime restarts."
|
|
356
|
+
),
|
|
357
|
+
"upgrade_hint": "copilot update",
|
|
340
358
|
},
|
|
341
359
|
}
|
|
342
360
|
|
|
@@ -634,23 +652,50 @@ class AgentDiscovery:
|
|
|
634
652
|
# ── Advisory file locks ──
|
|
635
653
|
|
|
636
654
|
|
|
637
|
-
def acquire_file_lock(
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
655
|
+
def acquire_file_lock(
|
|
656
|
+
path: str | Path,
|
|
657
|
+
*,
|
|
658
|
+
blocking: bool = False,
|
|
659
|
+
timeout_seconds: float = 10.0,
|
|
660
|
+
):
|
|
661
|
+
"""Open ``path`` and take an exclusive OS-level lock on it.
|
|
662
|
+
|
|
663
|
+
By default the lock is non-blocking. Set ``blocking=True`` for short,
|
|
664
|
+
bounded serialized state updates. Returns the open file handle to hold for
|
|
665
|
+
the lock's duration, or ``None`` when the lock cannot be acquired in time.
|
|
643
666
|
Callers must release via :func:`release_file_lock` in a ``finally`` block.
|
|
644
667
|
"""
|
|
645
668
|
lock_path = Path(path)
|
|
646
669
|
lock_path.parent.mkdir(parents=True, exist_ok=True)
|
|
647
|
-
lock_file = open(lock_path, "
|
|
670
|
+
lock_file = open(lock_path, "a+b")
|
|
648
671
|
try:
|
|
672
|
+
lock_file.seek(0, os.SEEK_END)
|
|
673
|
+
if lock_file.tell() == 0:
|
|
674
|
+
lock_file.write(b"\0")
|
|
675
|
+
lock_file.flush()
|
|
676
|
+
lock_file.seek(0)
|
|
677
|
+
deadline = time.monotonic() + timeout_seconds if blocking else None
|
|
649
678
|
if sys.platform == "win32":
|
|
650
679
|
import msvcrt
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
680
|
+
while True:
|
|
681
|
+
try:
|
|
682
|
+
if sys.platform == "win32":
|
|
683
|
+
msvcrt.locking(lock_file.fileno(), msvcrt.LK_NBLCK, 1)
|
|
684
|
+
else:
|
|
685
|
+
fcntl.flock(
|
|
686
|
+
lock_file.fileno(),
|
|
687
|
+
fcntl.LOCK_EX | fcntl.LOCK_NB,
|
|
688
|
+
)
|
|
689
|
+
break
|
|
690
|
+
except OSError as exc:
|
|
691
|
+
if (
|
|
692
|
+
not blocking
|
|
693
|
+
or exc.errno not in {errno.EACCES, errno.EAGAIN}
|
|
694
|
+
or deadline is None
|
|
695
|
+
or time.monotonic() >= deadline
|
|
696
|
+
):
|
|
697
|
+
raise
|
|
698
|
+
time.sleep(0.05)
|
|
654
699
|
except OSError:
|
|
655
700
|
lock_file.close()
|
|
656
701
|
return None
|
|
@@ -668,10 +713,13 @@ def release_file_lock(lock_file) -> None:
|
|
|
668
713
|
msvcrt.locking(lock_file.fileno(), msvcrt.LK_UNLCK, 1)
|
|
669
714
|
else:
|
|
670
715
|
fcntl.flock(lock_file.fileno(), fcntl.LOCK_UN)
|
|
671
|
-
except
|
|
716
|
+
except Exception:
|
|
672
717
|
pass
|
|
673
718
|
finally:
|
|
674
|
-
|
|
719
|
+
try:
|
|
720
|
+
lock_file.close()
|
|
721
|
+
except Exception:
|
|
722
|
+
pass
|
|
675
723
|
|
|
676
724
|
|
|
677
725
|
# ── Process Manager ──
|
|
@@ -1721,6 +1769,194 @@ def copilot_base_env(env: dict[str, str], *, copilot_home: str | Path) -> dict[s
|
|
|
1721
1769
|
return env
|
|
1722
1770
|
|
|
1723
1771
|
|
|
1772
|
+
COPILOT_TOKEN_ENV_VARS = ("COPILOT_GITHUB_TOKEN", "GH_TOKEN", "GITHUB_TOKEN")
|
|
1773
|
+
|
|
1774
|
+
|
|
1775
|
+
def copilot_interactive_home() -> Path:
|
|
1776
|
+
"""Return the login home selected for interactive Copilot CLI use."""
|
|
1777
|
+
configured_home = os.environ.get("COPILOT_HOME", "").strip()
|
|
1778
|
+
if configured_home:
|
|
1779
|
+
return Path(configured_home).expanduser()
|
|
1780
|
+
return Path.home() / ".copilot"
|
|
1781
|
+
|
|
1782
|
+
|
|
1783
|
+
def copilot_recovery_home(destination_home: str | Path) -> Path | None:
|
|
1784
|
+
"""Return a populated login home distinct from the failed destination."""
|
|
1785
|
+
destination = Path(destination_home).resolve()
|
|
1786
|
+
candidates = (copilot_interactive_home(), Path.home() / ".copilot")
|
|
1787
|
+
seen: set[Path] = set()
|
|
1788
|
+
for candidate in candidates:
|
|
1789
|
+
resolved = candidate.resolve()
|
|
1790
|
+
if resolved in seen or resolved == destination:
|
|
1791
|
+
continue
|
|
1792
|
+
seen.add(resolved)
|
|
1793
|
+
if (resolved / "config.json").is_file():
|
|
1794
|
+
return resolved
|
|
1795
|
+
return None
|
|
1796
|
+
|
|
1797
|
+
|
|
1798
|
+
def is_copilot_model_discovery_auth_error(*messages: str | None) -> bool:
|
|
1799
|
+
"""Return whether Copilot failed before work while resolving model access."""
|
|
1800
|
+
detail = " ".join(str(message or "").lower() for message in messages)
|
|
1801
|
+
return (
|
|
1802
|
+
"421" in detail
|
|
1803
|
+
and "misdirected request" in detail
|
|
1804
|
+
and (
|
|
1805
|
+
"retrieve the list of available models" in detail
|
|
1806
|
+
or "failed to load models" in detail
|
|
1807
|
+
)
|
|
1808
|
+
)
|
|
1809
|
+
|
|
1810
|
+
|
|
1811
|
+
def copilot_auth_state_signature(copilot_home: str | Path) -> str | None:
|
|
1812
|
+
"""Return a content signature for Copilot's persisted authentication state."""
|
|
1813
|
+
config_file = Path(copilot_home) / "config.json"
|
|
1814
|
+
if not config_file.exists():
|
|
1815
|
+
return ""
|
|
1816
|
+
try:
|
|
1817
|
+
return hashlib.sha256(config_file.read_bytes()).hexdigest()
|
|
1818
|
+
except OSError:
|
|
1819
|
+
logger.warning("Could not read Copilot authentication state", exc_info=True)
|
|
1820
|
+
return None
|
|
1821
|
+
|
|
1822
|
+
|
|
1823
|
+
def _copilot_auth_lock_path(copilot_home: str | Path) -> Path:
|
|
1824
|
+
home = Path(copilot_home)
|
|
1825
|
+
return home.with_name(f".{home.name}-auth-state.lock")
|
|
1826
|
+
|
|
1827
|
+
|
|
1828
|
+
def replace_copilot_auth_state(
|
|
1829
|
+
source_home: str | Path,
|
|
1830
|
+
destination_home: str | Path,
|
|
1831
|
+
expected_destination_signature: str | None = None,
|
|
1832
|
+
) -> bool:
|
|
1833
|
+
"""Replace an isolated Copilot auth file from a known login home."""
|
|
1834
|
+
source = Path(source_home) / "config.json"
|
|
1835
|
+
destination_dir = Path(destination_home)
|
|
1836
|
+
destination = destination_dir / source.name
|
|
1837
|
+
if not source.is_file():
|
|
1838
|
+
return False
|
|
1839
|
+
lock_file = None
|
|
1840
|
+
temp_name: str | None = None
|
|
1841
|
+
try:
|
|
1842
|
+
source_signature = copilot_auth_state_signature(source_home)
|
|
1843
|
+
if source_signature is None:
|
|
1844
|
+
return False
|
|
1845
|
+
destination_dir.mkdir(parents=True, exist_ok=True)
|
|
1846
|
+
lock_file = acquire_file_lock(
|
|
1847
|
+
_copilot_auth_lock_path(destination_dir),
|
|
1848
|
+
blocking=True,
|
|
1849
|
+
)
|
|
1850
|
+
if lock_file is None:
|
|
1851
|
+
logger.warning("Could not lock Copilot authentication state for restore")
|
|
1852
|
+
return False
|
|
1853
|
+
destination_signature = copilot_auth_state_signature(destination_home)
|
|
1854
|
+
if (
|
|
1855
|
+
expected_destination_signature is not None
|
|
1856
|
+
and destination_signature != expected_destination_signature
|
|
1857
|
+
):
|
|
1858
|
+
return False
|
|
1859
|
+
if destination_signature == source_signature:
|
|
1860
|
+
return False
|
|
1861
|
+
temp_fd, temp_name = tempfile.mkstemp(prefix=".config.json.", dir=destination_dir)
|
|
1862
|
+
os.close(temp_fd)
|
|
1863
|
+
shutil.copy2(source, temp_name)
|
|
1864
|
+
os.chmod(temp_name, 0o600)
|
|
1865
|
+
for attempt in range(3):
|
|
1866
|
+
try:
|
|
1867
|
+
os.replace(temp_name, destination)
|
|
1868
|
+
temp_name = None
|
|
1869
|
+
break
|
|
1870
|
+
except PermissionError:
|
|
1871
|
+
if sys.platform != "win32" or attempt == 2:
|
|
1872
|
+
raise
|
|
1873
|
+
time.sleep(0.05)
|
|
1874
|
+
return True
|
|
1875
|
+
except OSError:
|
|
1876
|
+
logger.warning("Could not restore interactive Copilot credentials", exc_info=True)
|
|
1877
|
+
return False
|
|
1878
|
+
finally:
|
|
1879
|
+
if temp_name is not None:
|
|
1880
|
+
try:
|
|
1881
|
+
Path(temp_name).unlink(missing_ok=True)
|
|
1882
|
+
except OSError:
|
|
1883
|
+
logger.debug("Could not remove temporary Copilot config", exc_info=True)
|
|
1884
|
+
release_file_lock(lock_file)
|
|
1885
|
+
|
|
1886
|
+
|
|
1887
|
+
def persist_copilot_auth_state(
|
|
1888
|
+
run_home: str | Path,
|
|
1889
|
+
persistent_home: str | Path,
|
|
1890
|
+
baseline_signature: str | None = None,
|
|
1891
|
+
) -> None:
|
|
1892
|
+
"""Persist credentials refreshed inside an isolated Copilot home.
|
|
1893
|
+
|
|
1894
|
+
Copilot stores credentials in ``config.json`` when no system credential
|
|
1895
|
+
store is available. Session data must remain isolated because reusing it
|
|
1896
|
+
can make retries inherit completed todos from an earlier task.
|
|
1897
|
+
"""
|
|
1898
|
+
lock_file = None
|
|
1899
|
+
temp_name: str | None = None
|
|
1900
|
+
try:
|
|
1901
|
+
source = Path(run_home) / "config.json"
|
|
1902
|
+
if not source.is_file():
|
|
1903
|
+
return
|
|
1904
|
+
source_signature = copilot_auth_state_signature(run_home)
|
|
1905
|
+
if baseline_signature is not None and source_signature == baseline_signature:
|
|
1906
|
+
return
|
|
1907
|
+
|
|
1908
|
+
destination_dir = Path(persistent_home)
|
|
1909
|
+
destination_dir.mkdir(parents=True, exist_ok=True)
|
|
1910
|
+
lock_file = acquire_file_lock(
|
|
1911
|
+
_copilot_auth_lock_path(destination_dir),
|
|
1912
|
+
blocking=True,
|
|
1913
|
+
)
|
|
1914
|
+
if lock_file is None:
|
|
1915
|
+
logger.warning("Could not lock Copilot authentication state for update")
|
|
1916
|
+
return
|
|
1917
|
+
|
|
1918
|
+
destination = destination_dir / source.name
|
|
1919
|
+
destination_signature = copilot_auth_state_signature(destination_dir)
|
|
1920
|
+
if destination_signature == source_signature:
|
|
1921
|
+
return
|
|
1922
|
+
if (
|
|
1923
|
+
baseline_signature is not None
|
|
1924
|
+
and destination_signature != baseline_signature
|
|
1925
|
+
):
|
|
1926
|
+
logger.info(
|
|
1927
|
+
"Skipping stale Copilot authentication update because another "
|
|
1928
|
+
"run refreshed the persistent state first"
|
|
1929
|
+
)
|
|
1930
|
+
return
|
|
1931
|
+
if baseline_signature is None and destination.is_file():
|
|
1932
|
+
if destination.stat().st_mtime_ns >= source.stat().st_mtime_ns:
|
|
1933
|
+
return
|
|
1934
|
+
|
|
1935
|
+
temp_fd, temp_name = tempfile.mkstemp(prefix=".config.json.", dir=destination_dir)
|
|
1936
|
+
os.close(temp_fd)
|
|
1937
|
+
shutil.copy2(source, temp_name)
|
|
1938
|
+
os.chmod(temp_name, 0o600)
|
|
1939
|
+
os.utime(temp_name, None)
|
|
1940
|
+
for attempt in range(3):
|
|
1941
|
+
try:
|
|
1942
|
+
os.replace(temp_name, destination)
|
|
1943
|
+
temp_name = None
|
|
1944
|
+
break
|
|
1945
|
+
except PermissionError:
|
|
1946
|
+
if sys.platform != "win32" or attempt == 2:
|
|
1947
|
+
raise
|
|
1948
|
+
time.sleep(0.05)
|
|
1949
|
+
except OSError:
|
|
1950
|
+
logger.warning("Could not persist refreshed Copilot credentials", exc_info=True)
|
|
1951
|
+
finally:
|
|
1952
|
+
if temp_name is not None:
|
|
1953
|
+
try:
|
|
1954
|
+
Path(temp_name).unlink(missing_ok=True)
|
|
1955
|
+
except OSError:
|
|
1956
|
+
logger.debug("Could not remove temporary Copilot config", exc_info=True)
|
|
1957
|
+
release_file_lock(lock_file)
|
|
1958
|
+
|
|
1959
|
+
|
|
1724
1960
|
def prepare_copilot_home() -> str:
|
|
1725
1961
|
"""Prepare a writable Copilot home under ~/.forgexa for sandboxed runs.
|
|
1726
1962
|
|
|
@@ -1733,80 +1969,122 @@ def prepare_copilot_home() -> str:
|
|
|
1733
1969
|
Mirror the user-facing ~/.copilot state into a writable daemon-owned
|
|
1734
1970
|
directory, then point COPILOT_HOME there for non-interactive runs.
|
|
1735
1971
|
|
|
1736
|
-
|
|
1737
|
-
|
|
1738
|
-
|
|
1739
|
-
|
|
1740
|
-
(the user's interactive home), so the normal mirror loop never
|
|
1741
|
-
overwrites them — they persist across restarts and poison isolated
|
|
1742
|
-
temp dirs. We clean up any file/dir in the master home that has no
|
|
1743
|
-
counterpart in ~/.copilot/ (excluding session-state and logs which
|
|
1744
|
-
are deliberately retained) to ensure the master home stays a clean
|
|
1745
|
-
mirror of the user-facing directory.
|
|
1972
|
+
A one-time migration removes stale files left when older daemons pointed
|
|
1973
|
+
COPILOT_HOME directly at the master directory. After migration, refreshed
|
|
1974
|
+
authentication state written by isolated runs is retained even when it
|
|
1975
|
+
has no counterpart in the interactive home.
|
|
1746
1976
|
"""
|
|
1747
|
-
source_root =
|
|
1977
|
+
source_root = copilot_interactive_home()
|
|
1748
1978
|
target_root = Path.home() / ".forgexa" / "copilot-home"
|
|
1749
|
-
|
|
1750
|
-
|
|
1751
|
-
|
|
1752
|
-
|
|
1753
|
-
|
|
1754
|
-
|
|
1755
|
-
|
|
1756
|
-
|
|
1757
|
-
|
|
1758
|
-
|
|
1759
|
-
|
|
1760
|
-
|
|
1761
|
-
|
|
1762
|
-
|
|
1763
|
-
|
|
1764
|
-
|
|
1765
|
-
|
|
1766
|
-
|
|
1767
|
-
|
|
1768
|
-
|
|
1769
|
-
|
|
1770
|
-
|
|
1771
|
-
|
|
1772
|
-
|
|
1773
|
-
|
|
1774
|
-
|
|
1775
|
-
|
|
1776
|
-
|
|
1777
|
-
|
|
1778
|
-
|
|
1779
|
-
|
|
1780
|
-
|
|
1979
|
+
lock_file = None
|
|
1980
|
+
try:
|
|
1981
|
+
target_root.mkdir(parents=True, exist_ok=True)
|
|
1982
|
+
lock_file = acquire_file_lock(
|
|
1983
|
+
_copilot_auth_lock_path(target_root),
|
|
1984
|
+
blocking=True,
|
|
1985
|
+
)
|
|
1986
|
+
if lock_file is None:
|
|
1987
|
+
logger.warning("Could not lock Copilot home for synchronization")
|
|
1988
|
+
return str(target_root)
|
|
1989
|
+
if source_root.resolve() == target_root.resolve():
|
|
1990
|
+
return str(target_root)
|
|
1991
|
+
if not source_root.exists():
|
|
1992
|
+
return str(target_root)
|
|
1993
|
+
|
|
1994
|
+
prune_cutoff = time.time() - 7 * 86400
|
|
1995
|
+
for prune_dir_name in ("session-state", "logs"):
|
|
1996
|
+
prune_dir = target_root / prune_dir_name
|
|
1997
|
+
if not prune_dir.is_dir():
|
|
1998
|
+
continue
|
|
1999
|
+
for old_file in prune_dir.iterdir():
|
|
2000
|
+
try:
|
|
2001
|
+
if old_file.is_file() and old_file.stat().st_mtime < prune_cutoff:
|
|
2002
|
+
old_file.unlink(missing_ok=True)
|
|
2003
|
+
logger.debug("Pruned old Copilot home file: %s", old_file)
|
|
2004
|
+
except OSError as exc:
|
|
2005
|
+
logger.debug("Could not prune %s: %s", old_file, exc)
|
|
2006
|
+
|
|
2007
|
+
source_names = {child.name for child in source_root.iterdir()}
|
|
2008
|
+
migration_marker = target_root.parent / ".copilot-stale-auth-cleaned-v2"
|
|
2009
|
+
if not migration_marker.exists():
|
|
2010
|
+
cleanup_succeeded = True
|
|
2011
|
+
for target_child in list(target_root.iterdir()):
|
|
2012
|
+
if target_child.name in {"session-state", "logs"}:
|
|
2013
|
+
continue
|
|
2014
|
+
if target_child.name not in source_names:
|
|
2015
|
+
try:
|
|
2016
|
+
if target_child.is_dir():
|
|
2017
|
+
shutil.rmtree(str(target_child))
|
|
2018
|
+
else:
|
|
2019
|
+
target_child.unlink(missing_ok=True)
|
|
2020
|
+
logger.debug("Copilot home: removed stale %s", target_child.name)
|
|
2021
|
+
except OSError as exc:
|
|
2022
|
+
cleanup_succeeded = False
|
|
2023
|
+
logger.debug(
|
|
2024
|
+
"Copilot home: could not remove stale %s: %s",
|
|
2025
|
+
target_child.name,
|
|
2026
|
+
exc,
|
|
2027
|
+
)
|
|
2028
|
+
if cleanup_succeeded:
|
|
2029
|
+
migration_marker.touch(exist_ok=True)
|
|
2030
|
+
os.chmod(migration_marker, 0o600)
|
|
2031
|
+
|
|
2032
|
+
source_auth_signature = copilot_auth_state_signature(source_root)
|
|
2033
|
+
source_signature_marker = target_root.parent / ".copilot-login-source.sha256"
|
|
2034
|
+
recorded_source_signature: str | None = None
|
|
2035
|
+
if source_signature_marker.is_file():
|
|
1781
2036
|
try:
|
|
1782
|
-
|
|
1783
|
-
|
|
1784
|
-
|
|
1785
|
-
|
|
1786
|
-
|
|
1787
|
-
|
|
1788
|
-
|
|
1789
|
-
|
|
1790
|
-
|
|
1791
|
-
|
|
1792
|
-
|
|
2037
|
+
recorded_source_signature = source_signature_marker.read_text().strip()
|
|
2038
|
+
except OSError:
|
|
2039
|
+
logger.debug("Could not read Copilot login source signature", exc_info=True)
|
|
2040
|
+
source_auth_changed = bool(
|
|
2041
|
+
source_auth_signature
|
|
2042
|
+
and recorded_source_signature is not None
|
|
2043
|
+
and source_auth_signature != recorded_source_signature
|
|
2044
|
+
)
|
|
2045
|
+
source_config_synced = False
|
|
2046
|
+
for child in source_root.iterdir():
|
|
2047
|
+
if child.name in {"logs", "session-state"}:
|
|
2048
|
+
continue
|
|
1793
2049
|
|
|
1794
|
-
|
|
1795
|
-
|
|
1796
|
-
|
|
1797
|
-
|
|
1798
|
-
continue
|
|
1799
|
-
dest.mkdir(parents=True, exist_ok=True)
|
|
1800
|
-
for sub in child.iterdir():
|
|
1801
|
-
if not sub.is_file() or sub.name.endswith(".lock"):
|
|
2050
|
+
dest = target_root / child.name
|
|
2051
|
+
try:
|
|
2052
|
+
if child.is_dir():
|
|
2053
|
+
if child.name != "ide":
|
|
1802
2054
|
continue
|
|
1803
|
-
|
|
1804
|
-
|
|
1805
|
-
|
|
1806
|
-
|
|
1807
|
-
|
|
1808
|
-
|
|
1809
|
-
|
|
1810
|
-
|
|
2055
|
+
dest.mkdir(parents=True, exist_ok=True)
|
|
2056
|
+
for sub in child.iterdir():
|
|
2057
|
+
if not sub.is_file() or sub.name.endswith(".lock"):
|
|
2058
|
+
continue
|
|
2059
|
+
shutil.copy2(sub, dest / sub.name)
|
|
2060
|
+
elif child.is_file():
|
|
2061
|
+
if (
|
|
2062
|
+
child.name == "config.json"
|
|
2063
|
+
and not source_auth_changed
|
|
2064
|
+
and dest.is_file()
|
|
2065
|
+
and dest.stat().st_mtime_ns >= child.stat().st_mtime_ns
|
|
2066
|
+
):
|
|
2067
|
+
source_config_synced = True
|
|
2068
|
+
continue
|
|
2069
|
+
shutil.copy2(child, dest)
|
|
2070
|
+
if child.name == "config.json":
|
|
2071
|
+
source_config_synced = True
|
|
2072
|
+
except OSError as exc:
|
|
2073
|
+
logger.debug(
|
|
2074
|
+
"Copilot home mirror skipped %s -> %s: %s",
|
|
2075
|
+
child,
|
|
2076
|
+
dest,
|
|
2077
|
+
exc,
|
|
2078
|
+
)
|
|
2079
|
+
if source_config_synced and source_auth_signature:
|
|
2080
|
+
try:
|
|
2081
|
+
source_signature_marker.write_text(source_auth_signature)
|
|
2082
|
+
os.chmod(source_signature_marker, 0o600)
|
|
2083
|
+
except OSError:
|
|
2084
|
+
logger.debug("Could not record Copilot login source signature", exc_info=True)
|
|
2085
|
+
except OSError:
|
|
2086
|
+
logger.warning("Could not prepare writable Copilot home", exc_info=True)
|
|
2087
|
+
finally:
|
|
2088
|
+
release_file_lock(lock_file)
|
|
1811
2089
|
|
|
1812
2090
|
return str(target_root)
|
|
@@ -910,7 +910,7 @@ except (ImportError, ModuleNotFoundError):
|
|
|
910
910
|
# DAEMON_VERSION is the protocol/logic version of the daemon code.
|
|
911
911
|
# Kept in sync with pyproject.toml version via bump-version.sh.
|
|
912
912
|
# CLIENT_TYPE identifies which packaging/distribution this daemon runs in.
|
|
913
|
-
DAEMON_VERSION = "1.
|
|
913
|
+
DAEMON_VERSION = "1.43.2"
|
|
914
914
|
|
|
915
915
|
|
|
916
916
|
def _detect_client_type() -> str:
|
|
@@ -972,6 +972,9 @@ logging.basicConfig(
|
|
|
972
972
|
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
|
|
973
973
|
handlers=_log_handlers,
|
|
974
974
|
)
|
|
975
|
+
# Routine poll/heartbeat requests otherwise consume the entire 500-line remote
|
|
976
|
+
# log tail in about 20 minutes, hiding the task failure operators need to see.
|
|
977
|
+
logging.getLogger("httpx").setLevel(logging.WARNING)
|
|
975
978
|
logger = logging.getLogger("daemon")
|
|
976
979
|
logger.info("Daemon log: %s", DAEMON_LOG_PATH)
|
|
977
980
|
|
|
@@ -984,6 +987,29 @@ def _format_exc(exc: BaseException) -> str:
|
|
|
984
987
|
return f"{type(exc).__name__}: {detail}" if detail else type(exc).__name__
|
|
985
988
|
|
|
986
989
|
|
|
990
|
+
def _safe_agent_error_detail(value: object) -> str:
|
|
991
|
+
"""Return a bounded agent error detail without credentials or tokens."""
|
|
992
|
+
text = re.sub(r"\s+", " ", str(value or "")).strip()
|
|
993
|
+
text = re.sub(
|
|
994
|
+
r"(?i)\b(authorization|api[_-]?key|token|password|secret)\b"
|
|
995
|
+
r"(\s*[:=]\s*)(?:bearer\s+)?[^\s,;]+",
|
|
996
|
+
r"\1\2[redacted]",
|
|
997
|
+
text,
|
|
998
|
+
)
|
|
999
|
+
text = re.sub(
|
|
1000
|
+
r"(?i)([?&](?:authorization|api[_-]?key|token|password|secret)=)[^&\s]+",
|
|
1001
|
+
r"\1[redacted]",
|
|
1002
|
+
text,
|
|
1003
|
+
)
|
|
1004
|
+
text = re.sub(r"(?i)\bbearer\s+[^\s,;]+", "Bearer [redacted]", text)
|
|
1005
|
+
text = re.sub(
|
|
1006
|
+
r"(?i)\b(?:github_pat_[a-z0-9_]+|gh[pousr]_[a-z0-9]+)\b",
|
|
1007
|
+
"[redacted-github-token]",
|
|
1008
|
+
text,
|
|
1009
|
+
)
|
|
1010
|
+
return text[:1500]
|
|
1011
|
+
|
|
1012
|
+
|
|
987
1013
|
# ── Hardware ID — stable cross-IP machine fingerprint ──
|
|
988
1014
|
|
|
989
1015
|
|
|
@@ -4755,7 +4781,8 @@ class ProcessManager:
|
|
|
4755
4781
|
"error during sign-in",
|
|
4756
4782
|
"websockettransporterror",
|
|
4757
4783
|
"websocket receive failed",
|
|
4758
|
-
"
|
|
4784
|
+
"misdirected request",
|
|
4785
|
+
"could not retrieve the list of available models",
|
|
4759
4786
|
"failed to initialize mcp client",
|
|
4760
4787
|
# "api error" removed: too broad — matches agent-generated code/output
|
|
4761
4788
|
# discussing API errors. Real API transport errors are covered by the
|
|
@@ -5034,7 +5061,10 @@ class ProcessManager:
|
|
|
5034
5061
|
# ── Copilot: JSONL mode but no turn completion and no content ──
|
|
5035
5062
|
if agent_id == "copilot" and json_line_count > 0:
|
|
5036
5063
|
if not has_result and not has_meaningful_content and not has_assistant_events:
|
|
5037
|
-
return
|
|
5064
|
+
return (
|
|
5065
|
+
"Copilot produced no result output "
|
|
5066
|
+
"(check authentication: run 'copilot login')"
|
|
5067
|
+
)
|
|
5038
5068
|
|
|
5039
5069
|
return None
|
|
5040
5070
|
|
|
@@ -5967,13 +5997,15 @@ class ProcessManager:
|
|
|
5967
5997
|
*,
|
|
5968
5998
|
disable_builtin_mcps: bool = False,
|
|
5969
5999
|
_retry_without_effort: bool = False,
|
|
6000
|
+
_retry_without_token_env: bool = False,
|
|
5970
6001
|
_isolated_copilot_home: str | None = None,
|
|
6002
|
+
_auth_state: dict[str, bool] | None = None,
|
|
5971
6003
|
) -> TaskResult:
|
|
5972
6004
|
"""Run GitHub Copilot CLI in non-interactive JSON-streaming mode.
|
|
5973
6005
|
|
|
5974
6006
|
Uses TERM=dumb to suppress TTY-detection (copilot suspends when it
|
|
5975
|
-
can't acquire a pseudo-terminal).
|
|
5976
|
-
|
|
6007
|
+
can't acquire a pseudo-terminal). Requires ``copilot login`` or an
|
|
6008
|
+
explicitly configured supported token environment variable.
|
|
5977
6009
|
|
|
5978
6010
|
Flags:
|
|
5979
6011
|
-p / --prompt Non-interactive prompt (exits after completion).
|
|
@@ -6003,11 +6035,15 @@ class ProcessManager:
|
|
|
6003
6035
|
# session-state persists from the prior attempt all todos are already
|
|
6004
6036
|
# "done" and the agent exits immediately without producing any output.
|
|
6005
6037
|
_is_outer_call = _isolated_copilot_home is None
|
|
6038
|
+
if _auth_state is None:
|
|
6039
|
+
_auth_state = {"persist": True}
|
|
6040
|
+
_master_copilot_home: str | None = None
|
|
6041
|
+
_copilot_auth_baseline: str | None = None
|
|
6006
6042
|
if _is_outer_call:
|
|
6007
|
-
|
|
6043
|
+
_master_copilot_home = await asyncio.to_thread(self._prepare_copilot_home)
|
|
6008
6044
|
_isolated_copilot_home = tempfile.mkdtemp(prefix=f"copilot-{task_id[:8]}-")
|
|
6009
6045
|
try:
|
|
6010
|
-
for child in Path(
|
|
6046
|
+
for child in Path(_master_copilot_home).iterdir():
|
|
6011
6047
|
if child.name in {"session-state", "logs"}:
|
|
6012
6048
|
continue
|
|
6013
6049
|
dest = Path(_isolated_copilot_home) / child.name
|
|
@@ -6020,42 +6056,30 @@ class ProcessManager:
|
|
|
6020
6056
|
logger.debug("Copilot home copy %s -> %s: %s", child, dest, _cp_exc)
|
|
6021
6057
|
except Exception as _iter_exc:
|
|
6022
6058
|
logger.debug("Copilot home isolation warning: %s", _iter_exc)
|
|
6059
|
+
_copilot_auth_baseline = agent_core.copilot_auth_state_signature(
|
|
6060
|
+
_isolated_copilot_home,
|
|
6061
|
+
)
|
|
6023
6062
|
|
|
6024
6063
|
env = agent_core.copilot_base_env(
|
|
6025
6064
|
os.environ.copy(),
|
|
6026
6065
|
copilot_home=_isolated_copilot_home,
|
|
6027
6066
|
)
|
|
6028
|
-
|
|
6029
|
-
|
|
6030
|
-
|
|
6031
|
-
|
|
6032
|
-
|
|
6033
|
-
|
|
6034
|
-
|
|
6035
|
-
|
|
6036
|
-
|
|
6037
|
-
|
|
6038
|
-
|
|
6039
|
-
|
|
6040
|
-
|
|
6041
|
-
|
|
6042
|
-
|
|
6043
|
-
|
|
6044
|
-
"gh", "auth", "token",
|
|
6045
|
-
stdout=asyncio.subprocess.PIPE,
|
|
6046
|
-
stderr=asyncio.subprocess.DEVNULL,
|
|
6047
|
-
stdin=asyncio.subprocess.DEVNULL,
|
|
6048
|
-
)
|
|
6049
|
-
_gh_token_out, _ = await asyncio.wait_for(_gh_proc.communicate(), timeout=10.0)
|
|
6050
|
-
_gh_token = (_gh_token_out or b"").decode().strip()
|
|
6051
|
-
if _gh_token:
|
|
6052
|
-
env["GH_TOKEN"] = _gh_token
|
|
6053
|
-
logger.debug("Copilot task %s: injected GH_TOKEN from gh CLI", task_id)
|
|
6054
|
-
except Exception as _gh_exc:
|
|
6055
|
-
logger.debug(
|
|
6056
|
-
"Copilot task %s: could not inject GH_TOKEN (%s); falling back to stored auth",
|
|
6057
|
-
task_id, _gh_exc,
|
|
6058
|
-
)
|
|
6067
|
+
if _retry_without_token_env:
|
|
6068
|
+
for key in agent_core.COPILOT_TOKEN_ENV_VARS:
|
|
6069
|
+
env.pop(key, None)
|
|
6070
|
+
auth_env_var = next(
|
|
6071
|
+
(
|
|
6072
|
+
key
|
|
6073
|
+
for key in agent_core.COPILOT_TOKEN_ENV_VARS
|
|
6074
|
+
if env.get(key)
|
|
6075
|
+
),
|
|
6076
|
+
None,
|
|
6077
|
+
)
|
|
6078
|
+
logger.info(
|
|
6079
|
+
"Copilot task %s authentication source: %s",
|
|
6080
|
+
task_id,
|
|
6081
|
+
auth_env_var or "stored Copilot login",
|
|
6082
|
+
)
|
|
6059
6083
|
|
|
6060
6084
|
model_override = os.environ.get("FACTORY_COPILOT_MODEL")
|
|
6061
6085
|
# FACTORY_COPILOT_REASONING is opt-in: only pass --effort when the env
|
|
@@ -6112,6 +6136,19 @@ class ProcessManager:
|
|
|
6112
6136
|
# stdin=DEVNULL (below) covers the primary check (GetConsoleMode on stdin);
|
|
6113
6137
|
# CREATE_NO_WINDOW covers this secondary check so Copilot always sees a fully
|
|
6114
6138
|
# headless environment and processes -p as the complete task specification.
|
|
6139
|
+
proc: asyncio.subprocess.Process | None = None
|
|
6140
|
+
|
|
6141
|
+
async def _terminate_copilot_process(
|
|
6142
|
+
process: asyncio.subprocess.Process | None,
|
|
6143
|
+
) -> None:
|
|
6144
|
+
if process is None:
|
|
6145
|
+
return
|
|
6146
|
+
_kill_proc(process)
|
|
6147
|
+
try:
|
|
6148
|
+
await asyncio.wait_for(process.wait(), timeout=5)
|
|
6149
|
+
except (Exception, asyncio.CancelledError):
|
|
6150
|
+
pass
|
|
6151
|
+
|
|
6115
6152
|
try:
|
|
6116
6153
|
proc = await asyncio.create_subprocess_exec(
|
|
6117
6154
|
*cmd,
|
|
@@ -6201,11 +6238,58 @@ class ProcessManager:
|
|
|
6201
6238
|
on_chunk,
|
|
6202
6239
|
disable_builtin_mcps=True,
|
|
6203
6240
|
_retry_without_effort=_retry_without_effort,
|
|
6241
|
+
_retry_without_token_env=_retry_without_token_env,
|
|
6204
6242
|
_isolated_copilot_home=_isolated_copilot_home,
|
|
6243
|
+
_auth_state=_auth_state,
|
|
6205
6244
|
)
|
|
6206
6245
|
retry_result.metrics["copilot_builtin_mcp_fallback"] = True
|
|
6207
6246
|
return retry_result
|
|
6208
6247
|
|
|
6248
|
+
model_discovery_error = agent_core.is_copilot_model_discovery_auth_error(
|
|
6249
|
+
*structured_errors,
|
|
6250
|
+
stderr,
|
|
6251
|
+
)
|
|
6252
|
+
if model_discovery_error and not self._copilot_called_any_tools(stdout):
|
|
6253
|
+
isolated_config = Path(_isolated_copilot_home) / "config.json"
|
|
6254
|
+
recovery_home = agent_core.copilot_recovery_home(
|
|
6255
|
+
_isolated_copilot_home,
|
|
6256
|
+
)
|
|
6257
|
+
can_retry_native_auth = bool(
|
|
6258
|
+
isolated_config.is_file()
|
|
6259
|
+
or recovery_home is not None
|
|
6260
|
+
)
|
|
6261
|
+
if not _retry_without_token_env and can_retry_native_auth:
|
|
6262
|
+
restored_interactive_auth = bool(
|
|
6263
|
+
recovery_home
|
|
6264
|
+
and await asyncio.to_thread(
|
|
6265
|
+
agent_core.replace_copilot_auth_state,
|
|
6266
|
+
recovery_home,
|
|
6267
|
+
_isolated_copilot_home,
|
|
6268
|
+
)
|
|
6269
|
+
)
|
|
6270
|
+
logger.warning(
|
|
6271
|
+
"Copilot model discovery failed with HTTP 421 before work for "
|
|
6272
|
+
"task %s; retrying once with token environment variables removed%s",
|
|
6273
|
+
task_id,
|
|
6274
|
+
" and interactive login restored" if restored_interactive_auth else "",
|
|
6275
|
+
)
|
|
6276
|
+
retry_result = await self._run_copilot(
|
|
6277
|
+
agent,
|
|
6278
|
+
prompt,
|
|
6279
|
+
cwd,
|
|
6280
|
+
timeout,
|
|
6281
|
+
task_id,
|
|
6282
|
+
on_chunk,
|
|
6283
|
+
disable_builtin_mcps=disable_builtin_mcps,
|
|
6284
|
+
_retry_without_effort=_retry_without_effort,
|
|
6285
|
+
_retry_without_token_env=True,
|
|
6286
|
+
_isolated_copilot_home=_isolated_copilot_home,
|
|
6287
|
+
_auth_state=_auth_state,
|
|
6288
|
+
)
|
|
6289
|
+
retry_result.metrics["copilot_model_discovery_recovery"] = True
|
|
6290
|
+
return retry_result
|
|
6291
|
+
_auth_state["persist"] = False
|
|
6292
|
+
|
|
6209
6293
|
if copilot_exit == 0 and returncode == 0:
|
|
6210
6294
|
return TaskResult(
|
|
6211
6295
|
status="success",
|
|
@@ -6261,14 +6345,17 @@ class ProcessManager:
|
|
|
6261
6345
|
agent, prompt, cwd, timeout, task_id, on_chunk,
|
|
6262
6346
|
disable_builtin_mcps=disable_builtin_mcps,
|
|
6263
6347
|
_retry_without_effort=True,
|
|
6348
|
+
_retry_without_token_env=_retry_without_token_env,
|
|
6264
6349
|
_isolated_copilot_home=_isolated_copilot_home,
|
|
6350
|
+
_auth_state=_auth_state,
|
|
6265
6351
|
)
|
|
6266
6352
|
# Exhausted retries — emit an actionable diagnostic hint
|
|
6267
6353
|
if effective_rc == 1 and _no_tools and not copilot_specific_error:
|
|
6268
6354
|
logger.error(
|
|
6269
6355
|
"Copilot exitCode=1 with no tool calls for task %s after retries. "
|
|
6270
|
-
"Likely causes: (1)
|
|
6271
|
-
"
|
|
6356
|
+
"Likely causes: (1) Copilot auth expired — run `copilot login`; "
|
|
6357
|
+
"if COPILOT_GITHUB_TOKEN, GH_TOKEN, or GITHUB_TOKEN is explicitly set, "
|
|
6358
|
+
"verify that it belongs to the intended Copilot account; "
|
|
6272
6359
|
"(2) Copilot subscription inactive; "
|
|
6273
6360
|
"(3) --allow-all flag not supported by this CLI version (%s). "
|
|
6274
6361
|
"To reproduce manually: %s",
|
|
@@ -6282,9 +6369,9 @@ class ProcessManager:
|
|
|
6282
6369
|
if _is_auth_error:
|
|
6283
6370
|
logger.error(
|
|
6284
6371
|
"Copilot auth error for task %s: %s. "
|
|
6285
|
-
"Fix: (1) run '
|
|
6286
|
-
"(2)
|
|
6287
|
-
"
|
|
6372
|
+
"Fix: (1) run 'copilot login' to refresh Copilot credentials; "
|
|
6373
|
+
"(2) verify any explicitly configured COPILOT_GITHUB_TOKEN, GH_TOKEN, "
|
|
6374
|
+
"or GITHUB_TOKEN belongs to the intended Copilot account.",
|
|
6288
6375
|
task_id,
|
|
6289
6376
|
structured_errors[-1] if structured_errors else "auth failure",
|
|
6290
6377
|
)
|
|
@@ -6299,6 +6386,13 @@ class ProcessManager:
|
|
|
6299
6386
|
failure_error = f"Copilot exited with code {effective_rc}: {stderr[-500:].strip()}"
|
|
6300
6387
|
else:
|
|
6301
6388
|
failure_error = f"Copilot exited with code {effective_rc}"
|
|
6389
|
+
logger.error(
|
|
6390
|
+
"Copilot task %s failed (process_rc=%s, result_exit=%s): %s",
|
|
6391
|
+
task_id,
|
|
6392
|
+
returncode,
|
|
6393
|
+
copilot_exit,
|
|
6394
|
+
_safe_agent_error_detail(failure_error),
|
|
6395
|
+
)
|
|
6302
6396
|
return TaskResult(
|
|
6303
6397
|
status="failed",
|
|
6304
6398
|
exit_code=effective_rc,
|
|
@@ -6307,6 +6401,10 @@ class ProcessManager:
|
|
|
6307
6401
|
error=failure_error,
|
|
6308
6402
|
metrics=metrics,
|
|
6309
6403
|
)
|
|
6404
|
+
except asyncio.CancelledError:
|
|
6405
|
+
active_proc = self.active_processes.get(task_id) or proc
|
|
6406
|
+
await _terminate_copilot_process(active_proc)
|
|
6407
|
+
raise
|
|
6310
6408
|
except FileNotFoundError as exc:
|
|
6311
6409
|
logger.exception("Copilot executable was not found for task %s", task_id)
|
|
6312
6410
|
return TaskResult(
|
|
@@ -6321,7 +6419,9 @@ class ProcessManager:
|
|
|
6321
6419
|
failure_code="agent_launch_not_found",
|
|
6322
6420
|
)
|
|
6323
6421
|
except asyncio.TimeoutError as exc:
|
|
6324
|
-
|
|
6422
|
+
await _terminate_copilot_process(
|
|
6423
|
+
self.active_processes.pop(task_id, None) or proc,
|
|
6424
|
+
)
|
|
6325
6425
|
_err = (
|
|
6326
6426
|
f"Agent produced no stdout, stderr, or workspace changes for "
|
|
6327
6427
|
f"{exc.idle_seconds:.0f}s — process terminated. Inspect retained "
|
|
@@ -6337,11 +6437,9 @@ class ProcessManager:
|
|
|
6337
6437
|
)
|
|
6338
6438
|
except Exception as exc:
|
|
6339
6439
|
logger.exception("Copilot stream error for task %s", task_id)
|
|
6340
|
-
|
|
6341
|
-
|
|
6342
|
-
|
|
6343
|
-
except Exception:
|
|
6344
|
-
pass
|
|
6440
|
+
await _terminate_copilot_process(
|
|
6441
|
+
self.active_processes.pop(task_id, None) or proc,
|
|
6442
|
+
)
|
|
6345
6443
|
return TaskResult(
|
|
6346
6444
|
status="failed", exit_code=-1, stdout="", stderr="",
|
|
6347
6445
|
error=f"Stream processing error: {exc}",
|
|
@@ -6349,6 +6447,13 @@ class ProcessManager:
|
|
|
6349
6447
|
finally:
|
|
6350
6448
|
self.active_processes.pop(task_id, None)
|
|
6351
6449
|
if _is_outer_call and _isolated_copilot_home:
|
|
6450
|
+
if _master_copilot_home and _auth_state["persist"]:
|
|
6451
|
+
await asyncio.to_thread(
|
|
6452
|
+
agent_core.persist_copilot_auth_state,
|
|
6453
|
+
_isolated_copilot_home,
|
|
6454
|
+
_master_copilot_home,
|
|
6455
|
+
_copilot_auth_baseline,
|
|
6456
|
+
)
|
|
6352
6457
|
WorkspaceManager._safe_rmtree(Path(_isolated_copilot_home))
|
|
6353
6458
|
if _task_file is not None:
|
|
6354
6459
|
try:
|
|
@@ -9054,7 +9159,10 @@ class RuntimeDaemon:
|
|
|
9054
9159
|
"opencode": "curl -sSL https://opencode.ai/install | sh",
|
|
9055
9160
|
"gemini": "npm install -g @google/gemini-cli",
|
|
9056
9161
|
"kimi": "curl -fsSL https://code.kimi.com/kimi-code/install.sh | bash",
|
|
9057
|
-
"copilot":
|
|
9162
|
+
"copilot": (
|
|
9163
|
+
"Install GitHub Copilot CLI or the VS Code Copilot Chat "
|
|
9164
|
+
"extension. Then run: copilot login"
|
|
9165
|
+
),
|
|
9058
9166
|
}
|
|
9059
9167
|
hint = _INSTALL_HINTS.get(task.agent_type, f"install the '{task.agent_type}' CLI tool")
|
|
9060
9168
|
logger.error("No agent found for type '%s' on this runtime", task.agent_type)
|
|
@@ -11157,7 +11265,10 @@ class RuntimeDaemon:
|
|
|
11157
11265
|
"opencode": "curl -sSL https://opencode.ai/install | sh",
|
|
11158
11266
|
"gemini": "npm install -g @google/gemini-cli",
|
|
11159
11267
|
"kimi": "curl -fsSL https://code.kimi.com/kimi-code/install.sh | bash",
|
|
11160
|
-
"copilot":
|
|
11268
|
+
"copilot": (
|
|
11269
|
+
"Install GitHub Copilot CLI or the VS Code Copilot Chat "
|
|
11270
|
+
"extension. Then run: copilot login"
|
|
11271
|
+
),
|
|
11161
11272
|
}
|
|
11162
11273
|
hint = _INSTALL_HINTS.get(agent_type, f"install the '{agent_type}' CLI tool")
|
|
11163
11274
|
no_agent_attempts = (
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|