forgexa-cli 1.2.7__tar.gz → 1.3.2__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.2.7
3
+ Version: 1.3.2
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.2.7"
2
+ __version__ = "1.3.2"
@@ -312,6 +312,7 @@ class TaskInfo:
312
312
  requirement_workflow_id: str | None = None
313
313
  requirement_key: str | None = None
314
314
  graph_type: str = "execution"
315
+ analysis_branch: str | None = None
315
316
 
316
317
 
317
318
  @dataclass
@@ -640,6 +641,7 @@ class WorkspaceManager:
640
641
  project_dir, repo_url, default_branch, workspace_key, branch_name,
641
642
  fresh_start=is_fresh_start,
642
643
  project_key=project_key,
644
+ expect_branch=bool(task.analysis_branch),
643
645
  )
644
646
  # Refine mode: ensure we're on the analysis branch with its history
645
647
  # (not reset to default_branch)
@@ -722,7 +724,7 @@ class WorkspaceManager:
722
724
  async def _create_worktree(
723
725
  self, project_dir: Path, repo_url: str, default_branch: str,
724
726
  workspace_key: str, branch_name: str, *, fresh_start: bool = False,
725
- project_key: str = "default",
727
+ project_key: str = "default", expect_branch: bool = False,
726
728
  ) -> Path:
727
729
  main_repo = project_dir / "_main"
728
730
  ws_path = project_dir / workspace_key
@@ -773,29 +775,75 @@ class WorkspaceManager:
773
775
  # This is critical for cross-machine execution where a previous
774
776
  # node on another daemon pushed commits to the branch.
775
777
  logger.info("Syncing worktree %s to latest origin/%s", ws_path, branch_name)
776
- try:
777
- await self._git("checkout", branch_name, cwd=ws_path)
778
- except RuntimeError:
779
- # Branch might not exist locally yet — create tracking branch
778
+ sync_success = False
779
+ for _sync_attempt in range(3):
780
+ try:
781
+ await self._git("checkout", branch_name, cwd=ws_path)
782
+ except RuntimeError:
783
+ # Branch might not exist locally yet — create tracking branch
784
+ try:
785
+ await self._git(
786
+ "checkout", "-B", branch_name, f"origin/{branch_name}",
787
+ cwd=ws_path,
788
+ )
789
+ except RuntimeError as exc:
790
+ if _sync_attempt < 2:
791
+ logger.info(
792
+ "Branch %s not available yet (attempt %d/3), re-fetching...",
793
+ branch_name, _sync_attempt + 1,
794
+ )
795
+ await asyncio.sleep(2 * (_sync_attempt + 1))
796
+ try:
797
+ await self._git(
798
+ "fetch", "origin", cwd=ws_path,
799
+ project_key=project_key,
800
+ )
801
+ except RuntimeError:
802
+ pass
803
+ continue
804
+ else:
805
+ logger.warning("Failed to checkout %s after retries: %s", branch_name, exc)
806
+ # Reset working tree to match remote branch (fast-forward)
780
807
  try:
781
808
  await self._git(
782
- "checkout", "-B", branch_name, f"origin/{branch_name}",
809
+ "reset", "--hard", f"origin/{branch_name}",
783
810
  cwd=ws_path,
784
811
  )
812
+ sync_success = True
813
+ break
785
814
  except RuntimeError as exc:
786
- logger.warning("Failed to checkout %s: %s", branch_name, exc)
787
- # Reset working tree to match remote branch (fast-forward)
788
- try:
789
- await self._git(
790
- "reset", "--hard", f"origin/{branch_name}",
791
- cwd=ws_path,
792
- )
793
- except RuntimeError as exc:
794
- # Remote branch might not exist yet (first node in graph)
795
- logger.debug(
796
- "Could not reset to origin/%s: %s — branch may not exist on remote yet",
797
- branch_name, exc,
798
- )
815
+ if _sync_attempt < 2:
816
+ logger.info(
817
+ "Could not reset to origin/%s (attempt %d/3), re-fetching...",
818
+ branch_name, _sync_attempt + 1,
819
+ )
820
+ await asyncio.sleep(2 * (_sync_attempt + 1))
821
+ try:
822
+ await self._git(
823
+ "fetch", "origin", cwd=ws_path,
824
+ project_key=project_key,
825
+ )
826
+ except RuntimeError:
827
+ pass
828
+ else:
829
+ logger.warning(
830
+ "Could not reset to origin/%s after retries: %s — "
831
+ "workspace may lack latest commits from prior phases",
832
+ branch_name, exc,
833
+ )
834
+ if not sync_success:
835
+ if expect_branch:
836
+ raise RuntimeError(
837
+ f"Failed to sync branch '{branch_name}' from remote after 3 attempts. "
838
+ f"The branch should exist (pushed by prior analysis/design phase). "
839
+ f"This task will be retried by the orchestrator."
840
+ )
841
+ else:
842
+ logger.warning(
843
+ "Git sync to origin/%s failed — branch may not exist yet "
844
+ "(no prior analysis phase). Proceeding with current state.",
845
+ branch_name,
846
+ )
799
847
  return ws_path
800
848
 
801
849
  # Ensure _main repo is present and up-to-date
@@ -804,6 +852,15 @@ class WorkspaceManager:
804
852
  else:
805
853
  await self._git("fetch", "--all", cwd=main_repo, timeout=300, project_key=project_key)
806
854
 
855
+ # Prune stale worktree references (e.g. directories deleted externally
856
+ # when simulating cross-runtime or after disk cleanup). Without this,
857
+ # `git worktree add` refuses to create a branch that is "already checked out"
858
+ # in the now-missing worktree directory.
859
+ try:
860
+ await self._git("worktree", "prune", cwd=main_repo)
861
+ except RuntimeError:
862
+ pass
863
+
807
864
  # Fast-forward _main's local default branch to match origin so
808
865
  # that `git worktree add ... {default_branch}` uses latest code.
809
866
  # We fetch first, then update the local ref directly (avoids
@@ -818,22 +875,46 @@ class WorkspaceManager:
818
875
  logger.warning("Could not fast-forward _main/%s: %s", default_branch, exc)
819
876
 
820
877
  # branch_name is passed in (feature/{requirement_key} or feature/{workspace_key})
821
- # First check if the branch already exists on remote (for refine/continuation)
878
+ # First check if the branch already exists on remote (for refine/continuation).
879
+ # Retry up to 2 times with short delay for cross-runtime timing issues
880
+ # (analysis daemon may have just pushed the branch).
822
881
  branch_exists_remote = False
823
- try:
824
- check_proc = await asyncio.create_subprocess_exec(
825
- "git", "rev-parse", "--verify", f"refs/remotes/origin/{branch_name}",
826
- cwd=str(main_repo),
827
- stdout=asyncio.subprocess.PIPE,
828
- stderr=asyncio.subprocess.PIPE,
829
- )
830
- await check_proc.communicate()
831
- branch_exists_remote = check_proc.returncode == 0
832
- except Exception:
833
- pass
882
+ for _check_attempt in range(3 if not fresh_start else 1):
883
+ try:
884
+ check_proc = await asyncio.create_subprocess_exec(
885
+ "git", "rev-parse", "--verify", f"refs/remotes/origin/{branch_name}",
886
+ cwd=str(main_repo),
887
+ stdout=asyncio.subprocess.PIPE,
888
+ stderr=asyncio.subprocess.PIPE,
889
+ )
890
+ await check_proc.communicate()
891
+ branch_exists_remote = check_proc.returncode == 0
892
+ except Exception:
893
+ pass
894
+ if branch_exists_remote or fresh_start:
895
+ break
896
+ if _check_attempt < 2:
897
+ logger.info(
898
+ "Branch %s not found on remote (attempt %d/3), re-fetching...",
899
+ branch_name, _check_attempt + 1,
900
+ )
901
+ await asyncio.sleep(2 * (_check_attempt + 1))
902
+ try:
903
+ await self._git("fetch", "--all", cwd=main_repo, timeout=60, project_key=project_key)
904
+ except RuntimeError:
905
+ pass
834
906
 
835
907
  if branch_exists_remote and not fresh_start:
836
908
  # Branch exists on remote and we want to preserve it (refine mode)
909
+ # First, update local branch ref to match remote (in case it's stale)
910
+ try:
911
+ await self._git(
912
+ "update-ref", f"refs/heads/{branch_name}",
913
+ f"refs/remotes/origin/{branch_name}",
914
+ cwd=main_repo,
915
+ )
916
+ except RuntimeError:
917
+ pass
837
918
  try:
838
919
  await self._git(
839
920
  "worktree", "add", "--track", "-b", branch_name,
@@ -967,7 +1048,8 @@ class WorkspaceManager:
967
1048
  class ProcessManager:
968
1049
  """Manages Agent CLI subprocess lifecycle."""
969
1050
 
970
- # Patterns that indicate an agent hit its rate/usage limit
1051
+ # Patterns that indicate an agent hit its rate/usage limit or its API is
1052
+ # unavailable — triggers fallback to a different agent
971
1053
  RATE_LIMIT_PATTERNS = [
972
1054
  "usage limit",
973
1055
  "rate limit",
@@ -979,18 +1061,194 @@ class ProcessManager:
979
1061
  "capacity",
980
1062
  "try again",
981
1063
  "credit",
1064
+ "insufficient_quota",
1065
+ "billing",
1066
+ ]
1067
+
1068
+ # Patterns indicating the agent's API is unreachable/misconfigured —
1069
+ # a different agent (using a different API backend) may succeed.
1070
+ AGENT_UNAVAILABLE_PATTERNS = [
1071
+ "404 not found",
1072
+ "503 service unavailable",
1073
+ "502 bad gateway",
1074
+ "connection refused",
1075
+ "connection reset",
1076
+ "connection timed out",
1077
+ "name or service not known",
1078
+ "no such host",
1079
+ "network is unreachable",
982
1080
  ]
983
1081
 
984
1082
  def __init__(self):
985
1083
  self.active_processes: dict[str, asyncio.subprocess.Process] = {}
986
1084
 
1085
+ @staticmethod
1086
+ def _has_failure_pattern(text: str) -> str | None:
1087
+ lower = (text or "").lower()
1088
+ if any(p in lower for p in ProcessManager.RATE_LIMIT_PATTERNS):
1089
+ return "Agent hit rate/usage limit or quota exhaustion"
1090
+ if any(p in lower for p in ProcessManager.AGENT_UNAVAILABLE_PATTERNS):
1091
+ return "Agent backend unavailable or unreachable"
1092
+ return None
1093
+
1094
+ @staticmethod
1095
+ def _extract_output_signals(text: str) -> dict[str, Any]:
1096
+ """Parse stdout/stderr-like streams for success and failure signals."""
1097
+ has_turn_completed = False
1098
+ has_turn_failed = False
1099
+ has_result = False
1100
+ has_meaningful_content = False
1101
+ error_messages: list[str] = []
1102
+ json_line_count = 0
1103
+
1104
+ for raw_line in (text or "").split("\n"):
1105
+ raw_line = raw_line.strip()
1106
+ if not raw_line:
1107
+ continue
1108
+ try:
1109
+ data = json.loads(raw_line)
1110
+ except json.JSONDecodeError:
1111
+ has_meaningful_content = True
1112
+ continue
1113
+
1114
+ if not isinstance(data, dict):
1115
+ continue
1116
+
1117
+ json_line_count += 1
1118
+ ev_type = str(data.get("type", ""))
1119
+
1120
+ if ev_type == "turn.completed":
1121
+ has_turn_completed = True
1122
+ elif ev_type == "turn.failed":
1123
+ has_turn_failed = True
1124
+ err = data.get("error") or {}
1125
+ if isinstance(err, dict):
1126
+ error_messages.append(err.get("message", "turn failed"))
1127
+ elif isinstance(err, str):
1128
+ error_messages.append(err)
1129
+ elif ev_type == "result":
1130
+ has_result = True
1131
+ has_meaningful_content = True
1132
+ elif ev_type == "error":
1133
+ msg = data.get("message", "")
1134
+ if msg:
1135
+ error_messages.append(msg)
1136
+ elif ev_type in (
1137
+ "assistant",
1138
+ "content_block_delta",
1139
+ "message_delta",
1140
+ "step_finish",
1141
+ "message",
1142
+ ):
1143
+ has_meaningful_content = True
1144
+ elif isinstance(data.get("content"), str) and data.get("content", "").strip():
1145
+ has_meaningful_content = True
1146
+
1147
+ return {
1148
+ "has_turn_completed": has_turn_completed,
1149
+ "has_turn_failed": has_turn_failed,
1150
+ "has_result": has_result,
1151
+ "has_meaningful_content": has_meaningful_content,
1152
+ "error_messages": error_messages,
1153
+ "json_line_count": json_line_count,
1154
+ }
1155
+
1156
+ @staticmethod
1157
+ def has_meaningful_agent_output(result: "TaskResult") -> bool:
1158
+ """Return True when the agent emitted real user-meaningful output."""
1159
+ combined = "\n".join(part for part in (result.stdout, result.stderr) if part)
1160
+ signals = ProcessManager._extract_output_signals(combined)
1161
+ return bool(
1162
+ signals["has_result"]
1163
+ or signals["has_turn_completed"]
1164
+ or signals["has_meaningful_content"]
1165
+ )
1166
+
987
1167
  @staticmethod
988
1168
  def is_rate_limited(result: "TaskResult") -> bool:
989
- """Check if an agent failure was caused by a rate/usage limit."""
1169
+ """Check if an agent failure warrants trying a different agent.
1170
+
1171
+ Returns True for rate/quota limits AND API unavailability errors,
1172
+ since a different agent (using a different API backend) may succeed.
1173
+ """
990
1174
  if result.status == "success":
991
1175
  return False
992
1176
  combined = (result.stdout + result.stderr + result.error).lower()
993
- return any(p in combined for p in ProcessManager.RATE_LIMIT_PATTERNS)
1177
+ return (
1178
+ any(p in combined for p in ProcessManager.RATE_LIMIT_PATTERNS)
1179
+ or any(p in combined for p in ProcessManager.AGENT_UNAVAILABLE_PATTERNS)
1180
+ )
1181
+
1182
+ @staticmethod
1183
+ def _detect_agent_output_failure(result: "TaskResult", agent_id: str) -> str | None:
1184
+ """Detect agent-level failures despite exit code 0.
1185
+
1186
+ Some agents (particularly Codex/OpenAI) exit with code 0 even when:
1187
+ - The API returned errors (404, 500, connection refused)
1188
+ - The turn failed (turn.failed event in JSONL)
1189
+ - No actual work was performed
1190
+
1191
+ Returns error description if failure detected, None if result seems valid.
1192
+ """
1193
+ if result.status != "success":
1194
+ return None
1195
+
1196
+ combined = "\n".join(part for part in (result.stdout, result.stderr, result.error) if part)
1197
+ pattern_failure = ProcessManager._has_failure_pattern(combined)
1198
+ if pattern_failure:
1199
+ return pattern_failure
1200
+
1201
+ stdout = result.stdout or ""
1202
+ stderr = result.stderr or ""
1203
+ if not stdout.strip() and not stderr.strip():
1204
+ return None # Empty output handled separately by server-side check
1205
+
1206
+ signals = ProcessManager._extract_output_signals(
1207
+ "\n".join(part for part in (stdout, stderr) if part)
1208
+ )
1209
+ has_turn_completed = signals["has_turn_completed"]
1210
+ has_turn_failed = signals["has_turn_failed"]
1211
+ has_result = signals["has_result"]
1212
+ has_meaningful_content = signals["has_meaningful_content"]
1213
+ error_messages = signals["error_messages"]
1214
+ json_line_count = signals["json_line_count"]
1215
+
1216
+ stderr_lower = stderr.lower()
1217
+ if (
1218
+ stderr.strip()
1219
+ and not stdout.strip()
1220
+ and any(
1221
+ marker in stderr_lower
1222
+ for marker in (
1223
+ "error",
1224
+ "failed",
1225
+ "exception",
1226
+ "unauthorized",
1227
+ "forbidden",
1228
+ "invalid api key",
1229
+ "authentication",
1230
+ "permission denied",
1231
+ )
1232
+ )
1233
+ ):
1234
+ return stderr.strip().splitlines()[-1][:300]
1235
+
1236
+ # ── Codex/OpenAI: turn.failed without any turn.completed ──
1237
+ if has_turn_failed and not has_turn_completed:
1238
+ err_detail = error_messages[-1] if error_messages else "Agent turn failed"
1239
+ return f"Agent turn failed: {err_detail}"
1240
+
1241
+ # ── All-errors pattern: only errors, no success indicators ──
1242
+ if (error_messages and not has_turn_completed and not has_result
1243
+ and not has_meaningful_content and json_line_count > 0):
1244
+ return f"Agent encountered errors without producing output: {error_messages[0]}"
1245
+
1246
+ # ── Claude: JSON output mode but no result object and no content ──
1247
+ if agent_id == "claude-code" and json_line_count > 0:
1248
+ if not has_result and not has_meaningful_content:
1249
+ return "Claude produced no result output"
1250
+
1251
+ return None
994
1252
 
995
1253
  async def run_agent(
996
1254
  self,
@@ -1022,6 +1280,16 @@ class ProcessManager:
1022
1280
  elapsed = time.monotonic() - start_time
1023
1281
  result.metrics["duration_seconds"] = round(elapsed, 2)
1024
1282
 
1283
+ # Detect agent-level failures despite exit code 0
1284
+ output_failure = self._detect_agent_output_failure(result, agent.agent_id)
1285
+ if output_failure:
1286
+ logger.warning(
1287
+ "Agent '%s' exited 0 but output indicates failure: %s",
1288
+ agent.agent_id, output_failure,
1289
+ )
1290
+ result.status = "failed"
1291
+ result.error = output_failure
1292
+
1025
1293
  # Collect git changes
1026
1294
  result.git = await self._collect_git_info(workspace_path)
1027
1295
  result.files_changed = result.git.get("files_changed", [])
@@ -1030,6 +1298,45 @@ class ProcessManager:
1030
1298
 
1031
1299
  return result
1032
1300
 
1301
+ @staticmethod
1302
+ def _normalize_repo_paths(paths: list[str] | None) -> set[str]:
1303
+ normalized: set[str] = set()
1304
+ for path in paths or []:
1305
+ path_str = str(path or "").replace("\\", "/").lstrip("./")
1306
+ if path_str:
1307
+ normalized.add(path_str)
1308
+ return normalized
1309
+
1310
+ def _required_deliverable_paths(self, task: TaskInfo) -> set[str]:
1311
+ output_dir = str((task.input_data or {}).get("output_dir", "") or "")
1312
+ output_dir = output_dir.replace("\\", "/").lstrip("./").rstrip("/")
1313
+ if not output_dir:
1314
+ return set()
1315
+
1316
+ if task.node_type == "analysis":
1317
+ req_type = (task.input_data or {}).get("requirement_type", "feature")
1318
+ try:
1319
+ from app.services.type_workflow_profiles import get_profile
1320
+ required_files = list(get_profile(req_type).analysis_outputs)
1321
+ except Exception:
1322
+ required_files = ["PRD.md", "SDD.md", "TASKS.md", "analysis.json", "test-intent.json"]
1323
+ elif task.node_type == "design":
1324
+ required_files = ["design.md"]
1325
+ else:
1326
+ return set()
1327
+
1328
+ return {f"{output_dir}/{fname}" for fname in required_files}
1329
+
1330
+ def _has_required_deliverable_updates(self, task: TaskInfo, *path_lists: list[str] | None) -> bool:
1331
+ required_paths = self._required_deliverable_paths(task)
1332
+ if not required_paths:
1333
+ return False
1334
+
1335
+ changed_paths: set[str] = set()
1336
+ for paths in path_lists:
1337
+ changed_paths.update(self._normalize_repo_paths(paths))
1338
+ return bool(required_paths & changed_paths)
1339
+
1033
1340
  def _build_prompt(self, task: TaskInfo) -> str:
1034
1341
  """Build the prompt to send to the agent.
1035
1342
 
@@ -1914,6 +2221,7 @@ class TaskPoller:
1914
2221
  requirement_workflow_id=t.get("requirement_workflow_id"),
1915
2222
  requirement_key=t.get("requirement_key"),
1916
2223
  graph_type=t.get("graph_type", "execution"),
2224
+ analysis_branch=t.get("analysis_branch"),
1917
2225
  ))
1918
2226
  return tasks
1919
2227
  except Exception as e:
@@ -2190,14 +2498,48 @@ class RuntimeDaemon:
2190
2498
  This handles orphaned processes from crashed desktop apps, duplicate
2191
2499
  CLI starts, etc.
2192
2500
  """
2501
+ lock_path = Path.home() / ".forgexa" / "daemon" / "daemon.lock"
2502
+ lock_path.parent.mkdir(parents=True, exist_ok=True)
2503
+
2504
+ if sys.platform == "win32":
2505
+ # Windows: use msvcrt file locking
2506
+ import msvcrt
2507
+
2508
+ self._lock_file = open(lock_path, "w")
2509
+ try:
2510
+ msvcrt.locking(self._lock_file.fileno(), msvcrt.LK_NBLCK, 1)
2511
+ except (IOError, OSError):
2512
+ # Lock held — try to kill old process via PID file
2513
+ try:
2514
+ old_pid = int(lock_path.read_text().strip())
2515
+ logger.warning("Another daemon is running (PID %d). Terminating...", old_pid)
2516
+ import subprocess as _sp
2517
+ _sp.run(["taskkill", "/PID", str(old_pid), "/F"],
2518
+ capture_output=True)
2519
+ time.sleep(1)
2520
+ except (ValueError, FileNotFoundError, PermissionError, OSError):
2521
+ pass
2522
+
2523
+ # Retry
2524
+ self._lock_file.close()
2525
+ self._lock_file = open(lock_path, "w")
2526
+ try:
2527
+ msvcrt.locking(self._lock_file.fileno(), msvcrt.LK_NBLCK, 1)
2528
+ except (IOError, OSError):
2529
+ logger.error("Cannot acquire daemon lock — another instance may still be running")
2530
+ raise SystemExit(1)
2531
+
2532
+ self._lock_file.seek(0)
2533
+ self._lock_file.truncate()
2534
+ self._lock_file.write(str(os.getpid()))
2535
+ self._lock_file.flush()
2536
+ logger.info("Acquired exclusive daemon lock (pid=%d)", os.getpid())
2537
+ return
2538
+
2193
2539
  if fcntl is None:
2194
- # Windows: skip file locking (fcntl not available)
2195
2540
  logger.info("File locking not available on this platform; skipping")
2196
2541
  return
2197
2542
 
2198
- lock_path = Path.home() / ".forgexa" / "daemon" / "daemon.lock"
2199
- lock_path.parent.mkdir(parents=True, exist_ok=True)
2200
-
2201
2543
  self._lock_file = open(lock_path, "w")
2202
2544
 
2203
2545
  try:
@@ -2432,10 +2774,10 @@ class RuntimeDaemon:
2432
2774
 
2433
2775
  tried_agents.add(agent.agent_id)
2434
2776
 
2435
- # ── Rate-limit fallback: if agent hit usage/rate limit, try next agent ──
2777
+ # ── Agent fallback: if agent hit rate limit or API is unavailable, try next agent ──
2436
2778
  if self.process_manager.is_rate_limited(result):
2437
2779
  logger.warning(
2438
- "Agent '%s' hit rate/usage limit for task %s, attempting fallback",
2780
+ "Agent '%s' unavailable/rate-limited for task %s, attempting fallback",
2439
2781
  agent.agent_id, task.task_id,
2440
2782
  )
2441
2783
  fallback_agent = self._select_fallback_agent(
@@ -2443,16 +2785,17 @@ class RuntimeDaemon:
2443
2785
  )
2444
2786
  while fallback_agent:
2445
2787
  logger.info(
2446
- "Rate-limit fallback: switching from '%s' to '%s' for task %s",
2788
+ "Agent fallback: switching from '%s' to '%s' for task %s (reason: %s)",
2447
2789
  agent.agent_id, fallback_agent.agent_id, task.task_id,
2790
+ result.error[:100] if result.error else "rate-limited/unavailable",
2448
2791
  )
2449
2792
  agent = fallback_agent
2450
2793
  tried_agents.add(agent.agent_id)
2451
2794
 
2452
2795
  await reporter.report_progress(
2453
2796
  task.task_id, 10,
2454
- f"rate_limit_fallback: retrying with {agent.agent_id}",
2455
- output_lines=[f"[daemon] Agent rate-limited, switching to {agent.agent_id}"],
2797
+ f"agent_fallback: retrying with {agent.agent_id}",
2798
+ output_lines=[f"[daemon] Agent unavailable/rate-limited, switching to {agent.agent_id}"],
2456
2799
  )
2457
2800
 
2458
2801
  # Re-run with fallback agent
@@ -2497,10 +2840,10 @@ class RuntimeDaemon:
2497
2840
  _line_buffer.clear()
2498
2841
 
2499
2842
  if not self.process_manager.is_rate_limited(result):
2500
- break # Success or non-rate-limit failure
2843
+ break # Success or non-retriable failure
2501
2844
 
2502
2845
  logger.warning(
2503
- "Fallback agent '%s' also rate-limited for task %s",
2846
+ "Fallback agent '%s' also unavailable/rate-limited for task %s",
2504
2847
  agent.agent_id, task.task_id,
2505
2848
  )
2506
2849
  fallback_agent = self._select_fallback_agent(
@@ -2509,20 +2852,50 @@ class RuntimeDaemon:
2509
2852
 
2510
2853
  if self.process_manager.is_rate_limited(result):
2511
2854
  result.error = (
2512
- f"All agents rate-limited (tried: {', '.join(tried_agents)}). "
2855
+ f"All agents unavailable/rate-limited (tried: {', '.join(tried_agents)}). "
2513
2856
  f"Original error: {result.error}"
2514
2857
  )
2858
+ result.status = "failed"
2515
2859
 
2516
2860
  # 4. Collect git info BEFORE commit (shows uncommitted changes)
2517
2861
  pre_commit_git = await self.process_manager._collect_git_info(workspace_path)
2518
2862
 
2863
+ # 4.05 Sanity check: "success" but no evidence of work for node types
2864
+ # that MUST produce changes.
2865
+ # This catches agents that exit 0 without doing anything useful
2866
+ # (e.g., after rate-limit fallback, or API errors not caught by output parsing).
2867
+ if result.status == "success" and task.node_type in ("coding", "fix", "testing"):
2868
+ has_uncommitted = bool(pre_commit_git.get("files_changed"))
2869
+ committed_git = await self.process_manager._collect_git_info_vs_parent(workspace_path)
2870
+ has_committed = bool(committed_git.get("files_changed"))
2871
+ has_tokens = (
2872
+ int(result.metrics.get("token_input", 0) or 0)
2873
+ + int(result.metrics.get("token_output", 0) or 0)
2874
+ ) > 0
2875
+ if not has_uncommitted and not has_committed and not has_tokens:
2876
+ logger.warning(
2877
+ "Task %s (%s) agent reported success but produced no file changes "
2878
+ "and no token usage — marking as failed",
2879
+ task.task_id, task.node_type,
2880
+ )
2881
+ result.status = "failed"
2882
+ result.error = (
2883
+ f"Agent reported success but produced no code changes "
2884
+ f"(node_type={task.node_type}, agent={agent.agent_id})"
2885
+ )
2886
+
2519
2887
  # 4.1 Recovery: agent exited non-zero but already committed code
2520
2888
  # (e.g. OpenCode EBADF crash on exit after successful work)
2521
2889
  if result.status == "failed" and result.exit_code not in (None, -1):
2522
2890
  committed_git = await self.process_manager._collect_git_info_vs_parent(workspace_path)
2523
2891
  has_committed_changes = bool(committed_git.get("files_changed"))
2524
2892
  has_no_uncommitted = not pre_commit_git.get("files_changed")
2525
- if has_committed_changes and has_no_uncommitted:
2893
+ has_tokens = (
2894
+ int(result.metrics.get("token_input", 0) or 0)
2895
+ + int(result.metrics.get("token_output", 0) or 0)
2896
+ ) > 0
2897
+ has_meaningful_output = self.process_manager.has_meaningful_agent_output(result)
2898
+ if has_committed_changes and has_no_uncommitted and (has_tokens or has_meaningful_output):
2526
2899
  logger.warning(
2527
2900
  "Task %s agent exited with code %s but has committed changes — "
2528
2901
  "recovering as success (agent likely crashed during cleanup)",
@@ -2545,11 +2918,37 @@ class RuntimeDaemon:
2545
2918
  except Exception:
2546
2919
  logger.exception("Validation gate error for task %s (proceeding anyway)", task.task_id)
2547
2920
 
2921
+ # 4.55 Analysis/design nodes must update their deliverables in THIS run.
2922
+ # Existing files from a prior iteration are not sufficient evidence.
2923
+ if result.status == "success" and task.node_type in ("analysis", "design"):
2924
+ committed_git = await self.process_manager._collect_git_info_vs_parent(workspace_path)
2925
+ if not self._has_required_deliverable_updates(
2926
+ task,
2927
+ pre_commit_git.get("files_changed"),
2928
+ committed_git.get("files_changed"),
2929
+ result.files_changed,
2930
+ (result.git or {}).get("files_changed"),
2931
+ ):
2932
+ logger.warning(
2933
+ "Task %s (%s) reported success but did not update required deliverables",
2934
+ task.task_id, task.node_type,
2935
+ )
2936
+ result.status = "failed"
2937
+ result.error = (
2938
+ f"Agent reported success but did not update required {task.node_type} deliverables "
2939
+ f"(agent={agent.agent_id})"
2940
+ )
2941
+
2548
2942
  # 4.6 For analysis nodes: attach output file contents as inline artifacts
2549
2943
  # so the backend always has the documents even if git push fails later.
2550
2944
  if result.status == "success" and task.node_type == "analysis":
2551
2945
  await self._collect_analysis_artifacts(workspace_path, task, result)
2552
2946
 
2947
+ # 4.7 For design nodes: attach design.md as inline artifact so that
2948
+ # downstream nodes (coding/testing) can access it even if git sync lags.
2949
+ if result.status == "success" and task.node_type == "design":
2950
+ await self._collect_design_artifacts(workspace_path, task, result)
2951
+
2553
2952
  # 5. Auto-commit and push if changes exist
2554
2953
  if result.status == "success":
2555
2954
  commit_result = await self._auto_commit(workspace_path, task)
@@ -2647,48 +3046,63 @@ class RuntimeDaemon:
2647
3046
  """Run deterministic validations on agent output.
2648
3047
 
2649
3048
  Returns a list of issue descriptions. Empty list = all OK.
3049
+ Type-aware: uses the requirement type from task.input_data to determine
3050
+ which files are required (via type_workflow_profiles).
2650
3051
  """
2651
3052
  import json as _json
2652
3053
 
2653
3054
  issues: list[str] = []
2654
3055
  node_type = task.node_type
3056
+ req_type = (task.input_data or {}).get("requirement_type", "feature")
2655
3057
 
2656
3058
  if node_type == "analysis":
2657
- # Check required files exist
3059
+ # Use type profile to determine required analysis outputs
3060
+ try:
3061
+ from app.services.type_workflow_profiles import get_profile
3062
+ profile = get_profile(req_type)
3063
+ required_files = profile.analysis_outputs
3064
+ except Exception:
3065
+ # Fallback to full set if profile import fails
3066
+ required_files = ["PRD.md", "SDD.md", "TASKS.md", "analysis.json", "test-intent.json"]
3067
+
2658
3068
  doc_dir = (task.input_data or {}).get("output_dir", "")
2659
3069
  if doc_dir:
2660
3070
  base = workspace_path / doc_dir
2661
3071
  else:
2662
3072
  base = workspace_path
2663
- for fname in ("PRD.md", "SDD.md", "TASKS.md", "analysis.json", "test-intent.json"):
3073
+
3074
+ # Check required files based on type profile
3075
+ for fname in required_files:
2664
3076
  fpath = base / fname
2665
3077
  if not fpath.exists():
2666
3078
  issues.append(f"Required file missing: {doc_dir}/{fname}")
2667
3079
  elif fpath.stat().st_size == 0:
2668
3080
  issues.append(f"Required file is empty: {doc_dir}/{fname}")
2669
3081
 
2670
- # Validate analysis.json
2671
- json_path = base / "analysis.json"
2672
- if json_path.exists() and json_path.stat().st_size > 0:
2673
- try:
2674
- _json.loads(json_path.read_text(encoding="utf-8"))
2675
- except _json.JSONDecodeError as e:
2676
- issues.append(f"analysis.json is not valid JSON: {e}")
2677
-
2678
- # Validate test-intent.json
2679
- ti_path = base / "test-intent.json"
2680
- if ti_path.exists() and ti_path.stat().st_size > 0:
2681
- try:
2682
- ti_data = _json.loads(ti_path.read_text(encoding="utf-8"))
2683
- intents = ti_data.get("intents", [])
2684
- if not intents:
2685
- issues.append("test-intent.json contains no test intents")
2686
- for ti in intents[:20]:
2687
- if not ti.get("id") or not ti.get("title"):
2688
- issues.append(f"Test intent missing 'id' or 'title': {ti.get('id', '?')}")
2689
- break
2690
- except _json.JSONDecodeError as e:
2691
- issues.append(f"test-intent.json is not valid JSON: {e}")
3082
+ # Validate analysis.json if required by this type
3083
+ if "analysis.json" in required_files:
3084
+ json_path = base / "analysis.json"
3085
+ if json_path.exists() and json_path.stat().st_size > 0:
3086
+ try:
3087
+ _json.loads(json_path.read_text(encoding="utf-8"))
3088
+ except _json.JSONDecodeError as e:
3089
+ issues.append(f"analysis.json is not valid JSON: {e}")
3090
+
3091
+ # Validate test-intent.json if required by this type
3092
+ if "test-intent.json" in required_files:
3093
+ ti_path = base / "test-intent.json"
3094
+ if ti_path.exists() and ti_path.stat().st_size > 0:
3095
+ try:
3096
+ ti_data = _json.loads(ti_path.read_text(encoding="utf-8"))
3097
+ intents = ti_data.get("intents", [])
3098
+ if not intents:
3099
+ issues.append("test-intent.json contains no test intents")
3100
+ for ti in intents[:20]:
3101
+ if not ti.get("id") or not ti.get("title"):
3102
+ issues.append(f"Test intent missing 'id' or 'title': {ti.get('id', '?')}")
3103
+ break
3104
+ except _json.JSONDecodeError as e:
3105
+ issues.append(f"test-intent.json is not valid JSON: {e}")
2692
3106
 
2693
3107
  elif node_type == "design":
2694
3108
  doc_dir = (task.input_data or {}).get("output_dir", "")
@@ -2699,7 +3113,7 @@ class RuntimeDaemon:
2699
3113
  elif design_path.stat().st_size == 0:
2700
3114
  issues.append(f"Design document is empty: {doc_dir}/design.md")
2701
3115
 
2702
- elif node_type in ("coding", "testing"):
3116
+ elif node_type in ("coding", "testing", "fix"):
2703
3117
  # Syntax-check modified Python and JS/TS files
2704
3118
  for f in result.files_changed:
2705
3119
  fpath = workspace_path / f
@@ -2724,55 +3138,65 @@ class RuntimeDaemon:
2724
3138
 
2725
3139
  # Testing-specific: validate structured test assets
2726
3140
  if node_type == "testing":
2727
- doc_dir = (task.input_data or {}).get("output_dir", "")
2728
- if doc_dir:
2729
- base = workspace_path / doc_dir
2730
- else:
2731
- base = workspace_path
2732
-
2733
- # --- test-cases.json validation ---
2734
- tc_path = base / "test-cases.json"
2735
- if tc_path.exists():
2736
- try:
2737
- tc_data = _json.loads(tc_path.read_text(encoding="utf-8"))
2738
- cases = tc_data.get("test_cases", [])
2739
- if not cases:
2740
- issues.append("test-cases.json exists but contains no test cases")
2741
- else:
2742
- for tc in cases[:20]:
2743
- if not tc.get("id") or not tc.get("title"):
2744
- issues.append(f"Test case missing 'id' or 'title': {tc.get('id', '?')}")
2745
- break
2746
- if not tc.get("steps"):
2747
- issues.append(f"Test case {tc['id']} has no 'steps'")
2748
- break
2749
- p0_cases = [c for c in cases if c.get("priority") == "P0"]
2750
- if not p0_cases:
2751
- issues.append("No P0 priority test cases found in test-cases.json")
2752
- except (_json.JSONDecodeError, UnicodeDecodeError) as e:
2753
- issues.append(f"test-cases.json is not valid JSON: {e}")
2754
- else:
2755
- issues.append(f"test-cases.json not found in {doc_dir or 'workspace root'}")
3141
+ # Check if this type requires full test artifacts
3142
+ _skip_test_artifacts = False
3143
+ try:
3144
+ from app.services.type_workflow_profiles import get_profile
3145
+ _profile = get_profile(req_type)
3146
+ _skip_test_artifacts = "test_coverage" in _profile.skip_dimensions
3147
+ except Exception:
3148
+ pass
2756
3149
 
2757
- # --- coverage-matrix.json validation ---
2758
- cm_path = base / "coverage-matrix.json"
2759
- if cm_path.exists():
2760
- try:
2761
- cm_data = _json.loads(cm_path.read_text(encoding="utf-8"))
2762
- ac_list = cm_data.get("acceptance_criteria", [])
2763
- uncovered = [ac for ac in ac_list if ac.get("status") != "covered"]
2764
- if uncovered:
2765
- ids = ", ".join(ac.get("id", "?") for ac in uncovered[:5])
2766
- issues.append(f"Uncovered acceptance criteria in coverage-matrix.json: {ids}")
2767
- except (_json.JSONDecodeError, UnicodeDecodeError) as e:
2768
- issues.append(f"coverage-matrix.json is not valid JSON: {e}")
2769
- else:
2770
- issues.append(f"coverage-matrix.json not found in {doc_dir or 'workspace root'}")
3150
+ if not _skip_test_artifacts:
3151
+ doc_dir = (task.input_data or {}).get("output_dir", "")
3152
+ if doc_dir:
3153
+ base = workspace_path / doc_dir
3154
+ else:
3155
+ base = workspace_path
2771
3156
 
2772
- # --- test-report.md validation ---
2773
- report_path = base / "test-report.md"
2774
- if not report_path.exists():
2775
- issues.append(f"test-report.md not found in {doc_dir or 'workspace root'}")
3157
+ # --- test-cases.json validation ---
3158
+ tc_path = base / "test-cases.json"
3159
+ if tc_path.exists():
3160
+ try:
3161
+ tc_data = _json.loads(tc_path.read_text(encoding="utf-8"))
3162
+ cases = tc_data.get("test_cases", [])
3163
+ if not cases:
3164
+ issues.append("test-cases.json exists but contains no test cases")
3165
+ else:
3166
+ for tc in cases[:20]:
3167
+ if not tc.get("id") or not tc.get("title"):
3168
+ issues.append(f"Test case missing 'id' or 'title': {tc.get('id', '?')}")
3169
+ break
3170
+ if not tc.get("steps"):
3171
+ issues.append(f"Test case {tc['id']} has no 'steps'")
3172
+ break
3173
+ p0_cases = [c for c in cases if c.get("priority") == "P0"]
3174
+ if not p0_cases:
3175
+ issues.append("No P0 priority test cases found in test-cases.json")
3176
+ except (_json.JSONDecodeError, UnicodeDecodeError) as e:
3177
+ issues.append(f"test-cases.json is not valid JSON: {e}")
3178
+ else:
3179
+ issues.append(f"test-cases.json not found in {doc_dir or 'workspace root'}")
3180
+
3181
+ # --- coverage-matrix.json validation ---
3182
+ cm_path = base / "coverage-matrix.json"
3183
+ if cm_path.exists():
3184
+ try:
3185
+ cm_data = _json.loads(cm_path.read_text(encoding="utf-8"))
3186
+ ac_list = cm_data.get("acceptance_criteria", [])
3187
+ uncovered = [ac for ac in ac_list if ac.get("status") != "covered"]
3188
+ if uncovered:
3189
+ ids = ", ".join(ac.get("id", "?") for ac in uncovered[:5])
3190
+ issues.append(f"Uncovered acceptance criteria in coverage-matrix.json: {ids}")
3191
+ except (_json.JSONDecodeError, UnicodeDecodeError) as e:
3192
+ issues.append(f"coverage-matrix.json is not valid JSON: {e}")
3193
+ else:
3194
+ issues.append(f"coverage-matrix.json not found in {doc_dir or 'workspace root'}")
3195
+
3196
+ # --- test-report.md validation ---
3197
+ report_path = base / "test-report.md"
3198
+ if not report_path.exists():
3199
+ issues.append(f"test-report.md not found in {doc_dir or 'workspace root'}")
2776
3200
 
2777
3201
  return issues
2778
3202
 
@@ -2838,10 +3262,32 @@ class RuntimeDaemon:
2838
3262
  # Final check after all retries
2839
3263
  remaining = self._validate_outputs(workspace_path, task, result)
2840
3264
  if remaining:
2841
- logger.warning(
2842
- "Validation gate: %d issues remain after %d retries for task %s (proceeding anyway)",
2843
- len(remaining), max_retries, task.task_id,
2844
- )
3265
+ # Distinguish critical issues (no output produced) from minor ones (syntax)
3266
+ critical_patterns = ("missing", "not found", "is empty")
3267
+ critical_issues = [
3268
+ iss for iss in remaining
3269
+ if any(p in iss.lower() for p in critical_patterns)
3270
+ ]
3271
+ if critical_issues:
3272
+ # Agent didn't produce required output — mark as failed
3273
+ logger.warning(
3274
+ "Validation gate: %d critical issues remain after %d retries for task %s — "
3275
+ "marking as failed:\n%s",
3276
+ len(critical_issues), max_retries, task.task_id,
3277
+ "\n".join(f" - {iss}" for iss in critical_issues),
3278
+ )
3279
+ result.status = "failed"
3280
+ result.error = (
3281
+ f"Agent failed to produce required output after {max_retries} retries: "
3282
+ + "; ".join(critical_issues[:3])
3283
+ )
3284
+ else:
3285
+ # Non-critical issues (syntax warnings) — proceed with warning
3286
+ logger.warning(
3287
+ "Validation gate: %d non-critical issues remain after %d retries for task %s "
3288
+ "(proceeding anyway)",
3289
+ len(remaining), max_retries, task.task_id,
3290
+ )
2845
3291
  result.metrics["validation_issues"] = remaining
2846
3292
  return result
2847
3293
 
@@ -2883,6 +3329,39 @@ class RuntimeDaemon:
2883
3329
  except Exception as e:
2884
3330
  logger.warning("Failed to read analysis artifact %s: %s", fname, e)
2885
3331
 
3332
+ async def _collect_design_artifacts(
3333
+ self, workspace_path: Path, task: TaskInfo, result: TaskResult
3334
+ ) -> None:
3335
+ """Attach design.md as inline artifact for downstream node access.
3336
+
3337
+ Similar to _collect_analysis_artifacts but for the design phase output.
3338
+ Ensures coding/testing nodes can access design documents even when
3339
+ running on a different runtime where git sync may lag.
3340
+ """
3341
+ doc_dir = (task.input_data or {}).get("output_dir", "")
3342
+ if not doc_dir:
3343
+ return
3344
+
3345
+ base = workspace_path / doc_dir.lstrip("./")
3346
+ existing_artifact_paths = {a.get("path", "") for a in result.artifacts}
3347
+
3348
+ design_path = base / "design.md"
3349
+ if not design_path.exists() or design_path.stat().st_size == 0:
3350
+ return
3351
+ try:
3352
+ rel_path = str(design_path.relative_to(workspace_path))
3353
+ if rel_path in existing_artifact_paths:
3354
+ return
3355
+ content = design_path.read_text(encoding="utf-8", errors="replace")
3356
+ result.artifacts.append({
3357
+ "path": rel_path,
3358
+ "content": content,
3359
+ "type": "text/markdown",
3360
+ })
3361
+ logger.debug("Attached design artifact inline: %s (%d bytes)", rel_path, len(content))
3362
+ except Exception as e:
3363
+ logger.warning("Failed to read design artifact: %s", e)
3364
+
2886
3365
  async def _auto_commit(self, workspace_path: Path, task: TaskInfo) -> dict:
2887
3366
  """Auto-commit and push agent changes.
2888
3367
 
@@ -3528,8 +4007,13 @@ async def main():
3528
4007
  daemon = RuntimeDaemon()
3529
4008
 
3530
4009
  loop = asyncio.get_event_loop()
3531
- for sig in (signal.SIGINT, signal.SIGTERM):
3532
- loop.add_signal_handler(sig, daemon.handle_signal, sig)
4010
+ if sys.platform != "win32":
4011
+ for sig in (signal.SIGINT, signal.SIGTERM):
4012
+ loop.add_signal_handler(sig, daemon.handle_signal, sig)
4013
+ else:
4014
+ # Windows: asyncio loop doesn't support add_signal_handler.
4015
+ # Use traditional signal handler for Ctrl+C (SIGINT).
4016
+ signal.signal(signal.SIGINT, lambda s, _f: daemon.handle_signal(s))
3533
4017
 
3534
4018
  await daemon.start()
3535
4019
 
@@ -208,8 +208,15 @@ def cmd_daemon_stop(_args: argparse.Namespace) -> None:
208
208
  sys.exit(1)
209
209
  pid = int(pid_file.read_text().strip())
210
210
  try:
211
- os.kill(pid, signal.SIGTERM)
212
- print(f"Sent SIGTERM to daemon (PID {pid})")
211
+ if sys.platform == "win32":
212
+ # Windows: SIGTERM calls TerminateProcess (no graceful shutdown).
213
+ # Use taskkill for cleaner UX; fall back to os.kill.
214
+ import subprocess as sp
215
+ sp.run(["taskkill", "/PID", str(pid), "/F"], capture_output=True)
216
+ print(f"Terminated daemon (PID {pid})")
217
+ else:
218
+ os.kill(pid, signal.SIGTERM)
219
+ print(f"Sent SIGTERM to daemon (PID {pid})")
213
220
  pid_file.unlink()
214
221
  except ProcessLookupError:
215
222
  print(f"Daemon process {pid} not found (already stopped?)")
@@ -239,12 +246,15 @@ def cmd_daemon_start(args: argparse.Namespace) -> None:
239
246
 
240
247
  cmd = [sys.executable, "-m", "forgexa_cli.daemon"]
241
248
  with open(log_path, "a", encoding="utf-8") as log_fh:
242
- proc = sp.Popen(
243
- cmd,
244
- stdout=sp.DEVNULL,
245
- stderr=log_fh,
246
- start_new_session=True,
247
- )
249
+ popen_kwargs: dict = dict(stdout=sp.DEVNULL, stderr=log_fh)
250
+ if sys.platform == "win32":
251
+ # Windows: use creation flags to detach the process
252
+ popen_kwargs["creationflags"] = (
253
+ sp.CREATE_NEW_PROCESS_GROUP | sp.DETACHED_PROCESS
254
+ )
255
+ else:
256
+ popen_kwargs["start_new_session"] = True
257
+ proc = sp.Popen(cmd, **popen_kwargs)
248
258
  pid_file = Path.home() / ".forgexa-daemon.pid"
249
259
  pid_file.write_text(str(proc.pid))
250
260
  print(f"Daemon started in background (PID {proc.pid})")
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: forgexa-cli
3
- Version: 1.2.7
3
+ Version: 1.3.2
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.2.7"
3
+ version = "1.3.2"
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