forgexa-cli 1.2.6__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.6
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.6"
2
+ __version__ = "1.3.2"
@@ -242,6 +242,47 @@ def get_hardware_id() -> str:
242
242
  return uuid.uuid4().hex[:24]
243
243
 
244
244
 
245
+ def get_os_info() -> str:
246
+ """Return a concise OS/arch summary string, e.g. 'macOS 15.0 arm64', 'Ubuntu 24.04 x86_64'."""
247
+ system = platform.system()
248
+ machine = platform.machine()
249
+ if system == "Darwin":
250
+ # e.g. "macOS 15.0 arm64"
251
+ mac_ver = platform.mac_ver()[0] or platform.release()
252
+ return f"macOS {mac_ver} {machine}"
253
+ elif system == "Linux":
254
+ # Try to get distro name+version from /etc/os-release
255
+ distro = _get_linux_distro()
256
+ if distro:
257
+ return f"{distro} {machine}"
258
+ return f"Linux {platform.release().split('-')[0]} {machine}"
259
+ elif system == "Windows":
260
+ # e.g. "Windows 10.0 AMD64"
261
+ win_ver = platform.version().split('.')[0:2]
262
+ return f"Windows {'.'.join(win_ver)} {machine}"
263
+ else:
264
+ return f"{system} {platform.release()} {machine}"
265
+
266
+
267
+ def _get_linux_distro() -> str:
268
+ """Parse /etc/os-release to get distro name and version, e.g. 'Ubuntu 24.04'."""
269
+ try:
270
+ with open("/etc/os-release") as f:
271
+ info = {}
272
+ for line in f:
273
+ line = line.strip()
274
+ if "=" in line:
275
+ key, _, val = line.partition("=")
276
+ info[key] = val.strip('"')
277
+ name = info.get("NAME", "")
278
+ version = info.get("VERSION_ID", "")
279
+ if name:
280
+ return f"{name} {version}".strip()
281
+ except OSError:
282
+ pass
283
+ return ""
284
+
285
+
245
286
  # ── Data Classes ──
246
287
 
247
288
 
@@ -271,6 +312,7 @@ class TaskInfo:
271
312
  requirement_workflow_id: str | None = None
272
313
  requirement_key: str | None = None
273
314
  graph_type: str = "execution"
315
+ analysis_branch: str | None = None
274
316
 
275
317
 
276
318
  @dataclass
@@ -599,6 +641,7 @@ class WorkspaceManager:
599
641
  project_dir, repo_url, default_branch, workspace_key, branch_name,
600
642
  fresh_start=is_fresh_start,
601
643
  project_key=project_key,
644
+ expect_branch=bool(task.analysis_branch),
602
645
  )
603
646
  # Refine mode: ensure we're on the analysis branch with its history
604
647
  # (not reset to default_branch)
@@ -681,7 +724,7 @@ class WorkspaceManager:
681
724
  async def _create_worktree(
682
725
  self, project_dir: Path, repo_url: str, default_branch: str,
683
726
  workspace_key: str, branch_name: str, *, fresh_start: bool = False,
684
- project_key: str = "default",
727
+ project_key: str = "default", expect_branch: bool = False,
685
728
  ) -> Path:
686
729
  main_repo = project_dir / "_main"
687
730
  ws_path = project_dir / workspace_key
@@ -726,6 +769,81 @@ class WorkspaceManager:
726
769
  )
727
770
  except RuntimeError as exc2:
728
771
  logger.warning("Failed to reset to origin/%s: %s", default_branch, exc2)
772
+ else:
773
+ # Non-fresh-start (design/coding/testing): ensure working tree
774
+ # is on the correct branch and has the latest commits from remote.
775
+ # This is critical for cross-machine execution where a previous
776
+ # node on another daemon pushed commits to the branch.
777
+ logger.info("Syncing worktree %s to latest origin/%s", ws_path, branch_name)
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)
807
+ try:
808
+ await self._git(
809
+ "reset", "--hard", f"origin/{branch_name}",
810
+ cwd=ws_path,
811
+ )
812
+ sync_success = True
813
+ break
814
+ except RuntimeError as exc:
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
+ )
729
847
  return ws_path
730
848
 
731
849
  # Ensure _main repo is present and up-to-date
@@ -734,6 +852,15 @@ class WorkspaceManager:
734
852
  else:
735
853
  await self._git("fetch", "--all", cwd=main_repo, timeout=300, project_key=project_key)
736
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
+
737
864
  # Fast-forward _main's local default branch to match origin so
738
865
  # that `git worktree add ... {default_branch}` uses latest code.
739
866
  # We fetch first, then update the local ref directly (avoids
@@ -748,22 +875,46 @@ class WorkspaceManager:
748
875
  logger.warning("Could not fast-forward _main/%s: %s", default_branch, exc)
749
876
 
750
877
  # branch_name is passed in (feature/{requirement_key} or feature/{workspace_key})
751
- # 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).
752
881
  branch_exists_remote = False
753
- try:
754
- check_proc = await asyncio.create_subprocess_exec(
755
- "git", "rev-parse", "--verify", f"refs/remotes/origin/{branch_name}",
756
- cwd=str(main_repo),
757
- stdout=asyncio.subprocess.PIPE,
758
- stderr=asyncio.subprocess.PIPE,
759
- )
760
- await check_proc.communicate()
761
- branch_exists_remote = check_proc.returncode == 0
762
- except Exception:
763
- 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
764
906
 
765
907
  if branch_exists_remote and not fresh_start:
766
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
767
918
  try:
768
919
  await self._git(
769
920
  "worktree", "add", "--track", "-b", branch_name,
@@ -897,7 +1048,8 @@ class WorkspaceManager:
897
1048
  class ProcessManager:
898
1049
  """Manages Agent CLI subprocess lifecycle."""
899
1050
 
900
- # 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
901
1053
  RATE_LIMIT_PATTERNS = [
902
1054
  "usage limit",
903
1055
  "rate limit",
@@ -909,18 +1061,194 @@ class ProcessManager:
909
1061
  "capacity",
910
1062
  "try again",
911
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",
912
1080
  ]
913
1081
 
914
1082
  def __init__(self):
915
1083
  self.active_processes: dict[str, asyncio.subprocess.Process] = {}
916
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
+
917
1167
  @staticmethod
918
1168
  def is_rate_limited(result: "TaskResult") -> bool:
919
- """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
+ """
920
1174
  if result.status == "success":
921
1175
  return False
922
1176
  combined = (result.stdout + result.stderr + result.error).lower()
923
- 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
924
1252
 
925
1253
  async def run_agent(
926
1254
  self,
@@ -952,6 +1280,16 @@ class ProcessManager:
952
1280
  elapsed = time.monotonic() - start_time
953
1281
  result.metrics["duration_seconds"] = round(elapsed, 2)
954
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
+
955
1293
  # Collect git changes
956
1294
  result.git = await self._collect_git_info(workspace_path)
957
1295
  result.files_changed = result.git.get("files_changed", [])
@@ -960,6 +1298,45 @@ class ProcessManager:
960
1298
 
961
1299
  return result
962
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
+
963
1340
  def _build_prompt(self, task: TaskInfo) -> str:
964
1341
  """Build the prompt to send to the agent.
965
1342
 
@@ -1761,6 +2138,7 @@ class HeartbeatService:
1761
2138
  "active_tasks": self._active_tasks,
1762
2139
  "available_agents": self._agents,
1763
2140
  "system_metrics": self._collect_system_metrics(),
2141
+ "os_info": get_os_info(),
1764
2142
  },
1765
2143
  timeout=10,
1766
2144
  )
@@ -1843,6 +2221,7 @@ class TaskPoller:
1843
2221
  requirement_workflow_id=t.get("requirement_workflow_id"),
1844
2222
  requirement_key=t.get("requirement_key"),
1845
2223
  graph_type=t.get("graph_type", "execution"),
2224
+ analysis_branch=t.get("analysis_branch"),
1846
2225
  ))
1847
2226
  return tasks
1848
2227
  except Exception as e:
@@ -1941,6 +2320,7 @@ class ServerConnection:
1941
2320
  "daemon_id": self.daemon_id,
1942
2321
  "hardware_id": self.hardware_id,
1943
2322
  "device_name": platform.node(),
2323
+ "os_info": get_os_info(),
1944
2324
  "available_agents": agent_dicts,
1945
2325
  "max_concurrent_tasks": max_concurrent,
1946
2326
  "capabilities": {
@@ -2118,14 +2498,48 @@ class RuntimeDaemon:
2118
2498
  This handles orphaned processes from crashed desktop apps, duplicate
2119
2499
  CLI starts, etc.
2120
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
+
2121
2539
  if fcntl is None:
2122
- # Windows: skip file locking (fcntl not available)
2123
2540
  logger.info("File locking not available on this platform; skipping")
2124
2541
  return
2125
2542
 
2126
- lock_path = Path.home() / ".forgexa" / "daemon" / "daemon.lock"
2127
- lock_path.parent.mkdir(parents=True, exist_ok=True)
2128
-
2129
2543
  self._lock_file = open(lock_path, "w")
2130
2544
 
2131
2545
  try:
@@ -2360,10 +2774,10 @@ class RuntimeDaemon:
2360
2774
 
2361
2775
  tried_agents.add(agent.agent_id)
2362
2776
 
2363
- # ── 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 ──
2364
2778
  if self.process_manager.is_rate_limited(result):
2365
2779
  logger.warning(
2366
- "Agent '%s' hit rate/usage limit for task %s, attempting fallback",
2780
+ "Agent '%s' unavailable/rate-limited for task %s, attempting fallback",
2367
2781
  agent.agent_id, task.task_id,
2368
2782
  )
2369
2783
  fallback_agent = self._select_fallback_agent(
@@ -2371,16 +2785,17 @@ class RuntimeDaemon:
2371
2785
  )
2372
2786
  while fallback_agent:
2373
2787
  logger.info(
2374
- "Rate-limit fallback: switching from '%s' to '%s' for task %s",
2788
+ "Agent fallback: switching from '%s' to '%s' for task %s (reason: %s)",
2375
2789
  agent.agent_id, fallback_agent.agent_id, task.task_id,
2790
+ result.error[:100] if result.error else "rate-limited/unavailable",
2376
2791
  )
2377
2792
  agent = fallback_agent
2378
2793
  tried_agents.add(agent.agent_id)
2379
2794
 
2380
2795
  await reporter.report_progress(
2381
2796
  task.task_id, 10,
2382
- f"rate_limit_fallback: retrying with {agent.agent_id}",
2383
- 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}"],
2384
2799
  )
2385
2800
 
2386
2801
  # Re-run with fallback agent
@@ -2425,10 +2840,10 @@ class RuntimeDaemon:
2425
2840
  _line_buffer.clear()
2426
2841
 
2427
2842
  if not self.process_manager.is_rate_limited(result):
2428
- break # Success or non-rate-limit failure
2843
+ break # Success or non-retriable failure
2429
2844
 
2430
2845
  logger.warning(
2431
- "Fallback agent '%s' also rate-limited for task %s",
2846
+ "Fallback agent '%s' also unavailable/rate-limited for task %s",
2432
2847
  agent.agent_id, task.task_id,
2433
2848
  )
2434
2849
  fallback_agent = self._select_fallback_agent(
@@ -2437,20 +2852,50 @@ class RuntimeDaemon:
2437
2852
 
2438
2853
  if self.process_manager.is_rate_limited(result):
2439
2854
  result.error = (
2440
- f"All agents rate-limited (tried: {', '.join(tried_agents)}). "
2855
+ f"All agents unavailable/rate-limited (tried: {', '.join(tried_agents)}). "
2441
2856
  f"Original error: {result.error}"
2442
2857
  )
2858
+ result.status = "failed"
2443
2859
 
2444
2860
  # 4. Collect git info BEFORE commit (shows uncommitted changes)
2445
2861
  pre_commit_git = await self.process_manager._collect_git_info(workspace_path)
2446
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
+
2447
2887
  # 4.1 Recovery: agent exited non-zero but already committed code
2448
2888
  # (e.g. OpenCode EBADF crash on exit after successful work)
2449
2889
  if result.status == "failed" and result.exit_code not in (None, -1):
2450
2890
  committed_git = await self.process_manager._collect_git_info_vs_parent(workspace_path)
2451
2891
  has_committed_changes = bool(committed_git.get("files_changed"))
2452
2892
  has_no_uncommitted = not pre_commit_git.get("files_changed")
2453
- 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):
2454
2899
  logger.warning(
2455
2900
  "Task %s agent exited with code %s but has committed changes — "
2456
2901
  "recovering as success (agent likely crashed during cleanup)",
@@ -2473,11 +2918,37 @@ class RuntimeDaemon:
2473
2918
  except Exception:
2474
2919
  logger.exception("Validation gate error for task %s (proceeding anyway)", task.task_id)
2475
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
+
2476
2942
  # 4.6 For analysis nodes: attach output file contents as inline artifacts
2477
2943
  # so the backend always has the documents even if git push fails later.
2478
2944
  if result.status == "success" and task.node_type == "analysis":
2479
2945
  await self._collect_analysis_artifacts(workspace_path, task, result)
2480
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
+
2481
2952
  # 5. Auto-commit and push if changes exist
2482
2953
  if result.status == "success":
2483
2954
  commit_result = await self._auto_commit(workspace_path, task)
@@ -2575,48 +3046,63 @@ class RuntimeDaemon:
2575
3046
  """Run deterministic validations on agent output.
2576
3047
 
2577
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).
2578
3051
  """
2579
3052
  import json as _json
2580
3053
 
2581
3054
  issues: list[str] = []
2582
3055
  node_type = task.node_type
3056
+ req_type = (task.input_data or {}).get("requirement_type", "feature")
2583
3057
 
2584
3058
  if node_type == "analysis":
2585
- # 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
+
2586
3068
  doc_dir = (task.input_data or {}).get("output_dir", "")
2587
3069
  if doc_dir:
2588
3070
  base = workspace_path / doc_dir
2589
3071
  else:
2590
3072
  base = workspace_path
2591
- 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:
2592
3076
  fpath = base / fname
2593
3077
  if not fpath.exists():
2594
3078
  issues.append(f"Required file missing: {doc_dir}/{fname}")
2595
3079
  elif fpath.stat().st_size == 0:
2596
3080
  issues.append(f"Required file is empty: {doc_dir}/{fname}")
2597
3081
 
2598
- # Validate analysis.json
2599
- json_path = base / "analysis.json"
2600
- if json_path.exists() and json_path.stat().st_size > 0:
2601
- try:
2602
- _json.loads(json_path.read_text(encoding="utf-8"))
2603
- except _json.JSONDecodeError as e:
2604
- issues.append(f"analysis.json is not valid JSON: {e}")
2605
-
2606
- # Validate test-intent.json
2607
- ti_path = base / "test-intent.json"
2608
- if ti_path.exists() and ti_path.stat().st_size > 0:
2609
- try:
2610
- ti_data = _json.loads(ti_path.read_text(encoding="utf-8"))
2611
- intents = ti_data.get("intents", [])
2612
- if not intents:
2613
- issues.append("test-intent.json contains no test intents")
2614
- for ti in intents[:20]:
2615
- if not ti.get("id") or not ti.get("title"):
2616
- issues.append(f"Test intent missing 'id' or 'title': {ti.get('id', '?')}")
2617
- break
2618
- except _json.JSONDecodeError as e:
2619
- 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}")
2620
3106
 
2621
3107
  elif node_type == "design":
2622
3108
  doc_dir = (task.input_data or {}).get("output_dir", "")
@@ -2627,7 +3113,7 @@ class RuntimeDaemon:
2627
3113
  elif design_path.stat().st_size == 0:
2628
3114
  issues.append(f"Design document is empty: {doc_dir}/design.md")
2629
3115
 
2630
- elif node_type in ("coding", "testing"):
3116
+ elif node_type in ("coding", "testing", "fix"):
2631
3117
  # Syntax-check modified Python and JS/TS files
2632
3118
  for f in result.files_changed:
2633
3119
  fpath = workspace_path / f
@@ -2652,55 +3138,65 @@ class RuntimeDaemon:
2652
3138
 
2653
3139
  # Testing-specific: validate structured test assets
2654
3140
  if node_type == "testing":
2655
- doc_dir = (task.input_data or {}).get("output_dir", "")
2656
- if doc_dir:
2657
- base = workspace_path / doc_dir
2658
- else:
2659
- base = workspace_path
2660
-
2661
- # --- test-cases.json validation ---
2662
- tc_path = base / "test-cases.json"
2663
- if tc_path.exists():
2664
- try:
2665
- tc_data = _json.loads(tc_path.read_text(encoding="utf-8"))
2666
- cases = tc_data.get("test_cases", [])
2667
- if not cases:
2668
- issues.append("test-cases.json exists but contains no test cases")
2669
- else:
2670
- for tc in cases[:20]:
2671
- if not tc.get("id") or not tc.get("title"):
2672
- issues.append(f"Test case missing 'id' or 'title': {tc.get('id', '?')}")
2673
- break
2674
- if not tc.get("steps"):
2675
- issues.append(f"Test case {tc['id']} has no 'steps'")
2676
- break
2677
- p0_cases = [c for c in cases if c.get("priority") == "P0"]
2678
- if not p0_cases:
2679
- issues.append("No P0 priority test cases found in test-cases.json")
2680
- except (_json.JSONDecodeError, UnicodeDecodeError) as e:
2681
- issues.append(f"test-cases.json is not valid JSON: {e}")
2682
- else:
2683
- 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
2684
3149
 
2685
- # --- coverage-matrix.json validation ---
2686
- cm_path = base / "coverage-matrix.json"
2687
- if cm_path.exists():
2688
- try:
2689
- cm_data = _json.loads(cm_path.read_text(encoding="utf-8"))
2690
- ac_list = cm_data.get("acceptance_criteria", [])
2691
- uncovered = [ac for ac in ac_list if ac.get("status") != "covered"]
2692
- if uncovered:
2693
- ids = ", ".join(ac.get("id", "?") for ac in uncovered[:5])
2694
- issues.append(f"Uncovered acceptance criteria in coverage-matrix.json: {ids}")
2695
- except (_json.JSONDecodeError, UnicodeDecodeError) as e:
2696
- issues.append(f"coverage-matrix.json is not valid JSON: {e}")
2697
- else:
2698
- 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
2699
3156
 
2700
- # --- test-report.md validation ---
2701
- report_path = base / "test-report.md"
2702
- if not report_path.exists():
2703
- 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'}")
2704
3200
 
2705
3201
  return issues
2706
3202
 
@@ -2766,10 +3262,32 @@ class RuntimeDaemon:
2766
3262
  # Final check after all retries
2767
3263
  remaining = self._validate_outputs(workspace_path, task, result)
2768
3264
  if remaining:
2769
- logger.warning(
2770
- "Validation gate: %d issues remain after %d retries for task %s (proceeding anyway)",
2771
- len(remaining), max_retries, task.task_id,
2772
- )
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
+ )
2773
3291
  result.metrics["validation_issues"] = remaining
2774
3292
  return result
2775
3293
 
@@ -2811,6 +3329,39 @@ class RuntimeDaemon:
2811
3329
  except Exception as e:
2812
3330
  logger.warning("Failed to read analysis artifact %s: %s", fname, e)
2813
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
+
2814
3365
  async def _auto_commit(self, workspace_path: Path, task: TaskInfo) -> dict:
2815
3366
  """Auto-commit and push agent changes.
2816
3367
 
@@ -3456,8 +4007,13 @@ async def main():
3456
4007
  daemon = RuntimeDaemon()
3457
4008
 
3458
4009
  loop = asyncio.get_event_loop()
3459
- for sig in (signal.SIGINT, signal.SIGTERM):
3460
- 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))
3461
4017
 
3462
4018
  await daemon.start()
3463
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.6
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.6"
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