forgexa-cli 1.8.3__tar.gz → 1.8.5__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.8.3 → forgexa_cli-1.8.5}/PKG-INFO +2 -1
- {forgexa_cli-1.8.3 → forgexa_cli-1.8.5}/forgexa_cli/__init__.py +1 -1
- {forgexa_cli-1.8.3 → forgexa_cli-1.8.5}/forgexa_cli/daemon.py +408 -74
- {forgexa_cli-1.8.3 → forgexa_cli-1.8.5}/forgexa_cli.egg-info/PKG-INFO +2 -1
- {forgexa_cli-1.8.3 → forgexa_cli-1.8.5}/pyproject.toml +2 -1
- {forgexa_cli-1.8.3 → forgexa_cli-1.8.5}/README.md +0 -0
- {forgexa_cli-1.8.3 → forgexa_cli-1.8.5}/forgexa_cli/_build_config.py +0 -0
- {forgexa_cli-1.8.3 → forgexa_cli-1.8.5}/forgexa_cli/main.py +0 -0
- {forgexa_cli-1.8.3 → forgexa_cli-1.8.5}/forgexa_cli/py.typed +0 -0
- {forgexa_cli-1.8.3 → forgexa_cli-1.8.5}/forgexa_cli.egg-info/SOURCES.txt +0 -0
- {forgexa_cli-1.8.3 → forgexa_cli-1.8.5}/forgexa_cli.egg-info/dependency_links.txt +0 -0
- {forgexa_cli-1.8.3 → forgexa_cli-1.8.5}/forgexa_cli.egg-info/entry_points.txt +0 -0
- {forgexa_cli-1.8.3 → forgexa_cli-1.8.5}/forgexa_cli.egg-info/requires.txt +0 -0
- {forgexa_cli-1.8.3 → forgexa_cli-1.8.5}/forgexa_cli.egg-info/top_level.txt +0 -0
- {forgexa_cli-1.8.3 → forgexa_cli-1.8.5}/setup.cfg +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: forgexa-cli
|
|
3
|
-
Version: 1.8.
|
|
3
|
+
Version: 1.8.5
|
|
4
4
|
Summary: Forgexa CLI — command-line client and AI agent runtime for the Forgexa platform
|
|
5
5
|
Author-email: Jason Sun <dev.winds@gmail.com>
|
|
6
6
|
License: MIT
|
|
@@ -20,6 +20,7 @@ Classifier: Programming Language :: Python :: 3.10
|
|
|
20
20
|
Classifier: Programming Language :: Python :: 3.11
|
|
21
21
|
Classifier: Programming Language :: Python :: 3.12
|
|
22
22
|
Classifier: Programming Language :: Python :: 3.13
|
|
23
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
23
24
|
Classifier: Topic :: Software Development :: Build Tools
|
|
24
25
|
Classifier: Topic :: Software Development :: Quality Assurance
|
|
25
26
|
Requires-Python: >=3.9
|
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
"""forgexa-cli — Forgexa command-line client."""
|
|
2
|
-
__version__ = "1.8.
|
|
2
|
+
__version__ = "1.8.5"
|
|
@@ -15,11 +15,14 @@ import sys
|
|
|
15
15
|
# ── Python version gate — must run before any other imports ──────────────────
|
|
16
16
|
# Emit a machine-readable DAEMON_ERROR so the desktop app shows a clear
|
|
17
17
|
# message instead of a cryptic traceback.
|
|
18
|
+
# Minimum: 3.9 (macOS ships 3.9; CLI/daemon run on end-user machines).
|
|
19
|
+
# from __future__ import annotations (line 11) makes all X|Y union hints
|
|
20
|
+
# lazy strings on 3.9/3.10, so no runtime SyntaxError on those versions.
|
|
18
21
|
if sys.version_info < (3, 9):
|
|
19
22
|
_ver = f"{sys.version_info.major}.{sys.version_info.minor}"
|
|
20
23
|
print(
|
|
21
|
-
f"DAEMON_ERROR: Python {_ver} is too old. Forgexa Daemon requires Python
|
|
22
|
-
f"newer. Please upgrade Python from https://www.python.org/downloads/",
|
|
24
|
+
f"DAEMON_ERROR: Python {_ver} is too old. Forgexa Daemon requires Python "
|
|
25
|
+
f"3.9 or newer. Please upgrade Python from https://www.python.org/downloads/",
|
|
23
26
|
file=sys.stderr,
|
|
24
27
|
)
|
|
25
28
|
sys.exit(1)
|
|
@@ -82,24 +85,27 @@ def _try_install_httpx(deps_dir: str) -> tuple[bool, str]:
|
|
|
82
85
|
|
|
83
86
|
# Try pip --target first (most universally compatible).
|
|
84
87
|
# Falls back to --user, then --break-system-packages as last resort.
|
|
85
|
-
# We explicitly list httpcore alongside httpx because pip
|
|
86
|
-
# skip transitive deps it finds in system site-packages,
|
|
87
|
-
# they won't be importable from the isolated deps directory.
|
|
88
|
+
# We explicitly list httpcore and certifi alongside httpx because pip
|
|
89
|
+
# --target may skip transitive deps it finds in system site-packages,
|
|
90
|
+
# even though they won't be importable from the isolated deps directory.
|
|
91
|
+
# certifi must be included so that its cacert.pem bundle is copied into
|
|
92
|
+
# the deps dir; without it httpx raises FileNotFoundError when building
|
|
93
|
+
# the default SSL context (observed on Python 3.14 standalone / Windows).
|
|
88
94
|
strategies: list[tuple[str, list[str]]] = [
|
|
89
95
|
(
|
|
90
96
|
"pip install --target (isolated deps)",
|
|
91
97
|
[python, "-m", "pip", "install", "--target", deps_dir,
|
|
92
|
-
"--quiet", "--upgrade", "httpx>=0.24", "httpcore"],
|
|
98
|
+
"--quiet", "--upgrade", "httpx>=0.24", "httpcore", "certifi"],
|
|
93
99
|
),
|
|
94
100
|
(
|
|
95
101
|
"pip install --user",
|
|
96
102
|
[python, "-m", "pip", "install", "--user", "--quiet",
|
|
97
|
-
"httpx>=0.24", "httpcore"],
|
|
103
|
+
"httpx>=0.24", "httpcore", "certifi"],
|
|
98
104
|
),
|
|
99
105
|
(
|
|
100
106
|
"pip install --break-system-packages",
|
|
101
107
|
[python, "-m", "pip", "install", "--quiet",
|
|
102
|
-
"--break-system-packages", "httpx>=0.24", "httpcore"],
|
|
108
|
+
"--break-system-packages", "httpx>=0.24", "httpcore", "certifi"],
|
|
103
109
|
),
|
|
104
110
|
]
|
|
105
111
|
|
|
@@ -190,19 +196,33 @@ def _die_missing_httpx(detail: str) -> None:
|
|
|
190
196
|
def _validate_httpx_imports() -> tuple[bool, str]:
|
|
191
197
|
"""Validate that httpx and its critical transitive deps are importable.
|
|
192
198
|
|
|
193
|
-
A bare ``import httpx`` can succeed even when httpcore is
|
|
194
|
-
because httpx lazily imports its transport
|
|
195
|
-
the full chain so the daemon fails fast with a clear
|
|
196
|
-
of crashing mid-operation
|
|
197
|
-
the transport.
|
|
199
|
+
A bare ``import httpx`` can succeed even when httpcore or certifi is
|
|
200
|
+
missing, because httpx lazily imports its transport and SSL layers.
|
|
201
|
+
We eagerly check the full chain so the daemon fails fast with a clear
|
|
202
|
+
message instead of crashing mid-operation.
|
|
198
203
|
|
|
199
|
-
|
|
204
|
+
Also verifies that certifi's CA bundle file actually exists on disk.
|
|
205
|
+
A ``pip install --target`` run may install certifi's Python package but
|
|
206
|
+
omit the package-data file (cacert.pem) when pip detects certifi in
|
|
207
|
+
the system site-packages and skips re-copying it. Without the bundle,
|
|
208
|
+
httpx raises FileNotFoundError when building the default SSL context
|
|
209
|
+
(observed on Python 3.14 standalone builds on Windows).
|
|
210
|
+
|
|
211
|
+
Returns (ok, missing_module_name). 'certifi:bundle' means certifi is
|
|
212
|
+
importable but its CA bundle file is absent.
|
|
200
213
|
"""
|
|
201
|
-
for mod_name in ("httpx", "httpcore"):
|
|
214
|
+
for mod_name in ("httpx", "httpcore", "certifi"):
|
|
202
215
|
try:
|
|
203
216
|
__import__(mod_name)
|
|
204
217
|
except ImportError:
|
|
205
218
|
return False, mod_name
|
|
219
|
+
# Verify the certifi CA bundle file actually exists on disk
|
|
220
|
+
try:
|
|
221
|
+
import certifi as _certifi_check
|
|
222
|
+
if not os.path.isfile(_certifi_check.where()):
|
|
223
|
+
return False, "certifi:bundle"
|
|
224
|
+
except Exception:
|
|
225
|
+
return False, "certifi"
|
|
206
226
|
return True, ""
|
|
207
227
|
|
|
208
228
|
|
|
@@ -223,8 +243,8 @@ if not _httpx_ok:
|
|
|
223
243
|
if _httpx_missing != "httpx":
|
|
224
244
|
shutil.rmtree(_HTTPX_DEPS_DIR, ignore_errors=True)
|
|
225
245
|
for _mod_key in list(sys.modules):
|
|
226
|
-
if _mod_key in ("httpx", "httpcore") or \
|
|
227
|
-
_mod_key.startswith(("httpx.", "httpcore.")):
|
|
246
|
+
if _mod_key in ("httpx", "httpcore", "certifi") or \
|
|
247
|
+
_mod_key.startswith(("httpx.", "httpcore.", "certifi.")):
|
|
228
248
|
del sys.modules[_mod_key]
|
|
229
249
|
|
|
230
250
|
# Attempt auto-install to user-writable deps directory
|
|
@@ -245,6 +265,46 @@ import httpx # noqa: E402 — guaranteed available after validation above
|
|
|
245
265
|
|
|
246
266
|
del _httpx_ok, _httpx_missing
|
|
247
267
|
|
|
268
|
+
|
|
269
|
+
def _make_httpx_ssl_context():
|
|
270
|
+
"""Build an SSL context resilient to missing or mislocated certifi bundles.
|
|
271
|
+
|
|
272
|
+
On Windows 10/11 with Python 3.13+ / 3.14+, ``ssl.create_default_context()``
|
|
273
|
+
can use the built-in Windows certificate store directly and does NOT need
|
|
274
|
+
an external CA bundle file. However, older httpx versions call
|
|
275
|
+
``certifi.where()`` and pass the resulting path to
|
|
276
|
+
``ssl.create_default_context(cafile=...)``. When running from an
|
|
277
|
+
isolated deps directory, the certifi package may be importable (found
|
|
278
|
+
in system site-packages via sys.path) but its ``cacert.pem`` bundle may
|
|
279
|
+
not exist at the expected path, causing a ``FileNotFoundError``.
|
|
280
|
+
|
|
281
|
+
Strategy (in order):
|
|
282
|
+
1. Explicit certifi bundle — portable; pinned root CAs.
|
|
283
|
+
2. System truststore — works on Windows 11 (cert store), macOS
|
|
284
|
+
(Keychain), and Linux (/etc/ssl). No external file needed.
|
|
285
|
+
3. httpx default (verify=True) — last resort; httpx makes its own
|
|
286
|
+
SSL choice.
|
|
287
|
+
|
|
288
|
+
Returns a value suitable for ``httpx.AsyncClient(verify=...)``.
|
|
289
|
+
"""
|
|
290
|
+
import ssl as _ssl
|
|
291
|
+
# Strategy 1: explicit certifi bundle (most portable)
|
|
292
|
+
try:
|
|
293
|
+
import certifi as _certifi
|
|
294
|
+
cafile = _certifi.where()
|
|
295
|
+
if os.path.isfile(cafile):
|
|
296
|
+
return _ssl.create_default_context(cafile=cafile)
|
|
297
|
+
except Exception:
|
|
298
|
+
pass
|
|
299
|
+
# Strategy 2: system truststore (no external file needed)
|
|
300
|
+
try:
|
|
301
|
+
return _ssl.create_default_context()
|
|
302
|
+
except Exception:
|
|
303
|
+
pass
|
|
304
|
+
# Strategy 3: let httpx use its own SSL handling
|
|
305
|
+
return True
|
|
306
|
+
|
|
307
|
+
|
|
248
308
|
# ── Settings: graceful fallback when running standalone (outside backend package) ──
|
|
249
309
|
try:
|
|
250
310
|
from app.config import settings
|
|
@@ -332,7 +392,7 @@ except (ImportError, ModuleNotFoundError):
|
|
|
332
392
|
# DAEMON_VERSION is the protocol/logic version of the daemon code.
|
|
333
393
|
# Kept in sync with pyproject.toml version via bump-version.sh.
|
|
334
394
|
# CLIENT_TYPE identifies which packaging/distribution this daemon runs in.
|
|
335
|
-
DAEMON_VERSION = "1.8.
|
|
395
|
+
DAEMON_VERSION = "1.8.5"
|
|
336
396
|
|
|
337
397
|
|
|
338
398
|
def _detect_client_type() -> str:
|
|
@@ -651,7 +711,8 @@ class AgentDiscovery:
|
|
|
651
711
|
},
|
|
652
712
|
"copilot": {
|
|
653
713
|
"commands": ["copilot"],
|
|
654
|
-
"detect": "copilot
|
|
714
|
+
"detect": "copilot version",
|
|
715
|
+
"detect_alt": "copilot --version",
|
|
655
716
|
"invoke_modes": ["cli"],
|
|
656
717
|
"env_path_override": "FACTORY_COPILOT_PATH",
|
|
657
718
|
"compatibility_level": "L3",
|
|
@@ -679,12 +740,30 @@ class AgentDiscovery:
|
|
|
679
740
|
localappdata = Path(os.environ.get("LOCALAPPDATA", home / "AppData" / "Local"))
|
|
680
741
|
extra_dirs += [
|
|
681
742
|
appdata / "npm", # npm -g installs
|
|
682
|
-
localappdata / "Programs" / "Python" / "Scripts",
|
|
683
743
|
home / ".opencode" / "bin",
|
|
684
744
|
home / ".cargo" / "bin",
|
|
685
745
|
home / ".bun" / "bin",
|
|
686
746
|
home / "scoop" / "shims", # scoop package manager
|
|
687
747
|
]
|
|
748
|
+
# Python 3.x on Windows installs Scripts to a versioned subdirectory:
|
|
749
|
+
# %LOCALAPPDATA%\Programs\Python\Python312\Scripts (etc.).
|
|
750
|
+
# Glob all Python3* dirs so we catch whichever version is installed.
|
|
751
|
+
py_base = localappdata / "Programs" / "Python"
|
|
752
|
+
if py_base.is_dir():
|
|
753
|
+
for py_dir in sorted(py_base.glob("Python3*/"), reverse=True):
|
|
754
|
+
scripts = py_dir / "Scripts"
|
|
755
|
+
if scripts.is_dir():
|
|
756
|
+
extra_dirs.append(scripts)
|
|
757
|
+
# Flat path kept for compatibility with non-standard Python installers.
|
|
758
|
+
extra_dirs.append(py_base / "Scripts")
|
|
759
|
+
# GitHub Copilot CLI — VS Code extension installs the binary into
|
|
760
|
+
# AppData\Roaming\Code\User\globalStorage\github.copilot-chat\copilotCli\
|
|
761
|
+
# This dir is NOT in system PATH so the daemon must add it explicitly.
|
|
762
|
+
for vs_variant in ("Code", "Code - Insiders", "VSCodium"):
|
|
763
|
+
extra_dirs.append(
|
|
764
|
+
appdata / vs_variant / "User" / "globalStorage"
|
|
765
|
+
/ "github.copilot-chat" / "copilotCli"
|
|
766
|
+
)
|
|
688
767
|
# nvm-windows stores versions differently
|
|
689
768
|
nvm_home = os.environ.get("NVM_HOME", "")
|
|
690
769
|
if nvm_home:
|
|
@@ -756,6 +835,19 @@ class AgentDiscovery:
|
|
|
756
835
|
resolved = shutil.which(cmd)
|
|
757
836
|
if resolved:
|
|
758
837
|
version = await self._get_version(spec["detect"])
|
|
838
|
+
if version is None and "detect_alt" in spec:
|
|
839
|
+
# Primary detect command failed; try the alternative.
|
|
840
|
+
# Example: new GitHub Copilot CLI 1.0.x uses `copilot version`
|
|
841
|
+
# as a subcommand while older releases used `copilot --version`.
|
|
842
|
+
version = await self._get_version(spec["detect_alt"])
|
|
843
|
+
if version is None:
|
|
844
|
+
# Binary found but version check failed — it is a stub or
|
|
845
|
+
# not properly installed (e.g. copilot prompts to install).
|
|
846
|
+
logger.warning(
|
|
847
|
+
"Agent %s found at %s but version check failed — skipping",
|
|
848
|
+
agent_id, resolved,
|
|
849
|
+
)
|
|
850
|
+
continue
|
|
759
851
|
available.append(DiscoveredAgent(
|
|
760
852
|
agent_id=agent_id,
|
|
761
853
|
command=resolved,
|
|
@@ -766,18 +858,58 @@ class AgentDiscovery:
|
|
|
766
858
|
logger.info("Discovered agent: %s v%s (%s)", agent_id, version, resolved)
|
|
767
859
|
return available
|
|
768
860
|
|
|
769
|
-
async def _get_version(self, detect_cmd: str) -> str:
|
|
861
|
+
async def _get_version(self, detect_cmd: str) -> str | None:
|
|
862
|
+
"""Run <detect_cmd> and return the first line of output as a version string.
|
|
863
|
+
|
|
864
|
+
Returns ``None`` if the command exits with a non-zero code, times out,
|
|
865
|
+
or produces output that doesn't look like a version number (e.g. an
|
|
866
|
+
interactive install prompt). Callers should treat ``None`` as
|
|
867
|
+
"binary found but not functional".
|
|
868
|
+
"""
|
|
869
|
+
import re
|
|
770
870
|
try:
|
|
771
871
|
parts = detect_cmd.split()
|
|
872
|
+
# On Windows, agent CLIs installed via npm create .cmd wrapper scripts
|
|
873
|
+
# (e.g. %APPDATA%\npm\copilot.cmd). Python's asyncio.create_subprocess_exec
|
|
874
|
+
# calls CreateProcess which cannot execute .cmd/.bat files directly —
|
|
875
|
+
# they need cmd.exe as the interpreter. Detect and wrap automatically.
|
|
876
|
+
if sys.platform == "win32":
|
|
877
|
+
resolved = shutil.which(parts[0])
|
|
878
|
+
if resolved:
|
|
879
|
+
ext = Path(resolved).suffix.lower()
|
|
880
|
+
if ext in ('.cmd', '.bat'):
|
|
881
|
+
parts = ['cmd', '/c', resolved] + parts[1:]
|
|
882
|
+
elif ext == '.ps1':
|
|
883
|
+
parts = [
|
|
884
|
+
'powershell', '-NoProfile', '-NonInteractive',
|
|
885
|
+
'-Command', resolved,
|
|
886
|
+
] + parts[1:]
|
|
772
887
|
proc = await asyncio.create_subprocess_exec(
|
|
773
888
|
*parts,
|
|
889
|
+
stdin=asyncio.subprocess.DEVNULL,
|
|
774
890
|
stdout=asyncio.subprocess.PIPE,
|
|
775
891
|
stderr=asyncio.subprocess.PIPE,
|
|
776
892
|
)
|
|
777
|
-
stdout,
|
|
778
|
-
|
|
893
|
+
stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=10)
|
|
894
|
+
if proc.returncode != 0:
|
|
895
|
+
return None
|
|
896
|
+
output = stdout.decode().strip().split("\n")[0][:100]
|
|
897
|
+
if not output:
|
|
898
|
+
# Some tools write their version to stderr (e.g. some Node CLIs)
|
|
899
|
+
output = stderr.decode().strip().split("\n")[0][:100]
|
|
900
|
+
# Reject non-version output such as interactive install prompts.
|
|
901
|
+
# A valid version string contains a digit sequence like 1.2.3 or v1.2.
|
|
902
|
+
# Use re.search so we match versions embedded in text like:
|
|
903
|
+
# 'Kimi Code 1.0.0', '@openai/codex 0.1.x', 'GitHub Copilot 1.2.3'
|
|
904
|
+
if not re.search(r'v?\d+[.\d]', output):
|
|
905
|
+
logger.warning(
|
|
906
|
+
"Version check for %r returned unexpected output: %r — treating as not available",
|
|
907
|
+
detect_cmd, output,
|
|
908
|
+
)
|
|
909
|
+
return None
|
|
910
|
+
return output
|
|
779
911
|
except Exception:
|
|
780
|
-
return
|
|
912
|
+
return None
|
|
781
913
|
|
|
782
914
|
@staticmethod
|
|
783
915
|
async def _probe_bwrap_support() -> None:
|
|
@@ -3415,6 +3547,7 @@ class ServerConnection:
|
|
|
3415
3547
|
self.runtime_id: str | None = None
|
|
3416
3548
|
self.client = httpx.AsyncClient(
|
|
3417
3549
|
headers={"Authorization": f"Bearer {api_token}"} if api_token else {},
|
|
3550
|
+
verify=_make_httpx_ssl_context(),
|
|
3418
3551
|
)
|
|
3419
3552
|
self.heartbeat: HeartbeatService | None = None
|
|
3420
3553
|
self.poller: TaskPoller | None = None
|
|
@@ -4647,9 +4780,12 @@ class RuntimeDaemon:
|
|
|
4647
4780
|
timeout=10,
|
|
4648
4781
|
)
|
|
4649
4782
|
|
|
4650
|
-
# 1. Select agent — normalize legacy aliases to canonical IDs
|
|
4783
|
+
# 1. Select agent — normalize legacy aliases to canonical IDs.
|
|
4784
|
+
# When no agent_override is specified (empty/None), pass an empty
|
|
4785
|
+
# string so _select_agent falls through to its "any available L3
|
|
4786
|
+
# agent" logic instead of hard-failing on the 'claude' default.
|
|
4651
4787
|
_AGENT_ALIASES = {"claude": "claude", "kimi": "kimi"}
|
|
4652
|
-
agent_type = _AGENT_ALIASES.get(agent_override or "", agent_override or "
|
|
4788
|
+
agent_type = _AGENT_ALIASES.get(agent_override or "", agent_override or "")
|
|
4653
4789
|
agent = self._select_agent(agent_type, [])
|
|
4654
4790
|
if not agent:
|
|
4655
4791
|
_INSTALL_HINTS = {
|
|
@@ -4679,7 +4815,11 @@ class RuntimeDaemon:
|
|
|
4679
4815
|
full_prompt = f"{system_prompt}\n\n{user_prompt}" if system_prompt else user_prompt
|
|
4680
4816
|
fake_task = TaskInfo(
|
|
4681
4817
|
task_id=job_id,
|
|
4682
|
-
graph_id
|
|
4818
|
+
# Use job_id as graph_id so workspace_key is non-empty.
|
|
4819
|
+
# If graph_id="" then workspace_key="" and ws_path == project_dir
|
|
4820
|
+
# (Python: Path("x") / "" == Path("x")), causing git clone to
|
|
4821
|
+
# fail with "destination path already exists" on the second run.
|
|
4822
|
+
graph_id=job_id,
|
|
4683
4823
|
node_type="ai_job",
|
|
4684
4824
|
agent_type=agent_type,
|
|
4685
4825
|
input_prompt=full_prompt,
|
|
@@ -4704,40 +4844,86 @@ class RuntimeDaemon:
|
|
|
4704
4844
|
timeout=10,
|
|
4705
4845
|
)
|
|
4706
4846
|
|
|
4707
|
-
# 3. Run agent with prompt
|
|
4847
|
+
# 3. Run agent with prompt — stream output lines back to server in
|
|
4848
|
+
# real-time so the UI black box shows agent activity instead of
|
|
4849
|
+
# staying empty for the entire (potentially long) run.
|
|
4708
4850
|
_line_buffer: list[str] = []
|
|
4851
|
+
_chunk_state = {"sent_count": 0, "last_flush": time.monotonic()}
|
|
4852
|
+
|
|
4853
|
+
async def _flush_output_to_server():
|
|
4854
|
+
pending = _line_buffer[_chunk_state["sent_count"]:]
|
|
4855
|
+
if not pending:
|
|
4856
|
+
return
|
|
4857
|
+
try:
|
|
4858
|
+
await conn.client.post(
|
|
4859
|
+
f"{reporter_url}/progress",
|
|
4860
|
+
json={"output_lines": pending, "agent_id": agent.agent_id},
|
|
4861
|
+
timeout=5,
|
|
4862
|
+
)
|
|
4863
|
+
except Exception:
|
|
4864
|
+
pass # never let streaming errors affect agent execution
|
|
4865
|
+
_chunk_state["sent_count"] += len(pending)
|
|
4866
|
+
_chunk_state["last_flush"] = time.monotonic()
|
|
4709
4867
|
|
|
4710
4868
|
async def on_chunk(lines: list[str]):
|
|
4711
4869
|
_line_buffer.extend(lines)
|
|
4870
|
+
now = time.monotonic()
|
|
4871
|
+
pending_count = len(_line_buffer) - _chunk_state["sent_count"]
|
|
4872
|
+
# Flush every 10 new lines or every 8 seconds, whichever first
|
|
4873
|
+
if pending_count >= 10 or (now - _chunk_state["last_flush"]) >= 8.0:
|
|
4874
|
+
await _flush_output_to_server()
|
|
4712
4875
|
|
|
4713
4876
|
result = await self.process_manager.run_agent(
|
|
4714
4877
|
agent, fake_task, workspace_path, on_chunk=on_chunk,
|
|
4715
4878
|
)
|
|
4879
|
+
# Flush any remaining buffered lines after agent finishes
|
|
4880
|
+
await _flush_output_to_server()
|
|
4716
4881
|
|
|
4717
4882
|
# 4. Auto-commit if successful
|
|
4883
|
+
input_ctx = aj.get("input_context", {})
|
|
4718
4884
|
git_info = {}
|
|
4719
4885
|
if result.status == "success" and result.files_changed:
|
|
4720
4886
|
git_info = await self._auto_commit(workspace_path, fake_task)
|
|
4721
4887
|
|
|
4722
4888
|
# 5. Report completion
|
|
4723
|
-
|
|
4889
|
+
# For deliverables: allow up to 200K chars (full document); others: last 20K
|
|
4890
|
+
max_content = 200000 if task_type == "deliverable_generate" else 20000
|
|
4891
|
+
output_content = (result.stdout or "")[-max_content:] if result.stdout else ""
|
|
4724
4892
|
scripts: dict = {}
|
|
4725
4893
|
|
|
4726
|
-
|
|
4727
|
-
scenario_ids = aj.get("input_context", {}).get("scenario_ids", [])
|
|
4894
|
+
scenario_ids = input_ctx.get("scenario_ids", [])
|
|
4728
4895
|
if scenario_ids and output_content:
|
|
4729
|
-
#
|
|
4730
|
-
#
|
|
4896
|
+
# Primary: extract scripts using structured SCRIPT_START/END markers
|
|
4897
|
+
# inserted by poll_ai_jobs into the multi-scenario prompt.
|
|
4898
|
+
import re as _re
|
|
4731
4899
|
for sid in scenario_ids:
|
|
4732
|
-
|
|
4733
|
-
|
|
4734
|
-
|
|
4735
|
-
|
|
4736
|
-
|
|
4737
|
-
|
|
4738
|
-
|
|
4739
|
-
|
|
4740
|
-
|
|
4900
|
+
pattern = (
|
|
4901
|
+
r"##\s*SCRIPT_START::" + _re.escape(sid)
|
|
4902
|
+
+ r"\s*\n(.*?)\n##\s*SCRIPT_END::" + _re.escape(sid)
|
|
4903
|
+
)
|
|
4904
|
+
m = _re.search(pattern, output_content, _re.DOTALL)
|
|
4905
|
+
if m:
|
|
4906
|
+
scripts[sid] = m.group(1).strip()
|
|
4907
|
+
|
|
4908
|
+
# Fallback: if no markers found but only one scenario, treat
|
|
4909
|
+
# the entire output as that scenario's script.
|
|
4910
|
+
if not scripts and len(scenario_ids) == 1:
|
|
4911
|
+
scripts[scenario_ids[0]] = output_content.strip()
|
|
4912
|
+
|
|
4913
|
+
# Fallback: check workspace for test files named after scenario
|
|
4914
|
+
if not scripts:
|
|
4915
|
+
import glob as _glob
|
|
4916
|
+
for sid in scenario_ids:
|
|
4917
|
+
test_files = _glob.glob(
|
|
4918
|
+
str(workspace_path / "tests" / "**" / f"*{sid[:8]}*"),
|
|
4919
|
+
recursive=True,
|
|
4920
|
+
)
|
|
4921
|
+
if test_files:
|
|
4922
|
+
try:
|
|
4923
|
+
with open(test_files[0], "r") as f:
|
|
4924
|
+
scripts[sid] = f.read()
|
|
4925
|
+
except Exception:
|
|
4926
|
+
pass
|
|
4741
4927
|
|
|
4742
4928
|
complete_payload = {
|
|
4743
4929
|
"status": "success" if result.status == "success" else "failed",
|
|
@@ -5349,12 +5535,18 @@ class RuntimeDaemon:
|
|
|
5349
5535
|
self, workspace_path: Path, default_branch: str, task: TaskInfo,
|
|
5350
5536
|
project_key: str = "default",
|
|
5351
5537
|
):
|
|
5352
|
-
"""
|
|
5353
|
-
|
|
5354
|
-
Strategy
|
|
5355
|
-
|
|
5356
|
-
|
|
5357
|
-
|
|
5538
|
+
"""Integrate the current branch with ``origin/{default_branch}``.
|
|
5539
|
+
|
|
5540
|
+
Strategy:
|
|
5541
|
+
- If the current branch already exists on remote (was previously pushed):
|
|
5542
|
+
Skip rebase entirely and use merge only. Rebase rewrites commit
|
|
5543
|
+
SHAs of already-published commits, which creates divergence and
|
|
5544
|
+
requires a force-push. Many servers (e.g. Bitbucket Server with
|
|
5545
|
+
branch protection) forbid force-push, so we must preserve the
|
|
5546
|
+
existing remote history by using merge instead.
|
|
5547
|
+
- If the branch is new (first push): try rebase first for a clean
|
|
5548
|
+
linear history; fall back to merge on conflicts.
|
|
5549
|
+
- 3-tier merge fallback: merge → AI-assisted conflict resolution.
|
|
5358
5550
|
"""
|
|
5359
5551
|
git = self.workspace_manager._git
|
|
5360
5552
|
target = f"origin/{default_branch}"
|
|
@@ -5363,10 +5555,10 @@ class RuntimeDaemon:
|
|
|
5363
5555
|
try:
|
|
5364
5556
|
await git("fetch", "origin", cwd=workspace_path, timeout=300, project_key=project_key)
|
|
5365
5557
|
except RuntimeError as exc:
|
|
5366
|
-
logger.warning("Pre-push fetch failed: %s — skipping
|
|
5558
|
+
logger.warning("Pre-push fetch failed: %s — skipping integration", exc)
|
|
5367
5559
|
return
|
|
5368
5560
|
|
|
5369
|
-
# Check if
|
|
5561
|
+
# Check if integration is needed (any commits on origin/default ahead of us?)
|
|
5370
5562
|
try:
|
|
5371
5563
|
behind = await git(
|
|
5372
5564
|
"rev-list", "--count", f"HEAD..{target}", cwd=workspace_path,
|
|
@@ -5374,27 +5566,56 @@ class RuntimeDaemon:
|
|
|
5374
5566
|
if behind.strip() == "0":
|
|
5375
5567
|
logger.info("Branch is already up-to-date with %s", target)
|
|
5376
5568
|
return
|
|
5377
|
-
logger.info("Branch is %s commit(s) behind %s —
|
|
5569
|
+
logger.info("Branch is %s commit(s) behind %s — integrating", behind.strip(), target)
|
|
5378
5570
|
except RuntimeError:
|
|
5379
|
-
# Can't determine — proceed
|
|
5571
|
+
# Can't determine — proceed anyway
|
|
5380
5572
|
pass
|
|
5381
5573
|
|
|
5382
|
-
#
|
|
5574
|
+
# Determine if the current branch already exists on remote.
|
|
5575
|
+
# If it does, a rebase would rewrite the SHAs of already-pushed commits,
|
|
5576
|
+
# causing divergence that requires a force-push (often blocked by server
|
|
5577
|
+
# branch-protection rules). Use merge-only in that case.
|
|
5578
|
+
current_branch = ""
|
|
5579
|
+
remote_branch_exists = False
|
|
5383
5580
|
try:
|
|
5384
|
-
await git(
|
|
5385
|
-
"-
|
|
5386
|
-
|
|
5387
|
-
"rebase", target,
|
|
5388
|
-
cwd=workspace_path, timeout=120,
|
|
5389
|
-
)
|
|
5390
|
-
logger.info("Rebase onto %s succeeded", target)
|
|
5391
|
-
return # done — clean linear history
|
|
5581
|
+
current_branch = (await git(
|
|
5582
|
+
"rev-parse", "--abbrev-ref", "HEAD", cwd=workspace_path,
|
|
5583
|
+
)).strip()
|
|
5392
5584
|
except RuntimeError:
|
|
5393
|
-
|
|
5585
|
+
pass
|
|
5586
|
+
if current_branch and current_branch != "HEAD":
|
|
5394
5587
|
try:
|
|
5395
|
-
await git(
|
|
5588
|
+
await git(
|
|
5589
|
+
"rev-parse", "--verify", f"origin/{current_branch}",
|
|
5590
|
+
cwd=workspace_path,
|
|
5591
|
+
)
|
|
5592
|
+
remote_branch_exists = True
|
|
5593
|
+
except RuntimeError:
|
|
5594
|
+
remote_branch_exists = False
|
|
5595
|
+
|
|
5596
|
+
if not remote_branch_exists:
|
|
5597
|
+
# ── Tier 1: rebase (safe — branch not yet on remote) ──
|
|
5598
|
+
try:
|
|
5599
|
+
await git(
|
|
5600
|
+
"-c", "user.name=Forgexa Agent",
|
|
5601
|
+
"-c", "user.email=agent@forgexa.net",
|
|
5602
|
+
"rebase", target,
|
|
5603
|
+
cwd=workspace_path, timeout=120,
|
|
5604
|
+
)
|
|
5605
|
+
logger.info("Rebase onto %s succeeded", target)
|
|
5606
|
+
return # done — clean linear history
|
|
5396
5607
|
except RuntimeError:
|
|
5397
|
-
|
|
5608
|
+
logger.info("Rebase onto %s had conflicts — aborting rebase", target)
|
|
5609
|
+
try:
|
|
5610
|
+
await git("rebase", "--abort", cwd=workspace_path)
|
|
5611
|
+
except RuntimeError:
|
|
5612
|
+
pass # already aborted or not in rebase state
|
|
5613
|
+
else:
|
|
5614
|
+
logger.info(
|
|
5615
|
+
"Branch %s already exists on remote — skipping rebase to preserve "
|
|
5616
|
+
"published commit SHAs (force-push not required)",
|
|
5617
|
+
current_branch,
|
|
5618
|
+
)
|
|
5398
5619
|
|
|
5399
5620
|
# ── Tier 2: merge ──
|
|
5400
5621
|
try:
|
|
@@ -5628,19 +5849,132 @@ class RuntimeDaemon:
|
|
|
5628
5849
|
return None
|
|
5629
5850
|
except RuntimeError as exc:
|
|
5630
5851
|
logger.error("Force-push (with lease) failed for %s: %s", branch, exc)
|
|
5852
|
+
exc_str = str(exc).lower()
|
|
5853
|
+
# Detect permanent server-side force-push prohibition
|
|
5854
|
+
# (e.g. Bitbucket Server branch-protection pre-receive hook).
|
|
5855
|
+
# In this case we must NOT retry with force — instead, recover
|
|
5856
|
+
# by resetting to the remote HEAD and cherry-picking only the
|
|
5857
|
+
# truly new commits, then doing a regular (non-force) push.
|
|
5858
|
+
force_push_blocked = (
|
|
5859
|
+
"force-pushing" in exc_str
|
|
5860
|
+
or "force pushing" in exc_str
|
|
5861
|
+
or "pre-receive hook declined" in exc_str
|
|
5862
|
+
)
|
|
5863
|
+
if force_push_blocked:
|
|
5864
|
+
logger.warning(
|
|
5865
|
+
"Remote has force-push disabled for branch %s — "
|
|
5866
|
+
"attempting cherry-pick recovery to avoid force-push",
|
|
5867
|
+
branch,
|
|
5868
|
+
)
|
|
5869
|
+
try:
|
|
5870
|
+
# Identify commits that are genuinely new (not
|
|
5871
|
+
# equivalent to any remote commit). git-cherry
|
|
5872
|
+
# lines prefixed with '+' are truly missing from
|
|
5873
|
+
# origin; '-' lines are already incorporated (same
|
|
5874
|
+
# patch, different SHA — result of prior rebase).
|
|
5875
|
+
cherry_out = (await git(
|
|
5876
|
+
"cherry", "HEAD", f"origin/{branch}",
|
|
5877
|
+
cwd=workspace_path,
|
|
5878
|
+
)).strip()
|
|
5879
|
+
new_shas = [
|
|
5880
|
+
line.split()[1]
|
|
5881
|
+
for line in cherry_out.splitlines()
|
|
5882
|
+
if line.startswith("+ ")
|
|
5883
|
+
]
|
|
5884
|
+
if not new_shas:
|
|
5885
|
+
# Nothing genuinely new — remote is already
|
|
5886
|
+
# up-to-date, treat as success.
|
|
5887
|
+
logger.info(
|
|
5888
|
+
"Recovery: no truly new commits on %s — "
|
|
5889
|
+
"remote already has equivalent content",
|
|
5890
|
+
branch,
|
|
5891
|
+
)
|
|
5892
|
+
return None
|
|
5893
|
+
# Reset local branch to match remote exactly,
|
|
5894
|
+
# then replay only the new commits on top.
|
|
5895
|
+
await git(
|
|
5896
|
+
"reset", "--hard", f"origin/{branch}",
|
|
5897
|
+
cwd=workspace_path,
|
|
5898
|
+
)
|
|
5899
|
+
await git(
|
|
5900
|
+
"-c", "user.name=Forgexa Agent",
|
|
5901
|
+
"-c", "user.email=agent@forgexa.net",
|
|
5902
|
+
"cherry-pick", *new_shas,
|
|
5903
|
+
cwd=workspace_path,
|
|
5904
|
+
)
|
|
5905
|
+
# Now a regular push should succeed.
|
|
5906
|
+
await git(
|
|
5907
|
+
"push", "-u", "origin", branch,
|
|
5908
|
+
cwd=workspace_path, project_key=project_key,
|
|
5909
|
+
)
|
|
5910
|
+
logger.info(
|
|
5911
|
+
"Recovery push succeeded for branch %s "
|
|
5912
|
+
"(%d new commit(s) cherry-picked)",
|
|
5913
|
+
branch, len(new_shas),
|
|
5914
|
+
)
|
|
5915
|
+
return None
|
|
5916
|
+
except RuntimeError as recovery_exc:
|
|
5917
|
+
logger.error(
|
|
5918
|
+
"Cherry-pick recovery also failed for %s: %s",
|
|
5919
|
+
branch, recovery_exc,
|
|
5920
|
+
)
|
|
5921
|
+
return (
|
|
5922
|
+
f"Push failed: remote has force-push disabled "
|
|
5923
|
+
f"and cherry-pick recovery failed: {recovery_exc}"
|
|
5924
|
+
)
|
|
5631
5925
|
return f"Push failed: {exc}"
|
|
5632
5926
|
else:
|
|
5633
|
-
|
|
5634
|
-
|
|
5635
|
-
|
|
5636
|
-
|
|
5637
|
-
|
|
5638
|
-
|
|
5639
|
-
|
|
5640
|
-
|
|
5641
|
-
f"commit(s) not in local history. Force-pushing would "
|
|
5642
|
-
f"destroy prior implementation work."
|
|
5927
|
+
# Remote has genuinely new commits not in local history.
|
|
5928
|
+
# Before refusing, attempt fetch + rebase: if the local
|
|
5929
|
+
# commits are documentation-only (analysis) or purely
|
|
5930
|
+
# additive they will rebase cleanly onto the remote HEAD.
|
|
5931
|
+
logger.warning(
|
|
5932
|
+
"Branch %s: remote has %d commit(s) not in local history — "
|
|
5933
|
+
"attempting fetch+rebase recovery before refusing push",
|
|
5934
|
+
branch, remote_count,
|
|
5643
5935
|
)
|
|
5936
|
+
try:
|
|
5937
|
+
# 1. Fetch the specific remote branch
|
|
5938
|
+
await git(
|
|
5939
|
+
"fetch", "origin", branch,
|
|
5940
|
+
cwd=workspace_path,
|
|
5941
|
+
)
|
|
5942
|
+
# 2. Rebase local commits onto the updated remote HEAD
|
|
5943
|
+
await git(
|
|
5944
|
+
"-c", "user.name=Forgexa Agent",
|
|
5945
|
+
"-c", "user.email=agent@forgexa.net",
|
|
5946
|
+
"rebase", f"origin/{branch}",
|
|
5947
|
+
cwd=workspace_path,
|
|
5948
|
+
)
|
|
5949
|
+
# 3. Push normally (no force needed — we're ahead of origin now)
|
|
5950
|
+
await git(
|
|
5951
|
+
"push", "-u", "origin", branch,
|
|
5952
|
+
cwd=workspace_path, project_key=project_key,
|
|
5953
|
+
)
|
|
5954
|
+
logger.info(
|
|
5955
|
+
"Fetch+rebase recovery succeeded for branch %s "
|
|
5956
|
+
"(%d remote commit(s) incorporated)",
|
|
5957
|
+
branch, remote_count,
|
|
5958
|
+
)
|
|
5959
|
+
return None
|
|
5960
|
+
except RuntimeError as rebase_exc:
|
|
5961
|
+
rebase_exc_str = str(rebase_exc)
|
|
5962
|
+
# Abort any in-progress rebase to leave workspace clean
|
|
5963
|
+
try:
|
|
5964
|
+
await git("rebase", "--abort", cwd=workspace_path)
|
|
5965
|
+
except RuntimeError:
|
|
5966
|
+
pass
|
|
5967
|
+
logger.error(
|
|
5968
|
+
"Fetch+rebase recovery failed for branch %s: %s — "
|
|
5969
|
+
"refusing push to protect remote work",
|
|
5970
|
+
branch, rebase_exc_str,
|
|
5971
|
+
)
|
|
5972
|
+
return (
|
|
5973
|
+
f"Push refused: remote branch '{branch}' has {remote_count} "
|
|
5974
|
+
f"commit(s) not in local history, and automatic rebase failed "
|
|
5975
|
+
f"(likely a merge conflict). Manual resolution required. "
|
|
5976
|
+
f"Details: {rebase_exc_str[:300]}"
|
|
5977
|
+
)
|
|
5644
5978
|
|
|
5645
5979
|
logger.info("Found unpushed commits on %s, pushing...", branch)
|
|
5646
5980
|
last_push_exc: Exception | None = None
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: forgexa-cli
|
|
3
|
-
Version: 1.8.
|
|
3
|
+
Version: 1.8.5
|
|
4
4
|
Summary: Forgexa CLI — command-line client and AI agent runtime for the Forgexa platform
|
|
5
5
|
Author-email: Jason Sun <dev.winds@gmail.com>
|
|
6
6
|
License: MIT
|
|
@@ -20,6 +20,7 @@ Classifier: Programming Language :: Python :: 3.10
|
|
|
20
20
|
Classifier: Programming Language :: Python :: 3.11
|
|
21
21
|
Classifier: Programming Language :: Python :: 3.12
|
|
22
22
|
Classifier: Programming Language :: Python :: 3.13
|
|
23
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
23
24
|
Classifier: Topic :: Software Development :: Build Tools
|
|
24
25
|
Classifier: Topic :: Software Development :: Quality Assurance
|
|
25
26
|
Requires-Python: >=3.9
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "forgexa-cli"
|
|
3
|
-
version = "1.8.
|
|
3
|
+
version = "1.8.5"
|
|
4
4
|
description = "Forgexa CLI — command-line client and AI agent runtime for the Forgexa platform"
|
|
5
5
|
requires-python = ">=3.9"
|
|
6
6
|
license = { text = "MIT" }
|
|
@@ -21,6 +21,7 @@ classifiers = [
|
|
|
21
21
|
"Programming Language :: Python :: 3.11",
|
|
22
22
|
"Programming Language :: Python :: 3.12",
|
|
23
23
|
"Programming Language :: Python :: 3.13",
|
|
24
|
+
"Programming Language :: Python :: 3.14",
|
|
24
25
|
"Topic :: Software Development :: Build Tools",
|
|
25
26
|
"Topic :: Software Development :: Quality Assurance",
|
|
26
27
|
]
|
|
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
|