forgexa-cli 1.4.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.4.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.4.2"
2
+ __version__ = "1.6.1"
@@ -279,6 +279,10 @@ except (ImportError, ModuleNotFoundError):
279
279
  def AGENT_TIMEOUT(self) -> int:
280
280
  return int(os.environ.get("AGENT_TIMEOUT", "3600"))
281
281
 
282
+ @property
283
+ def GIT_CLONE_TIMEOUT(self) -> int:
284
+ return int(os.environ.get("GIT_CLONE_TIMEOUT", "600"))
285
+
282
286
  @property
283
287
  def AGENT_MAX_OUTPUT_SIZE(self) -> int:
284
288
  return int(os.environ.get("AGENT_MAX_OUTPUT_SIZE", "100000"))
@@ -303,7 +307,7 @@ except (ImportError, ModuleNotFoundError):
303
307
  # DAEMON_VERSION is the protocol/logic version of the daemon code.
304
308
  # Kept in sync with pyproject.toml version via bump-version.sh.
305
309
  # CLIENT_TYPE identifies which packaging/distribution this daemon runs in.
306
- DAEMON_VERSION = "1.4.2"
310
+ DAEMON_VERSION = "1.6.1"
307
311
 
308
312
 
309
313
  def _detect_client_type() -> str:
@@ -469,9 +473,12 @@ def get_os_info() -> str:
469
473
  return f"{distro} {machine}"
470
474
  return f"Linux {platform.release().split('-')[0]} {machine}"
471
475
  elif system == "Windows":
472
- # e.g. "Windows 10.0 AMD64"
473
- win_ver = platform.version().split('.')[0:2]
474
- 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}"
475
482
  else:
476
483
  return f"{system} {platform.release()} {machine}"
477
484
 
@@ -865,12 +872,19 @@ class WorkspaceManager:
865
872
  branch_name = f"feature/{workspace_key}"
866
873
 
867
874
  # Determine whether this node is the "first" in a new graph that needs
868
- # a fresh base from the default branch. Analysis nodes always start
869
- # fresh unless using "refine" mode which preserves the existing branch.
870
- 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")
871
885
  is_fresh_start = (
872
886
  task.node_type == "analysis"
873
- and analysis_mode != "refine"
887
+ and analysis_mode == "fresh"
874
888
  )
875
889
 
876
890
  if repo_url:
@@ -973,11 +987,52 @@ class WorkspaceManager:
973
987
  logger.warning("Broken worktree detected at %s — removing and recreating", ws_path)
974
988
  await self._remove_broken_worktree(main_repo, ws_path, workspace_key)
975
989
  else:
976
- # 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.
977
995
  try:
978
996
  await self._git("fetch", "origin", cwd=ws_path, project_key=project_key)
979
997
  except RuntimeError:
980
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
981
1036
 
982
1037
  if fresh_start:
983
1038
  logger.info("Fresh start: resetting %s to origin/%s", ws_path, default_branch)
@@ -1032,7 +1087,9 @@ class WorkspaceManager:
1032
1087
  await asyncio.sleep(2 * (_sync_attempt + 1))
1033
1088
  try:
1034
1089
  await self._git(
1035
- "fetch", "origin", cwd=ws_path,
1090
+ "fetch", "origin",
1091
+ f"{branch_name}:refs/remotes/origin/{branch_name}",
1092
+ cwd=ws_path,
1036
1093
  project_key=project_key,
1037
1094
  )
1038
1095
  except RuntimeError:
@@ -1057,7 +1114,9 @@ class WorkspaceManager:
1057
1114
  await asyncio.sleep(2 * (_sync_attempt + 1))
1058
1115
  try:
1059
1116
  await self._git(
1060
- "fetch", "origin", cwd=ws_path,
1117
+ "fetch", "origin",
1118
+ f"{branch_name}:refs/remotes/origin/{branch_name}",
1119
+ cwd=ws_path,
1061
1120
  project_key=project_key,
1062
1121
  )
1063
1122
  except RuntimeError:
@@ -1085,10 +1144,26 @@ class WorkspaceManager:
1085
1144
 
1086
1145
  # Ensure _main repo is present and up-to-date
1087
1146
  if not main_repo.exists():
1088
- await self._git("clone", repo_url, str(main_repo), timeout=600, project_key=project_key)
1147
+ await self._git(
1148
+ "clone", "--single-branch", "--no-tags",
1149
+ repo_url, str(main_repo), timeout=settings.GIT_CLONE_TIMEOUT, project_key=project_key,
1150
+ )
1089
1151
  else:
1090
1152
  await self._git("fetch", "--all", cwd=main_repo, timeout=300, project_key=project_key)
1091
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
+
1092
1167
  # Prune stale worktree references (e.g. directories deleted externally
1093
1168
  # when simulating cross-runtime or after disk cleanup). Without this,
1094
1169
  # `git worktree add` refuses to create a branch that is "already checked out"
@@ -1137,7 +1212,11 @@ class WorkspaceManager:
1137
1212
  )
1138
1213
  await asyncio.sleep(2 * (_check_attempt + 1))
1139
1214
  try:
1140
- 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
+ )
1141
1220
  except RuntimeError:
1142
1221
  pass
1143
1222
 
@@ -1173,7 +1252,10 @@ class WorkspaceManager:
1173
1252
  )
1174
1253
  except Exception:
1175
1254
  ws_path.mkdir(parents=True, exist_ok=True)
1176
- await self._git("clone", repo_url, str(ws_path), project_key=project_key)
1255
+ await self._git(
1256
+ "clone", "--single-branch", "--no-tags",
1257
+ repo_url, str(ws_path), timeout=settings.GIT_CLONE_TIMEOUT, project_key=project_key,
1258
+ )
1177
1259
  # Ensure we're on the correct branch after clone
1178
1260
  try:
1179
1261
  await self._git("checkout", "-B", branch_name, cwd=ws_path)
@@ -1195,7 +1277,10 @@ class WorkspaceManager:
1195
1277
  except Exception:
1196
1278
  # Fallback to simple clone
1197
1279
  ws_path.mkdir(parents=True, exist_ok=True)
1198
- await self._git("clone", repo_url, str(ws_path), project_key=project_key)
1280
+ await self._git(
1281
+ "clone", "--single-branch", "--no-tags",
1282
+ repo_url, str(ws_path), timeout=settings.GIT_CLONE_TIMEOUT, project_key=project_key,
1283
+ )
1199
1284
  # Ensure we're on the correct branch after clone
1200
1285
  try:
1201
1286
  await self._git("checkout", "-B", branch_name, cwd=ws_path)
@@ -1214,6 +1299,18 @@ class WorkspaceManager:
1214
1299
 
1215
1300
  If a project SSH key is registered, remote-touching commands
1216
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.
1217
1314
  """
1218
1315
  env = None
1219
1316
  git_prefix_args: list[str] = []
@@ -1242,6 +1339,14 @@ class WorkspaceManager:
1242
1339
  f" -o StrictHostKeyChecking=accept-new"
1243
1340
  f" -o UserKnownHostsFile=/dev/null"
1244
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"
1245
1350
  ),
1246
1351
  }
1247
1352
  except Exception:
@@ -1261,18 +1366,53 @@ class WorkspaceManager:
1261
1366
  if git_prefix_args:
1262
1367
  env = {**(env or os.environ), "GIT_TERMINAL_PROMPT": "0"}
1263
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.
1264
1375
  proc = await asyncio.create_subprocess_exec(
1265
1376
  "git", *git_prefix_args, *args,
1266
1377
  stdout=asyncio.subprocess.PIPE,
1267
1378
  stderr=asyncio.subprocess.PIPE,
1268
1379
  cwd=str(cwd) if cwd else None,
1269
1380
  env=env,
1381
+ start_new_session=True,
1270
1382
  )
1271
1383
  try:
1272
1384
  stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=timeout)
1273
1385
  except (TimeoutError, asyncio.TimeoutError):
1274
- proc.kill()
1275
- 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
1276
1416
  raise RuntimeError(f"git {' '.join(args)} timed out after {timeout}s")
1277
1417
  finally:
1278
1418
  # Clean up temp SSH key file if created
@@ -1340,11 +1480,27 @@ class ProcessManager:
1340
1480
 
1341
1481
  @staticmethod
1342
1482
  def _extract_output_signals(text: str) -> dict[str, Any]:
1343
- """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
+ """
1344
1499
  has_turn_completed = False
1345
1500
  has_turn_failed = False
1346
1501
  has_result = False
1347
1502
  has_meaningful_content = False
1503
+ has_assistant_events = False # True once any 'assistant' event is seen
1348
1504
  error_messages: list[str] = []
1349
1505
  json_line_count = 0
1350
1506
 
@@ -1374,18 +1530,31 @@ class ProcessManager:
1374
1530
  elif isinstance(err, str):
1375
1531
  error_messages.append(err)
1376
1532
  elif ev_type == "result":
1533
+ result_text = str(data.get("result", "") or "")
1377
1534
  if data.get("is_error"):
1378
- 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")
1379
1536
  error_messages.append(err_text)
1380
1537
  else:
1381
- has_result = True
1382
- 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
1383
1550
  elif ev_type == "error":
1384
1551
  msg = data.get("message", "")
1385
1552
  if msg:
1386
1553
  error_messages.append(msg)
1554
+ elif ev_type == "assistant":
1555
+ has_meaningful_content = True
1556
+ has_assistant_events = True
1387
1557
  elif ev_type in (
1388
- "assistant",
1389
1558
  "content_block_delta",
1390
1559
  "message_delta",
1391
1560
  "step_finish",
@@ -1422,21 +1591,29 @@ class ProcessManager:
1422
1591
  Returns True for rate/quota limits AND API unavailability errors,
1423
1592
  since a different agent (using a different API backend) may succeed.
1424
1593
 
1425
- IMPORTANT: Only checks stderr, error message, and the tail of stdout.
1426
- The full stdout contains the agent's work output (e.g., analysis text
1427
- about APIs, retry logic, HTTP status codes) which naturally contains
1428
- patterns like "429", "try again", "capacity" these are NOT indicators
1429
- of the agent CLI itself being rate-limited.
1594
+ IMPORTANT: Only checks stderr and error message. When exit code is
1595
+ non-zero, also checks the tail of stdout (last 3000 chars) since the
1596
+ error is likely at the end. When exit code is 0 (agent reported
1597
+ success but _detect_agent_output_failure set status to failed), do
1598
+ NOT scan stdout — it contains the agent's work output (configs, code)
1599
+ which naturally has terms like "rate_limit", "API_RATE_LIMIT_PER_MINUTE"
1600
+ that trigger false positives.
1430
1601
  """
1431
1602
  if result.status == "success":
1432
1603
  return False
1433
- # Search error channels: stderr (CLI errors) + error message + tail of stdout
1434
- # (last 3000 chars catches any CLI-level error at the end of output)
1435
- error_text = (
1436
- (result.stderr or "")
1437
- + "\n" + (result.error or "")
1438
- + "\n" + (result.stdout or "")[-3000:]
1439
- ).lower()
1604
+ # When exit code is 0, _detect_agent_output_failure already checked
1605
+ # stderr+error for rate-limit patterns. Don't re-scan stdout here.
1606
+ if result.exit_code == 0:
1607
+ error_text = (
1608
+ (result.stderr or "")
1609
+ + "\n" + (result.error or "")
1610
+ ).lower()
1611
+ else:
1612
+ error_text = (
1613
+ (result.stderr or "")
1614
+ + "\n" + (result.error or "")
1615
+ + "\n" + (result.stdout or "")[-3000:]
1616
+ ).lower()
1440
1617
  return (
1441
1618
  any(p in error_text for p in ProcessManager.RATE_LIMIT_PATTERNS)
1442
1619
  or any(p in error_text for p in ProcessManager.AGENT_UNAVAILABLE_PATTERNS)
@@ -1456,16 +1633,16 @@ class ProcessManager:
1456
1633
  if result.status != "success":
1457
1634
  return None
1458
1635
 
1459
- # For rate/unavailability pattern detection, only check error channels
1460
- # (stderr, error field) plus the TAIL of stdout. The full stdout contains
1461
- # the agent's work output (analysis text, generated docs) which naturally
1462
- # mentions terms like "rate limit", "429", "capacity", "credit" etc.
1463
- error_channels = (
1464
- (result.stderr or "")
1465
- + "\n" + (result.error or "")
1466
- + "\n" + (result.stdout or "")[-3000:]
1467
- )
1468
- pattern_failure = ProcessManager._has_failure_pattern(error_channels)
1636
+ # For exit-code-0 (success) cases, only scan stderr and the error field
1637
+ # for rate-limit / unavailability patterns. Stdout contains the agent's
1638
+ # actual task output (code, configs, analysis docs) which may legitimately
1639
+ # contain substrings like "rate_limit", "429", "quota", etc. — e.g. writing
1640
+ # a config file with API_RATE_LIMIT_PER_MINUTE=1000 would previously trigger
1641
+ # a false "quota exhaustion" failure even though the agent succeeded.
1642
+ # stdout[-N:] is only safe to scan when the agent already failed (exit != 0),
1643
+ # which is handled by is_rate_limited() called at the orchestrator level.
1644
+ error_only_channels = (result.stderr or "") + "\n" + (result.error or "")
1645
+ pattern_failure = ProcessManager._has_failure_pattern(error_only_channels)
1469
1646
  if pattern_failure:
1470
1647
  return pattern_failure
1471
1648
 
@@ -1536,7 +1713,10 @@ class ProcessManager:
1536
1713
  start_time = time.monotonic()
1537
1714
 
1538
1715
  if agent.agent_id == "claude-code":
1539
- 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
+ )
1540
1720
  elif agent.agent_id == "codex":
1541
1721
  result = await self._run_codex(agent, prompt, workspace_path, timeout, task.task_id, on_chunk)
1542
1722
  elif agent.agent_id == "opencode":
@@ -1757,18 +1937,37 @@ class ProcessManager:
1757
1937
 
1758
1938
  async def _run_claude(
1759
1939
  self, agent: DiscoveredAgent, prompt: str, cwd: Path, timeout: int, task_id: str,
1760
- on_chunk: Any = None,
1940
+ on_chunk: Any = None, node_type: str = "",
1761
1941
  ) -> TaskResult:
1762
1942
  """Run Claude Code CLI in print mode with auto-approve permissions.
1763
1943
 
1764
1944
  Uses stdin pipe for prompt delivery to avoid ARG_MAX limits.
1765
1945
  Uses --dangerously-skip-permissions for autonomous file operations.
1946
+ Uses --max-turns to prevent context window overflow during long sessions.
1766
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
+
1767
1965
  cmd = [
1768
1966
  agent.command,
1769
1967
  "-p",
1770
1968
  "--output-format", "stream-json",
1771
1969
  "--verbose",
1970
+ "--max-turns", str(max_turns),
1772
1971
  "--dangerously-skip-permissions",
1773
1972
  ]
1774
1973
 
@@ -1804,6 +2003,24 @@ class ProcessManager:
1804
2003
  metrics=metrics,
1805
2004
  )
1806
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
+ )
1807
2024
  return TaskResult(
1808
2025
  status="failed",
1809
2026
  exit_code=returncode,
@@ -1849,7 +2066,13 @@ class ProcessManager:
1849
2066
  on_chunk: Any = None,
1850
2067
  ) -> TaskResult:
1851
2068
  """Run OpenCode CLI in non-interactive mode."""
1852
- cmd = [agent.command, "run", "--format", "json", "--dangerously-skip-permissions", prompt]
2069
+ cmd = [
2070
+ agent.command, "run",
2071
+ "--format", "json",
2072
+ "--dangerously-skip-permissions",
2073
+ "--cwd", str(cwd),
2074
+ prompt,
2075
+ ]
1853
2076
  result = await self._run_cli(cmd, cwd, timeout, task_id, on_chunk=on_chunk)
1854
2077
  parsed_metrics = self._parse_agent_jsonl_output(result.stdout)
1855
2078
  result.metrics.update(parsed_metrics)
@@ -3605,9 +3828,13 @@ class RuntimeDaemon:
3605
3828
  required_files = _get_analysis_outputs_for_type(req_type)
3606
3829
 
3607
3830
  # Analysis deliverables live in analysis_output_dir (docs/requirements/...)
3831
+ _input = task.input_data or {}
3608
3832
  doc_dir = (
3609
- (task.input_data or {}).get("analysis_output_dir", "")
3610
- 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 ""
3611
3838
  )
3612
3839
  if doc_dir:
3613
3840
  base = workspace_path / doc_dir
@@ -3691,7 +3918,12 @@ class RuntimeDaemon:
3691
3918
  pass
3692
3919
 
3693
3920
  if not _skip_test_artifacts:
3694
- 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
+ )
3695
3927
  if doc_dir:
3696
3928
  base = workspace_path / doc_dir
3697
3929
  else:
@@ -3782,11 +4014,25 @@ class RuntimeDaemon:
3782
4014
  ],
3783
4015
  )
3784
4016
 
3785
- # 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
+ )
3786
4024
  fix_prompt = (
3787
4025
  "The previous execution produced output with validation errors.\n"
3788
4026
  "Please fix ALL of the following issues:\n\n"
3789
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 += (
3790
4036
  "Fix the issues in-place. Do NOT recreate files that are already correct.\n"
3791
4037
  "Only fix the specific problems listed above."
3792
4038
  )
@@ -3997,7 +4243,13 @@ class RuntimeDaemon:
3997
4243
  # ── Pre-push rebase onto latest default branch ──
3998
4244
  # This ensures the AI's branch incorporates the latest remote
3999
4245
  # changes, dramatically reducing PR merge conflicts.
4000
- 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)
4001
4253
 
4002
4254
  # ── Verify we're on the correct branch before pushing ──
4003
4255
  current_branch = ""
@@ -4523,10 +4775,79 @@ class RuntimeDaemon:
4523
4775
  unpushed = "first-push"
4524
4776
 
4525
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
+
4526
4847
  logger.info("Found unpushed commits on %s, pushing...", branch)
4527
4848
  try:
4528
4849
  await git(
4529
- "push", "--force-with-lease", "-u", "origin", branch,
4850
+ "push", "-u", "origin", branch,
4530
4851
  cwd=workspace_path, project_key=project_key,
4531
4852
  )
4532
4853
  logger.info("Pushed branch %s to origin", branch)
@@ -4,17 +4,20 @@ Forgexa CLI — command-line client for the Forgexa platform.
4
4
  A lightweight, standalone CLI that communicates with the Forgexa server via REST API.
5
5
  Zero external dependencies — uses only Python stdlib.
6
6
 
7
- Configuration:
8
- FORGEXA_SERVER_URL Server URL (default: https://api.forgexa.net)
9
- FORGEXA_TOKEN Bearer token (obtain via `forgexa login`)
7
+ Configuration (in priority order):
8
+ 1. --server-url flag Per-command override
9
+ 2. FORGEXA_SERVER_URL env var Session-level override
10
+ 3. ~/.forgexa/config Saved via `forgexa login --server <url>`
11
+ 4. Build default https://api.forgexa.net
10
12
 
11
13
  Usage:
12
- forgexa login
14
+ forgexa login --server https://your-server.com
13
15
  forgexa workspace list
14
16
  forgexa project list --workspace <id>
15
17
  forgexa requirement list --project <id>
16
18
  forgexa daemon status
17
19
  forgexa gates pending
20
+ forgexa config show
18
21
  forgexa --help
19
22
  """
20
23
  from __future__ import annotations
@@ -27,23 +30,56 @@ import signal
27
30
  import sys
28
31
  from pathlib import Path
29
32
 
30
- # ── HTTP helpers (stdlib only) ──
33
+ # ── Build-time default ──
34
+ # Resolved in priority order at runtime (see _api_url()):
35
+ # 1. --server-url flag
36
+ # 2. FORGEXA_SERVER_URL environment variable
37
+ # 3. ~/.forgexa/config (saved by `forgexa login --server <url>`)
38
+ # 4. This build default
31
39
 
32
-
33
- # Default server URL — resolved in priority order:
34
- # 1. FORGEXA_SERVER_URL environment variable (runtime override)
35
- # 2. _build_config.py — generated by publish.sh at wheel-build time
36
- # 3. Hardcoded fallback — https://api.forgexa.net
37
- #
38
- # For local development use: FORGEXA_SERVER_URL=http://localhost:8000 forgexa ...
39
40
  try:
40
- from forgexa_cli._build_config import BUILD_SERVER_URL as _DEFAULT_SERVER_URL
41
+ from forgexa_cli._build_config import BUILD_SERVER_URL as _BUILD_DEFAULT
41
42
  except ImportError:
42
- _DEFAULT_SERVER_URL = "https://api.forgexa.net"
43
+ _BUILD_DEFAULT = "https://api.forgexa.net"
44
+
45
+ # Module-level override applied by main() when --server-url is passed.
46
+ _SERVER_URL_OVERRIDE: str | None = None
47
+
48
+
49
+ # ── Config file helpers (~/.forgexa/config) ──
50
+
51
+ def _config_path() -> Path:
52
+ return Path.home() / ".forgexa" / "config"
53
+
54
+
55
+ def _load_config() -> dict:
56
+ p = _config_path()
57
+ if p.exists():
58
+ try:
59
+ return json.loads(p.read_text())
60
+ except Exception:
61
+ return {}
62
+ return {}
63
+
64
+
65
+ def _save_config(data: dict) -> None:
66
+ p = _config_path()
67
+ p.parent.mkdir(exist_ok=True)
68
+ p.write_text(json.dumps(data, indent=2))
69
+ p.chmod(0o600)
43
70
 
44
71
 
45
72
  def _api_url() -> str:
46
- return os.environ.get("FORGEXA_SERVER_URL", _DEFAULT_SERVER_URL)
73
+ """Resolve the server URL using priority chain."""
74
+ if _SERVER_URL_OVERRIDE:
75
+ return _SERVER_URL_OVERRIDE
76
+ env = os.environ.get("FORGEXA_SERVER_URL")
77
+ if env:
78
+ return env
79
+ cfg = _load_config()
80
+ if cfg.get("server_url"):
81
+ return cfg["server_url"]
82
+ return _BUILD_DEFAULT
47
83
 
48
84
 
49
85
  def _token() -> str | None:
@@ -53,7 +89,8 @@ def _token() -> str | None:
53
89
  token_file = Path.home() / ".forgexa" / "token"
54
90
  if token_file.exists():
55
91
  return token_file.read_text().strip()
56
- return None
92
+ cfg = _load_config()
93
+ return cfg.get("token") or None
57
94
 
58
95
 
59
96
  def _headers() -> dict[str, str]:
@@ -157,26 +194,83 @@ def _print_table(headers: list[str], rows: list[list[str]], fmt: str | None = No
157
194
 
158
195
 
159
196
  def cmd_login(args: argparse.Namespace) -> None:
197
+ # If --server was given, apply it for this login request and save it.
198
+ server = getattr(args, "server", None)
199
+ if server:
200
+ global _SERVER_URL_OVERRIDE
201
+ _SERVER_URL_OVERRIDE = server.rstrip("/")
202
+
160
203
  email = args.email or input("Email: ")
161
204
  password = args.password or getpass.getpass("Password: ")
162
205
  result = _post("/auth/login", {"email": email, "password": password})
163
206
  token = result.get("access_token", "")
164
- # Save token to file
207
+
208
+ # Save to config file (server_url + token in one place)
209
+ cfg = _load_config()
210
+ if server:
211
+ cfg["server_url"] = _SERVER_URL_OVERRIDE
212
+ cfg["token"] = token
213
+ _save_config(cfg)
214
+
215
+ # Also keep the legacy token file for backwards compatibility
165
216
  token_dir = Path.home() / ".forgexa"
166
217
  token_dir.mkdir(exist_ok=True)
167
218
  (token_dir / "token").write_text(token)
168
219
  (token_dir / "token").chmod(0o600)
169
- print(f"Login successful. Token saved to ~/.forgexa/token")
170
- print(f"Or set manually: export FORGEXA_TOKEN={token}")
220
+
221
+ active_server = _api_url()
222
+ print(f"Login successful.")
223
+ print(f" Server : {active_server}")
224
+ print(f" Config : ~/.forgexa/config")
171
225
 
172
226
 
173
227
  def cmd_logout(_args: argparse.Namespace) -> None:
228
+ cleared = False
174
229
  token_file = Path.home() / ".forgexa" / "token"
175
230
  if token_file.exists():
176
231
  token_file.unlink()
232
+ cleared = True
233
+ cfg = _load_config()
234
+ if "token" in cfg:
235
+ del cfg["token"]
236
+ _save_config(cfg)
237
+ cleared = True
238
+ if cleared:
177
239
  print("Logged out. Token removed.")
178
240
  else:
179
- print("No token file found.")
241
+ print("No token found.")
242
+
243
+
244
+ def cmd_config_show(_args: argparse.Namespace) -> None:
245
+ """Show current CLI configuration."""
246
+ cfg = _load_config()
247
+ token = _token()
248
+ active_url = _api_url()
249
+ source = "build default"
250
+ if _SERVER_URL_OVERRIDE:
251
+ source = "--server-url flag"
252
+ elif os.environ.get("FORGEXA_SERVER_URL"):
253
+ source = "FORGEXA_SERVER_URL env var"
254
+ elif cfg.get("server_url"):
255
+ source = f"~/.forgexa/config"
256
+ print(f"Server URL : {active_url} (source: {source})")
257
+ print(f"Auth token : {'set' if token else 'not set'}")
258
+ print(f"Config file: {_config_path()}")
259
+
260
+
261
+ def cmd_config_set(args: argparse.Namespace) -> None:
262
+ """Set a configuration value."""
263
+ cfg = _load_config()
264
+ if args.key == "server-url":
265
+ url = args.value.rstrip("/")
266
+ cfg["server_url"] = url
267
+ _save_config(cfg)
268
+ print(f"Server URL set to: {url}")
269
+ print(f"Saved to: {_config_path()}")
270
+ else:
271
+ print(f"Unknown config key: {args.key}", file=sys.stderr)
272
+ print("Available keys: server-url", file=sys.stderr)
273
+ sys.exit(1)
180
274
 
181
275
 
182
276
  def cmd_daemon_status(_args: argparse.Namespace) -> None:
@@ -467,20 +561,28 @@ def cmd_version(_args: argparse.Namespace) -> None:
467
561
 
468
562
 
469
563
  def main() -> None:
564
+ global _SERVER_URL_OVERRIDE, _output_format
565
+
566
+ active_url = _api_url() # resolved before parsing (for help text)
567
+
470
568
  parser = argparse.ArgumentParser(
471
569
  prog="forgexa",
472
570
  description="Forgexa CLI — communicates with the Forgexa server via REST API.",
473
571
  epilog=(
474
572
  "Configuration:\n"
475
- f" FORGEXA_SERVER_URL Server URL (default: {_DEFAULT_SERVER_URL})\n"
476
- " FORGEXA_TOKEN Bearer token (or use `forgexa login`)\n"
573
+ f" Current server : {active_url}\n"
574
+ " Change server : forgexa login --server <url>\n"
575
+ " forgexa config set server-url <url>\n"
576
+ " export FORGEXA_SERVER_URL=<url>\n"
577
+ " Auth token : forgexa login (saved to ~/.forgexa/config)\n"
477
578
  ),
478
579
  formatter_class=argparse.RawDescriptionHelpFormatter,
479
580
  )
480
581
  parser.add_argument(
481
582
  "--server-url",
482
583
  default=None,
483
- help=f"Server URL (default: $FORGEXA_SERVER_URL or {_DEFAULT_SERVER_URL})",
584
+ metavar="URL",
585
+ help="Server URL override for this command (also: $FORGEXA_SERVER_URL or ~/.forgexa/config)",
484
586
  )
485
587
  parser.add_argument(
486
588
  "--format",
@@ -495,10 +597,19 @@ def main() -> None:
495
597
 
496
598
  # auth
497
599
  login_p = sub.add_parser("login", help="Login and save access token")
600
+ login_p.add_argument("--server", metavar="URL", help="Server URL to connect to (saved to ~/.forgexa/config)")
498
601
  login_p.add_argument("--email", help="Email address")
499
602
  login_p.add_argument("--password", help="Password")
500
603
  sub.add_parser("logout", help="Remove saved access token")
501
604
 
605
+ # config
606
+ config_p = sub.add_parser("config", help="View or change CLI configuration")
607
+ config_sub = config_p.add_subparsers(dest="config_cmd")
608
+ config_sub.add_parser("show", help="Show current configuration")
609
+ cfg_set = config_sub.add_parser("set", help="Set a configuration value")
610
+ cfg_set.add_argument("key", choices=["server-url"], help="Config key")
611
+ cfg_set.add_argument("value", help="Config value")
612
+
502
613
  # daemon (remote API only — use forgexa-daemon for local daemon management)
503
614
  daemon_p = sub.add_parser("daemon", help="Daemon management")
504
615
  daemon_sub = daemon_p.add_subparsers(dest="daemon_cmd")
@@ -583,9 +694,8 @@ def main() -> None:
583
694
  args = parser.parse_args()
584
695
 
585
696
  if args.server_url:
586
- os.environ["FORGEXA_SERVER_URL"] = args.server_url
697
+ _SERVER_URL_OVERRIDE = args.server_url.rstrip("/")
587
698
 
588
- global _output_format
589
699
  _output_format = args.format or "table"
590
700
 
591
701
  cmd = args.command
@@ -597,6 +707,10 @@ def main() -> None:
597
707
  "version": cmd_version,
598
708
  "login": cmd_login,
599
709
  "logout": cmd_logout,
710
+ "config": lambda a: {
711
+ "show": cmd_config_show,
712
+ "set": cmd_config_set,
713
+ }.get(a.config_cmd, lambda _: config_p.print_help())(a),
600
714
  "daemon": lambda a: {
601
715
  "start": cmd_daemon_start,
602
716
  "status": cmd_daemon_status,
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: forgexa-cli
3
- Version: 1.4.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.4.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