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