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.
- {forgexa_cli-1.2.6 → forgexa_cli-1.3.2}/PKG-INFO +1 -1
- {forgexa_cli-1.2.6 → forgexa_cli-1.3.2}/forgexa_cli/__init__.py +1 -1
- {forgexa_cli-1.2.6 → forgexa_cli-1.3.2}/forgexa_cli/daemon.py +663 -107
- {forgexa_cli-1.2.6 → forgexa_cli-1.3.2}/forgexa_cli/main.py +18 -8
- {forgexa_cli-1.2.6 → forgexa_cli-1.3.2}/forgexa_cli.egg-info/PKG-INFO +1 -1
- {forgexa_cli-1.2.6 → forgexa_cli-1.3.2}/pyproject.toml +1 -1
- {forgexa_cli-1.2.6 → forgexa_cli-1.3.2}/README.md +0 -0
- {forgexa_cli-1.2.6 → forgexa_cli-1.3.2}/forgexa_cli/_build_config.py +0 -0
- {forgexa_cli-1.2.6 → forgexa_cli-1.3.2}/forgexa_cli/py.typed +0 -0
- {forgexa_cli-1.2.6 → forgexa_cli-1.3.2}/forgexa_cli.egg-info/SOURCES.txt +0 -0
- {forgexa_cli-1.2.6 → forgexa_cli-1.3.2}/forgexa_cli.egg-info/dependency_links.txt +0 -0
- {forgexa_cli-1.2.6 → forgexa_cli-1.3.2}/forgexa_cli.egg-info/entry_points.txt +0 -0
- {forgexa_cli-1.2.6 → forgexa_cli-1.3.2}/forgexa_cli.egg-info/requires.txt +0 -0
- {forgexa_cli-1.2.6 → forgexa_cli-1.3.2}/forgexa_cli.egg-info/top_level.txt +0 -0
- {forgexa_cli-1.2.6 → forgexa_cli-1.3.2}/setup.cfg +0 -0
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
"""forgexa-cli — Forgexa command-line client."""
|
|
2
|
-
__version__ = "1.2
|
|
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
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
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
|
|
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
|
|
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
|
-
# ──
|
|
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'
|
|
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
|
-
"
|
|
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"
|
|
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-
|
|
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
|
-
|
|
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
|
-
#
|
|
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
|
-
|
|
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
|
-
|
|
2600
|
-
|
|
2601
|
-
|
|
2602
|
-
|
|
2603
|
-
|
|
2604
|
-
|
|
2605
|
-
|
|
2606
|
-
|
|
2607
|
-
|
|
2608
|
-
if
|
|
2609
|
-
|
|
2610
|
-
|
|
2611
|
-
|
|
2612
|
-
|
|
2613
|
-
|
|
2614
|
-
|
|
2615
|
-
|
|
2616
|
-
|
|
2617
|
-
|
|
2618
|
-
|
|
2619
|
-
|
|
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
|
-
|
|
2656
|
-
|
|
2657
|
-
|
|
2658
|
-
|
|
2659
|
-
|
|
2660
|
-
|
|
2661
|
-
|
|
2662
|
-
|
|
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
|
-
|
|
2686
|
-
|
|
2687
|
-
|
|
2688
|
-
|
|
2689
|
-
|
|
2690
|
-
|
|
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
|
-
|
|
2701
|
-
|
|
2702
|
-
|
|
2703
|
-
|
|
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
|
-
|
|
2770
|
-
|
|
2771
|
-
|
|
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
|
-
|
|
3460
|
-
|
|
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
|
-
|
|
212
|
-
|
|
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
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
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})")
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|