forgexa-cli 1.14.3__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.3
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.3"
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.3"
526
+ DAEMON_VERSION = "1.14.4"
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"]
@@ -5177,10 +5210,36 @@ class ProcessManager:
5177
5210
  class ProgressReporter:
5178
5211
  """Reports task progress back to the server."""
5179
5212
 
5213
+ _MIN_HEARTBEAT_INTERVAL_SECONDS = 5.0
5214
+ _MIN_PROGRESS_DELTA = 5
5215
+
5180
5216
  def __init__(self, client: httpx.AsyncClient, server_url: str, runtime_id: str):
5181
5217
  self.client = client
5182
5218
  self.server_url = server_url.rstrip("/")
5183
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
5184
5243
 
5185
5244
  async def report_progress(
5186
5245
  self,
@@ -5192,24 +5251,43 @@ class ProgressReporter:
5192
5251
  lines_added: int = 0,
5193
5252
  lines_removed: int = 0,
5194
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
+
5195
5267
  try:
5196
5268
  await self.client.post(
5197
5269
  f"{self.server_url}/api/v1/runtimes/{self.runtime_id}/tasks/{task_id}/progress",
5198
5270
  json={
5199
5271
  "progress_percent": progress_percent,
5200
5272
  "current_step": current_step,
5201
- "output_lines": output_lines or [],
5202
- "files_changed": files_changed or [],
5273
+ "output_lines": output_lines,
5274
+ "files_changed": files_changed,
5203
5275
  "lines_added": lines_added,
5204
5276
  "lines_removed": lines_removed,
5205
5277
  },
5206
5278
  timeout=10,
5207
5279
  )
5280
+ self._last_progress_sent[task_id] = {
5281
+ "progress_percent": progress_percent,
5282
+ "current_step": current_step,
5283
+ "sent_at": time.monotonic(),
5284
+ }
5208
5285
  except Exception as e:
5209
5286
  logger.warning("Failed to report progress for task %s: %s", task_id, e)
5210
5287
 
5211
5288
  async def report_complete(self, task_id: str, result: TaskResult):
5212
5289
  """Report task completion with retry."""
5290
+ self._last_progress_sent.pop(task_id, None)
5213
5291
  payload = {
5214
5292
  "status": result.status,
5215
5293
  "exit_code": result.exit_code,
@@ -5403,6 +5481,8 @@ class LogUploader:
5403
5481
  class TaskPoller:
5404
5482
  """Polls the server for assigned tasks."""
5405
5483
 
5484
+ _EMPTY_BACKOFF_STEPS = (0, 0, 1, 2, 3, 5)
5485
+
5406
5486
  def __init__(
5407
5487
  self,
5408
5488
  client: httpx.AsyncClient,
@@ -5416,6 +5496,16 @@ class TaskPoller:
5416
5496
  self.interval = interval
5417
5497
  self._on_success = lambda: None # Callbacks set by ServerConnection
5418
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]
5419
5509
 
5420
5510
  async def poll(self) -> list[TaskInfo]:
5421
5511
  """Poll for tasks assigned to this daemon."""
@@ -6146,15 +6236,28 @@ class RuntimeDaemon:
6146
6236
  # 3. Main loop
6147
6237
  try:
6148
6238
  while not self._shutdown:
6149
- await self._poll_and_execute()
6150
- 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)
6151
6253
  except asyncio.CancelledError:
6152
6254
  pass
6153
6255
  finally:
6154
6256
  await self._shutdown_gracefully()
6155
6257
 
6156
- async def _poll_and_execute(self):
6258
+ async def _poll_and_execute(self) -> bool:
6157
6259
  """Poll for tasks from all connected servers and execute them."""
6260
+ had_work = False
6158
6261
  # Clean up completed tasks
6159
6262
  for task_id in list(self.active_tasks):
6160
6263
  if self.active_tasks[task_id].done():
@@ -6169,7 +6272,7 @@ class RuntimeDaemon:
6169
6272
 
6170
6273
  # Check capacity
6171
6274
  if total_active >= self.max_concurrent:
6172
- return
6275
+ return True
6173
6276
 
6174
6277
  # Poll from all connected servers
6175
6278
  for conn in self.connections:
@@ -6177,6 +6280,12 @@ class RuntimeDaemon:
6177
6280
  break
6178
6281
 
6179
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)
6180
6289
 
6181
6290
  for task in tasks:
6182
6291
  if task.task_id in self.active_tasks:
@@ -6194,21 +6303,21 @@ class RuntimeDaemon:
6194
6303
  )
6195
6304
 
6196
6305
  # 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
- )
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
6212
6321
 
6213
6322
  def _recover_timeout_from_workspace_changes(
6214
6323
  self,
@@ -6875,12 +6984,23 @@ class RuntimeDaemon:
6875
6984
  # as failed so the orchestrator can retry (transient network).
6876
6985
  if commit_result.get("push_error"):
6877
6986
  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
6987
  result.status = "failed"
6883
- 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}"
6884
7004
  # Re-collect git info after commit (compare with parent)
6885
7005
  post_commit_git = await self.process_manager._collect_git_info_vs_parent(workspace_path)
6886
7006
  # Merge: use the pre-commit file list if post-commit is empty
@@ -7117,12 +7237,14 @@ class RuntimeDaemon:
7117
7237
 
7118
7238
  elif node_type == "design":
7119
7239
  doc_dir = (task.input_data or {}).get("output_dir", "")
7240
+ req_type = (task.input_data or {}).get("requirement_type", "feature")
7120
7241
  if doc_dir:
7121
- 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
7122
7244
  if not design_path.exists():
7123
- issues.append(f"Design document missing: {doc_dir}/design.md")
7245
+ issues.append(f"Design document missing: {doc_dir}/{design_filename}")
7124
7246
  elif design_path.stat().st_size == 0:
7125
- issues.append(f"Design document is empty: {doc_dir}/design.md")
7247
+ issues.append(f"Design document is empty: {doc_dir}/{design_filename}")
7126
7248
 
7127
7249
  elif node_type in ("coding", "testing", "fix", "review"):
7128
7250
  # Syntax-check modified Python and JS/TS files
@@ -7860,11 +7982,11 @@ class RuntimeDaemon:
7860
7982
  Some agents (e.g. claude) commit changes internally, so we must
7861
7983
  also push even when the working directory is clean.
7862
7984
 
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.
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.
7868
7990
 
7869
7991
  Returns a dict with commit/push status info (empty on success).
7870
7992
  """
@@ -7979,6 +8101,35 @@ class RuntimeDaemon:
7979
8101
  )
7980
8102
  return {"push_error": f"Would push to default branch {current_branch}"}
7981
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
+
7982
8133
  # ── Push ──
7983
8134
  push_error = await self._push_branch(workspace_path, project_key, task=task)
7984
8135
  if push_error:
@@ -8792,6 +8943,11 @@ class RuntimeDaemon:
8792
8943
  ):
8793
8944
  """Integrate the current branch with ``origin/{default_branch}``.
8794
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
+
8795
8951
  Strategy:
8796
8952
  - If the current branch already exists on remote (was previously pushed):
8797
8953
  Skip rebase entirely and use merge only. Rebase rewrites commit
@@ -8807,6 +8963,15 @@ class RuntimeDaemon:
8807
8963
  target = f"origin/{default_branch}"
8808
8964
  _git_name, _git_email = _resolve_git_author(task.project)
8809
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
+
8810
8975
  # Fetch latest remote state
8811
8976
  try:
8812
8977
  await git("fetch", "origin", cwd=workspace_path,
@@ -8890,6 +9055,38 @@ class RuntimeDaemon:
8890
9055
  # ── Tier 3: AI-assisted conflict resolution ──
8891
9056
  await self._ai_resolve_conflicts(workspace_path, default_branch, task)
8892
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
+
8893
9090
  async def _ai_resolve_conflicts(
8894
9091
  self, workspace_path: Path, default_branch: str, task: TaskInfo,
8895
9092
  ):
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: forgexa-cli
3
- Version: 1.14.3
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.3"
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