forgexa-cli 1.5.2__tar.gz → 1.6.1__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.5.2
3
+ Version: 1.6.1
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
@@ -1,2 +1,2 @@
1
1
  """forgexa-cli — Forgexa command-line client."""
2
- __version__ = "1.5.2"
2
+ __version__ = "1.6.1"
@@ -307,7 +307,7 @@ except (ImportError, ModuleNotFoundError):
307
307
  # DAEMON_VERSION is the protocol/logic version of the daemon code.
308
308
  # Kept in sync with pyproject.toml version via bump-version.sh.
309
309
  # CLIENT_TYPE identifies which packaging/distribution this daemon runs in.
310
- DAEMON_VERSION = "1.5.2"
310
+ DAEMON_VERSION = "1.6.1"
311
311
 
312
312
 
313
313
  def _detect_client_type() -> str:
@@ -473,9 +473,12 @@ def get_os_info() -> str:
473
473
  return f"{distro} {machine}"
474
474
  return f"Linux {platform.release().split('-')[0]} {machine}"
475
475
  elif system == "Windows":
476
- # e.g. "Windows 10.0 AMD64"
477
- win_ver = platform.version().split('.')[0:2]
478
- return f"Windows {'.'.join(win_ver)} {machine}"
476
+ # platform.version() returns the NT kernel version (10.0.x) for BOTH
477
+ # Windows 10 and Windows 11, so it cannot distinguish them.
478
+ # platform.win32_ver()[0] returns the marketing release: "10" or "11".
479
+ win_release = platform.win32_ver()[0] or platform.version().split('.')[0]
480
+ build = platform.version() # e.g. "10.0.26200"
481
+ return f"Windows {win_release} ({build}) {machine}"
479
482
  else:
480
483
  return f"{system} {platform.release()} {machine}"
481
484
 
@@ -869,12 +872,19 @@ class WorkspaceManager:
869
872
  branch_name = f"feature/{workspace_key}"
870
873
 
871
874
  # Determine whether this node is the "first" in a new graph that needs
872
- # a fresh base from the default branch. Analysis nodes always start
873
- # fresh unless using "refine" mode which preserves the existing branch.
874
- analysis_mode = task.input_data.get("analysis_mode", "fresh")
875
+ # a fresh base from the default branch. Only analysis nodes with
876
+ # explicit analysis_mode="fresh" should start fresh. All other modes
877
+ # (including the default when no mode is specified) preserve the
878
+ # existing branch to avoid destroying prior implementation commits.
879
+ #
880
+ # SAFETY: Defaulting to "refine" ensures that any code path which
881
+ # forgets to set analysis_mode will safely preserve the branch.
882
+ # Only the initial requirement analysis endpoint (requirements.py)
883
+ # explicitly passes "fresh" when creating a brand-new requirement branch.
884
+ analysis_mode = task.input_data.get("analysis_mode", "refine")
875
885
  is_fresh_start = (
876
886
  task.node_type == "analysis"
877
- and analysis_mode != "refine"
887
+ and analysis_mode == "fresh"
878
888
  )
879
889
 
880
890
  if repo_url:
@@ -977,11 +987,52 @@ class WorkspaceManager:
977
987
  logger.warning("Broken worktree detected at %s — removing and recreating", ws_path)
978
988
  await self._remove_broken_worktree(main_repo, ws_path, workspace_key)
979
989
  else:
980
- # Healthy worktree — fetch and optionally reset
990
+ # Healthy worktree — fetch and optionally reset.
991
+ # The _main repo uses --single-branch, so `git fetch origin`
992
+ # only fetches the default branch. Explicitly fetch the
993
+ # feature branch with a full refspec so that
994
+ # origin/{branch_name} is available for checkout/reset.
981
995
  try:
982
996
  await self._git("fetch", "origin", cwd=ws_path, project_key=project_key)
983
997
  except RuntimeError:
984
998
  pass
999
+ try:
1000
+ await self._git(
1001
+ "fetch", "origin",
1002
+ f"{branch_name}:refs/remotes/origin/{branch_name}",
1003
+ cwd=ws_path, project_key=project_key,
1004
+ )
1005
+ except RuntimeError:
1006
+ pass # Branch may not exist on remote yet
1007
+
1008
+ if fresh_start:
1009
+ # Safety check: if the branch already exists on remote with
1010
+ # commits beyond the default branch, do NOT reset to
1011
+ # origin/default_branch — that would destroy prior work.
1012
+ # This guards against accidental branch destruction when
1013
+ # analysis_mode is incorrectly set to "fresh" for
1014
+ # verification or iterative nodes.
1015
+ branch_has_remote_commits = False
1016
+ try:
1017
+ result = await self._git(
1018
+ "rev-list", "--count",
1019
+ f"origin/{default_branch}..origin/{branch_name}",
1020
+ cwd=ws_path,
1021
+ )
1022
+ ahead_count = int(result.strip()) if result.strip() else 0
1023
+ branch_has_remote_commits = ahead_count > 0
1024
+ except (RuntimeError, ValueError):
1025
+ pass # Branch may not exist on remote yet
1026
+
1027
+ if branch_has_remote_commits:
1028
+ logger.warning(
1029
+ "Fresh start requested for %s but remote branch has %d commit(s) "
1030
+ "ahead of %s — switching to safe sync instead of resetting to "
1031
+ "avoid destroying prior work",
1032
+ branch_name, ahead_count, default_branch,
1033
+ )
1034
+ # Override: fall through to the non-fresh sync path
1035
+ fresh_start = False
985
1036
 
986
1037
  if fresh_start:
987
1038
  logger.info("Fresh start: resetting %s to origin/%s", ws_path, default_branch)
@@ -1036,7 +1087,9 @@ class WorkspaceManager:
1036
1087
  await asyncio.sleep(2 * (_sync_attempt + 1))
1037
1088
  try:
1038
1089
  await self._git(
1039
- "fetch", "origin", cwd=ws_path,
1090
+ "fetch", "origin",
1091
+ f"{branch_name}:refs/remotes/origin/{branch_name}",
1092
+ cwd=ws_path,
1040
1093
  project_key=project_key,
1041
1094
  )
1042
1095
  except RuntimeError:
@@ -1061,7 +1114,9 @@ class WorkspaceManager:
1061
1114
  await asyncio.sleep(2 * (_sync_attempt + 1))
1062
1115
  try:
1063
1116
  await self._git(
1064
- "fetch", "origin", cwd=ws_path,
1117
+ "fetch", "origin",
1118
+ f"{branch_name}:refs/remotes/origin/{branch_name}",
1119
+ cwd=ws_path,
1065
1120
  project_key=project_key,
1066
1121
  )
1067
1122
  except RuntimeError:
@@ -1096,6 +1151,19 @@ class WorkspaceManager:
1096
1151
  else:
1097
1152
  await self._git("fetch", "--all", cwd=main_repo, timeout=300, project_key=project_key)
1098
1153
 
1154
+ # --single-branch clone only fetches the default branch.
1155
+ # Explicitly fetch the feature branch so origin/{branch_name}
1156
+ # is available for worktree creation and checkout.
1157
+ if not fresh_start:
1158
+ try:
1159
+ await self._git(
1160
+ "fetch", "origin",
1161
+ f"{branch_name}:refs/remotes/origin/{branch_name}",
1162
+ cwd=main_repo, timeout=60, project_key=project_key,
1163
+ )
1164
+ except RuntimeError:
1165
+ pass # Branch may not exist on remote yet (first analysis)
1166
+
1099
1167
  # Prune stale worktree references (e.g. directories deleted externally
1100
1168
  # when simulating cross-runtime or after disk cleanup). Without this,
1101
1169
  # `git worktree add` refuses to create a branch that is "already checked out"
@@ -1144,7 +1212,11 @@ class WorkspaceManager:
1144
1212
  )
1145
1213
  await asyncio.sleep(2 * (_check_attempt + 1))
1146
1214
  try:
1147
- await self._git("fetch", "--all", cwd=main_repo, timeout=60, project_key=project_key)
1215
+ await self._git(
1216
+ "fetch", "origin",
1217
+ f"{branch_name}:refs/remotes/origin/{branch_name}",
1218
+ cwd=main_repo, timeout=60, project_key=project_key,
1219
+ )
1148
1220
  except RuntimeError:
1149
1221
  pass
1150
1222
 
@@ -1227,6 +1299,18 @@ class WorkspaceManager:
1227
1299
 
1228
1300
  If a project SSH key is registered, remote-touching commands
1229
1301
  (clone, fetch, pull, push) will use GIT_SSH_COMMAND.
1302
+
1303
+ Process-group isolation: git is started in its own session so that
1304
+ SIGKILL on timeout propagates to all of git's children (especially
1305
+ the ssh subprocess that git forks for remote operations). Without
1306
+ this, killing git leaves orphaned ssh processes that hold the stderr
1307
+ pipe open — preventing asyncio from detecting EOF and causing the
1308
+ parent to hang until the GIT_CLONE_TIMEOUT fires.
1309
+
1310
+ SSH keepalives: GIT_SSH_COMMAND now includes ServerAliveInterval=30
1311
+ and ServerAliveCountMax=3 so that a stalled TCP connection (server
1312
+ accepts but never sends the git protocol banner) is detected within
1313
+ ~90 s rather than waiting forever.
1230
1314
  """
1231
1315
  env = None
1232
1316
  git_prefix_args: list[str] = []
@@ -1255,6 +1339,14 @@ class WorkspaceManager:
1255
1339
  f" -o StrictHostKeyChecking=accept-new"
1256
1340
  f" -o UserKnownHostsFile=/dev/null"
1257
1341
  f" -o IdentitiesOnly=yes"
1342
+ # Detect a stalled TCP connection (server accepts but
1343
+ # never sends the git protocol banner). After 30 s of
1344
+ # silence the client sends a keepalive; after 3 missed
1345
+ # responses (≤ 90 s total) SSH exits, closes the pipes,
1346
+ # and lets asyncio's communicate() return.
1347
+ f" -o ConnectTimeout=30"
1348
+ f" -o ServerAliveInterval=30"
1349
+ f" -o ServerAliveCountMax=3"
1258
1350
  ),
1259
1351
  }
1260
1352
  except Exception:
@@ -1274,18 +1366,53 @@ class WorkspaceManager:
1274
1366
  if git_prefix_args:
1275
1367
  env = {**(env or os.environ), "GIT_TERMINAL_PROMPT": "0"}
1276
1368
 
1369
+ # start_new_session=True puts git in its own process group.
1370
+ # On timeout we send SIGKILL to the entire group, which includes
1371
+ # any ssh/gpg/credential-helper children that git forked — preventing
1372
+ # orphaned processes from keeping pipes alive.
1373
+ # Windows note: start_new_session creates a new console process group;
1374
+ # we use taskkill /T there instead of killpg.
1277
1375
  proc = await asyncio.create_subprocess_exec(
1278
1376
  "git", *git_prefix_args, *args,
1279
1377
  stdout=asyncio.subprocess.PIPE,
1280
1378
  stderr=asyncio.subprocess.PIPE,
1281
1379
  cwd=str(cwd) if cwd else None,
1282
1380
  env=env,
1381
+ start_new_session=True,
1283
1382
  )
1284
1383
  try:
1285
1384
  stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=timeout)
1286
1385
  except (TimeoutError, asyncio.TimeoutError):
1287
- proc.kill()
1288
- await proc.wait()
1386
+ # Kill the entire process group so that SSH (and any other
1387
+ # child processes git may have spawned) are also terminated.
1388
+ # A plain proc.kill() only kills the direct child (git); the
1389
+ # ssh grandchild becomes orphaned, keeps the stderr pipe open,
1390
+ # and proc.communicate() can never return EOF.
1391
+ try:
1392
+ if sys.platform != "win32":
1393
+ import signal as _signal
1394
+ try:
1395
+ os.killpg(os.getpgid(proc.pid), _signal.SIGKILL)
1396
+ except (ProcessLookupError, PermissionError, OSError):
1397
+ pass
1398
+ else:
1399
+ # Windows: taskkill /F /T kills the process tree
1400
+ import subprocess as _subprocess
1401
+ _subprocess.run(
1402
+ ["taskkill", "/F", "/T", "/PID", str(proc.pid)],
1403
+ capture_output=True,
1404
+ )
1405
+ except Exception:
1406
+ pass
1407
+ finally:
1408
+ try:
1409
+ proc.kill()
1410
+ except Exception:
1411
+ pass
1412
+ try:
1413
+ await asyncio.wait_for(proc.wait(), timeout=5.0)
1414
+ except Exception:
1415
+ pass
1289
1416
  raise RuntimeError(f"git {' '.join(args)} timed out after {timeout}s")
1290
1417
  finally:
1291
1418
  # Clean up temp SSH key file if created
@@ -1353,11 +1480,27 @@ class ProcessManager:
1353
1480
 
1354
1481
  @staticmethod
1355
1482
  def _extract_output_signals(text: str) -> dict[str, Any]:
1356
- """Parse stdout/stderr-like streams for success and failure signals."""
1483
+ """Parse stdout/stderr-like streams for success and failure signals.
1484
+
1485
+ Uses structural signal detection — NOT keyword matching on result text.
1486
+
1487
+ The key invariant for Claude stream-json:
1488
+ - A genuine execution always emits at least one 'assistant' event AND
1489
+ has total_input_tokens > 0 in the result event, because the CLI made
1490
+ a real API call.
1491
+ - A pre-call failure (API Error, Connection error, auth failure) exits
1492
+ immediately: no 'assistant' events, and total_input_tokens == 0 in
1493
+ the result event. The result text contains the CLI error message.
1494
+
1495
+ This avoids false positives from agent work output that legitimately
1496
+ contains words like 'rate limit', 'connection error', 'API error', etc.
1497
+ (e.g. implementing a rate-limiter feature, or documenting error codes).
1498
+ """
1357
1499
  has_turn_completed = False
1358
1500
  has_turn_failed = False
1359
1501
  has_result = False
1360
1502
  has_meaningful_content = False
1503
+ has_assistant_events = False # True once any 'assistant' event is seen
1361
1504
  error_messages: list[str] = []
1362
1505
  json_line_count = 0
1363
1506
 
@@ -1387,18 +1530,31 @@ class ProcessManager:
1387
1530
  elif isinstance(err, str):
1388
1531
  error_messages.append(err)
1389
1532
  elif ev_type == "result":
1533
+ result_text = str(data.get("result", "") or "")
1390
1534
  if data.get("is_error"):
1391
- err_text = str(data.get("result", "") or data.get("error", "") or "result marked as error")
1535
+ err_text = result_text or str(data.get("error", "") or "result marked as error")
1392
1536
  error_messages.append(err_text)
1393
1537
  else:
1394
- has_result = True
1395
- has_meaningful_content = True
1538
+ # Structural check: if no tokens were consumed AND no assistant
1539
+ # events appeared, the CLI never made an API call. The result
1540
+ # text is a CLI-level error (e.g. "API Error: Connection error.")
1541
+ # rather than the agent's actual work output.
1542
+ tok_in = int(data.get("total_input_tokens", 0) or 0)
1543
+ tok_out = int(data.get("total_output_tokens", 0) or 0)
1544
+ no_api_call = (tok_in + tok_out == 0) and not has_assistant_events
1545
+ if no_api_call and result_text:
1546
+ error_messages.append(result_text)
1547
+ else:
1548
+ has_result = True
1549
+ has_meaningful_content = True
1396
1550
  elif ev_type == "error":
1397
1551
  msg = data.get("message", "")
1398
1552
  if msg:
1399
1553
  error_messages.append(msg)
1554
+ elif ev_type == "assistant":
1555
+ has_meaningful_content = True
1556
+ has_assistant_events = True
1400
1557
  elif ev_type in (
1401
- "assistant",
1402
1558
  "content_block_delta",
1403
1559
  "message_delta",
1404
1560
  "step_finish",
@@ -1557,7 +1713,10 @@ class ProcessManager:
1557
1713
  start_time = time.monotonic()
1558
1714
 
1559
1715
  if agent.agent_id == "claude-code":
1560
- result = await self._run_claude(agent, prompt, workspace_path, timeout, task.task_id, on_chunk)
1716
+ result = await self._run_claude(
1717
+ agent, prompt, workspace_path, timeout, task.task_id, on_chunk,
1718
+ node_type=task.node_type,
1719
+ )
1561
1720
  elif agent.agent_id == "codex":
1562
1721
  result = await self._run_codex(agent, prompt, workspace_path, timeout, task.task_id, on_chunk)
1563
1722
  elif agent.agent_id == "opencode":
@@ -1778,18 +1937,37 @@ class ProcessManager:
1778
1937
 
1779
1938
  async def _run_claude(
1780
1939
  self, agent: DiscoveredAgent, prompt: str, cwd: Path, timeout: int, task_id: str,
1781
- on_chunk: Any = None,
1940
+ on_chunk: Any = None, node_type: str = "",
1782
1941
  ) -> TaskResult:
1783
1942
  """Run Claude Code CLI in print mode with auto-approve permissions.
1784
1943
 
1785
1944
  Uses stdin pipe for prompt delivery to avoid ARG_MAX limits.
1786
1945
  Uses --dangerously-skip-permissions for autonomous file operations.
1946
+ Uses --max-turns to prevent context window overflow during long sessions.
1787
1947
  """
1948
+ # Determine max turns based on node type.
1949
+ # Analysis/coding nodes need more turns (read many files, write multiple outputs).
1950
+ # Review/testing nodes are typically shorter.
1951
+ # This prevents runaway context accumulation that leads to "Prompt is too long".
1952
+ max_turns_map = {
1953
+ "analysis": 80,
1954
+ "coding": 80,
1955
+ "design": 50,
1956
+ "testing": 60,
1957
+ "review": 40,
1958
+ "fix": 60,
1959
+ }
1960
+ max_turns = int(os.environ.get(
1961
+ "FACTORY_CLAUDE_MAX_TURNS",
1962
+ str(max_turns_map.get(node_type, 60)),
1963
+ ))
1964
+
1788
1965
  cmd = [
1789
1966
  agent.command,
1790
1967
  "-p",
1791
1968
  "--output-format", "stream-json",
1792
1969
  "--verbose",
1970
+ "--max-turns", str(max_turns),
1793
1971
  "--dangerously-skip-permissions",
1794
1972
  ]
1795
1973
 
@@ -1825,6 +2003,24 @@ class ProcessManager:
1825
2003
  metrics=metrics,
1826
2004
  )
1827
2005
  else:
2006
+ # Detect context overflow: Claude exits 1 with "Prompt is too long" in output
2007
+ _combined_output = stdout[-20000:] + stderr[-500:]
2008
+ if "Prompt is too long" in _combined_output or "prompt is too long" in _combined_output:
2009
+ logger.error(
2010
+ "Context overflow for task %s: Claude context window exceeded "
2011
+ "(%d stdout chars). Consider reducing prompt size.",
2012
+ task_id, len(stdout),
2013
+ )
2014
+ return TaskResult(
2015
+ status="failed",
2016
+ exit_code=returncode,
2017
+ stdout=stdout[-settings.AGENT_MAX_OUTPUT_SIZE:],
2018
+ stderr=stderr[-10000:],
2019
+ error="context_overflow: Claude's context window was exceeded during execution. "
2020
+ "The accumulated conversation history (initial prompt + tool call results) "
2021
+ "exceeded the model's limit. Reduce prompt size or shorten file reads.",
2022
+ metrics=metrics,
2023
+ )
1828
2024
  return TaskResult(
1829
2025
  status="failed",
1830
2026
  exit_code=returncode,
@@ -3632,9 +3828,13 @@ class RuntimeDaemon:
3632
3828
  required_files = _get_analysis_outputs_for_type(req_type)
3633
3829
 
3634
3830
  # Analysis deliverables live in analysis_output_dir (docs/requirements/...)
3831
+ _input = task.input_data or {}
3635
3832
  doc_dir = (
3636
- (task.input_data or {}).get("analysis_output_dir", "")
3637
- or (task.input_data or {}).get("output_dir", "")
3833
+ _input.get("analysis_output_dir")
3834
+ or _input.get("context", {}).get("analysis_output_dir")
3835
+ or _input.get("output_dir")
3836
+ or _input.get("context", {}).get("output_dir")
3837
+ or ""
3638
3838
  )
3639
3839
  if doc_dir:
3640
3840
  base = workspace_path / doc_dir
@@ -3718,7 +3918,12 @@ class RuntimeDaemon:
3718
3918
  pass
3719
3919
 
3720
3920
  if not _skip_test_artifacts:
3721
- doc_dir = (task.input_data or {}).get("output_dir", "")
3921
+ _input = task.input_data or {}
3922
+ doc_dir = (
3923
+ _input.get("output_dir")
3924
+ or _input.get("context", {}).get("output_dir")
3925
+ or ""
3926
+ )
3722
3927
  if doc_dir:
3723
3928
  base = workspace_path / doc_dir
3724
3929
  else:
@@ -3809,11 +4014,25 @@ class RuntimeDaemon:
3809
4014
  ],
3810
4015
  )
3811
4016
 
3812
- # Build a targeted fix prompt
4017
+ # Build a targeted fix prompt with output directory context
4018
+ _input = task.input_data or {}
4019
+ _fix_doc_dir = (
4020
+ _input.get("output_dir")
4021
+ or _input.get("context", {}).get("output_dir")
4022
+ or ""
4023
+ )
3813
4024
  fix_prompt = (
3814
4025
  "The previous execution produced output with validation errors.\n"
3815
4026
  "Please fix ALL of the following issues:\n\n"
3816
4027
  f"{issues_text}\n\n"
4028
+ )
4029
+ if _fix_doc_dir:
4030
+ fix_prompt += (
4031
+ f"IMPORTANT: All deliverable files (test-cases.json, coverage-matrix.json, "
4032
+ f"test-report.md, design.md, etc.) MUST be written to the `{_fix_doc_dir}/` "
4033
+ f"directory, NOT the workspace root.\n\n"
4034
+ )
4035
+ fix_prompt += (
3817
4036
  "Fix the issues in-place. Do NOT recreate files that are already correct.\n"
3818
4037
  "Only fix the specific problems listed above."
3819
4038
  )
@@ -4024,7 +4243,13 @@ class RuntimeDaemon:
4024
4243
  # ── Pre-push rebase onto latest default branch ──
4025
4244
  # This ensures the AI's branch incorporates the latest remote
4026
4245
  # changes, dramatically reducing PR merge conflicts.
4027
- await self._rebase_onto_latest(workspace_path, default_branch, task, project_key)
4246
+ # SKIP for analysis nodes: analysis produces documentation files
4247
+ # (PRD.md, SDD.md, etc.) that are independent of codebase changes.
4248
+ # Rebasing analysis commits onto default_branch is unnecessary
4249
+ # overhead and rewrites commit SHAs, which complicates pushes
4250
+ # on subsequent iterations of the same requirement.
4251
+ if task.node_type != "analysis":
4252
+ await self._rebase_onto_latest(workspace_path, default_branch, task, project_key)
4028
4253
 
4029
4254
  # ── Verify we're on the correct branch before pushing ──
4030
4255
  current_branch = ""
@@ -4550,10 +4775,79 @@ class RuntimeDaemon:
4550
4775
  unpushed = "first-push"
4551
4776
 
4552
4777
  if unpushed:
4778
+ # Check if remote has commits not in local history (diverged).
4779
+ try:
4780
+ remote_ahead = (await git(
4781
+ "log", f"HEAD..origin/{branch}", "--oneline", cwd=workspace_path,
4782
+ )).strip()
4783
+ except RuntimeError:
4784
+ remote_ahead = "" # Remote branch doesn't exist yet
4785
+
4786
+ if remote_ahead:
4787
+ remote_count = len(remote_ahead.splitlines())
4788
+ # Divergence detected — but this is expected after rebase.
4789
+ # Check if the remote commits are already incorporated
4790
+ # (i.e., their patches are empty against our branch, meaning
4791
+ # the rebase already applied them with new SHAs).
4792
+ # `git cherry HEAD origin/branch` lists commits from
4793
+ # origin/branch not in HEAD; lines starting with "-" are
4794
+ # already incorporated (equivalent patch exists).
4795
+ rebase_divergence = False
4796
+ try:
4797
+ cherry_out = (await git(
4798
+ "cherry", "HEAD", f"origin/{branch}",
4799
+ cwd=workspace_path,
4800
+ )).strip()
4801
+ if cherry_out:
4802
+ # All lines starting with "-" means all remote
4803
+ # commits are already in our branch (rebased).
4804
+ # Lines starting with "+" are truly missing.
4805
+ truly_missing = [
4806
+ line for line in cherry_out.splitlines()
4807
+ if line.startswith("+ ")
4808
+ ]
4809
+ rebase_divergence = len(truly_missing) == 0
4810
+ else:
4811
+ # No cherry output — remote commits are empty or
4812
+ # fully equivalent
4813
+ rebase_divergence = True
4814
+ except RuntimeError:
4815
+ pass
4816
+
4817
+ if rebase_divergence:
4818
+ logger.info(
4819
+ "Branch %s diverged from origin (%d remote commit(s)) "
4820
+ "but all are already incorporated (rebase). "
4821
+ "Using --force-with-lease to push safely.",
4822
+ branch, remote_count,
4823
+ )
4824
+ try:
4825
+ await git(
4826
+ "push", "--force-with-lease", "-u", "origin", branch,
4827
+ cwd=workspace_path, project_key=project_key,
4828
+ )
4829
+ logger.info("Force-pushed (with lease) branch %s to origin", branch)
4830
+ return None
4831
+ except RuntimeError as exc:
4832
+ logger.error("Force-push (with lease) failed for %s: %s", branch, exc)
4833
+ return f"Push failed: {exc}"
4834
+ else:
4835
+ logger.error(
4836
+ "SAFETY: Refusing to push %s — remote has %d commit(s) "
4837
+ "not in local branch. This would destroy prior work. "
4838
+ "Remote-only commits:\n%s",
4839
+ branch, remote_count, remote_ahead,
4840
+ )
4841
+ return (
4842
+ f"Push refused: remote branch '{branch}' has {remote_count} "
4843
+ f"commit(s) not in local history. Force-pushing would "
4844
+ f"destroy prior implementation work."
4845
+ )
4846
+
4553
4847
  logger.info("Found unpushed commits on %s, pushing...", branch)
4554
4848
  try:
4555
4849
  await git(
4556
- "push", "--force-with-lease", "-u", "origin", branch,
4850
+ "push", "-u", "origin", branch,
4557
4851
  cwd=workspace_path, project_key=project_key,
4558
4852
  )
4559
4853
  logger.info("Pushed branch %s to origin", branch)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: forgexa-cli
3
- Version: 1.5.2
3
+ Version: 1.6.1
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
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "forgexa-cli"
3
- version = "1.5.2"
3
+ version = "1.6.1"
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" }
File without changes
File without changes