forgexa-cli 1.14.2__tar.gz → 1.14.4__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {forgexa_cli-1.14.2 → forgexa_cli-1.14.4}/PKG-INFO +1 -1
- {forgexa_cli-1.14.2 → forgexa_cli-1.14.4}/forgexa_cli/__init__.py +1 -1
- {forgexa_cli-1.14.2 → forgexa_cli-1.14.4}/forgexa_cli/daemon.py +515 -86
- {forgexa_cli-1.14.2 → forgexa_cli-1.14.4}/forgexa_cli.egg-info/PKG-INFO +1 -1
- {forgexa_cli-1.14.2 → forgexa_cli-1.14.4}/pyproject.toml +1 -1
- {forgexa_cli-1.14.2 → forgexa_cli-1.14.4}/README.md +0 -0
- {forgexa_cli-1.14.2 → forgexa_cli-1.14.4}/forgexa_cli/_build_config.py +0 -0
- {forgexa_cli-1.14.2 → forgexa_cli-1.14.4}/forgexa_cli/main.py +0 -0
- {forgexa_cli-1.14.2 → forgexa_cli-1.14.4}/forgexa_cli/py.typed +0 -0
- {forgexa_cli-1.14.2 → forgexa_cli-1.14.4}/forgexa_cli.egg-info/SOURCES.txt +0 -0
- {forgexa_cli-1.14.2 → forgexa_cli-1.14.4}/forgexa_cli.egg-info/dependency_links.txt +0 -0
- {forgexa_cli-1.14.2 → forgexa_cli-1.14.4}/forgexa_cli.egg-info/entry_points.txt +0 -0
- {forgexa_cli-1.14.2 → forgexa_cli-1.14.4}/forgexa_cli.egg-info/requires.txt +0 -0
- {forgexa_cli-1.14.2 → forgexa_cli-1.14.4}/forgexa_cli.egg-info/top_level.txt +0 -0
- {forgexa_cli-1.14.2 → forgexa_cli-1.14.4}/setup.cfg +0 -0
- {forgexa_cli-1.14.2 → forgexa_cli-1.14.4}/tests/test_auth_and_runtime_commands.py +0 -0
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
"""forgexa-cli — Forgexa command-line client."""
|
|
2
|
-
__version__ = "1.14.
|
|
2
|
+
__version__ = "1.14.4"
|
|
@@ -523,7 +523,7 @@ except (ImportError, ModuleNotFoundError):
|
|
|
523
523
|
# DAEMON_VERSION is the protocol/logic version of the daemon code.
|
|
524
524
|
# Kept in sync with pyproject.toml version via bump-version.sh.
|
|
525
525
|
# CLIENT_TYPE identifies which packaging/distribution this daemon runs in.
|
|
526
|
-
DAEMON_VERSION = "1.14.
|
|
526
|
+
DAEMON_VERSION = "1.14.4"
|
|
527
527
|
|
|
528
528
|
|
|
529
529
|
def _detect_client_type() -> str:
|
|
@@ -1074,6 +1074,85 @@ def _extract_ai_job_scripts(
|
|
|
1074
1074
|
return scripts
|
|
1075
1075
|
|
|
1076
1076
|
|
|
1077
|
+
def _resolve_ai_job_workspace_scope(ai_job: dict) -> str | None:
|
|
1078
|
+
workspace_key = str(ai_job.get("workspace_key") or "").strip()
|
|
1079
|
+
if workspace_key:
|
|
1080
|
+
return workspace_key
|
|
1081
|
+
|
|
1082
|
+
input_ctx = ai_job.get("input_context") or {}
|
|
1083
|
+
|
|
1084
|
+
explicit_key = str(input_ctx.get("workspace_key") or "").strip()
|
|
1085
|
+
if explicit_key:
|
|
1086
|
+
return explicit_key
|
|
1087
|
+
|
|
1088
|
+
suite_id = str(input_ctx.get("suite_id") or "").strip()
|
|
1089
|
+
if suite_id:
|
|
1090
|
+
return f"qa-suite-{suite_id}"
|
|
1091
|
+
|
|
1092
|
+
requirement_id = str(input_ctx.get("requirement_id") or "").strip()
|
|
1093
|
+
if requirement_id:
|
|
1094
|
+
return f"requirement-{requirement_id}"
|
|
1095
|
+
|
|
1096
|
+
requirement_key = str(ai_job.get("requirement_key") or "").strip()
|
|
1097
|
+
if requirement_key:
|
|
1098
|
+
return f"requirement-{requirement_key}"
|
|
1099
|
+
|
|
1100
|
+
return None
|
|
1101
|
+
|
|
1102
|
+
|
|
1103
|
+
def _summarize_workspace_source(workspace_path: Path) -> str | None:
|
|
1104
|
+
"""Extract a lightweight local workspace summary for prompt grounding."""
|
|
1105
|
+
import os
|
|
1106
|
+
|
|
1107
|
+
if not workspace_path.exists():
|
|
1108
|
+
return None
|
|
1109
|
+
|
|
1110
|
+
file_list: list[str] = []
|
|
1111
|
+
for root, dirs, files in os.walk(workspace_path):
|
|
1112
|
+
dirs[:] = [
|
|
1113
|
+
d for d in dirs
|
|
1114
|
+
if not d.startswith(".") and d not in (
|
|
1115
|
+
"node_modules", "__pycache__", ".git", "dist", "build", "venv", ".venv",
|
|
1116
|
+
)
|
|
1117
|
+
]
|
|
1118
|
+
rel = os.path.relpath(root, workspace_path)
|
|
1119
|
+
for file_name in files:
|
|
1120
|
+
if file_name.startswith("."):
|
|
1121
|
+
continue
|
|
1122
|
+
path = f"{rel}/{file_name}" if rel != "." else file_name
|
|
1123
|
+
file_list.append(path)
|
|
1124
|
+
if len(file_list) >= 250:
|
|
1125
|
+
break
|
|
1126
|
+
if len(file_list) >= 250:
|
|
1127
|
+
break
|
|
1128
|
+
|
|
1129
|
+
if not file_list:
|
|
1130
|
+
return None
|
|
1131
|
+
|
|
1132
|
+
lines: list[str] = ["## Project Structure", "```", *file_list[:250], "```"]
|
|
1133
|
+
priority_patterns = [
|
|
1134
|
+
"routes", "router", "api", "endpoints", "views",
|
|
1135
|
+
"models", "schema", "types", "entities",
|
|
1136
|
+
"package.json", "pyproject.toml", "requirements.txt",
|
|
1137
|
+
"dockerfile", "docker-compose", "nginx.conf",
|
|
1138
|
+
"readme",
|
|
1139
|
+
]
|
|
1140
|
+
key_files = [
|
|
1141
|
+
path for path in file_list
|
|
1142
|
+
if any(pattern in path.lower() for pattern in priority_patterns)
|
|
1143
|
+
][:8]
|
|
1144
|
+
|
|
1145
|
+
for key_file in key_files:
|
|
1146
|
+
full_path = workspace_path / key_file
|
|
1147
|
+
try:
|
|
1148
|
+
content = full_path.read_text(encoding="utf-8", errors="ignore")[:3000]
|
|
1149
|
+
except Exception:
|
|
1150
|
+
continue
|
|
1151
|
+
lines.append(f"\n### {key_file}\n```\n{content}\n```")
|
|
1152
|
+
|
|
1153
|
+
return "\n".join(lines)[:8000]
|
|
1154
|
+
|
|
1155
|
+
|
|
1077
1156
|
def _resolve_git_author(project: dict) -> tuple[str, str]:
|
|
1078
1157
|
"""Resolve git commit author (name, email) using a 5-tier fallback chain.
|
|
1079
1158
|
|
|
@@ -1146,6 +1225,38 @@ def _get_analysis_outputs_for_type(req_type: str) -> list[str]:
|
|
|
1146
1225
|
return _ANALYSIS_OUTPUTS_BY_TYPE.get(req_type, _ANALYSIS_OUTPUTS_BY_TYPE["feature"])
|
|
1147
1226
|
|
|
1148
1227
|
|
|
1228
|
+
# Mirrors type_workflow_profiles.py — maps req type to the expected design-phase output filename.
|
|
1229
|
+
# Most types use design.md; spike uses research.md (produced by _DESIGN_SPIKE prompt).
|
|
1230
|
+
_DESIGN_OUTPUT_BY_TYPE: dict[str, str] = {
|
|
1231
|
+
"spike": "research.md",
|
|
1232
|
+
# All other types produce design.md (the default).
|
|
1233
|
+
}
|
|
1234
|
+
|
|
1235
|
+
|
|
1236
|
+
def _get_design_output_for_type(req_type: str) -> str:
|
|
1237
|
+
"""Get the expected design-phase output filename for a requirement type.
|
|
1238
|
+
|
|
1239
|
+
Used by _validate_outputs and _required_deliverable_paths to avoid
|
|
1240
|
+
hardcoding 'design.md' for all node_type='design' nodes — spike produces
|
|
1241
|
+
'research.md' instead.
|
|
1242
|
+
"""
|
|
1243
|
+
try:
|
|
1244
|
+
from app.services.type_workflow_profiles import get_profile
|
|
1245
|
+
profile = get_profile(req_type)
|
|
1246
|
+
for phase in profile.execution_phases:
|
|
1247
|
+
if phase.node_type == "design":
|
|
1248
|
+
from app.services.type_prompts import TYPE_PROMPT_TEMPLATES
|
|
1249
|
+
tmpl = TYPE_PROMPT_TEMPLATES.get(phase.prompt_key, "")
|
|
1250
|
+
import re as _re
|
|
1251
|
+
m = _re.search(r"{doc_dir}/([\w.-]+)", tmpl)
|
|
1252
|
+
if m:
|
|
1253
|
+
return m.group(1)
|
|
1254
|
+
break
|
|
1255
|
+
except Exception:
|
|
1256
|
+
pass
|
|
1257
|
+
return _DESIGN_OUTPUT_BY_TYPE.get(req_type, "design.md")
|
|
1258
|
+
|
|
1259
|
+
|
|
1149
1260
|
# ── Agent Discovery ──
|
|
1150
1261
|
|
|
1151
1262
|
|
|
@@ -1576,11 +1687,13 @@ class WorkspaceManager:
|
|
|
1576
1687
|
"timeout",
|
|
1577
1688
|
"timed out",
|
|
1578
1689
|
"server not responding",
|
|
1690
|
+
"received disconnect",
|
|
1579
1691
|
"unexpected disconnect",
|
|
1580
1692
|
"early eof",
|
|
1581
1693
|
"invalid index-pack output",
|
|
1582
1694
|
"fetch-pack",
|
|
1583
1695
|
"remote hung up unexpectedly",
|
|
1696
|
+
"too many concurrent connections",
|
|
1584
1697
|
"connection reset",
|
|
1585
1698
|
"connection closed",
|
|
1586
1699
|
"connection refused",
|
|
@@ -1901,6 +2014,42 @@ class WorkspaceManager:
|
|
|
1901
2014
|
_shutil.rmtree(wt, ignore_errors=True)
|
|
1902
2015
|
_shutil.rmtree(main_repo, ignore_errors=True)
|
|
1903
2016
|
|
|
2017
|
+
async def _find_existing_worktree_for_branch(
|
|
2018
|
+
self,
|
|
2019
|
+
main_repo: Path,
|
|
2020
|
+
branch_name: str,
|
|
2021
|
+
) -> Path | None:
|
|
2022
|
+
"""Return an existing healthy linked worktree already on branch_name."""
|
|
2023
|
+
try:
|
|
2024
|
+
raw = await self._git("worktree", "list", "--porcelain", cwd=main_repo)
|
|
2025
|
+
except RuntimeError:
|
|
2026
|
+
return None
|
|
2027
|
+
|
|
2028
|
+
target_ref = f"refs/heads/{branch_name}"
|
|
2029
|
+
entries: list[tuple[Path, str | None]] = []
|
|
2030
|
+
current_path: Path | None = None
|
|
2031
|
+
current_branch: str | None = None
|
|
2032
|
+
|
|
2033
|
+
for line in raw.splitlines():
|
|
2034
|
+
if line.startswith("worktree "):
|
|
2035
|
+
if current_path is not None:
|
|
2036
|
+
entries.append((current_path, current_branch))
|
|
2037
|
+
current_path = Path(line.split(" ", 1)[1].strip())
|
|
2038
|
+
current_branch = None
|
|
2039
|
+
elif line.startswith("branch "):
|
|
2040
|
+
current_branch = line.split(" ", 1)[1].strip()
|
|
2041
|
+
|
|
2042
|
+
if current_path is not None:
|
|
2043
|
+
entries.append((current_path, current_branch))
|
|
2044
|
+
|
|
2045
|
+
for candidate_path, candidate_branch in entries:
|
|
2046
|
+
if candidate_path == main_repo or candidate_branch != target_ref:
|
|
2047
|
+
continue
|
|
2048
|
+
if candidate_path.exists() and await self._is_healthy_worktree(candidate_path, main_repo=main_repo):
|
|
2049
|
+
return candidate_path
|
|
2050
|
+
|
|
2051
|
+
return None
|
|
2052
|
+
|
|
1904
2053
|
async def _detect_unrelated_histories(self, repo_path: Path, project_key: str) -> bool:
|
|
1905
2054
|
"""Detect whether local clone has diverged from remote due to history rewrite.
|
|
1906
2055
|
|
|
@@ -2550,12 +2699,34 @@ class WorkspaceManager:
|
|
|
2550
2699
|
cwd=main_repo,
|
|
2551
2700
|
)
|
|
2552
2701
|
except Exception:
|
|
2702
|
+
existing_branch_worktree = await self._find_existing_worktree_for_branch(
|
|
2703
|
+
main_repo, branch_name,
|
|
2704
|
+
)
|
|
2705
|
+
if existing_branch_worktree is not None:
|
|
2706
|
+
logger.info(
|
|
2707
|
+
"Reusing existing worktree %s for branch %s instead of creating %s",
|
|
2708
|
+
existing_branch_worktree,
|
|
2709
|
+
branch_name,
|
|
2710
|
+
ws_path,
|
|
2711
|
+
)
|
|
2712
|
+
return existing_branch_worktree
|
|
2553
2713
|
try:
|
|
2554
2714
|
await self._git(
|
|
2555
2715
|
"worktree", "add", str(ws_path), branch_name,
|
|
2556
2716
|
cwd=main_repo,
|
|
2557
2717
|
)
|
|
2558
2718
|
except Exception:
|
|
2719
|
+
existing_branch_worktree = await self._find_existing_worktree_for_branch(
|
|
2720
|
+
main_repo, branch_name,
|
|
2721
|
+
)
|
|
2722
|
+
if existing_branch_worktree is not None:
|
|
2723
|
+
logger.info(
|
|
2724
|
+
"Reusing existing worktree %s for branch %s instead of creating %s",
|
|
2725
|
+
existing_branch_worktree,
|
|
2726
|
+
branch_name,
|
|
2727
|
+
ws_path,
|
|
2728
|
+
)
|
|
2729
|
+
return existing_branch_worktree
|
|
2559
2730
|
# Fallback: create from default_branch
|
|
2560
2731
|
try:
|
|
2561
2732
|
await self._git(
|
|
@@ -2563,6 +2734,17 @@ class WorkspaceManager:
|
|
|
2563
2734
|
cwd=main_repo,
|
|
2564
2735
|
)
|
|
2565
2736
|
except Exception:
|
|
2737
|
+
existing_branch_worktree = await self._find_existing_worktree_for_branch(
|
|
2738
|
+
main_repo, branch_name,
|
|
2739
|
+
)
|
|
2740
|
+
if existing_branch_worktree is not None:
|
|
2741
|
+
logger.info(
|
|
2742
|
+
"Reusing existing worktree %s for branch %s instead of cloning %s",
|
|
2743
|
+
existing_branch_worktree,
|
|
2744
|
+
branch_name,
|
|
2745
|
+
ws_path,
|
|
2746
|
+
)
|
|
2747
|
+
return existing_branch_worktree
|
|
2566
2748
|
ws_path.mkdir(parents=True, exist_ok=True)
|
|
2567
2749
|
await self._clone_repo(
|
|
2568
2750
|
repo_url,
|
|
@@ -2891,6 +3073,7 @@ class ProcessManager:
|
|
|
2891
3073
|
"rate limit",
|
|
2892
3074
|
"rate_limit",
|
|
2893
3075
|
"quota exceeded",
|
|
3076
|
+
"monthly quota", # Copilot: "You have exceeded your monthly quota"
|
|
2894
3077
|
"too many requests",
|
|
2895
3078
|
"overloaded",
|
|
2896
3079
|
"insufficient_quota",
|
|
@@ -3028,7 +3211,12 @@ class ProcessManager:
|
|
|
3028
3211
|
else:
|
|
3029
3212
|
result_text = str(data.get("result", "") or "")
|
|
3030
3213
|
if data.get("is_error"):
|
|
3031
|
-
|
|
3214
|
+
raw_errors = data.get("errors")
|
|
3215
|
+
if isinstance(raw_errors, list):
|
|
3216
|
+
err_text = "; ".join(str(item).strip() for item in raw_errors if str(item).strip())
|
|
3217
|
+
else:
|
|
3218
|
+
err_text = ""
|
|
3219
|
+
err_text = err_text or result_text or str(data.get("error", "") or "result marked as error")
|
|
3032
3220
|
error_messages.append(err_text)
|
|
3033
3221
|
else:
|
|
3034
3222
|
# Structural check: if no tokens were consumed AND no assistant
|
|
@@ -3391,7 +3579,8 @@ class ProcessManager:
|
|
|
3391
3579
|
req_type = (task.input_data or {}).get("requirement_type", "feature")
|
|
3392
3580
|
required_files = _get_analysis_outputs_for_type(req_type)
|
|
3393
3581
|
elif task.node_type == "design":
|
|
3394
|
-
|
|
3582
|
+
req_type = (task.input_data or {}).get("requirement_type", "feature")
|
|
3583
|
+
required_files = [_get_design_output_for_type(req_type)]
|
|
3395
3584
|
elif task.node_type == "delivery":
|
|
3396
3585
|
# Required docs come from node input_data (set by delivery_doc_service)
|
|
3397
3586
|
required_files = (task.input_data or {}).get("required_docs") or ["release-note.md"]
|
|
@@ -3411,48 +3600,6 @@ class ProcessManager:
|
|
|
3411
3600
|
changed_paths.update(self._normalize_repo_paths(paths))
|
|
3412
3601
|
return bool(required_paths & changed_paths)
|
|
3413
3602
|
|
|
3414
|
-
def _recover_timeout_from_workspace_changes(
|
|
3415
|
-
self,
|
|
3416
|
-
task: TaskInfo,
|
|
3417
|
-
workspace_path: Path,
|
|
3418
|
-
result: TaskResult,
|
|
3419
|
-
pre_commit_git: dict,
|
|
3420
|
-
) -> bool:
|
|
3421
|
-
"""Recover absolute-timeout failures after validating workspace output."""
|
|
3422
|
-
error_lower = (result.error or "").lower()
|
|
3423
|
-
is_absolute_timeout = (
|
|
3424
|
-
"absolute limit" in error_lower or "timed out after" in error_lower
|
|
3425
|
-
)
|
|
3426
|
-
changed_files = list(pre_commit_git.get("files_changed") or [])
|
|
3427
|
-
if not is_absolute_timeout or not changed_files:
|
|
3428
|
-
return False
|
|
3429
|
-
if not self.process_manager.has_meaningful_agent_output(result):
|
|
3430
|
-
return False
|
|
3431
|
-
|
|
3432
|
-
if result.files_changed:
|
|
3433
|
-
result.files_changed = sorted(set(result.files_changed) | set(changed_files))
|
|
3434
|
-
else:
|
|
3435
|
-
result.files_changed = changed_files
|
|
3436
|
-
if not result.lines_added:
|
|
3437
|
-
result.lines_added = int(pre_commit_git.get("lines_added", 0) or 0)
|
|
3438
|
-
if not result.lines_removed:
|
|
3439
|
-
result.lines_removed = int(pre_commit_git.get("lines_removed", 0) or 0)
|
|
3440
|
-
|
|
3441
|
-
if not self.process_manager._has_required_deliverable_updates(
|
|
3442
|
-
task,
|
|
3443
|
-
changed_files,
|
|
3444
|
-
result.files_changed,
|
|
3445
|
-
):
|
|
3446
|
-
return False
|
|
3447
|
-
|
|
3448
|
-
validation_issues = self._validate_outputs(workspace_path, task, result)
|
|
3449
|
-
if validation_issues:
|
|
3450
|
-
result.metrics["timeout_recovery_validation_issues"] = validation_issues
|
|
3451
|
-
return False
|
|
3452
|
-
|
|
3453
|
-
result.metrics["recovered_from_timeout_uncommitted_changes"] = True
|
|
3454
|
-
return True
|
|
3455
|
-
|
|
3456
3603
|
def _build_prompt(self, task: TaskInfo) -> str:
|
|
3457
3604
|
"""Build the prompt to send to the agent.
|
|
3458
3605
|
|
|
@@ -3743,6 +3890,7 @@ class ProcessManager:
|
|
|
3743
3890
|
"--output-format", "stream-json",
|
|
3744
3891
|
"--verbose",
|
|
3745
3892
|
"--max-turns", str(max_turns),
|
|
3893
|
+
"--no-session-persistence",
|
|
3746
3894
|
"--dangerously-skip-permissions",
|
|
3747
3895
|
]
|
|
3748
3896
|
|
|
@@ -3750,6 +3898,7 @@ class ProcessManager:
|
|
|
3750
3898
|
model_override = os.environ.get("FACTORY_CLAUDE_MODEL")
|
|
3751
3899
|
if model_override:
|
|
3752
3900
|
env["ANTHROPIC_MODEL"] = model_override
|
|
3901
|
+
env.update(self._prepare_claude_environment())
|
|
3753
3902
|
|
|
3754
3903
|
try:
|
|
3755
3904
|
proc = await asyncio.create_subprocess_exec(
|
|
@@ -3770,6 +3919,8 @@ class ProcessManager:
|
|
|
3770
3919
|
|
|
3771
3920
|
# Parse Claude JSON output for metrics
|
|
3772
3921
|
metrics = self._parse_claude_output(stdout)
|
|
3922
|
+
structured_errors = self._extract_output_signals("\n".join(part for part in (stdout, stderr) if part))["error_messages"]
|
|
3923
|
+
structured_error_text = "; ".join(str(msg).strip() for msg in structured_errors if str(msg).strip())
|
|
3773
3924
|
|
|
3774
3925
|
if returncode == 0:
|
|
3775
3926
|
return TaskResult(
|
|
@@ -3798,6 +3949,15 @@ class ProcessManager:
|
|
|
3798
3949
|
"exceeded the model's limit. Reduce prompt size or shorten file reads.",
|
|
3799
3950
|
metrics=metrics,
|
|
3800
3951
|
)
|
|
3952
|
+
if structured_error_text:
|
|
3953
|
+
return TaskResult(
|
|
3954
|
+
status="failed",
|
|
3955
|
+
exit_code=returncode,
|
|
3956
|
+
stdout=stdout[-settings.AGENT_MAX_OUTPUT_SIZE:],
|
|
3957
|
+
stderr=stderr[-10000:],
|
|
3958
|
+
error=f"Claude exited with code {returncode}: {structured_error_text}",
|
|
3959
|
+
metrics=metrics,
|
|
3960
|
+
)
|
|
3801
3961
|
return TaskResult(
|
|
3802
3962
|
status="failed",
|
|
3803
3963
|
exit_code=returncode,
|
|
@@ -4101,6 +4261,58 @@ class ProcessManager:
|
|
|
4101
4261
|
result.error = error_clean or result.error
|
|
4102
4262
|
return result
|
|
4103
4263
|
|
|
4264
|
+
@staticmethod
|
|
4265
|
+
def _prepare_claude_environment() -> dict[str, str]:
|
|
4266
|
+
"""Prepare writable Claude config/data/cache dirs under ~/.forgexa.
|
|
4267
|
+
|
|
4268
|
+
The daemon service runs with ProtectSystem=strict and only allows
|
|
4269
|
+
writes under ~/.forgexa plus the workspace root. Claude Code writes
|
|
4270
|
+
config, debug logs, cache, and session state under XDG paths and
|
|
4271
|
+
~/.claude by default, which can fail with EROFS inside the daemon.
|
|
4272
|
+
|
|
4273
|
+
Point Claude at writable daemon-owned directories and mirror only the
|
|
4274
|
+
small set of user config files it needs for headless execution.
|
|
4275
|
+
"""
|
|
4276
|
+
sandbox_root = Path.home() / ".forgexa" / "claude-home"
|
|
4277
|
+
config_root = sandbox_root / "config"
|
|
4278
|
+
data_root = sandbox_root / "data"
|
|
4279
|
+
cache_root = sandbox_root / "cache"
|
|
4280
|
+
state_root = sandbox_root / "state"
|
|
4281
|
+
claude_config_dir = config_root / "claude"
|
|
4282
|
+
|
|
4283
|
+
for path in (config_root, data_root, cache_root, state_root, claude_config_dir):
|
|
4284
|
+
path.mkdir(parents=True, exist_ok=True)
|
|
4285
|
+
|
|
4286
|
+
source_files = [
|
|
4287
|
+
(Path.home() / ".claude.json", claude_config_dir / ".claude.json"),
|
|
4288
|
+
(Path.home() / ".claude" / "settings.json", claude_config_dir / "settings.json"),
|
|
4289
|
+
]
|
|
4290
|
+
for source, dest in source_files:
|
|
4291
|
+
try:
|
|
4292
|
+
if source.is_file() and not dest.exists():
|
|
4293
|
+
shutil.copy2(source, dest)
|
|
4294
|
+
except Exception as exc:
|
|
4295
|
+
logger.debug(
|
|
4296
|
+
"Claude config mirror skipped %s -> %s: %s",
|
|
4297
|
+
source, dest, exc,
|
|
4298
|
+
)
|
|
4299
|
+
|
|
4300
|
+
env = {
|
|
4301
|
+
"XDG_CONFIG_HOME": str(config_root),
|
|
4302
|
+
"XDG_DATA_HOME": str(data_root),
|
|
4303
|
+
"XDG_CACHE_HOME": str(cache_root),
|
|
4304
|
+
"XDG_STATE_HOME": str(state_root),
|
|
4305
|
+
"CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC": str(
|
|
4306
|
+
os.environ.get("CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC", "1")
|
|
4307
|
+
),
|
|
4308
|
+
}
|
|
4309
|
+
|
|
4310
|
+
if not os.environ.get("CLAUDE_CODE_DEBUG_LOGS_DIR"):
|
|
4311
|
+
env["CLAUDE_CODE_DEBUG_LOGS_DIR"] = str(cache_root / "claude-debug")
|
|
4312
|
+
Path(env["CLAUDE_CODE_DEBUG_LOGS_DIR"]).mkdir(parents=True, exist_ok=True)
|
|
4313
|
+
|
|
4314
|
+
return env
|
|
4315
|
+
|
|
4104
4316
|
@staticmethod
|
|
4105
4317
|
def _prepare_copilot_home() -> str:
|
|
4106
4318
|
"""Prepare a writable Copilot home under ~/.forgexa for sandboxed runs.
|
|
@@ -4998,10 +5210,36 @@ class ProcessManager:
|
|
|
4998
5210
|
class ProgressReporter:
|
|
4999
5211
|
"""Reports task progress back to the server."""
|
|
5000
5212
|
|
|
5213
|
+
_MIN_HEARTBEAT_INTERVAL_SECONDS = 5.0
|
|
5214
|
+
_MIN_PROGRESS_DELTA = 5
|
|
5215
|
+
|
|
5001
5216
|
def __init__(self, client: httpx.AsyncClient, server_url: str, runtime_id: str):
|
|
5002
5217
|
self.client = client
|
|
5003
5218
|
self.server_url = server_url.rstrip("/")
|
|
5004
5219
|
self.runtime_id = runtime_id
|
|
5220
|
+
self._last_progress_sent: dict[str, dict[str, Any]] = {}
|
|
5221
|
+
|
|
5222
|
+
def _should_skip_progress(
|
|
5223
|
+
self,
|
|
5224
|
+
task_id: str,
|
|
5225
|
+
progress_percent: int,
|
|
5226
|
+
current_step: str,
|
|
5227
|
+
output_lines: list[str],
|
|
5228
|
+
files_changed: list[str],
|
|
5229
|
+
lines_added: int,
|
|
5230
|
+
lines_removed: int,
|
|
5231
|
+
) -> bool:
|
|
5232
|
+
if output_lines or files_changed or lines_added or lines_removed:
|
|
5233
|
+
return False
|
|
5234
|
+
|
|
5235
|
+
last = self._last_progress_sent.get(task_id)
|
|
5236
|
+
if not last:
|
|
5237
|
+
return False
|
|
5238
|
+
if last["current_step"] != current_step:
|
|
5239
|
+
return False
|
|
5240
|
+
if progress_percent - int(last["progress_percent"]) >= self._MIN_PROGRESS_DELTA:
|
|
5241
|
+
return False
|
|
5242
|
+
return (time.monotonic() - float(last["sent_at"])) < self._MIN_HEARTBEAT_INTERVAL_SECONDS
|
|
5005
5243
|
|
|
5006
5244
|
async def report_progress(
|
|
5007
5245
|
self,
|
|
@@ -5013,24 +5251,43 @@ class ProgressReporter:
|
|
|
5013
5251
|
lines_added: int = 0,
|
|
5014
5252
|
lines_removed: int = 0,
|
|
5015
5253
|
):
|
|
5254
|
+
output_lines = output_lines or []
|
|
5255
|
+
files_changed = files_changed or []
|
|
5256
|
+
if self._should_skip_progress(
|
|
5257
|
+
task_id,
|
|
5258
|
+
progress_percent,
|
|
5259
|
+
current_step,
|
|
5260
|
+
output_lines,
|
|
5261
|
+
files_changed,
|
|
5262
|
+
lines_added,
|
|
5263
|
+
lines_removed,
|
|
5264
|
+
):
|
|
5265
|
+
return
|
|
5266
|
+
|
|
5016
5267
|
try:
|
|
5017
5268
|
await self.client.post(
|
|
5018
5269
|
f"{self.server_url}/api/v1/runtimes/{self.runtime_id}/tasks/{task_id}/progress",
|
|
5019
5270
|
json={
|
|
5020
5271
|
"progress_percent": progress_percent,
|
|
5021
5272
|
"current_step": current_step,
|
|
5022
|
-
"output_lines": output_lines
|
|
5023
|
-
"files_changed": files_changed
|
|
5273
|
+
"output_lines": output_lines,
|
|
5274
|
+
"files_changed": files_changed,
|
|
5024
5275
|
"lines_added": lines_added,
|
|
5025
5276
|
"lines_removed": lines_removed,
|
|
5026
5277
|
},
|
|
5027
5278
|
timeout=10,
|
|
5028
5279
|
)
|
|
5280
|
+
self._last_progress_sent[task_id] = {
|
|
5281
|
+
"progress_percent": progress_percent,
|
|
5282
|
+
"current_step": current_step,
|
|
5283
|
+
"sent_at": time.monotonic(),
|
|
5284
|
+
}
|
|
5029
5285
|
except Exception as e:
|
|
5030
5286
|
logger.warning("Failed to report progress for task %s: %s", task_id, e)
|
|
5031
5287
|
|
|
5032
5288
|
async def report_complete(self, task_id: str, result: TaskResult):
|
|
5033
5289
|
"""Report task completion with retry."""
|
|
5290
|
+
self._last_progress_sent.pop(task_id, None)
|
|
5034
5291
|
payload = {
|
|
5035
5292
|
"status": result.status,
|
|
5036
5293
|
"exit_code": result.exit_code,
|
|
@@ -5224,6 +5481,8 @@ class LogUploader:
|
|
|
5224
5481
|
class TaskPoller:
|
|
5225
5482
|
"""Polls the server for assigned tasks."""
|
|
5226
5483
|
|
|
5484
|
+
_EMPTY_BACKOFF_STEPS = (0, 0, 1, 2, 3, 5)
|
|
5485
|
+
|
|
5227
5486
|
def __init__(
|
|
5228
5487
|
self,
|
|
5229
5488
|
client: httpx.AsyncClient,
|
|
@@ -5237,6 +5496,16 @@ class TaskPoller:
|
|
|
5237
5496
|
self.interval = interval
|
|
5238
5497
|
self._on_success = lambda: None # Callbacks set by ServerConnection
|
|
5239
5498
|
self._on_auth_failure = lambda: None
|
|
5499
|
+
self._empty_cycles = 0
|
|
5500
|
+
|
|
5501
|
+
def note_activity(self, *, had_work: bool) -> None:
|
|
5502
|
+
if had_work:
|
|
5503
|
+
self._empty_cycles = 0
|
|
5504
|
+
return
|
|
5505
|
+
self._empty_cycles = min(self._empty_cycles + 1, len(self._EMPTY_BACKOFF_STEPS) - 1)
|
|
5506
|
+
|
|
5507
|
+
def next_delay_seconds(self) -> int:
|
|
5508
|
+
return self.interval + self._EMPTY_BACKOFF_STEPS[self._empty_cycles]
|
|
5240
5509
|
|
|
5241
5510
|
async def poll(self) -> list[TaskInfo]:
|
|
5242
5511
|
"""Poll for tasks assigned to this daemon."""
|
|
@@ -5967,15 +6236,28 @@ class RuntimeDaemon:
|
|
|
5967
6236
|
# 3. Main loop
|
|
5968
6237
|
try:
|
|
5969
6238
|
while not self._shutdown:
|
|
5970
|
-
await self._poll_and_execute()
|
|
5971
|
-
|
|
6239
|
+
had_work = await self._poll_and_execute()
|
|
6240
|
+
next_delay = self.poll_interval
|
|
6241
|
+
if self.connections:
|
|
6242
|
+
next_delay = min(
|
|
6243
|
+
conn.poller.next_delay_seconds()
|
|
6244
|
+
for conn in self.connections
|
|
6245
|
+
if conn.poller is not None
|
|
6246
|
+
)
|
|
6247
|
+
logger.debug(
|
|
6248
|
+
"Daemon poll cycle complete (had_work=%s, next_delay=%ss)",
|
|
6249
|
+
had_work,
|
|
6250
|
+
next_delay,
|
|
6251
|
+
)
|
|
6252
|
+
await asyncio.sleep(next_delay)
|
|
5972
6253
|
except asyncio.CancelledError:
|
|
5973
6254
|
pass
|
|
5974
6255
|
finally:
|
|
5975
6256
|
await self._shutdown_gracefully()
|
|
5976
6257
|
|
|
5977
|
-
async def _poll_and_execute(self):
|
|
6258
|
+
async def _poll_and_execute(self) -> bool:
|
|
5978
6259
|
"""Poll for tasks from all connected servers and execute them."""
|
|
6260
|
+
had_work = False
|
|
5979
6261
|
# Clean up completed tasks
|
|
5980
6262
|
for task_id in list(self.active_tasks):
|
|
5981
6263
|
if self.active_tasks[task_id].done():
|
|
@@ -5990,7 +6272,7 @@ class RuntimeDaemon:
|
|
|
5990
6272
|
|
|
5991
6273
|
# Check capacity
|
|
5992
6274
|
if total_active >= self.max_concurrent:
|
|
5993
|
-
return
|
|
6275
|
+
return True
|
|
5994
6276
|
|
|
5995
6277
|
# Poll from all connected servers
|
|
5996
6278
|
for conn in self.connections:
|
|
@@ -5998,6 +6280,12 @@ class RuntimeDaemon:
|
|
|
5998
6280
|
break
|
|
5999
6281
|
|
|
6000
6282
|
tasks = await conn.poller.poll()
|
|
6283
|
+
ai_jobs: list[dict] = []
|
|
6284
|
+
if len(self.active_tasks) < self.max_concurrent:
|
|
6285
|
+
ai_jobs = await conn.poller.poll_ai_jobs()
|
|
6286
|
+
|
|
6287
|
+
conn.poller.note_activity(had_work=bool(tasks or ai_jobs))
|
|
6288
|
+
had_work = had_work or bool(tasks or ai_jobs)
|
|
6001
6289
|
|
|
6002
6290
|
for task in tasks:
|
|
6003
6291
|
if task.task_id in self.active_tasks:
|
|
@@ -6015,21 +6303,63 @@ class RuntimeDaemon:
|
|
|
6015
6303
|
)
|
|
6016
6304
|
|
|
6017
6305
|
# Poll for AIJobs (workspace-mode tasks)
|
|
6018
|
-
|
|
6019
|
-
|
|
6020
|
-
|
|
6021
|
-
|
|
6022
|
-
|
|
6023
|
-
|
|
6024
|
-
|
|
6025
|
-
|
|
6026
|
-
|
|
6027
|
-
|
|
6028
|
-
|
|
6029
|
-
self.
|
|
6030
|
-
|
|
6031
|
-
|
|
6032
|
-
|
|
6306
|
+
for aj in ai_jobs:
|
|
6307
|
+
job_id = aj.get("job_id", "")
|
|
6308
|
+
ai_task_key = f"aijob_{job_id}"
|
|
6309
|
+
if ai_task_key in self.active_tasks:
|
|
6310
|
+
continue
|
|
6311
|
+
if len(self.active_tasks) >= self.max_concurrent:
|
|
6312
|
+
break
|
|
6313
|
+
logger.info("[%s] Starting AIJob %s (type=%s)",
|
|
6314
|
+
conn.label, job_id, aj.get("task_type"))
|
|
6315
|
+
self._task_connections[ai_task_key] = conn
|
|
6316
|
+
self.active_tasks[ai_task_key] = asyncio.create_task(
|
|
6317
|
+
self._execute_ai_job(aj, conn)
|
|
6318
|
+
)
|
|
6319
|
+
|
|
6320
|
+
return had_work
|
|
6321
|
+
|
|
6322
|
+
def _recover_timeout_from_workspace_changes(
|
|
6323
|
+
self,
|
|
6324
|
+
task: TaskInfo,
|
|
6325
|
+
workspace_path: Path,
|
|
6326
|
+
result: TaskResult,
|
|
6327
|
+
pre_commit_git: dict,
|
|
6328
|
+
) -> bool:
|
|
6329
|
+
"""Recover absolute-timeout failures after validating workspace output."""
|
|
6330
|
+
error_lower = (result.error or "").lower()
|
|
6331
|
+
is_absolute_timeout = (
|
|
6332
|
+
"absolute limit" in error_lower or "timed out after" in error_lower
|
|
6333
|
+
)
|
|
6334
|
+
changed_files = list(pre_commit_git.get("files_changed") or [])
|
|
6335
|
+
if not is_absolute_timeout or not changed_files:
|
|
6336
|
+
return False
|
|
6337
|
+
if not self.process_manager.has_meaningful_agent_output(result):
|
|
6338
|
+
return False
|
|
6339
|
+
|
|
6340
|
+
if result.files_changed:
|
|
6341
|
+
result.files_changed = sorted(set(result.files_changed) | set(changed_files))
|
|
6342
|
+
else:
|
|
6343
|
+
result.files_changed = changed_files
|
|
6344
|
+
if not result.lines_added:
|
|
6345
|
+
result.lines_added = int(pre_commit_git.get("lines_added", 0) or 0)
|
|
6346
|
+
if not result.lines_removed:
|
|
6347
|
+
result.lines_removed = int(pre_commit_git.get("lines_removed", 0) or 0)
|
|
6348
|
+
|
|
6349
|
+
if not self.process_manager._has_required_deliverable_updates(
|
|
6350
|
+
task,
|
|
6351
|
+
changed_files,
|
|
6352
|
+
result.files_changed,
|
|
6353
|
+
):
|
|
6354
|
+
return False
|
|
6355
|
+
|
|
6356
|
+
validation_issues = self._validate_outputs(workspace_path, task, result)
|
|
6357
|
+
if validation_issues:
|
|
6358
|
+
result.metrics["timeout_recovery_validation_issues"] = validation_issues
|
|
6359
|
+
return False
|
|
6360
|
+
|
|
6361
|
+
result.metrics["recovered_from_timeout_uncommitted_changes"] = True
|
|
6362
|
+
return True
|
|
6033
6363
|
|
|
6034
6364
|
async def _execute_task(self, task: TaskInfo, conn: ServerConnection):
|
|
6035
6365
|
"""Execute a single task, reporting to the originating server connection."""
|
|
@@ -6654,12 +6984,23 @@ class RuntimeDaemon:
|
|
|
6654
6984
|
# as failed so the orchestrator can retry (transient network).
|
|
6655
6985
|
if commit_result.get("push_error"):
|
|
6656
6986
|
push_err = commit_result["push_error"]
|
|
6657
|
-
logger.error(
|
|
6658
|
-
"Task %s: push failed, marking as failed so retry can attempt push again: %s",
|
|
6659
|
-
task.task_id, push_err,
|
|
6660
|
-
)
|
|
6661
6987
|
result.status = "failed"
|
|
6662
|
-
|
|
6988
|
+
if commit_result.get("push_error_code") == "forbidden_branch_merge":
|
|
6989
|
+
logger.error(
|
|
6990
|
+
"Task %s: push blocked by requirement-branch merge protection: %s",
|
|
6991
|
+
task.task_id, push_err,
|
|
6992
|
+
)
|
|
6993
|
+
result.failure_code = "forbidden_branch_merge"
|
|
6994
|
+
result.error = (
|
|
6995
|
+
commit_result.get("push_error_display")
|
|
6996
|
+
or f"Push blocked: {push_err}"
|
|
6997
|
+
)
|
|
6998
|
+
else:
|
|
6999
|
+
logger.error(
|
|
7000
|
+
"Task %s: push failed, marking as failed so retry can attempt push again: %s",
|
|
7001
|
+
task.task_id, push_err,
|
|
7002
|
+
)
|
|
7003
|
+
result.error = f"Git push failed: {push_err}"
|
|
6663
7004
|
# Re-collect git info after commit (compare with parent)
|
|
6664
7005
|
post_commit_git = await self.process_manager._collect_git_info_vs_parent(workspace_path)
|
|
6665
7006
|
# Merge: use the pre-commit file list if post-commit is empty
|
|
@@ -6896,12 +7237,14 @@ class RuntimeDaemon:
|
|
|
6896
7237
|
|
|
6897
7238
|
elif node_type == "design":
|
|
6898
7239
|
doc_dir = (task.input_data or {}).get("output_dir", "")
|
|
7240
|
+
req_type = (task.input_data or {}).get("requirement_type", "feature")
|
|
6899
7241
|
if doc_dir:
|
|
6900
|
-
|
|
7242
|
+
design_filename = _get_design_output_for_type(req_type)
|
|
7243
|
+
design_path = workspace_path / doc_dir / design_filename
|
|
6901
7244
|
if not design_path.exists():
|
|
6902
|
-
issues.append(f"Design document missing: {doc_dir}/
|
|
7245
|
+
issues.append(f"Design document missing: {doc_dir}/{design_filename}")
|
|
6903
7246
|
elif design_path.stat().st_size == 0:
|
|
6904
|
-
issues.append(f"Design document is empty: {doc_dir}/
|
|
7247
|
+
issues.append(f"Design document is empty: {doc_dir}/{design_filename}")
|
|
6905
7248
|
|
|
6906
7249
|
elif node_type in ("coding", "testing", "fix", "review"):
|
|
6907
7250
|
# Syntax-check modified Python and JS/TS files
|
|
@@ -7053,12 +7396,14 @@ class RuntimeDaemon:
|
|
|
7053
7396
|
agent_override = aj.get("agent_override")
|
|
7054
7397
|
system_prompt = aj.get("system_prompt", "")
|
|
7055
7398
|
user_prompt = aj.get("user_prompt", "")
|
|
7399
|
+
input_ctx = aj.get("input_context", {})
|
|
7400
|
+
ai_workspace_scope = _resolve_ai_job_workspace_scope(aj)
|
|
7056
7401
|
|
|
7057
7402
|
reporter_url = f"{conn.server_url.rstrip('/')}/api/v1/runtimes/{conn.runtime_id}/ai-jobs/{job_id}"
|
|
7058
7403
|
# Use workspace-level key (project:requirement or project:job_id) so AI jobs
|
|
7059
7404
|
# for different requirements run in parallel while same-requirement jobs serialize.
|
|
7060
7405
|
_aj_proj_key = project_info.get("project_key") or str(project_info.get("id") or "default")
|
|
7061
|
-
project_lock_key = f"{_aj_proj_key}:{
|
|
7406
|
+
project_lock_key = f"{_aj_proj_key}:{ai_workspace_scope or job_id}"
|
|
7062
7407
|
project_lock = await self._get_project_execution_lock(project_lock_key)
|
|
7063
7408
|
if project_lock.locked():
|
|
7064
7409
|
logger.info(
|
|
@@ -7111,11 +7456,10 @@ class RuntimeDaemon:
|
|
|
7111
7456
|
full_prompt = f"{system_prompt}\n\n{user_prompt}" if system_prompt else user_prompt
|
|
7112
7457
|
fake_task = TaskInfo(
|
|
7113
7458
|
task_id=job_id,
|
|
7114
|
-
#
|
|
7115
|
-
#
|
|
7116
|
-
#
|
|
7117
|
-
|
|
7118
|
-
graph_id=job_id,
|
|
7459
|
+
# Reuse a stable workspace key when the server provides one so
|
|
7460
|
+
# delegated retries do not create a fresh clone per AIJob row.
|
|
7461
|
+
# Fall back to job_id so workspace_key is always non-empty.
|
|
7462
|
+
graph_id=ai_workspace_scope or job_id,
|
|
7119
7463
|
node_type="ai_job",
|
|
7120
7464
|
agent_type=agent_type,
|
|
7121
7465
|
input_prompt=full_prompt,
|
|
@@ -7134,6 +7478,17 @@ class RuntimeDaemon:
|
|
|
7134
7478
|
project_info, fake_task,
|
|
7135
7479
|
)
|
|
7136
7480
|
|
|
7481
|
+
if task_type == "qa_scenario_generate_prompt" and not input_ctx.get("project_context"):
|
|
7482
|
+
local_project_context = _summarize_workspace_source(workspace_path)
|
|
7483
|
+
if local_project_context:
|
|
7484
|
+
fake_task.input_prompt = (
|
|
7485
|
+
f"{full_prompt}\n\n"
|
|
7486
|
+
"Use the provided project source context when grounding scenario coverage. "
|
|
7487
|
+
"Do not rely on additional workspace searching before responding.\n"
|
|
7488
|
+
"Do not call Grep or search tools against '.' or a bare directory path directly.\n\n"
|
|
7489
|
+
f"## Project Source Code Context\n{local_project_context}"
|
|
7490
|
+
)
|
|
7491
|
+
|
|
7137
7492
|
await conn.client.post(
|
|
7138
7493
|
f"{reporter_url}/progress",
|
|
7139
7494
|
json={"current_phase": "running", "current_step": "Running agent...", "progress_pct": 15},
|
|
@@ -7210,7 +7565,6 @@ class RuntimeDaemon:
|
|
|
7210
7565
|
# 4. Auto-commit if successful (always call _auto_commit on success:
|
|
7211
7566
|
# _auto_commit handles both uncommitted changes AND internally-committed
|
|
7212
7567
|
# changes that just need to be pushed — same as _execute_task).
|
|
7213
|
-
input_ctx = aj.get("input_context", {})
|
|
7214
7568
|
git_info = {}
|
|
7215
7569
|
if result.status == "success":
|
|
7216
7570
|
git_info = await self._auto_commit(workspace_path, fake_task)
|
|
@@ -7628,11 +7982,11 @@ class RuntimeDaemon:
|
|
|
7628
7982
|
Some agents (e.g. claude) commit changes internally, so we must
|
|
7629
7983
|
also push even when the working directory is clean.
|
|
7630
7984
|
|
|
7631
|
-
|
|
7632
|
-
|
|
7633
|
-
|
|
7634
|
-
|
|
7635
|
-
|
|
7985
|
+
For non-requirement branches, we may integrate the latest
|
|
7986
|
+
``origin/{default_branch}`` before push. Requirement branches are
|
|
7987
|
+
explicitly excluded because silently rebasing/merging the default
|
|
7988
|
+
branch into a requirement branch widens the change scope, pollutes gate
|
|
7989
|
+
review diffs, and can import unrelated requirements into the fix.
|
|
7636
7990
|
|
|
7637
7991
|
Returns a dict with commit/push status info (empty on success).
|
|
7638
7992
|
"""
|
|
@@ -7747,6 +8101,35 @@ class RuntimeDaemon:
|
|
|
7747
8101
|
)
|
|
7748
8102
|
return {"push_error": f"Would push to default branch {current_branch}"}
|
|
7749
8103
|
|
|
8104
|
+
if task.requirement_key and current_branch:
|
|
8105
|
+
forbidden_merges = await self._list_unpushed_merge_commits(
|
|
8106
|
+
workspace_path, current_branch, default_branch,
|
|
8107
|
+
)
|
|
8108
|
+
if forbidden_merges:
|
|
8109
|
+
short_merges = ", ".join(sha[:12] for sha in forbidden_merges[:5])
|
|
8110
|
+
logger.error(
|
|
8111
|
+
"Requirement branch %s has %d new merge commit(s) pending push [%s] — "
|
|
8112
|
+
"aborting push to prevent cross-requirement contamination",
|
|
8113
|
+
current_branch, len(forbidden_merges), short_merges,
|
|
8114
|
+
)
|
|
8115
|
+
display_error = (
|
|
8116
|
+
f"Push blocked: requirement branch '{current_branch}' contains "
|
|
8117
|
+
f"merge commit(s) pending push ({short_merges}). "
|
|
8118
|
+
"This would import default-branch or unrelated requirement changes "
|
|
8119
|
+
"into the current execution scope. Roll back or recreate this fix "
|
|
8120
|
+
"on a clean requirement branch. Do not merge, rebase, or pull the "
|
|
8121
|
+
"default branch into requirement branches."
|
|
8122
|
+
)
|
|
8123
|
+
return {
|
|
8124
|
+
"push_error_code": "forbidden_branch_merge",
|
|
8125
|
+
"push_error": (
|
|
8126
|
+
"Requirement branch contains merge commit(s) pending push: "
|
|
8127
|
+
f"{short_merges}. Merging default/other branches into "
|
|
8128
|
+
"requirement branches is forbidden."
|
|
8129
|
+
),
|
|
8130
|
+
"push_error_display": display_error,
|
|
8131
|
+
}
|
|
8132
|
+
|
|
7750
8133
|
# ── Push ──
|
|
7751
8134
|
push_error = await self._push_branch(workspace_path, project_key, task=task)
|
|
7752
8135
|
if push_error:
|
|
@@ -8560,6 +8943,11 @@ class RuntimeDaemon:
|
|
|
8560
8943
|
):
|
|
8561
8944
|
"""Integrate the current branch with ``origin/{default_branch}``.
|
|
8562
8945
|
|
|
8946
|
+
Requirement branches are intentionally excluded. They already sync to the
|
|
8947
|
+
latest remote requirement branch during workspace preparation, so pulling
|
|
8948
|
+
in ``origin/{default_branch}`` here only risks importing unrelated work
|
|
8949
|
+
into the requirement/fix scope.
|
|
8950
|
+
|
|
8563
8951
|
Strategy:
|
|
8564
8952
|
- If the current branch already exists on remote (was previously pushed):
|
|
8565
8953
|
Skip rebase entirely and use merge only. Rebase rewrites commit
|
|
@@ -8575,6 +8963,15 @@ class RuntimeDaemon:
|
|
|
8575
8963
|
target = f"origin/{default_branch}"
|
|
8576
8964
|
_git_name, _git_email = _resolve_git_author(task.project)
|
|
8577
8965
|
|
|
8966
|
+
if task.requirement_key:
|
|
8967
|
+
logger.info(
|
|
8968
|
+
"Skipping automatic integration of %s into requirement branch %s "
|
|
8969
|
+
"to preserve task scope and avoid cross-requirement contamination",
|
|
8970
|
+
target,
|
|
8971
|
+
task.requirement_key,
|
|
8972
|
+
)
|
|
8973
|
+
return
|
|
8974
|
+
|
|
8578
8975
|
# Fetch latest remote state
|
|
8579
8976
|
try:
|
|
8580
8977
|
await git("fetch", "origin", cwd=workspace_path,
|
|
@@ -8658,6 +9055,38 @@ class RuntimeDaemon:
|
|
|
8658
9055
|
# ── Tier 3: AI-assisted conflict resolution ──
|
|
8659
9056
|
await self._ai_resolve_conflicts(workspace_path, default_branch, task)
|
|
8660
9057
|
|
|
9058
|
+
async def _list_unpushed_merge_commits(
|
|
9059
|
+
self,
|
|
9060
|
+
workspace_path: Path,
|
|
9061
|
+
current_branch: str,
|
|
9062
|
+
default_branch: str,
|
|
9063
|
+
) -> list[str]:
|
|
9064
|
+
"""Return merge commits introduced locally and not yet on the remote branch.
|
|
9065
|
+
|
|
9066
|
+
Requirement branches must stay linear and requirement-scoped. Any new
|
|
9067
|
+
merge commit pending push indicates an explicit merge/rebase workflow that
|
|
9068
|
+
can import unrelated changes (for example, merging origin/develop into a
|
|
9069
|
+
fix branch). Those pushes are rejected by the caller.
|
|
9070
|
+
"""
|
|
9071
|
+
git = self.workspace_manager._git
|
|
9072
|
+
|
|
9073
|
+
try:
|
|
9074
|
+
await git("rev-parse", "--verify", f"origin/{current_branch}", cwd=workspace_path)
|
|
9075
|
+
out = await git(
|
|
9076
|
+
"rev-list", "--min-parents=2", f"origin/{current_branch}..HEAD",
|
|
9077
|
+
cwd=workspace_path,
|
|
9078
|
+
)
|
|
9079
|
+
except RuntimeError:
|
|
9080
|
+
try:
|
|
9081
|
+
out = await git(
|
|
9082
|
+
"rev-list", "--min-parents=2", "HEAD", "--not", f"origin/{default_branch}",
|
|
9083
|
+
cwd=workspace_path,
|
|
9084
|
+
)
|
|
9085
|
+
except RuntimeError:
|
|
9086
|
+
return []
|
|
9087
|
+
|
|
9088
|
+
return [line.strip() for line in out.splitlines() if line.strip()]
|
|
9089
|
+
|
|
8661
9090
|
async def _ai_resolve_conflicts(
|
|
8662
9091
|
self, workspace_path: Path, default_branch: str, task: TaskInfo,
|
|
8663
9092
|
):
|
|
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
|
|
File without changes
|
|
File without changes
|