forgexa-cli 1.14.2__tar.gz → 1.14.3__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.14.2
3
+ Version: 1.14.3
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.14.2"
2
+ __version__ = "1.14.3"
@@ -523,7 +523,7 @@ except (ImportError, ModuleNotFoundError):
523
523
  # DAEMON_VERSION is the protocol/logic version of the daemon code.
524
524
  # Kept in sync with pyproject.toml version via bump-version.sh.
525
525
  # CLIENT_TYPE identifies which packaging/distribution this daemon runs in.
526
- DAEMON_VERSION = "1.14.2"
526
+ DAEMON_VERSION = "1.14.3"
527
527
 
528
528
 
529
529
  def _detect_client_type() -> str:
@@ -1074,6 +1074,85 @@ def _extract_ai_job_scripts(
1074
1074
  return scripts
1075
1075
 
1076
1076
 
1077
+ def _resolve_ai_job_workspace_scope(ai_job: dict) -> str | None:
1078
+ workspace_key = str(ai_job.get("workspace_key") or "").strip()
1079
+ if workspace_key:
1080
+ return workspace_key
1081
+
1082
+ input_ctx = ai_job.get("input_context") or {}
1083
+
1084
+ explicit_key = str(input_ctx.get("workspace_key") or "").strip()
1085
+ if explicit_key:
1086
+ return explicit_key
1087
+
1088
+ suite_id = str(input_ctx.get("suite_id") or "").strip()
1089
+ if suite_id:
1090
+ return f"qa-suite-{suite_id}"
1091
+
1092
+ requirement_id = str(input_ctx.get("requirement_id") or "").strip()
1093
+ if requirement_id:
1094
+ return f"requirement-{requirement_id}"
1095
+
1096
+ requirement_key = str(ai_job.get("requirement_key") or "").strip()
1097
+ if requirement_key:
1098
+ return f"requirement-{requirement_key}"
1099
+
1100
+ return None
1101
+
1102
+
1103
+ def _summarize_workspace_source(workspace_path: Path) -> str | None:
1104
+ """Extract a lightweight local workspace summary for prompt grounding."""
1105
+ import os
1106
+
1107
+ if not workspace_path.exists():
1108
+ return None
1109
+
1110
+ file_list: list[str] = []
1111
+ for root, dirs, files in os.walk(workspace_path):
1112
+ dirs[:] = [
1113
+ d for d in dirs
1114
+ if not d.startswith(".") and d not in (
1115
+ "node_modules", "__pycache__", ".git", "dist", "build", "venv", ".venv",
1116
+ )
1117
+ ]
1118
+ rel = os.path.relpath(root, workspace_path)
1119
+ for file_name in files:
1120
+ if file_name.startswith("."):
1121
+ continue
1122
+ path = f"{rel}/{file_name}" if rel != "." else file_name
1123
+ file_list.append(path)
1124
+ if len(file_list) >= 250:
1125
+ break
1126
+ if len(file_list) >= 250:
1127
+ break
1128
+
1129
+ if not file_list:
1130
+ return None
1131
+
1132
+ lines: list[str] = ["## Project Structure", "```", *file_list[:250], "```"]
1133
+ priority_patterns = [
1134
+ "routes", "router", "api", "endpoints", "views",
1135
+ "models", "schema", "types", "entities",
1136
+ "package.json", "pyproject.toml", "requirements.txt",
1137
+ "dockerfile", "docker-compose", "nginx.conf",
1138
+ "readme",
1139
+ ]
1140
+ key_files = [
1141
+ path for path in file_list
1142
+ if any(pattern in path.lower() for pattern in priority_patterns)
1143
+ ][:8]
1144
+
1145
+ for key_file in key_files:
1146
+ full_path = workspace_path / key_file
1147
+ try:
1148
+ content = full_path.read_text(encoding="utf-8", errors="ignore")[:3000]
1149
+ except Exception:
1150
+ continue
1151
+ lines.append(f"\n### {key_file}\n```\n{content}\n```")
1152
+
1153
+ return "\n".join(lines)[:8000]
1154
+
1155
+
1077
1156
  def _resolve_git_author(project: dict) -> tuple[str, str]:
1078
1157
  """Resolve git commit author (name, email) using a 5-tier fallback chain.
1079
1158
 
@@ -1576,11 +1655,13 @@ class WorkspaceManager:
1576
1655
  "timeout",
1577
1656
  "timed out",
1578
1657
  "server not responding",
1658
+ "received disconnect",
1579
1659
  "unexpected disconnect",
1580
1660
  "early eof",
1581
1661
  "invalid index-pack output",
1582
1662
  "fetch-pack",
1583
1663
  "remote hung up unexpectedly",
1664
+ "too many concurrent connections",
1584
1665
  "connection reset",
1585
1666
  "connection closed",
1586
1667
  "connection refused",
@@ -1901,6 +1982,42 @@ class WorkspaceManager:
1901
1982
  _shutil.rmtree(wt, ignore_errors=True)
1902
1983
  _shutil.rmtree(main_repo, ignore_errors=True)
1903
1984
 
1985
+ async def _find_existing_worktree_for_branch(
1986
+ self,
1987
+ main_repo: Path,
1988
+ branch_name: str,
1989
+ ) -> Path | None:
1990
+ """Return an existing healthy linked worktree already on branch_name."""
1991
+ try:
1992
+ raw = await self._git("worktree", "list", "--porcelain", cwd=main_repo)
1993
+ except RuntimeError:
1994
+ return None
1995
+
1996
+ target_ref = f"refs/heads/{branch_name}"
1997
+ entries: list[tuple[Path, str | None]] = []
1998
+ current_path: Path | None = None
1999
+ current_branch: str | None = None
2000
+
2001
+ for line in raw.splitlines():
2002
+ if line.startswith("worktree "):
2003
+ if current_path is not None:
2004
+ entries.append((current_path, current_branch))
2005
+ current_path = Path(line.split(" ", 1)[1].strip())
2006
+ current_branch = None
2007
+ elif line.startswith("branch "):
2008
+ current_branch = line.split(" ", 1)[1].strip()
2009
+
2010
+ if current_path is not None:
2011
+ entries.append((current_path, current_branch))
2012
+
2013
+ for candidate_path, candidate_branch in entries:
2014
+ if candidate_path == main_repo or candidate_branch != target_ref:
2015
+ continue
2016
+ if candidate_path.exists() and await self._is_healthy_worktree(candidate_path, main_repo=main_repo):
2017
+ return candidate_path
2018
+
2019
+ return None
2020
+
1904
2021
  async def _detect_unrelated_histories(self, repo_path: Path, project_key: str) -> bool:
1905
2022
  """Detect whether local clone has diverged from remote due to history rewrite.
1906
2023
 
@@ -2550,12 +2667,34 @@ class WorkspaceManager:
2550
2667
  cwd=main_repo,
2551
2668
  )
2552
2669
  except Exception:
2670
+ existing_branch_worktree = await self._find_existing_worktree_for_branch(
2671
+ main_repo, branch_name,
2672
+ )
2673
+ if existing_branch_worktree is not None:
2674
+ logger.info(
2675
+ "Reusing existing worktree %s for branch %s instead of creating %s",
2676
+ existing_branch_worktree,
2677
+ branch_name,
2678
+ ws_path,
2679
+ )
2680
+ return existing_branch_worktree
2553
2681
  try:
2554
2682
  await self._git(
2555
2683
  "worktree", "add", str(ws_path), branch_name,
2556
2684
  cwd=main_repo,
2557
2685
  )
2558
2686
  except Exception:
2687
+ existing_branch_worktree = await self._find_existing_worktree_for_branch(
2688
+ main_repo, branch_name,
2689
+ )
2690
+ if existing_branch_worktree is not None:
2691
+ logger.info(
2692
+ "Reusing existing worktree %s for branch %s instead of creating %s",
2693
+ existing_branch_worktree,
2694
+ branch_name,
2695
+ ws_path,
2696
+ )
2697
+ return existing_branch_worktree
2559
2698
  # Fallback: create from default_branch
2560
2699
  try:
2561
2700
  await self._git(
@@ -2563,6 +2702,17 @@ class WorkspaceManager:
2563
2702
  cwd=main_repo,
2564
2703
  )
2565
2704
  except Exception:
2705
+ existing_branch_worktree = await self._find_existing_worktree_for_branch(
2706
+ main_repo, branch_name,
2707
+ )
2708
+ if existing_branch_worktree is not None:
2709
+ logger.info(
2710
+ "Reusing existing worktree %s for branch %s instead of cloning %s",
2711
+ existing_branch_worktree,
2712
+ branch_name,
2713
+ ws_path,
2714
+ )
2715
+ return existing_branch_worktree
2566
2716
  ws_path.mkdir(parents=True, exist_ok=True)
2567
2717
  await self._clone_repo(
2568
2718
  repo_url,
@@ -2891,6 +3041,7 @@ class ProcessManager:
2891
3041
  "rate limit",
2892
3042
  "rate_limit",
2893
3043
  "quota exceeded",
3044
+ "monthly quota", # Copilot: "You have exceeded your monthly quota"
2894
3045
  "too many requests",
2895
3046
  "overloaded",
2896
3047
  "insufficient_quota",
@@ -3028,7 +3179,12 @@ class ProcessManager:
3028
3179
  else:
3029
3180
  result_text = str(data.get("result", "") or "")
3030
3181
  if data.get("is_error"):
3031
- err_text = result_text or str(data.get("error", "") or "result marked as error")
3182
+ raw_errors = data.get("errors")
3183
+ if isinstance(raw_errors, list):
3184
+ err_text = "; ".join(str(item).strip() for item in raw_errors if str(item).strip())
3185
+ else:
3186
+ err_text = ""
3187
+ err_text = err_text or result_text or str(data.get("error", "") or "result marked as error")
3032
3188
  error_messages.append(err_text)
3033
3189
  else:
3034
3190
  # Structural check: if no tokens were consumed AND no assistant
@@ -3411,48 +3567,6 @@ class ProcessManager:
3411
3567
  changed_paths.update(self._normalize_repo_paths(paths))
3412
3568
  return bool(required_paths & changed_paths)
3413
3569
 
3414
- def _recover_timeout_from_workspace_changes(
3415
- self,
3416
- task: TaskInfo,
3417
- workspace_path: Path,
3418
- result: TaskResult,
3419
- pre_commit_git: dict,
3420
- ) -> bool:
3421
- """Recover absolute-timeout failures after validating workspace output."""
3422
- error_lower = (result.error or "").lower()
3423
- is_absolute_timeout = (
3424
- "absolute limit" in error_lower or "timed out after" in error_lower
3425
- )
3426
- changed_files = list(pre_commit_git.get("files_changed") or [])
3427
- if not is_absolute_timeout or not changed_files:
3428
- return False
3429
- if not self.process_manager.has_meaningful_agent_output(result):
3430
- return False
3431
-
3432
- if result.files_changed:
3433
- result.files_changed = sorted(set(result.files_changed) | set(changed_files))
3434
- else:
3435
- result.files_changed = changed_files
3436
- if not result.lines_added:
3437
- result.lines_added = int(pre_commit_git.get("lines_added", 0) or 0)
3438
- if not result.lines_removed:
3439
- result.lines_removed = int(pre_commit_git.get("lines_removed", 0) or 0)
3440
-
3441
- if not self.process_manager._has_required_deliverable_updates(
3442
- task,
3443
- changed_files,
3444
- result.files_changed,
3445
- ):
3446
- return False
3447
-
3448
- validation_issues = self._validate_outputs(workspace_path, task, result)
3449
- if validation_issues:
3450
- result.metrics["timeout_recovery_validation_issues"] = validation_issues
3451
- return False
3452
-
3453
- result.metrics["recovered_from_timeout_uncommitted_changes"] = True
3454
- return True
3455
-
3456
3570
  def _build_prompt(self, task: TaskInfo) -> str:
3457
3571
  """Build the prompt to send to the agent.
3458
3572
 
@@ -3743,6 +3857,7 @@ class ProcessManager:
3743
3857
  "--output-format", "stream-json",
3744
3858
  "--verbose",
3745
3859
  "--max-turns", str(max_turns),
3860
+ "--no-session-persistence",
3746
3861
  "--dangerously-skip-permissions",
3747
3862
  ]
3748
3863
 
@@ -3750,6 +3865,7 @@ class ProcessManager:
3750
3865
  model_override = os.environ.get("FACTORY_CLAUDE_MODEL")
3751
3866
  if model_override:
3752
3867
  env["ANTHROPIC_MODEL"] = model_override
3868
+ env.update(self._prepare_claude_environment())
3753
3869
 
3754
3870
  try:
3755
3871
  proc = await asyncio.create_subprocess_exec(
@@ -3770,6 +3886,8 @@ class ProcessManager:
3770
3886
 
3771
3887
  # Parse Claude JSON output for metrics
3772
3888
  metrics = self._parse_claude_output(stdout)
3889
+ structured_errors = self._extract_output_signals("\n".join(part for part in (stdout, stderr) if part))["error_messages"]
3890
+ structured_error_text = "; ".join(str(msg).strip() for msg in structured_errors if str(msg).strip())
3773
3891
 
3774
3892
  if returncode == 0:
3775
3893
  return TaskResult(
@@ -3798,6 +3916,15 @@ class ProcessManager:
3798
3916
  "exceeded the model's limit. Reduce prompt size or shorten file reads.",
3799
3917
  metrics=metrics,
3800
3918
  )
3919
+ if structured_error_text:
3920
+ return TaskResult(
3921
+ status="failed",
3922
+ exit_code=returncode,
3923
+ stdout=stdout[-settings.AGENT_MAX_OUTPUT_SIZE:],
3924
+ stderr=stderr[-10000:],
3925
+ error=f"Claude exited with code {returncode}: {structured_error_text}",
3926
+ metrics=metrics,
3927
+ )
3801
3928
  return TaskResult(
3802
3929
  status="failed",
3803
3930
  exit_code=returncode,
@@ -4101,6 +4228,58 @@ class ProcessManager:
4101
4228
  result.error = error_clean or result.error
4102
4229
  return result
4103
4230
 
4231
+ @staticmethod
4232
+ def _prepare_claude_environment() -> dict[str, str]:
4233
+ """Prepare writable Claude config/data/cache dirs under ~/.forgexa.
4234
+
4235
+ The daemon service runs with ProtectSystem=strict and only allows
4236
+ writes under ~/.forgexa plus the workspace root. Claude Code writes
4237
+ config, debug logs, cache, and session state under XDG paths and
4238
+ ~/.claude by default, which can fail with EROFS inside the daemon.
4239
+
4240
+ Point Claude at writable daemon-owned directories and mirror only the
4241
+ small set of user config files it needs for headless execution.
4242
+ """
4243
+ sandbox_root = Path.home() / ".forgexa" / "claude-home"
4244
+ config_root = sandbox_root / "config"
4245
+ data_root = sandbox_root / "data"
4246
+ cache_root = sandbox_root / "cache"
4247
+ state_root = sandbox_root / "state"
4248
+ claude_config_dir = config_root / "claude"
4249
+
4250
+ for path in (config_root, data_root, cache_root, state_root, claude_config_dir):
4251
+ path.mkdir(parents=True, exist_ok=True)
4252
+
4253
+ source_files = [
4254
+ (Path.home() / ".claude.json", claude_config_dir / ".claude.json"),
4255
+ (Path.home() / ".claude" / "settings.json", claude_config_dir / "settings.json"),
4256
+ ]
4257
+ for source, dest in source_files:
4258
+ try:
4259
+ if source.is_file() and not dest.exists():
4260
+ shutil.copy2(source, dest)
4261
+ except Exception as exc:
4262
+ logger.debug(
4263
+ "Claude config mirror skipped %s -> %s: %s",
4264
+ source, dest, exc,
4265
+ )
4266
+
4267
+ env = {
4268
+ "XDG_CONFIG_HOME": str(config_root),
4269
+ "XDG_DATA_HOME": str(data_root),
4270
+ "XDG_CACHE_HOME": str(cache_root),
4271
+ "XDG_STATE_HOME": str(state_root),
4272
+ "CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC": str(
4273
+ os.environ.get("CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC", "1")
4274
+ ),
4275
+ }
4276
+
4277
+ if not os.environ.get("CLAUDE_CODE_DEBUG_LOGS_DIR"):
4278
+ env["CLAUDE_CODE_DEBUG_LOGS_DIR"] = str(cache_root / "claude-debug")
4279
+ Path(env["CLAUDE_CODE_DEBUG_LOGS_DIR"]).mkdir(parents=True, exist_ok=True)
4280
+
4281
+ return env
4282
+
4104
4283
  @staticmethod
4105
4284
  def _prepare_copilot_home() -> str:
4106
4285
  """Prepare a writable Copilot home under ~/.forgexa for sandboxed runs.
@@ -6031,6 +6210,48 @@ class RuntimeDaemon:
6031
6210
  self._execute_ai_job(aj, conn)
6032
6211
  )
6033
6212
 
6213
+ def _recover_timeout_from_workspace_changes(
6214
+ self,
6215
+ task: TaskInfo,
6216
+ workspace_path: Path,
6217
+ result: TaskResult,
6218
+ pre_commit_git: dict,
6219
+ ) -> bool:
6220
+ """Recover absolute-timeout failures after validating workspace output."""
6221
+ error_lower = (result.error or "").lower()
6222
+ is_absolute_timeout = (
6223
+ "absolute limit" in error_lower or "timed out after" in error_lower
6224
+ )
6225
+ changed_files = list(pre_commit_git.get("files_changed") or [])
6226
+ if not is_absolute_timeout or not changed_files:
6227
+ return False
6228
+ if not self.process_manager.has_meaningful_agent_output(result):
6229
+ return False
6230
+
6231
+ if result.files_changed:
6232
+ result.files_changed = sorted(set(result.files_changed) | set(changed_files))
6233
+ else:
6234
+ result.files_changed = changed_files
6235
+ if not result.lines_added:
6236
+ result.lines_added = int(pre_commit_git.get("lines_added", 0) or 0)
6237
+ if not result.lines_removed:
6238
+ result.lines_removed = int(pre_commit_git.get("lines_removed", 0) or 0)
6239
+
6240
+ if not self.process_manager._has_required_deliverable_updates(
6241
+ task,
6242
+ changed_files,
6243
+ result.files_changed,
6244
+ ):
6245
+ return False
6246
+
6247
+ validation_issues = self._validate_outputs(workspace_path, task, result)
6248
+ if validation_issues:
6249
+ result.metrics["timeout_recovery_validation_issues"] = validation_issues
6250
+ return False
6251
+
6252
+ result.metrics["recovered_from_timeout_uncommitted_changes"] = True
6253
+ return True
6254
+
6034
6255
  async def _execute_task(self, task: TaskInfo, conn: ServerConnection):
6035
6256
  """Execute a single task, reporting to the originating server connection."""
6036
6257
  reporter = conn.reporter
@@ -7053,12 +7274,14 @@ class RuntimeDaemon:
7053
7274
  agent_override = aj.get("agent_override")
7054
7275
  system_prompt = aj.get("system_prompt", "")
7055
7276
  user_prompt = aj.get("user_prompt", "")
7277
+ input_ctx = aj.get("input_context", {})
7278
+ ai_workspace_scope = _resolve_ai_job_workspace_scope(aj)
7056
7279
 
7057
7280
  reporter_url = f"{conn.server_url.rstrip('/')}/api/v1/runtimes/{conn.runtime_id}/ai-jobs/{job_id}"
7058
7281
  # Use workspace-level key (project:requirement or project:job_id) so AI jobs
7059
7282
  # for different requirements run in parallel while same-requirement jobs serialize.
7060
7283
  _aj_proj_key = project_info.get("project_key") or str(project_info.get("id") or "default")
7061
- project_lock_key = f"{_aj_proj_key}:{requirement_key or job_id}"
7284
+ project_lock_key = f"{_aj_proj_key}:{ai_workspace_scope or job_id}"
7062
7285
  project_lock = await self._get_project_execution_lock(project_lock_key)
7063
7286
  if project_lock.locked():
7064
7287
  logger.info(
@@ -7111,11 +7334,10 @@ class RuntimeDaemon:
7111
7334
  full_prompt = f"{system_prompt}\n\n{user_prompt}" if system_prompt else user_prompt
7112
7335
  fake_task = TaskInfo(
7113
7336
  task_id=job_id,
7114
- # Use job_id as graph_id so workspace_key is non-empty.
7115
- # If graph_id="" then workspace_key="" and ws_path == project_dir
7116
- # (Python: Path("x") / "" == Path("x")), causing git clone to
7117
- # fail with "destination path already exists" on the second run.
7118
- graph_id=job_id,
7337
+ # Reuse a stable workspace key when the server provides one so
7338
+ # delegated retries do not create a fresh clone per AIJob row.
7339
+ # Fall back to job_id so workspace_key is always non-empty.
7340
+ graph_id=ai_workspace_scope or job_id,
7119
7341
  node_type="ai_job",
7120
7342
  agent_type=agent_type,
7121
7343
  input_prompt=full_prompt,
@@ -7134,6 +7356,17 @@ class RuntimeDaemon:
7134
7356
  project_info, fake_task,
7135
7357
  )
7136
7358
 
7359
+ if task_type == "qa_scenario_generate_prompt" and not input_ctx.get("project_context"):
7360
+ local_project_context = _summarize_workspace_source(workspace_path)
7361
+ if local_project_context:
7362
+ fake_task.input_prompt = (
7363
+ f"{full_prompt}\n\n"
7364
+ "Use the provided project source context when grounding scenario coverage. "
7365
+ "Do not rely on additional workspace searching before responding.\n"
7366
+ "Do not call Grep or search tools against '.' or a bare directory path directly.\n\n"
7367
+ f"## Project Source Code Context\n{local_project_context}"
7368
+ )
7369
+
7137
7370
  await conn.client.post(
7138
7371
  f"{reporter_url}/progress",
7139
7372
  json={"current_phase": "running", "current_step": "Running agent...", "progress_pct": 15},
@@ -7210,7 +7443,6 @@ class RuntimeDaemon:
7210
7443
  # 4. Auto-commit if successful (always call _auto_commit on success:
7211
7444
  # _auto_commit handles both uncommitted changes AND internally-committed
7212
7445
  # changes that just need to be pushed — same as _execute_task).
7213
- input_ctx = aj.get("input_context", {})
7214
7446
  git_info = {}
7215
7447
  if result.status == "success":
7216
7448
  git_info = await self._auto_commit(workspace_path, fake_task)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: forgexa-cli
3
- Version: 1.14.2
3
+ Version: 1.14.3
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.14.2"
3
+ version = "1.14.3"
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