forgexa-cli 1.14.2__tar.gz → 1.14.4__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.4
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.4"
@@ -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.4"
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
 
@@ -1146,6 +1225,38 @@ def _get_analysis_outputs_for_type(req_type: str) -> list[str]:
1146
1225
  return _ANALYSIS_OUTPUTS_BY_TYPE.get(req_type, _ANALYSIS_OUTPUTS_BY_TYPE["feature"])
1147
1226
 
1148
1227
 
1228
+ # Mirrors type_workflow_profiles.py — maps req type to the expected design-phase output filename.
1229
+ # Most types use design.md; spike uses research.md (produced by _DESIGN_SPIKE prompt).
1230
+ _DESIGN_OUTPUT_BY_TYPE: dict[str, str] = {
1231
+ "spike": "research.md",
1232
+ # All other types produce design.md (the default).
1233
+ }
1234
+
1235
+
1236
+ def _get_design_output_for_type(req_type: str) -> str:
1237
+ """Get the expected design-phase output filename for a requirement type.
1238
+
1239
+ Used by _validate_outputs and _required_deliverable_paths to avoid
1240
+ hardcoding 'design.md' for all node_type='design' nodes — spike produces
1241
+ 'research.md' instead.
1242
+ """
1243
+ try:
1244
+ from app.services.type_workflow_profiles import get_profile
1245
+ profile = get_profile(req_type)
1246
+ for phase in profile.execution_phases:
1247
+ if phase.node_type == "design":
1248
+ from app.services.type_prompts import TYPE_PROMPT_TEMPLATES
1249
+ tmpl = TYPE_PROMPT_TEMPLATES.get(phase.prompt_key, "")
1250
+ import re as _re
1251
+ m = _re.search(r"{doc_dir}/([\w.-]+)", tmpl)
1252
+ if m:
1253
+ return m.group(1)
1254
+ break
1255
+ except Exception:
1256
+ pass
1257
+ return _DESIGN_OUTPUT_BY_TYPE.get(req_type, "design.md")
1258
+
1259
+
1149
1260
  # ── Agent Discovery ──
1150
1261
 
1151
1262
 
@@ -1576,11 +1687,13 @@ class WorkspaceManager:
1576
1687
  "timeout",
1577
1688
  "timed out",
1578
1689
  "server not responding",
1690
+ "received disconnect",
1579
1691
  "unexpected disconnect",
1580
1692
  "early eof",
1581
1693
  "invalid index-pack output",
1582
1694
  "fetch-pack",
1583
1695
  "remote hung up unexpectedly",
1696
+ "too many concurrent connections",
1584
1697
  "connection reset",
1585
1698
  "connection closed",
1586
1699
  "connection refused",
@@ -1901,6 +2014,42 @@ class WorkspaceManager:
1901
2014
  _shutil.rmtree(wt, ignore_errors=True)
1902
2015
  _shutil.rmtree(main_repo, ignore_errors=True)
1903
2016
 
2017
+ async def _find_existing_worktree_for_branch(
2018
+ self,
2019
+ main_repo: Path,
2020
+ branch_name: str,
2021
+ ) -> Path | None:
2022
+ """Return an existing healthy linked worktree already on branch_name."""
2023
+ try:
2024
+ raw = await self._git("worktree", "list", "--porcelain", cwd=main_repo)
2025
+ except RuntimeError:
2026
+ return None
2027
+
2028
+ target_ref = f"refs/heads/{branch_name}"
2029
+ entries: list[tuple[Path, str | None]] = []
2030
+ current_path: Path | None = None
2031
+ current_branch: str | None = None
2032
+
2033
+ for line in raw.splitlines():
2034
+ if line.startswith("worktree "):
2035
+ if current_path is not None:
2036
+ entries.append((current_path, current_branch))
2037
+ current_path = Path(line.split(" ", 1)[1].strip())
2038
+ current_branch = None
2039
+ elif line.startswith("branch "):
2040
+ current_branch = line.split(" ", 1)[1].strip()
2041
+
2042
+ if current_path is not None:
2043
+ entries.append((current_path, current_branch))
2044
+
2045
+ for candidate_path, candidate_branch in entries:
2046
+ if candidate_path == main_repo or candidate_branch != target_ref:
2047
+ continue
2048
+ if candidate_path.exists() and await self._is_healthy_worktree(candidate_path, main_repo=main_repo):
2049
+ return candidate_path
2050
+
2051
+ return None
2052
+
1904
2053
  async def _detect_unrelated_histories(self, repo_path: Path, project_key: str) -> bool:
1905
2054
  """Detect whether local clone has diverged from remote due to history rewrite.
1906
2055
 
@@ -2550,12 +2699,34 @@ class WorkspaceManager:
2550
2699
  cwd=main_repo,
2551
2700
  )
2552
2701
  except Exception:
2702
+ existing_branch_worktree = await self._find_existing_worktree_for_branch(
2703
+ main_repo, branch_name,
2704
+ )
2705
+ if existing_branch_worktree is not None:
2706
+ logger.info(
2707
+ "Reusing existing worktree %s for branch %s instead of creating %s",
2708
+ existing_branch_worktree,
2709
+ branch_name,
2710
+ ws_path,
2711
+ )
2712
+ return existing_branch_worktree
2553
2713
  try:
2554
2714
  await self._git(
2555
2715
  "worktree", "add", str(ws_path), branch_name,
2556
2716
  cwd=main_repo,
2557
2717
  )
2558
2718
  except Exception:
2719
+ existing_branch_worktree = await self._find_existing_worktree_for_branch(
2720
+ main_repo, branch_name,
2721
+ )
2722
+ if existing_branch_worktree is not None:
2723
+ logger.info(
2724
+ "Reusing existing worktree %s for branch %s instead of creating %s",
2725
+ existing_branch_worktree,
2726
+ branch_name,
2727
+ ws_path,
2728
+ )
2729
+ return existing_branch_worktree
2559
2730
  # Fallback: create from default_branch
2560
2731
  try:
2561
2732
  await self._git(
@@ -2563,6 +2734,17 @@ class WorkspaceManager:
2563
2734
  cwd=main_repo,
2564
2735
  )
2565
2736
  except Exception:
2737
+ existing_branch_worktree = await self._find_existing_worktree_for_branch(
2738
+ main_repo, branch_name,
2739
+ )
2740
+ if existing_branch_worktree is not None:
2741
+ logger.info(
2742
+ "Reusing existing worktree %s for branch %s instead of cloning %s",
2743
+ existing_branch_worktree,
2744
+ branch_name,
2745
+ ws_path,
2746
+ )
2747
+ return existing_branch_worktree
2566
2748
  ws_path.mkdir(parents=True, exist_ok=True)
2567
2749
  await self._clone_repo(
2568
2750
  repo_url,
@@ -2891,6 +3073,7 @@ class ProcessManager:
2891
3073
  "rate limit",
2892
3074
  "rate_limit",
2893
3075
  "quota exceeded",
3076
+ "monthly quota", # Copilot: "You have exceeded your monthly quota"
2894
3077
  "too many requests",
2895
3078
  "overloaded",
2896
3079
  "insufficient_quota",
@@ -3028,7 +3211,12 @@ class ProcessManager:
3028
3211
  else:
3029
3212
  result_text = str(data.get("result", "") or "")
3030
3213
  if data.get("is_error"):
3031
- err_text = result_text or str(data.get("error", "") or "result marked as error")
3214
+ raw_errors = data.get("errors")
3215
+ if isinstance(raw_errors, list):
3216
+ err_text = "; ".join(str(item).strip() for item in raw_errors if str(item).strip())
3217
+ else:
3218
+ err_text = ""
3219
+ err_text = err_text or result_text or str(data.get("error", "") or "result marked as error")
3032
3220
  error_messages.append(err_text)
3033
3221
  else:
3034
3222
  # Structural check: if no tokens were consumed AND no assistant
@@ -3391,7 +3579,8 @@ class ProcessManager:
3391
3579
  req_type = (task.input_data or {}).get("requirement_type", "feature")
3392
3580
  required_files = _get_analysis_outputs_for_type(req_type)
3393
3581
  elif task.node_type == "design":
3394
- required_files = ["design.md"]
3582
+ req_type = (task.input_data or {}).get("requirement_type", "feature")
3583
+ required_files = [_get_design_output_for_type(req_type)]
3395
3584
  elif task.node_type == "delivery":
3396
3585
  # Required docs come from node input_data (set by delivery_doc_service)
3397
3586
  required_files = (task.input_data or {}).get("required_docs") or ["release-note.md"]
@@ -3411,48 +3600,6 @@ class ProcessManager:
3411
3600
  changed_paths.update(self._normalize_repo_paths(paths))
3412
3601
  return bool(required_paths & changed_paths)
3413
3602
 
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
3603
  def _build_prompt(self, task: TaskInfo) -> str:
3457
3604
  """Build the prompt to send to the agent.
3458
3605
 
@@ -3743,6 +3890,7 @@ class ProcessManager:
3743
3890
  "--output-format", "stream-json",
3744
3891
  "--verbose",
3745
3892
  "--max-turns", str(max_turns),
3893
+ "--no-session-persistence",
3746
3894
  "--dangerously-skip-permissions",
3747
3895
  ]
3748
3896
 
@@ -3750,6 +3898,7 @@ class ProcessManager:
3750
3898
  model_override = os.environ.get("FACTORY_CLAUDE_MODEL")
3751
3899
  if model_override:
3752
3900
  env["ANTHROPIC_MODEL"] = model_override
3901
+ env.update(self._prepare_claude_environment())
3753
3902
 
3754
3903
  try:
3755
3904
  proc = await asyncio.create_subprocess_exec(
@@ -3770,6 +3919,8 @@ class ProcessManager:
3770
3919
 
3771
3920
  # Parse Claude JSON output for metrics
3772
3921
  metrics = self._parse_claude_output(stdout)
3922
+ structured_errors = self._extract_output_signals("\n".join(part for part in (stdout, stderr) if part))["error_messages"]
3923
+ structured_error_text = "; ".join(str(msg).strip() for msg in structured_errors if str(msg).strip())
3773
3924
 
3774
3925
  if returncode == 0:
3775
3926
  return TaskResult(
@@ -3798,6 +3949,15 @@ class ProcessManager:
3798
3949
  "exceeded the model's limit. Reduce prompt size or shorten file reads.",
3799
3950
  metrics=metrics,
3800
3951
  )
3952
+ if structured_error_text:
3953
+ return TaskResult(
3954
+ status="failed",
3955
+ exit_code=returncode,
3956
+ stdout=stdout[-settings.AGENT_MAX_OUTPUT_SIZE:],
3957
+ stderr=stderr[-10000:],
3958
+ error=f"Claude exited with code {returncode}: {structured_error_text}",
3959
+ metrics=metrics,
3960
+ )
3801
3961
  return TaskResult(
3802
3962
  status="failed",
3803
3963
  exit_code=returncode,
@@ -4101,6 +4261,58 @@ class ProcessManager:
4101
4261
  result.error = error_clean or result.error
4102
4262
  return result
4103
4263
 
4264
+ @staticmethod
4265
+ def _prepare_claude_environment() -> dict[str, str]:
4266
+ """Prepare writable Claude config/data/cache dirs under ~/.forgexa.
4267
+
4268
+ The daemon service runs with ProtectSystem=strict and only allows
4269
+ writes under ~/.forgexa plus the workspace root. Claude Code writes
4270
+ config, debug logs, cache, and session state under XDG paths and
4271
+ ~/.claude by default, which can fail with EROFS inside the daemon.
4272
+
4273
+ Point Claude at writable daemon-owned directories and mirror only the
4274
+ small set of user config files it needs for headless execution.
4275
+ """
4276
+ sandbox_root = Path.home() / ".forgexa" / "claude-home"
4277
+ config_root = sandbox_root / "config"
4278
+ data_root = sandbox_root / "data"
4279
+ cache_root = sandbox_root / "cache"
4280
+ state_root = sandbox_root / "state"
4281
+ claude_config_dir = config_root / "claude"
4282
+
4283
+ for path in (config_root, data_root, cache_root, state_root, claude_config_dir):
4284
+ path.mkdir(parents=True, exist_ok=True)
4285
+
4286
+ source_files = [
4287
+ (Path.home() / ".claude.json", claude_config_dir / ".claude.json"),
4288
+ (Path.home() / ".claude" / "settings.json", claude_config_dir / "settings.json"),
4289
+ ]
4290
+ for source, dest in source_files:
4291
+ try:
4292
+ if source.is_file() and not dest.exists():
4293
+ shutil.copy2(source, dest)
4294
+ except Exception as exc:
4295
+ logger.debug(
4296
+ "Claude config mirror skipped %s -> %s: %s",
4297
+ source, dest, exc,
4298
+ )
4299
+
4300
+ env = {
4301
+ "XDG_CONFIG_HOME": str(config_root),
4302
+ "XDG_DATA_HOME": str(data_root),
4303
+ "XDG_CACHE_HOME": str(cache_root),
4304
+ "XDG_STATE_HOME": str(state_root),
4305
+ "CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC": str(
4306
+ os.environ.get("CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC", "1")
4307
+ ),
4308
+ }
4309
+
4310
+ if not os.environ.get("CLAUDE_CODE_DEBUG_LOGS_DIR"):
4311
+ env["CLAUDE_CODE_DEBUG_LOGS_DIR"] = str(cache_root / "claude-debug")
4312
+ Path(env["CLAUDE_CODE_DEBUG_LOGS_DIR"]).mkdir(parents=True, exist_ok=True)
4313
+
4314
+ return env
4315
+
4104
4316
  @staticmethod
4105
4317
  def _prepare_copilot_home() -> str:
4106
4318
  """Prepare a writable Copilot home under ~/.forgexa for sandboxed runs.
@@ -4998,10 +5210,36 @@ class ProcessManager:
4998
5210
  class ProgressReporter:
4999
5211
  """Reports task progress back to the server."""
5000
5212
 
5213
+ _MIN_HEARTBEAT_INTERVAL_SECONDS = 5.0
5214
+ _MIN_PROGRESS_DELTA = 5
5215
+
5001
5216
  def __init__(self, client: httpx.AsyncClient, server_url: str, runtime_id: str):
5002
5217
  self.client = client
5003
5218
  self.server_url = server_url.rstrip("/")
5004
5219
  self.runtime_id = runtime_id
5220
+ self._last_progress_sent: dict[str, dict[str, Any]] = {}
5221
+
5222
+ def _should_skip_progress(
5223
+ self,
5224
+ task_id: str,
5225
+ progress_percent: int,
5226
+ current_step: str,
5227
+ output_lines: list[str],
5228
+ files_changed: list[str],
5229
+ lines_added: int,
5230
+ lines_removed: int,
5231
+ ) -> bool:
5232
+ if output_lines or files_changed or lines_added or lines_removed:
5233
+ return False
5234
+
5235
+ last = self._last_progress_sent.get(task_id)
5236
+ if not last:
5237
+ return False
5238
+ if last["current_step"] != current_step:
5239
+ return False
5240
+ if progress_percent - int(last["progress_percent"]) >= self._MIN_PROGRESS_DELTA:
5241
+ return False
5242
+ return (time.monotonic() - float(last["sent_at"])) < self._MIN_HEARTBEAT_INTERVAL_SECONDS
5005
5243
 
5006
5244
  async def report_progress(
5007
5245
  self,
@@ -5013,24 +5251,43 @@ class ProgressReporter:
5013
5251
  lines_added: int = 0,
5014
5252
  lines_removed: int = 0,
5015
5253
  ):
5254
+ output_lines = output_lines or []
5255
+ files_changed = files_changed or []
5256
+ if self._should_skip_progress(
5257
+ task_id,
5258
+ progress_percent,
5259
+ current_step,
5260
+ output_lines,
5261
+ files_changed,
5262
+ lines_added,
5263
+ lines_removed,
5264
+ ):
5265
+ return
5266
+
5016
5267
  try:
5017
5268
  await self.client.post(
5018
5269
  f"{self.server_url}/api/v1/runtimes/{self.runtime_id}/tasks/{task_id}/progress",
5019
5270
  json={
5020
5271
  "progress_percent": progress_percent,
5021
5272
  "current_step": current_step,
5022
- "output_lines": output_lines or [],
5023
- "files_changed": files_changed or [],
5273
+ "output_lines": output_lines,
5274
+ "files_changed": files_changed,
5024
5275
  "lines_added": lines_added,
5025
5276
  "lines_removed": lines_removed,
5026
5277
  },
5027
5278
  timeout=10,
5028
5279
  )
5280
+ self._last_progress_sent[task_id] = {
5281
+ "progress_percent": progress_percent,
5282
+ "current_step": current_step,
5283
+ "sent_at": time.monotonic(),
5284
+ }
5029
5285
  except Exception as e:
5030
5286
  logger.warning("Failed to report progress for task %s: %s", task_id, e)
5031
5287
 
5032
5288
  async def report_complete(self, task_id: str, result: TaskResult):
5033
5289
  """Report task completion with retry."""
5290
+ self._last_progress_sent.pop(task_id, None)
5034
5291
  payload = {
5035
5292
  "status": result.status,
5036
5293
  "exit_code": result.exit_code,
@@ -5224,6 +5481,8 @@ class LogUploader:
5224
5481
  class TaskPoller:
5225
5482
  """Polls the server for assigned tasks."""
5226
5483
 
5484
+ _EMPTY_BACKOFF_STEPS = (0, 0, 1, 2, 3, 5)
5485
+
5227
5486
  def __init__(
5228
5487
  self,
5229
5488
  client: httpx.AsyncClient,
@@ -5237,6 +5496,16 @@ class TaskPoller:
5237
5496
  self.interval = interval
5238
5497
  self._on_success = lambda: None # Callbacks set by ServerConnection
5239
5498
  self._on_auth_failure = lambda: None
5499
+ self._empty_cycles = 0
5500
+
5501
+ def note_activity(self, *, had_work: bool) -> None:
5502
+ if had_work:
5503
+ self._empty_cycles = 0
5504
+ return
5505
+ self._empty_cycles = min(self._empty_cycles + 1, len(self._EMPTY_BACKOFF_STEPS) - 1)
5506
+
5507
+ def next_delay_seconds(self) -> int:
5508
+ return self.interval + self._EMPTY_BACKOFF_STEPS[self._empty_cycles]
5240
5509
 
5241
5510
  async def poll(self) -> list[TaskInfo]:
5242
5511
  """Poll for tasks assigned to this daemon."""
@@ -5967,15 +6236,28 @@ class RuntimeDaemon:
5967
6236
  # 3. Main loop
5968
6237
  try:
5969
6238
  while not self._shutdown:
5970
- await self._poll_and_execute()
5971
- await asyncio.sleep(self.poll_interval)
6239
+ had_work = await self._poll_and_execute()
6240
+ next_delay = self.poll_interval
6241
+ if self.connections:
6242
+ next_delay = min(
6243
+ conn.poller.next_delay_seconds()
6244
+ for conn in self.connections
6245
+ if conn.poller is not None
6246
+ )
6247
+ logger.debug(
6248
+ "Daemon poll cycle complete (had_work=%s, next_delay=%ss)",
6249
+ had_work,
6250
+ next_delay,
6251
+ )
6252
+ await asyncio.sleep(next_delay)
5972
6253
  except asyncio.CancelledError:
5973
6254
  pass
5974
6255
  finally:
5975
6256
  await self._shutdown_gracefully()
5976
6257
 
5977
- async def _poll_and_execute(self):
6258
+ async def _poll_and_execute(self) -> bool:
5978
6259
  """Poll for tasks from all connected servers and execute them."""
6260
+ had_work = False
5979
6261
  # Clean up completed tasks
5980
6262
  for task_id in list(self.active_tasks):
5981
6263
  if self.active_tasks[task_id].done():
@@ -5990,7 +6272,7 @@ class RuntimeDaemon:
5990
6272
 
5991
6273
  # Check capacity
5992
6274
  if total_active >= self.max_concurrent:
5993
- return
6275
+ return True
5994
6276
 
5995
6277
  # Poll from all connected servers
5996
6278
  for conn in self.connections:
@@ -5998,6 +6280,12 @@ class RuntimeDaemon:
5998
6280
  break
5999
6281
 
6000
6282
  tasks = await conn.poller.poll()
6283
+ ai_jobs: list[dict] = []
6284
+ if len(self.active_tasks) < self.max_concurrent:
6285
+ ai_jobs = await conn.poller.poll_ai_jobs()
6286
+
6287
+ conn.poller.note_activity(had_work=bool(tasks or ai_jobs))
6288
+ had_work = had_work or bool(tasks or ai_jobs)
6001
6289
 
6002
6290
  for task in tasks:
6003
6291
  if task.task_id in self.active_tasks:
@@ -6015,21 +6303,63 @@ class RuntimeDaemon:
6015
6303
  )
6016
6304
 
6017
6305
  # Poll for AIJobs (workspace-mode tasks)
6018
- if len(self.active_tasks) < self.max_concurrent:
6019
- ai_jobs = await conn.poller.poll_ai_jobs()
6020
- for aj in ai_jobs:
6021
- job_id = aj.get("job_id", "")
6022
- ai_task_key = f"aijob_{job_id}"
6023
- if ai_task_key in self.active_tasks:
6024
- continue
6025
- if len(self.active_tasks) >= self.max_concurrent:
6026
- break
6027
- logger.info("[%s] Starting AIJob %s (type=%s)",
6028
- conn.label, job_id, aj.get("task_type"))
6029
- self._task_connections[ai_task_key] = conn
6030
- self.active_tasks[ai_task_key] = asyncio.create_task(
6031
- self._execute_ai_job(aj, conn)
6032
- )
6306
+ for aj in ai_jobs:
6307
+ job_id = aj.get("job_id", "")
6308
+ ai_task_key = f"aijob_{job_id}"
6309
+ if ai_task_key in self.active_tasks:
6310
+ continue
6311
+ if len(self.active_tasks) >= self.max_concurrent:
6312
+ break
6313
+ logger.info("[%s] Starting AIJob %s (type=%s)",
6314
+ conn.label, job_id, aj.get("task_type"))
6315
+ self._task_connections[ai_task_key] = conn
6316
+ self.active_tasks[ai_task_key] = asyncio.create_task(
6317
+ self._execute_ai_job(aj, conn)
6318
+ )
6319
+
6320
+ return had_work
6321
+
6322
+ def _recover_timeout_from_workspace_changes(
6323
+ self,
6324
+ task: TaskInfo,
6325
+ workspace_path: Path,
6326
+ result: TaskResult,
6327
+ pre_commit_git: dict,
6328
+ ) -> bool:
6329
+ """Recover absolute-timeout failures after validating workspace output."""
6330
+ error_lower = (result.error or "").lower()
6331
+ is_absolute_timeout = (
6332
+ "absolute limit" in error_lower or "timed out after" in error_lower
6333
+ )
6334
+ changed_files = list(pre_commit_git.get("files_changed") or [])
6335
+ if not is_absolute_timeout or not changed_files:
6336
+ return False
6337
+ if not self.process_manager.has_meaningful_agent_output(result):
6338
+ return False
6339
+
6340
+ if result.files_changed:
6341
+ result.files_changed = sorted(set(result.files_changed) | set(changed_files))
6342
+ else:
6343
+ result.files_changed = changed_files
6344
+ if not result.lines_added:
6345
+ result.lines_added = int(pre_commit_git.get("lines_added", 0) or 0)
6346
+ if not result.lines_removed:
6347
+ result.lines_removed = int(pre_commit_git.get("lines_removed", 0) or 0)
6348
+
6349
+ if not self.process_manager._has_required_deliverable_updates(
6350
+ task,
6351
+ changed_files,
6352
+ result.files_changed,
6353
+ ):
6354
+ return False
6355
+
6356
+ validation_issues = self._validate_outputs(workspace_path, task, result)
6357
+ if validation_issues:
6358
+ result.metrics["timeout_recovery_validation_issues"] = validation_issues
6359
+ return False
6360
+
6361
+ result.metrics["recovered_from_timeout_uncommitted_changes"] = True
6362
+ return True
6033
6363
 
6034
6364
  async def _execute_task(self, task: TaskInfo, conn: ServerConnection):
6035
6365
  """Execute a single task, reporting to the originating server connection."""
@@ -6654,12 +6984,23 @@ class RuntimeDaemon:
6654
6984
  # as failed so the orchestrator can retry (transient network).
6655
6985
  if commit_result.get("push_error"):
6656
6986
  push_err = commit_result["push_error"]
6657
- logger.error(
6658
- "Task %s: push failed, marking as failed so retry can attempt push again: %s",
6659
- task.task_id, push_err,
6660
- )
6661
6987
  result.status = "failed"
6662
- result.error = f"Git push failed: {push_err}"
6988
+ if commit_result.get("push_error_code") == "forbidden_branch_merge":
6989
+ logger.error(
6990
+ "Task %s: push blocked by requirement-branch merge protection: %s",
6991
+ task.task_id, push_err,
6992
+ )
6993
+ result.failure_code = "forbidden_branch_merge"
6994
+ result.error = (
6995
+ commit_result.get("push_error_display")
6996
+ or f"Push blocked: {push_err}"
6997
+ )
6998
+ else:
6999
+ logger.error(
7000
+ "Task %s: push failed, marking as failed so retry can attempt push again: %s",
7001
+ task.task_id, push_err,
7002
+ )
7003
+ result.error = f"Git push failed: {push_err}"
6663
7004
  # Re-collect git info after commit (compare with parent)
6664
7005
  post_commit_git = await self.process_manager._collect_git_info_vs_parent(workspace_path)
6665
7006
  # Merge: use the pre-commit file list if post-commit is empty
@@ -6896,12 +7237,14 @@ class RuntimeDaemon:
6896
7237
 
6897
7238
  elif node_type == "design":
6898
7239
  doc_dir = (task.input_data or {}).get("output_dir", "")
7240
+ req_type = (task.input_data or {}).get("requirement_type", "feature")
6899
7241
  if doc_dir:
6900
- design_path = workspace_path / doc_dir / "design.md"
7242
+ design_filename = _get_design_output_for_type(req_type)
7243
+ design_path = workspace_path / doc_dir / design_filename
6901
7244
  if not design_path.exists():
6902
- issues.append(f"Design document missing: {doc_dir}/design.md")
7245
+ issues.append(f"Design document missing: {doc_dir}/{design_filename}")
6903
7246
  elif design_path.stat().st_size == 0:
6904
- issues.append(f"Design document is empty: {doc_dir}/design.md")
7247
+ issues.append(f"Design document is empty: {doc_dir}/{design_filename}")
6905
7248
 
6906
7249
  elif node_type in ("coding", "testing", "fix", "review"):
6907
7250
  # Syntax-check modified Python and JS/TS files
@@ -7053,12 +7396,14 @@ class RuntimeDaemon:
7053
7396
  agent_override = aj.get("agent_override")
7054
7397
  system_prompt = aj.get("system_prompt", "")
7055
7398
  user_prompt = aj.get("user_prompt", "")
7399
+ input_ctx = aj.get("input_context", {})
7400
+ ai_workspace_scope = _resolve_ai_job_workspace_scope(aj)
7056
7401
 
7057
7402
  reporter_url = f"{conn.server_url.rstrip('/')}/api/v1/runtimes/{conn.runtime_id}/ai-jobs/{job_id}"
7058
7403
  # Use workspace-level key (project:requirement or project:job_id) so AI jobs
7059
7404
  # for different requirements run in parallel while same-requirement jobs serialize.
7060
7405
  _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}"
7406
+ project_lock_key = f"{_aj_proj_key}:{ai_workspace_scope or job_id}"
7062
7407
  project_lock = await self._get_project_execution_lock(project_lock_key)
7063
7408
  if project_lock.locked():
7064
7409
  logger.info(
@@ -7111,11 +7456,10 @@ class RuntimeDaemon:
7111
7456
  full_prompt = f"{system_prompt}\n\n{user_prompt}" if system_prompt else user_prompt
7112
7457
  fake_task = TaskInfo(
7113
7458
  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,
7459
+ # Reuse a stable workspace key when the server provides one so
7460
+ # delegated retries do not create a fresh clone per AIJob row.
7461
+ # Fall back to job_id so workspace_key is always non-empty.
7462
+ graph_id=ai_workspace_scope or job_id,
7119
7463
  node_type="ai_job",
7120
7464
  agent_type=agent_type,
7121
7465
  input_prompt=full_prompt,
@@ -7134,6 +7478,17 @@ class RuntimeDaemon:
7134
7478
  project_info, fake_task,
7135
7479
  )
7136
7480
 
7481
+ if task_type == "qa_scenario_generate_prompt" and not input_ctx.get("project_context"):
7482
+ local_project_context = _summarize_workspace_source(workspace_path)
7483
+ if local_project_context:
7484
+ fake_task.input_prompt = (
7485
+ f"{full_prompt}\n\n"
7486
+ "Use the provided project source context when grounding scenario coverage. "
7487
+ "Do not rely on additional workspace searching before responding.\n"
7488
+ "Do not call Grep or search tools against '.' or a bare directory path directly.\n\n"
7489
+ f"## Project Source Code Context\n{local_project_context}"
7490
+ )
7491
+
7137
7492
  await conn.client.post(
7138
7493
  f"{reporter_url}/progress",
7139
7494
  json={"current_phase": "running", "current_step": "Running agent...", "progress_pct": 15},
@@ -7210,7 +7565,6 @@ class RuntimeDaemon:
7210
7565
  # 4. Auto-commit if successful (always call _auto_commit on success:
7211
7566
  # _auto_commit handles both uncommitted changes AND internally-committed
7212
7567
  # changes that just need to be pushed — same as _execute_task).
7213
- input_ctx = aj.get("input_context", {})
7214
7568
  git_info = {}
7215
7569
  if result.status == "success":
7216
7570
  git_info = await self._auto_commit(workspace_path, fake_task)
@@ -7628,11 +7982,11 @@ class RuntimeDaemon:
7628
7982
  Some agents (e.g. claude) commit changes internally, so we must
7629
7983
  also push even when the working directory is clean.
7630
7984
 
7631
- Before pushing, we rebase onto the latest ``origin/{default_branch}``
7632
- so that the PR is based on up-to-date code and avoids unnecessary
7633
- merge conflicts. If rebase fails (true conflict), we fall back to a
7634
- merge. If the merge also fails, we invoke the same AI agent to
7635
- resolve the remaining conflicts automatically.
7985
+ For non-requirement branches, we may integrate the latest
7986
+ ``origin/{default_branch}`` before push. Requirement branches are
7987
+ explicitly excluded because silently rebasing/merging the default
7988
+ branch into a requirement branch widens the change scope, pollutes gate
7989
+ review diffs, and can import unrelated requirements into the fix.
7636
7990
 
7637
7991
  Returns a dict with commit/push status info (empty on success).
7638
7992
  """
@@ -7747,6 +8101,35 @@ class RuntimeDaemon:
7747
8101
  )
7748
8102
  return {"push_error": f"Would push to default branch {current_branch}"}
7749
8103
 
8104
+ if task.requirement_key and current_branch:
8105
+ forbidden_merges = await self._list_unpushed_merge_commits(
8106
+ workspace_path, current_branch, default_branch,
8107
+ )
8108
+ if forbidden_merges:
8109
+ short_merges = ", ".join(sha[:12] for sha in forbidden_merges[:5])
8110
+ logger.error(
8111
+ "Requirement branch %s has %d new merge commit(s) pending push [%s] — "
8112
+ "aborting push to prevent cross-requirement contamination",
8113
+ current_branch, len(forbidden_merges), short_merges,
8114
+ )
8115
+ display_error = (
8116
+ f"Push blocked: requirement branch '{current_branch}' contains "
8117
+ f"merge commit(s) pending push ({short_merges}). "
8118
+ "This would import default-branch or unrelated requirement changes "
8119
+ "into the current execution scope. Roll back or recreate this fix "
8120
+ "on a clean requirement branch. Do not merge, rebase, or pull the "
8121
+ "default branch into requirement branches."
8122
+ )
8123
+ return {
8124
+ "push_error_code": "forbidden_branch_merge",
8125
+ "push_error": (
8126
+ "Requirement branch contains merge commit(s) pending push: "
8127
+ f"{short_merges}. Merging default/other branches into "
8128
+ "requirement branches is forbidden."
8129
+ ),
8130
+ "push_error_display": display_error,
8131
+ }
8132
+
7750
8133
  # ── Push ──
7751
8134
  push_error = await self._push_branch(workspace_path, project_key, task=task)
7752
8135
  if push_error:
@@ -8560,6 +8943,11 @@ class RuntimeDaemon:
8560
8943
  ):
8561
8944
  """Integrate the current branch with ``origin/{default_branch}``.
8562
8945
 
8946
+ Requirement branches are intentionally excluded. They already sync to the
8947
+ latest remote requirement branch during workspace preparation, so pulling
8948
+ in ``origin/{default_branch}`` here only risks importing unrelated work
8949
+ into the requirement/fix scope.
8950
+
8563
8951
  Strategy:
8564
8952
  - If the current branch already exists on remote (was previously pushed):
8565
8953
  Skip rebase entirely and use merge only. Rebase rewrites commit
@@ -8575,6 +8963,15 @@ class RuntimeDaemon:
8575
8963
  target = f"origin/{default_branch}"
8576
8964
  _git_name, _git_email = _resolve_git_author(task.project)
8577
8965
 
8966
+ if task.requirement_key:
8967
+ logger.info(
8968
+ "Skipping automatic integration of %s into requirement branch %s "
8969
+ "to preserve task scope and avoid cross-requirement contamination",
8970
+ target,
8971
+ task.requirement_key,
8972
+ )
8973
+ return
8974
+
8578
8975
  # Fetch latest remote state
8579
8976
  try:
8580
8977
  await git("fetch", "origin", cwd=workspace_path,
@@ -8658,6 +9055,38 @@ class RuntimeDaemon:
8658
9055
  # ── Tier 3: AI-assisted conflict resolution ──
8659
9056
  await self._ai_resolve_conflicts(workspace_path, default_branch, task)
8660
9057
 
9058
+ async def _list_unpushed_merge_commits(
9059
+ self,
9060
+ workspace_path: Path,
9061
+ current_branch: str,
9062
+ default_branch: str,
9063
+ ) -> list[str]:
9064
+ """Return merge commits introduced locally and not yet on the remote branch.
9065
+
9066
+ Requirement branches must stay linear and requirement-scoped. Any new
9067
+ merge commit pending push indicates an explicit merge/rebase workflow that
9068
+ can import unrelated changes (for example, merging origin/develop into a
9069
+ fix branch). Those pushes are rejected by the caller.
9070
+ """
9071
+ git = self.workspace_manager._git
9072
+
9073
+ try:
9074
+ await git("rev-parse", "--verify", f"origin/{current_branch}", cwd=workspace_path)
9075
+ out = await git(
9076
+ "rev-list", "--min-parents=2", f"origin/{current_branch}..HEAD",
9077
+ cwd=workspace_path,
9078
+ )
9079
+ except RuntimeError:
9080
+ try:
9081
+ out = await git(
9082
+ "rev-list", "--min-parents=2", "HEAD", "--not", f"origin/{default_branch}",
9083
+ cwd=workspace_path,
9084
+ )
9085
+ except RuntimeError:
9086
+ return []
9087
+
9088
+ return [line.strip() for line in out.splitlines() if line.strip()]
9089
+
8661
9090
  async def _ai_resolve_conflicts(
8662
9091
  self, workspace_path: Path, default_branch: str, task: TaskInfo,
8663
9092
  ):
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: forgexa-cli
3
- Version: 1.14.2
3
+ Version: 1.14.4
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.4"
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