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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: forgexa-cli
3
- Version: 1.8.3
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.3"
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 3.9 or "
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 --target may
86
- # skip transitive deps it finds in system site-packages, even though
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 missing,
194
- because httpx lazily imports its transport layer. We eagerly check
195
- the full chain so the daemon fails fast with a clear message instead
196
- of crashing mid-operation when ``httpx.AsyncClient()`` tries to load
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
- Returns (ok, missing_module_name).
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.3"
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 --version",
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, _ = await asyncio.wait_for(proc.communicate(), timeout=10)
778
- return stdout.decode().strip().split("\n")[0][:100]
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 "unknown"
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 "claude")
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
- output_content = result.stdout[-20000:] if result.stdout else ""
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
- # Try to extract per-scenario scripts from output
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
- # Simple heuristic: if output is a single script, map it to first scenario
4730
- # Daemon-generated scripts may be multiple files in workspace
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
- # Check if daemon wrote test files to workspace
4733
- import glob
4734
- test_files = glob.glob(str(workspace_path / "tests" / "**" / f"*{sid[:8]}*"), recursive=True)
4735
- if test_files:
4736
- try:
4737
- with open(test_files[0], "r") as f:
4738
- scripts[sid] = f.read()
4739
- except Exception:
4740
- pass
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
- """Rebase the current branch onto ``origin/{default_branch}``.
5353
-
5354
- Strategy (3-tier):
5355
- 1. ``git rebase origin/{default_branch}`` cleanest; linear history.
5356
- 2. If rebase conflicts abort, try ``git merge`` instead.
5357
- 3. If merge conflicts use the AI agent to auto-resolve.
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 rebase", exc)
5558
+ logger.warning("Pre-push fetch failed: %s — skipping integration", exc)
5367
5559
  return
5368
5560
 
5369
- # Check if rebase is needed (any commits on origin/default ahead of us?)
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 — rebasing", behind.strip(), target)
5569
+ logger.info("Branch is %s commit(s) behind %s — integrating", behind.strip(), target)
5378
5570
  except RuntimeError:
5379
- # Can't determine — proceed with rebase anyway
5571
+ # Can't determine — proceed anyway
5380
5572
  pass
5381
5573
 
5382
- # ── Tier 1: rebase ──
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
- "-c", "user.name=Forgexa Agent",
5386
- "-c", "user.email=agent@forgexa.net",
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
- logger.info("Rebase onto %s had conflicts — aborting rebase", target)
5585
+ pass
5586
+ if current_branch and current_branch != "HEAD":
5394
5587
  try:
5395
- await git("rebase", "--abort", cwd=workspace_path)
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
- pass # already aborted or not in rebase state
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
- logger.error(
5634
- "SAFETY: Refusing to push %s remote has %d commit(s) "
5635
- "not in local branch. This would destroy prior work. "
5636
- "Remote-only commits:\n%s",
5637
- branch, remote_count, remote_ahead,
5638
- )
5639
- return (
5640
- f"Push refused: remote branch '{branch}' has {remote_count} "
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
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"
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