forgexa-cli 1.14.3__tar.gz → 1.14.5__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: forgexa-cli
3
- Version: 1.14.3
3
+ Version: 1.14.5
4
4
  Summary: Forgexa CLI — command-line client and AI agent runtime for the Forgexa platform
5
5
  Author-email: Jason Sun <dev.winds@gmail.com>
6
6
  License: MIT
@@ -1,2 +1,2 @@
1
1
  """forgexa-cli — Forgexa command-line client."""
2
- __version__ = "1.14.3"
2
+ __version__ = "1.14.5"
@@ -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.3"
526
+ DAEMON_VERSION = "1.14.5"
527
527
 
528
528
 
529
529
  def _detect_client_type() -> str:
@@ -1225,6 +1225,38 @@ def _get_analysis_outputs_for_type(req_type: str) -> list[str]:
1225
1225
  return _ANALYSIS_OUTPUTS_BY_TYPE.get(req_type, _ANALYSIS_OUTPUTS_BY_TYPE["feature"])
1226
1226
 
1227
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
+
1228
1260
  # ── Agent Discovery ──
1229
1261
 
1230
1262
 
@@ -3547,7 +3579,8 @@ class ProcessManager:
3547
3579
  req_type = (task.input_data or {}).get("requirement_type", "feature")
3548
3580
  required_files = _get_analysis_outputs_for_type(req_type)
3549
3581
  elif task.node_type == "design":
3550
- 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)]
3551
3584
  elif task.node_type == "delivery":
3552
3585
  # Required docs come from node input_data (set by delivery_doc_service)
3553
3586
  required_files = (task.input_data or {}).get("required_docs") or ["release-note.md"]
@@ -5073,28 +5106,47 @@ class ProcessManager:
5073
5106
 
5074
5107
  return info
5075
5108
 
5076
- async def _collect_git_info_vs_parent(self, cwd: Path) -> dict:
5077
- """Collect git diff stats comparing HEAD vs merge-base with default branch.
5109
+ async def _collect_git_info_vs_parent(
5110
+ self,
5111
+ cwd: Path,
5112
+ *,
5113
+ default_branch: str = "",
5114
+ before_sha: str = "",
5115
+ ) -> dict:
5116
+ """Collect git diff stats comparing HEAD vs merge-base with the base branch.
5078
5117
 
5079
- Uses merge-base to capture ALL changes on the feature branch, not just
5118
+ Uses merge-base to capture ALL changes on the current branch, not just
5080
5119
  the last commit (agents like claude may create multiple commits).
5081
- Falls back to HEAD~1 if merge-base detection fails.
5120
+ Prefers the project's configured default branch, then falls back to the
5121
+ conventional `main` / `master` refs. Falls back to HEAD~1 if merge-base
5122
+ detection fails.
5082
5123
  """
5083
5124
  info: dict[str, Any] = {}
5084
5125
  try:
5085
- # Try to find merge-base with origin's default branch
5086
- base_ref = "HEAD~1"
5087
- for candidate in ("origin/master", "origin/main"):
5088
- proc_mb = await asyncio.create_subprocess_exec(
5089
- "git", "merge-base", candidate, "HEAD",
5090
- stdout=asyncio.subprocess.PIPE,
5091
- stderr=asyncio.subprocess.PIPE,
5092
- cwd=str(cwd),
5093
- )
5094
- mb_stdout, _ = await asyncio.wait_for(proc_mb.communicate(), timeout=10)
5095
- if proc_mb.returncode == 0 and mb_stdout.strip():
5096
- base_ref = mb_stdout.decode().strip()
5097
- break
5126
+ # Prefer the exact pre-node HEAD when available so the diff is
5127
+ # scoped to this node's work, not the whole requirement branch.
5128
+ base_ref = str(before_sha or "").strip() or "HEAD~1"
5129
+ if not str(before_sha or "").strip():
5130
+ candidate_refs: list[str] = []
5131
+ for candidate in (default_branch, "main", "master"):
5132
+ candidate = str(candidate or "").strip()
5133
+ if not candidate:
5134
+ continue
5135
+ ref = candidate if candidate.startswith("origin/") else f"origin/{candidate}"
5136
+ if ref not in candidate_refs:
5137
+ candidate_refs.append(ref)
5138
+
5139
+ for candidate in candidate_refs:
5140
+ proc_mb = await asyncio.create_subprocess_exec(
5141
+ "git", "merge-base", candidate, "HEAD",
5142
+ stdout=asyncio.subprocess.PIPE,
5143
+ stderr=asyncio.subprocess.PIPE,
5144
+ cwd=str(cwd),
5145
+ )
5146
+ mb_stdout, _ = await asyncio.wait_for(proc_mb.communicate(), timeout=10)
5147
+ if proc_mb.returncode == 0 and mb_stdout.strip():
5148
+ base_ref = mb_stdout.decode().strip()
5149
+ break
5098
5150
 
5099
5151
  # Changed files since branch point
5100
5152
  proc = await asyncio.create_subprocess_exec(
@@ -5140,6 +5192,8 @@ class ProcessManager:
5140
5192
  )
5141
5193
  stdout4, _ = await asyncio.wait_for(proc4.communicate(), timeout=10)
5142
5194
  info["commit_sha"] = stdout4.decode().strip()
5195
+ info["before_sha"] = base_ref
5196
+ info["after_sha"] = info["commit_sha"]
5143
5197
 
5144
5198
  except Exception as e:
5145
5199
  logger.debug("Git info (vs parent) collection failed: %s", e)
@@ -5177,10 +5231,36 @@ class ProcessManager:
5177
5231
  class ProgressReporter:
5178
5232
  """Reports task progress back to the server."""
5179
5233
 
5234
+ _MIN_HEARTBEAT_INTERVAL_SECONDS = 5.0
5235
+ _MIN_PROGRESS_DELTA = 5
5236
+
5180
5237
  def __init__(self, client: httpx.AsyncClient, server_url: str, runtime_id: str):
5181
5238
  self.client = client
5182
5239
  self.server_url = server_url.rstrip("/")
5183
5240
  self.runtime_id = runtime_id
5241
+ self._last_progress_sent: dict[str, dict[str, Any]] = {}
5242
+
5243
+ def _should_skip_progress(
5244
+ self,
5245
+ task_id: str,
5246
+ progress_percent: int,
5247
+ current_step: str,
5248
+ output_lines: list[str],
5249
+ files_changed: list[str],
5250
+ lines_added: int,
5251
+ lines_removed: int,
5252
+ ) -> bool:
5253
+ if output_lines or files_changed or lines_added or lines_removed:
5254
+ return False
5255
+
5256
+ last = self._last_progress_sent.get(task_id)
5257
+ if not last:
5258
+ return False
5259
+ if last["current_step"] != current_step:
5260
+ return False
5261
+ if progress_percent - int(last["progress_percent"]) >= self._MIN_PROGRESS_DELTA:
5262
+ return False
5263
+ return (time.monotonic() - float(last["sent_at"])) < self._MIN_HEARTBEAT_INTERVAL_SECONDS
5184
5264
 
5185
5265
  async def report_progress(
5186
5266
  self,
@@ -5192,24 +5272,43 @@ class ProgressReporter:
5192
5272
  lines_added: int = 0,
5193
5273
  lines_removed: int = 0,
5194
5274
  ):
5275
+ output_lines = output_lines or []
5276
+ files_changed = files_changed or []
5277
+ if self._should_skip_progress(
5278
+ task_id,
5279
+ progress_percent,
5280
+ current_step,
5281
+ output_lines,
5282
+ files_changed,
5283
+ lines_added,
5284
+ lines_removed,
5285
+ ):
5286
+ return
5287
+
5195
5288
  try:
5196
5289
  await self.client.post(
5197
5290
  f"{self.server_url}/api/v1/runtimes/{self.runtime_id}/tasks/{task_id}/progress",
5198
5291
  json={
5199
5292
  "progress_percent": progress_percent,
5200
5293
  "current_step": current_step,
5201
- "output_lines": output_lines or [],
5202
- "files_changed": files_changed or [],
5294
+ "output_lines": output_lines,
5295
+ "files_changed": files_changed,
5203
5296
  "lines_added": lines_added,
5204
5297
  "lines_removed": lines_removed,
5205
5298
  },
5206
5299
  timeout=10,
5207
5300
  )
5301
+ self._last_progress_sent[task_id] = {
5302
+ "progress_percent": progress_percent,
5303
+ "current_step": current_step,
5304
+ "sent_at": time.monotonic(),
5305
+ }
5208
5306
  except Exception as e:
5209
5307
  logger.warning("Failed to report progress for task %s: %s", task_id, e)
5210
5308
 
5211
5309
  async def report_complete(self, task_id: str, result: TaskResult):
5212
5310
  """Report task completion with retry."""
5311
+ self._last_progress_sent.pop(task_id, None)
5213
5312
  payload = {
5214
5313
  "status": result.status,
5215
5314
  "exit_code": result.exit_code,
@@ -5403,6 +5502,8 @@ class LogUploader:
5403
5502
  class TaskPoller:
5404
5503
  """Polls the server for assigned tasks."""
5405
5504
 
5505
+ _EMPTY_BACKOFF_STEPS = (0, 0, 1, 2, 3, 5)
5506
+
5406
5507
  def __init__(
5407
5508
  self,
5408
5509
  client: httpx.AsyncClient,
@@ -5416,6 +5517,16 @@ class TaskPoller:
5416
5517
  self.interval = interval
5417
5518
  self._on_success = lambda: None # Callbacks set by ServerConnection
5418
5519
  self._on_auth_failure = lambda: None
5520
+ self._empty_cycles = 0
5521
+
5522
+ def note_activity(self, *, had_work: bool) -> None:
5523
+ if had_work:
5524
+ self._empty_cycles = 0
5525
+ return
5526
+ self._empty_cycles = min(self._empty_cycles + 1, len(self._EMPTY_BACKOFF_STEPS) - 1)
5527
+
5528
+ def next_delay_seconds(self) -> int:
5529
+ return self.interval + self._EMPTY_BACKOFF_STEPS[self._empty_cycles]
5419
5530
 
5420
5531
  async def poll(self) -> list[TaskInfo]:
5421
5532
  """Poll for tasks assigned to this daemon."""
@@ -6146,15 +6257,28 @@ class RuntimeDaemon:
6146
6257
  # 3. Main loop
6147
6258
  try:
6148
6259
  while not self._shutdown:
6149
- await self._poll_and_execute()
6150
- await asyncio.sleep(self.poll_interval)
6260
+ had_work = await self._poll_and_execute()
6261
+ next_delay = self.poll_interval
6262
+ if self.connections:
6263
+ next_delay = min(
6264
+ conn.poller.next_delay_seconds()
6265
+ for conn in self.connections
6266
+ if conn.poller is not None
6267
+ )
6268
+ logger.debug(
6269
+ "Daemon poll cycle complete (had_work=%s, next_delay=%ss)",
6270
+ had_work,
6271
+ next_delay,
6272
+ )
6273
+ await asyncio.sleep(next_delay)
6151
6274
  except asyncio.CancelledError:
6152
6275
  pass
6153
6276
  finally:
6154
6277
  await self._shutdown_gracefully()
6155
6278
 
6156
- async def _poll_and_execute(self):
6279
+ async def _poll_and_execute(self) -> bool:
6157
6280
  """Poll for tasks from all connected servers and execute them."""
6281
+ had_work = False
6158
6282
  # Clean up completed tasks
6159
6283
  for task_id in list(self.active_tasks):
6160
6284
  if self.active_tasks[task_id].done():
@@ -6169,7 +6293,7 @@ class RuntimeDaemon:
6169
6293
 
6170
6294
  # Check capacity
6171
6295
  if total_active >= self.max_concurrent:
6172
- return
6296
+ return True
6173
6297
 
6174
6298
  # Poll from all connected servers
6175
6299
  for conn in self.connections:
@@ -6177,6 +6301,12 @@ class RuntimeDaemon:
6177
6301
  break
6178
6302
 
6179
6303
  tasks = await conn.poller.poll()
6304
+ ai_jobs: list[dict] = []
6305
+ if len(self.active_tasks) < self.max_concurrent:
6306
+ ai_jobs = await conn.poller.poll_ai_jobs()
6307
+
6308
+ conn.poller.note_activity(had_work=bool(tasks or ai_jobs))
6309
+ had_work = had_work or bool(tasks or ai_jobs)
6180
6310
 
6181
6311
  for task in tasks:
6182
6312
  if task.task_id in self.active_tasks:
@@ -6194,21 +6324,21 @@ class RuntimeDaemon:
6194
6324
  )
6195
6325
 
6196
6326
  # Poll for AIJobs (workspace-mode tasks)
6197
- if len(self.active_tasks) < self.max_concurrent:
6198
- ai_jobs = await conn.poller.poll_ai_jobs()
6199
- for aj in ai_jobs:
6200
- job_id = aj.get("job_id", "")
6201
- ai_task_key = f"aijob_{job_id}"
6202
- if ai_task_key in self.active_tasks:
6203
- continue
6204
- if len(self.active_tasks) >= self.max_concurrent:
6205
- break
6206
- logger.info("[%s] Starting AIJob %s (type=%s)",
6207
- conn.label, job_id, aj.get("task_type"))
6208
- self._task_connections[ai_task_key] = conn
6209
- self.active_tasks[ai_task_key] = asyncio.create_task(
6210
- self._execute_ai_job(aj, conn)
6211
- )
6327
+ for aj in ai_jobs:
6328
+ job_id = aj.get("job_id", "")
6329
+ ai_task_key = f"aijob_{job_id}"
6330
+ if ai_task_key in self.active_tasks:
6331
+ continue
6332
+ if len(self.active_tasks) >= self.max_concurrent:
6333
+ break
6334
+ logger.info("[%s] Starting AIJob %s (type=%s)",
6335
+ conn.label, job_id, aj.get("task_type"))
6336
+ self._task_connections[ai_task_key] = conn
6337
+ self.active_tasks[ai_task_key] = asyncio.create_task(
6338
+ self._execute_ai_job(aj, conn)
6339
+ )
6340
+
6341
+ return had_work
6212
6342
 
6213
6343
  def _recover_timeout_from_workspace_changes(
6214
6344
  self,
@@ -6255,6 +6385,8 @@ class RuntimeDaemon:
6255
6385
  async def _execute_task(self, task: TaskInfo, conn: ServerConnection):
6256
6386
  """Execute a single task, reporting to the originating server connection."""
6257
6387
  reporter = conn.reporter
6388
+ default_branch = str((task.project or {}).get("default_branch") or "").strip()
6389
+ node_before_sha = ""
6258
6390
  # Use workspace-level granularity (requirement_workflow_id or graph_id) so that
6259
6391
  # tasks for DIFFERENT requirements can run in parallel on the same daemon, while
6260
6392
  # tasks for the SAME requirement (e.g. analysis + retry) are still serialized to
@@ -6434,6 +6566,19 @@ class RuntimeDaemon:
6434
6566
  output_dir_norm, task.task_id,
6435
6567
  )
6436
6568
 
6569
+ try:
6570
+ node_before_sha = (
6571
+ await self.workspace_manager._git(
6572
+ "rev-parse", "HEAD", cwd=workspace_path, timeout=30,
6573
+ )
6574
+ ).strip()
6575
+ except Exception as _before_sha_err:
6576
+ logger.debug(
6577
+ "Could not resolve pre-node HEAD for task %s: %s",
6578
+ task.task_id,
6579
+ _before_sha_err,
6580
+ )
6581
+
6437
6582
  # 3. Run agent with real-time output streaming + periodic progress heartbeat
6438
6583
  await reporter.report_progress(task.task_id, 10, "running_agent")
6439
6584
 
@@ -6506,7 +6651,11 @@ class RuntimeDaemon:
6506
6651
  _skip_fallback = False
6507
6652
  if self.process_manager.is_rate_limited(result):
6508
6653
  _pre_fallback_git = await self.process_manager._collect_git_info(workspace_path)
6509
- _pre_fallback_committed = await self.process_manager._collect_git_info_vs_parent(workspace_path)
6654
+ _pre_fallback_committed = await self.process_manager._collect_git_info_vs_parent(
6655
+ workspace_path,
6656
+ default_branch=default_branch,
6657
+ before_sha=node_before_sha,
6658
+ )
6510
6659
  has_workspace_changes = (
6511
6660
  bool(_pre_fallback_git.get("files_changed"))
6512
6661
  or bool(_pre_fallback_committed.get("files_changed"))
@@ -6617,7 +6766,11 @@ class RuntimeDaemon:
6617
6766
  # (e.g., after rate-limit fallback, or API errors not caught by output parsing).
6618
6767
  if result.status == "success" and task.node_type in ("coding", "fix", "testing"):
6619
6768
  has_uncommitted = bool(pre_commit_git.get("files_changed"))
6620
- committed_git = await self.process_manager._collect_git_info_vs_parent(workspace_path)
6769
+ committed_git = await self.process_manager._collect_git_info_vs_parent(
6770
+ workspace_path,
6771
+ default_branch=default_branch,
6772
+ before_sha=node_before_sha,
6773
+ )
6621
6774
  has_committed = bool(committed_git.get("files_changed"))
6622
6775
  has_tokens = (
6623
6776
  int(result.metrics.get("token_input", 0) or 0)
@@ -6655,7 +6808,11 @@ class RuntimeDaemon:
6655
6808
  )
6656
6809
  if can_attempt_recovery:
6657
6810
  original_exit_code = result.exit_code
6658
- committed_git = await self.process_manager._collect_git_info_vs_parent(workspace_path)
6811
+ committed_git = await self.process_manager._collect_git_info_vs_parent(
6812
+ workspace_path,
6813
+ default_branch=default_branch,
6814
+ before_sha=node_before_sha,
6815
+ )
6659
6816
  has_committed_changes = bool(committed_git.get("files_changed"))
6660
6817
  has_uncommitted_changes = bool(pre_commit_git.get("files_changed"))
6661
6818
  has_no_uncommitted = not has_uncommitted_changes
@@ -6779,7 +6936,11 @@ class RuntimeDaemon:
6779
6936
  # 4.55 Analysis/design/fix nodes must update their deliverables in THIS run.
6780
6937
  # Existing files from a prior iteration are not sufficient evidence.
6781
6938
  if result.status == "success" and task.node_type in ("analysis", "design", "fix"):
6782
- committed_git = await self.process_manager._collect_git_info_vs_parent(workspace_path)
6939
+ committed_git = await self.process_manager._collect_git_info_vs_parent(
6940
+ workspace_path,
6941
+ default_branch=default_branch,
6942
+ before_sha=node_before_sha,
6943
+ )
6783
6944
  git_check_passed = self.process_manager._has_required_deliverable_updates(
6784
6945
  task,
6785
6946
  pre_commit_git.get("files_changed"),
@@ -6875,14 +7036,29 @@ class RuntimeDaemon:
6875
7036
  # as failed so the orchestrator can retry (transient network).
6876
7037
  if commit_result.get("push_error"):
6877
7038
  push_err = commit_result["push_error"]
6878
- logger.error(
6879
- "Task %s: push failed, marking as failed so retry can attempt push again: %s",
6880
- task.task_id, push_err,
6881
- )
6882
7039
  result.status = "failed"
6883
- result.error = f"Git push failed: {push_err}"
7040
+ if commit_result.get("push_error_code") == "forbidden_branch_merge":
7041
+ logger.error(
7042
+ "Task %s: push blocked by requirement-branch merge protection: %s",
7043
+ task.task_id, push_err,
7044
+ )
7045
+ result.failure_code = "forbidden_branch_merge"
7046
+ result.error = (
7047
+ commit_result.get("push_error_display")
7048
+ or f"Push blocked: {push_err}"
7049
+ )
7050
+ else:
7051
+ logger.error(
7052
+ "Task %s: push failed, marking as failed so retry can attempt push again: %s",
7053
+ task.task_id, push_err,
7054
+ )
7055
+ result.error = f"Git push failed: {push_err}"
6884
7056
  # Re-collect git info after commit (compare with parent)
6885
- post_commit_git = await self.process_manager._collect_git_info_vs_parent(workspace_path)
7057
+ post_commit_git = await self.process_manager._collect_git_info_vs_parent(
7058
+ workspace_path,
7059
+ default_branch=default_branch,
7060
+ before_sha=node_before_sha,
7061
+ )
6886
7062
  # Merge: use the pre-commit file list if post-commit is empty
6887
7063
  result.git = post_commit_git
6888
7064
  if not result.git.get("files_changed") and pre_commit_git.get("files_changed"):
@@ -7117,12 +7293,14 @@ class RuntimeDaemon:
7117
7293
 
7118
7294
  elif node_type == "design":
7119
7295
  doc_dir = (task.input_data or {}).get("output_dir", "")
7296
+ req_type = (task.input_data or {}).get("requirement_type", "feature")
7120
7297
  if doc_dir:
7121
- design_path = workspace_path / doc_dir / "design.md"
7298
+ design_filename = _get_design_output_for_type(req_type)
7299
+ design_path = workspace_path / doc_dir / design_filename
7122
7300
  if not design_path.exists():
7123
- issues.append(f"Design document missing: {doc_dir}/design.md")
7301
+ issues.append(f"Design document missing: {doc_dir}/{design_filename}")
7124
7302
  elif design_path.stat().st_size == 0:
7125
- issues.append(f"Design document is empty: {doc_dir}/design.md")
7303
+ issues.append(f"Design document is empty: {doc_dir}/{design_filename}")
7126
7304
 
7127
7305
  elif node_type in ("coding", "testing", "fix", "review"):
7128
7306
  # Syntax-check modified Python and JS/TS files
@@ -7860,11 +8038,11 @@ class RuntimeDaemon:
7860
8038
  Some agents (e.g. claude) commit changes internally, so we must
7861
8039
  also push even when the working directory is clean.
7862
8040
 
7863
- Before pushing, we rebase onto the latest ``origin/{default_branch}``
7864
- so that the PR is based on up-to-date code and avoids unnecessary
7865
- merge conflicts. If rebase fails (true conflict), we fall back to a
7866
- merge. If the merge also fails, we invoke the same AI agent to
7867
- resolve the remaining conflicts automatically.
8041
+ For non-requirement branches, we may integrate the latest
8042
+ ``origin/{default_branch}`` before push. Requirement branches are
8043
+ explicitly excluded because silently rebasing/merging the default
8044
+ branch into a requirement branch widens the change scope, pollutes gate
8045
+ review diffs, and can import unrelated requirements into the fix.
7868
8046
 
7869
8047
  Returns a dict with commit/push status info (empty on success).
7870
8048
  """
@@ -7979,6 +8157,35 @@ class RuntimeDaemon:
7979
8157
  )
7980
8158
  return {"push_error": f"Would push to default branch {current_branch}"}
7981
8159
 
8160
+ if task.requirement_key and current_branch:
8161
+ forbidden_merges = await self._list_unpushed_merge_commits(
8162
+ workspace_path, current_branch, default_branch,
8163
+ )
8164
+ if forbidden_merges:
8165
+ short_merges = ", ".join(sha[:12] for sha in forbidden_merges[:5])
8166
+ logger.error(
8167
+ "Requirement branch %s has %d new merge commit(s) pending push [%s] — "
8168
+ "aborting push to prevent cross-requirement contamination",
8169
+ current_branch, len(forbidden_merges), short_merges,
8170
+ )
8171
+ display_error = (
8172
+ f"Push blocked: requirement branch '{current_branch}' contains "
8173
+ f"merge commit(s) pending push ({short_merges}). "
8174
+ "This would import default-branch or unrelated requirement changes "
8175
+ "into the current execution scope. Roll back or recreate this fix "
8176
+ "on a clean requirement branch. Do not merge, rebase, or pull the "
8177
+ "default branch into requirement branches."
8178
+ )
8179
+ return {
8180
+ "push_error_code": "forbidden_branch_merge",
8181
+ "push_error": (
8182
+ "Requirement branch contains merge commit(s) pending push: "
8183
+ f"{short_merges}. Merging default/other branches into "
8184
+ "requirement branches is forbidden."
8185
+ ),
8186
+ "push_error_display": display_error,
8187
+ }
8188
+
7982
8189
  # ── Push ──
7983
8190
  push_error = await self._push_branch(workspace_path, project_key, task=task)
7984
8191
  if push_error:
@@ -8792,6 +8999,11 @@ class RuntimeDaemon:
8792
8999
  ):
8793
9000
  """Integrate the current branch with ``origin/{default_branch}``.
8794
9001
 
9002
+ Requirement branches are intentionally excluded. They already sync to the
9003
+ latest remote requirement branch during workspace preparation, so pulling
9004
+ in ``origin/{default_branch}`` here only risks importing unrelated work
9005
+ into the requirement/fix scope.
9006
+
8795
9007
  Strategy:
8796
9008
  - If the current branch already exists on remote (was previously pushed):
8797
9009
  Skip rebase entirely and use merge only. Rebase rewrites commit
@@ -8807,6 +9019,15 @@ class RuntimeDaemon:
8807
9019
  target = f"origin/{default_branch}"
8808
9020
  _git_name, _git_email = _resolve_git_author(task.project)
8809
9021
 
9022
+ if task.requirement_key:
9023
+ logger.info(
9024
+ "Skipping automatic integration of %s into requirement branch %s "
9025
+ "to preserve task scope and avoid cross-requirement contamination",
9026
+ target,
9027
+ task.requirement_key,
9028
+ )
9029
+ return
9030
+
8810
9031
  # Fetch latest remote state
8811
9032
  try:
8812
9033
  await git("fetch", "origin", cwd=workspace_path,
@@ -8890,6 +9111,38 @@ class RuntimeDaemon:
8890
9111
  # ── Tier 3: AI-assisted conflict resolution ──
8891
9112
  await self._ai_resolve_conflicts(workspace_path, default_branch, task)
8892
9113
 
9114
+ async def _list_unpushed_merge_commits(
9115
+ self,
9116
+ workspace_path: Path,
9117
+ current_branch: str,
9118
+ default_branch: str,
9119
+ ) -> list[str]:
9120
+ """Return merge commits introduced locally and not yet on the remote branch.
9121
+
9122
+ Requirement branches must stay linear and requirement-scoped. Any new
9123
+ merge commit pending push indicates an explicit merge/rebase workflow that
9124
+ can import unrelated changes (for example, merging origin/develop into a
9125
+ fix branch). Those pushes are rejected by the caller.
9126
+ """
9127
+ git = self.workspace_manager._git
9128
+
9129
+ try:
9130
+ await git("rev-parse", "--verify", f"origin/{current_branch}", cwd=workspace_path)
9131
+ out = await git(
9132
+ "rev-list", "--min-parents=2", f"origin/{current_branch}..HEAD",
9133
+ cwd=workspace_path,
9134
+ )
9135
+ except RuntimeError:
9136
+ try:
9137
+ out = await git(
9138
+ "rev-list", "--min-parents=2", "HEAD", "--not", f"origin/{default_branch}",
9139
+ cwd=workspace_path,
9140
+ )
9141
+ except RuntimeError:
9142
+ return []
9143
+
9144
+ return [line.strip() for line in out.splitlines() if line.strip()]
9145
+
8893
9146
  async def _ai_resolve_conflicts(
8894
9147
  self, workspace_path: Path, default_branch: str, task: TaskInfo,
8895
9148
  ):
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: forgexa-cli
3
- Version: 1.14.3
3
+ Version: 1.14.5
4
4
  Summary: Forgexa CLI — command-line client and AI agent runtime for the Forgexa platform
5
5
  Author-email: Jason Sun <dev.winds@gmail.com>
6
6
  License: MIT
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "forgexa-cli"
3
- version = "1.14.3"
3
+ version = "1.14.5"
4
4
  description = "Forgexa CLI — command-line client and AI agent runtime for the Forgexa platform"
5
5
  requires-python = ">=3.9"
6
6
  license = { text = "MIT" }
File without changes
File without changes