forgexa-cli 1.14.0__tar.gz → 1.14.3__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.0 → forgexa_cli-1.14.3}/PKG-INFO +1 -1
- {forgexa_cli-1.14.0 → forgexa_cli-1.14.3}/forgexa_cli/__init__.py +1 -1
- {forgexa_cli-1.14.0 → forgexa_cli-1.14.3}/forgexa_cli/daemon.py +283 -51
- {forgexa_cli-1.14.0 → forgexa_cli-1.14.3}/forgexa_cli.egg-info/PKG-INFO +1 -1
- {forgexa_cli-1.14.0 → forgexa_cli-1.14.3}/pyproject.toml +1 -1
- {forgexa_cli-1.14.0 → forgexa_cli-1.14.3}/README.md +0 -0
- {forgexa_cli-1.14.0 → forgexa_cli-1.14.3}/forgexa_cli/_build_config.py +0 -0
- {forgexa_cli-1.14.0 → forgexa_cli-1.14.3}/forgexa_cli/main.py +0 -0
- {forgexa_cli-1.14.0 → forgexa_cli-1.14.3}/forgexa_cli/py.typed +0 -0
- {forgexa_cli-1.14.0 → forgexa_cli-1.14.3}/forgexa_cli.egg-info/SOURCES.txt +0 -0
- {forgexa_cli-1.14.0 → forgexa_cli-1.14.3}/forgexa_cli.egg-info/dependency_links.txt +0 -0
- {forgexa_cli-1.14.0 → forgexa_cli-1.14.3}/forgexa_cli.egg-info/entry_points.txt +0 -0
- {forgexa_cli-1.14.0 → forgexa_cli-1.14.3}/forgexa_cli.egg-info/requires.txt +0 -0
- {forgexa_cli-1.14.0 → forgexa_cli-1.14.3}/forgexa_cli.egg-info/top_level.txt +0 -0
- {forgexa_cli-1.14.0 → forgexa_cli-1.14.3}/setup.cfg +0 -0
- {forgexa_cli-1.14.0 → forgexa_cli-1.14.3}/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.3"
|
|
@@ -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.3"
|
|
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
|
|
|
@@ -1576,11 +1655,13 @@ class WorkspaceManager:
|
|
|
1576
1655
|
"timeout",
|
|
1577
1656
|
"timed out",
|
|
1578
1657
|
"server not responding",
|
|
1658
|
+
"received disconnect",
|
|
1579
1659
|
"unexpected disconnect",
|
|
1580
1660
|
"early eof",
|
|
1581
1661
|
"invalid index-pack output",
|
|
1582
1662
|
"fetch-pack",
|
|
1583
1663
|
"remote hung up unexpectedly",
|
|
1664
|
+
"too many concurrent connections",
|
|
1584
1665
|
"connection reset",
|
|
1585
1666
|
"connection closed",
|
|
1586
1667
|
"connection refused",
|
|
@@ -1901,6 +1982,42 @@ class WorkspaceManager:
|
|
|
1901
1982
|
_shutil.rmtree(wt, ignore_errors=True)
|
|
1902
1983
|
_shutil.rmtree(main_repo, ignore_errors=True)
|
|
1903
1984
|
|
|
1985
|
+
async def _find_existing_worktree_for_branch(
|
|
1986
|
+
self,
|
|
1987
|
+
main_repo: Path,
|
|
1988
|
+
branch_name: str,
|
|
1989
|
+
) -> Path | None:
|
|
1990
|
+
"""Return an existing healthy linked worktree already on branch_name."""
|
|
1991
|
+
try:
|
|
1992
|
+
raw = await self._git("worktree", "list", "--porcelain", cwd=main_repo)
|
|
1993
|
+
except RuntimeError:
|
|
1994
|
+
return None
|
|
1995
|
+
|
|
1996
|
+
target_ref = f"refs/heads/{branch_name}"
|
|
1997
|
+
entries: list[tuple[Path, str | None]] = []
|
|
1998
|
+
current_path: Path | None = None
|
|
1999
|
+
current_branch: str | None = None
|
|
2000
|
+
|
|
2001
|
+
for line in raw.splitlines():
|
|
2002
|
+
if line.startswith("worktree "):
|
|
2003
|
+
if current_path is not None:
|
|
2004
|
+
entries.append((current_path, current_branch))
|
|
2005
|
+
current_path = Path(line.split(" ", 1)[1].strip())
|
|
2006
|
+
current_branch = None
|
|
2007
|
+
elif line.startswith("branch "):
|
|
2008
|
+
current_branch = line.split(" ", 1)[1].strip()
|
|
2009
|
+
|
|
2010
|
+
if current_path is not None:
|
|
2011
|
+
entries.append((current_path, current_branch))
|
|
2012
|
+
|
|
2013
|
+
for candidate_path, candidate_branch in entries:
|
|
2014
|
+
if candidate_path == main_repo or candidate_branch != target_ref:
|
|
2015
|
+
continue
|
|
2016
|
+
if candidate_path.exists() and await self._is_healthy_worktree(candidate_path, main_repo=main_repo):
|
|
2017
|
+
return candidate_path
|
|
2018
|
+
|
|
2019
|
+
return None
|
|
2020
|
+
|
|
1904
2021
|
async def _detect_unrelated_histories(self, repo_path: Path, project_key: str) -> bool:
|
|
1905
2022
|
"""Detect whether local clone has diverged from remote due to history rewrite.
|
|
1906
2023
|
|
|
@@ -2550,12 +2667,34 @@ class WorkspaceManager:
|
|
|
2550
2667
|
cwd=main_repo,
|
|
2551
2668
|
)
|
|
2552
2669
|
except Exception:
|
|
2670
|
+
existing_branch_worktree = await self._find_existing_worktree_for_branch(
|
|
2671
|
+
main_repo, branch_name,
|
|
2672
|
+
)
|
|
2673
|
+
if existing_branch_worktree is not None:
|
|
2674
|
+
logger.info(
|
|
2675
|
+
"Reusing existing worktree %s for branch %s instead of creating %s",
|
|
2676
|
+
existing_branch_worktree,
|
|
2677
|
+
branch_name,
|
|
2678
|
+
ws_path,
|
|
2679
|
+
)
|
|
2680
|
+
return existing_branch_worktree
|
|
2553
2681
|
try:
|
|
2554
2682
|
await self._git(
|
|
2555
2683
|
"worktree", "add", str(ws_path), branch_name,
|
|
2556
2684
|
cwd=main_repo,
|
|
2557
2685
|
)
|
|
2558
2686
|
except Exception:
|
|
2687
|
+
existing_branch_worktree = await self._find_existing_worktree_for_branch(
|
|
2688
|
+
main_repo, branch_name,
|
|
2689
|
+
)
|
|
2690
|
+
if existing_branch_worktree is not None:
|
|
2691
|
+
logger.info(
|
|
2692
|
+
"Reusing existing worktree %s for branch %s instead of creating %s",
|
|
2693
|
+
existing_branch_worktree,
|
|
2694
|
+
branch_name,
|
|
2695
|
+
ws_path,
|
|
2696
|
+
)
|
|
2697
|
+
return existing_branch_worktree
|
|
2559
2698
|
# Fallback: create from default_branch
|
|
2560
2699
|
try:
|
|
2561
2700
|
await self._git(
|
|
@@ -2563,6 +2702,17 @@ class WorkspaceManager:
|
|
|
2563
2702
|
cwd=main_repo,
|
|
2564
2703
|
)
|
|
2565
2704
|
except Exception:
|
|
2705
|
+
existing_branch_worktree = await self._find_existing_worktree_for_branch(
|
|
2706
|
+
main_repo, branch_name,
|
|
2707
|
+
)
|
|
2708
|
+
if existing_branch_worktree is not None:
|
|
2709
|
+
logger.info(
|
|
2710
|
+
"Reusing existing worktree %s for branch %s instead of cloning %s",
|
|
2711
|
+
existing_branch_worktree,
|
|
2712
|
+
branch_name,
|
|
2713
|
+
ws_path,
|
|
2714
|
+
)
|
|
2715
|
+
return existing_branch_worktree
|
|
2566
2716
|
ws_path.mkdir(parents=True, exist_ok=True)
|
|
2567
2717
|
await self._clone_repo(
|
|
2568
2718
|
repo_url,
|
|
@@ -2891,6 +3041,7 @@ class ProcessManager:
|
|
|
2891
3041
|
"rate limit",
|
|
2892
3042
|
"rate_limit",
|
|
2893
3043
|
"quota exceeded",
|
|
3044
|
+
"monthly quota", # Copilot: "You have exceeded your monthly quota"
|
|
2894
3045
|
"too many requests",
|
|
2895
3046
|
"overloaded",
|
|
2896
3047
|
"insufficient_quota",
|
|
@@ -3028,7 +3179,12 @@ class ProcessManager:
|
|
|
3028
3179
|
else:
|
|
3029
3180
|
result_text = str(data.get("result", "") or "")
|
|
3030
3181
|
if data.get("is_error"):
|
|
3031
|
-
|
|
3182
|
+
raw_errors = data.get("errors")
|
|
3183
|
+
if isinstance(raw_errors, list):
|
|
3184
|
+
err_text = "; ".join(str(item).strip() for item in raw_errors if str(item).strip())
|
|
3185
|
+
else:
|
|
3186
|
+
err_text = ""
|
|
3187
|
+
err_text = err_text or result_text or str(data.get("error", "") or "result marked as error")
|
|
3032
3188
|
error_messages.append(err_text)
|
|
3033
3189
|
else:
|
|
3034
3190
|
# Structural check: if no tokens were consumed AND no assistant
|
|
@@ -3411,48 +3567,6 @@ class ProcessManager:
|
|
|
3411
3567
|
changed_paths.update(self._normalize_repo_paths(paths))
|
|
3412
3568
|
return bool(required_paths & changed_paths)
|
|
3413
3569
|
|
|
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
3570
|
def _build_prompt(self, task: TaskInfo) -> str:
|
|
3457
3571
|
"""Build the prompt to send to the agent.
|
|
3458
3572
|
|
|
@@ -3743,6 +3857,7 @@ class ProcessManager:
|
|
|
3743
3857
|
"--output-format", "stream-json",
|
|
3744
3858
|
"--verbose",
|
|
3745
3859
|
"--max-turns", str(max_turns),
|
|
3860
|
+
"--no-session-persistence",
|
|
3746
3861
|
"--dangerously-skip-permissions",
|
|
3747
3862
|
]
|
|
3748
3863
|
|
|
@@ -3750,6 +3865,7 @@ class ProcessManager:
|
|
|
3750
3865
|
model_override = os.environ.get("FACTORY_CLAUDE_MODEL")
|
|
3751
3866
|
if model_override:
|
|
3752
3867
|
env["ANTHROPIC_MODEL"] = model_override
|
|
3868
|
+
env.update(self._prepare_claude_environment())
|
|
3753
3869
|
|
|
3754
3870
|
try:
|
|
3755
3871
|
proc = await asyncio.create_subprocess_exec(
|
|
@@ -3770,6 +3886,8 @@ class ProcessManager:
|
|
|
3770
3886
|
|
|
3771
3887
|
# Parse Claude JSON output for metrics
|
|
3772
3888
|
metrics = self._parse_claude_output(stdout)
|
|
3889
|
+
structured_errors = self._extract_output_signals("\n".join(part for part in (stdout, stderr) if part))["error_messages"]
|
|
3890
|
+
structured_error_text = "; ".join(str(msg).strip() for msg in structured_errors if str(msg).strip())
|
|
3773
3891
|
|
|
3774
3892
|
if returncode == 0:
|
|
3775
3893
|
return TaskResult(
|
|
@@ -3798,6 +3916,15 @@ class ProcessManager:
|
|
|
3798
3916
|
"exceeded the model's limit. Reduce prompt size or shorten file reads.",
|
|
3799
3917
|
metrics=metrics,
|
|
3800
3918
|
)
|
|
3919
|
+
if structured_error_text:
|
|
3920
|
+
return TaskResult(
|
|
3921
|
+
status="failed",
|
|
3922
|
+
exit_code=returncode,
|
|
3923
|
+
stdout=stdout[-settings.AGENT_MAX_OUTPUT_SIZE:],
|
|
3924
|
+
stderr=stderr[-10000:],
|
|
3925
|
+
error=f"Claude exited with code {returncode}: {structured_error_text}",
|
|
3926
|
+
metrics=metrics,
|
|
3927
|
+
)
|
|
3801
3928
|
return TaskResult(
|
|
3802
3929
|
status="failed",
|
|
3803
3930
|
exit_code=returncode,
|
|
@@ -4101,6 +4228,58 @@ class ProcessManager:
|
|
|
4101
4228
|
result.error = error_clean or result.error
|
|
4102
4229
|
return result
|
|
4103
4230
|
|
|
4231
|
+
@staticmethod
|
|
4232
|
+
def _prepare_claude_environment() -> dict[str, str]:
|
|
4233
|
+
"""Prepare writable Claude config/data/cache dirs under ~/.forgexa.
|
|
4234
|
+
|
|
4235
|
+
The daemon service runs with ProtectSystem=strict and only allows
|
|
4236
|
+
writes under ~/.forgexa plus the workspace root. Claude Code writes
|
|
4237
|
+
config, debug logs, cache, and session state under XDG paths and
|
|
4238
|
+
~/.claude by default, which can fail with EROFS inside the daemon.
|
|
4239
|
+
|
|
4240
|
+
Point Claude at writable daemon-owned directories and mirror only the
|
|
4241
|
+
small set of user config files it needs for headless execution.
|
|
4242
|
+
"""
|
|
4243
|
+
sandbox_root = Path.home() / ".forgexa" / "claude-home"
|
|
4244
|
+
config_root = sandbox_root / "config"
|
|
4245
|
+
data_root = sandbox_root / "data"
|
|
4246
|
+
cache_root = sandbox_root / "cache"
|
|
4247
|
+
state_root = sandbox_root / "state"
|
|
4248
|
+
claude_config_dir = config_root / "claude"
|
|
4249
|
+
|
|
4250
|
+
for path in (config_root, data_root, cache_root, state_root, claude_config_dir):
|
|
4251
|
+
path.mkdir(parents=True, exist_ok=True)
|
|
4252
|
+
|
|
4253
|
+
source_files = [
|
|
4254
|
+
(Path.home() / ".claude.json", claude_config_dir / ".claude.json"),
|
|
4255
|
+
(Path.home() / ".claude" / "settings.json", claude_config_dir / "settings.json"),
|
|
4256
|
+
]
|
|
4257
|
+
for source, dest in source_files:
|
|
4258
|
+
try:
|
|
4259
|
+
if source.is_file() and not dest.exists():
|
|
4260
|
+
shutil.copy2(source, dest)
|
|
4261
|
+
except Exception as exc:
|
|
4262
|
+
logger.debug(
|
|
4263
|
+
"Claude config mirror skipped %s -> %s: %s",
|
|
4264
|
+
source, dest, exc,
|
|
4265
|
+
)
|
|
4266
|
+
|
|
4267
|
+
env = {
|
|
4268
|
+
"XDG_CONFIG_HOME": str(config_root),
|
|
4269
|
+
"XDG_DATA_HOME": str(data_root),
|
|
4270
|
+
"XDG_CACHE_HOME": str(cache_root),
|
|
4271
|
+
"XDG_STATE_HOME": str(state_root),
|
|
4272
|
+
"CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC": str(
|
|
4273
|
+
os.environ.get("CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC", "1")
|
|
4274
|
+
),
|
|
4275
|
+
}
|
|
4276
|
+
|
|
4277
|
+
if not os.environ.get("CLAUDE_CODE_DEBUG_LOGS_DIR"):
|
|
4278
|
+
env["CLAUDE_CODE_DEBUG_LOGS_DIR"] = str(cache_root / "claude-debug")
|
|
4279
|
+
Path(env["CLAUDE_CODE_DEBUG_LOGS_DIR"]).mkdir(parents=True, exist_ok=True)
|
|
4280
|
+
|
|
4281
|
+
return env
|
|
4282
|
+
|
|
4104
4283
|
@staticmethod
|
|
4105
4284
|
def _prepare_copilot_home() -> str:
|
|
4106
4285
|
"""Prepare a writable Copilot home under ~/.forgexa for sandboxed runs.
|
|
@@ -6031,6 +6210,48 @@ class RuntimeDaemon:
|
|
|
6031
6210
|
self._execute_ai_job(aj, conn)
|
|
6032
6211
|
)
|
|
6033
6212
|
|
|
6213
|
+
def _recover_timeout_from_workspace_changes(
|
|
6214
|
+
self,
|
|
6215
|
+
task: TaskInfo,
|
|
6216
|
+
workspace_path: Path,
|
|
6217
|
+
result: TaskResult,
|
|
6218
|
+
pre_commit_git: dict,
|
|
6219
|
+
) -> bool:
|
|
6220
|
+
"""Recover absolute-timeout failures after validating workspace output."""
|
|
6221
|
+
error_lower = (result.error or "").lower()
|
|
6222
|
+
is_absolute_timeout = (
|
|
6223
|
+
"absolute limit" in error_lower or "timed out after" in error_lower
|
|
6224
|
+
)
|
|
6225
|
+
changed_files = list(pre_commit_git.get("files_changed") or [])
|
|
6226
|
+
if not is_absolute_timeout or not changed_files:
|
|
6227
|
+
return False
|
|
6228
|
+
if not self.process_manager.has_meaningful_agent_output(result):
|
|
6229
|
+
return False
|
|
6230
|
+
|
|
6231
|
+
if result.files_changed:
|
|
6232
|
+
result.files_changed = sorted(set(result.files_changed) | set(changed_files))
|
|
6233
|
+
else:
|
|
6234
|
+
result.files_changed = changed_files
|
|
6235
|
+
if not result.lines_added:
|
|
6236
|
+
result.lines_added = int(pre_commit_git.get("lines_added", 0) or 0)
|
|
6237
|
+
if not result.lines_removed:
|
|
6238
|
+
result.lines_removed = int(pre_commit_git.get("lines_removed", 0) or 0)
|
|
6239
|
+
|
|
6240
|
+
if not self.process_manager._has_required_deliverable_updates(
|
|
6241
|
+
task,
|
|
6242
|
+
changed_files,
|
|
6243
|
+
result.files_changed,
|
|
6244
|
+
):
|
|
6245
|
+
return False
|
|
6246
|
+
|
|
6247
|
+
validation_issues = self._validate_outputs(workspace_path, task, result)
|
|
6248
|
+
if validation_issues:
|
|
6249
|
+
result.metrics["timeout_recovery_validation_issues"] = validation_issues
|
|
6250
|
+
return False
|
|
6251
|
+
|
|
6252
|
+
result.metrics["recovered_from_timeout_uncommitted_changes"] = True
|
|
6253
|
+
return True
|
|
6254
|
+
|
|
6034
6255
|
async def _execute_task(self, task: TaskInfo, conn: ServerConnection):
|
|
6035
6256
|
"""Execute a single task, reporting to the originating server connection."""
|
|
6036
6257
|
reporter = conn.reporter
|
|
@@ -7053,12 +7274,14 @@ class RuntimeDaemon:
|
|
|
7053
7274
|
agent_override = aj.get("agent_override")
|
|
7054
7275
|
system_prompt = aj.get("system_prompt", "")
|
|
7055
7276
|
user_prompt = aj.get("user_prompt", "")
|
|
7277
|
+
input_ctx = aj.get("input_context", {})
|
|
7278
|
+
ai_workspace_scope = _resolve_ai_job_workspace_scope(aj)
|
|
7056
7279
|
|
|
7057
7280
|
reporter_url = f"{conn.server_url.rstrip('/')}/api/v1/runtimes/{conn.runtime_id}/ai-jobs/{job_id}"
|
|
7058
7281
|
# Use workspace-level key (project:requirement or project:job_id) so AI jobs
|
|
7059
7282
|
# for different requirements run in parallel while same-requirement jobs serialize.
|
|
7060
7283
|
_aj_proj_key = project_info.get("project_key") or str(project_info.get("id") or "default")
|
|
7061
|
-
project_lock_key = f"{_aj_proj_key}:{
|
|
7284
|
+
project_lock_key = f"{_aj_proj_key}:{ai_workspace_scope or job_id}"
|
|
7062
7285
|
project_lock = await self._get_project_execution_lock(project_lock_key)
|
|
7063
7286
|
if project_lock.locked():
|
|
7064
7287
|
logger.info(
|
|
@@ -7111,11 +7334,10 @@ class RuntimeDaemon:
|
|
|
7111
7334
|
full_prompt = f"{system_prompt}\n\n{user_prompt}" if system_prompt else user_prompt
|
|
7112
7335
|
fake_task = TaskInfo(
|
|
7113
7336
|
task_id=job_id,
|
|
7114
|
-
#
|
|
7115
|
-
#
|
|
7116
|
-
#
|
|
7117
|
-
|
|
7118
|
-
graph_id=job_id,
|
|
7337
|
+
# Reuse a stable workspace key when the server provides one so
|
|
7338
|
+
# delegated retries do not create a fresh clone per AIJob row.
|
|
7339
|
+
# Fall back to job_id so workspace_key is always non-empty.
|
|
7340
|
+
graph_id=ai_workspace_scope or job_id,
|
|
7119
7341
|
node_type="ai_job",
|
|
7120
7342
|
agent_type=agent_type,
|
|
7121
7343
|
input_prompt=full_prompt,
|
|
@@ -7134,6 +7356,17 @@ class RuntimeDaemon:
|
|
|
7134
7356
|
project_info, fake_task,
|
|
7135
7357
|
)
|
|
7136
7358
|
|
|
7359
|
+
if task_type == "qa_scenario_generate_prompt" and not input_ctx.get("project_context"):
|
|
7360
|
+
local_project_context = _summarize_workspace_source(workspace_path)
|
|
7361
|
+
if local_project_context:
|
|
7362
|
+
fake_task.input_prompt = (
|
|
7363
|
+
f"{full_prompt}\n\n"
|
|
7364
|
+
"Use the provided project source context when grounding scenario coverage. "
|
|
7365
|
+
"Do not rely on additional workspace searching before responding.\n"
|
|
7366
|
+
"Do not call Grep or search tools against '.' or a bare directory path directly.\n\n"
|
|
7367
|
+
f"## Project Source Code Context\n{local_project_context}"
|
|
7368
|
+
)
|
|
7369
|
+
|
|
7137
7370
|
await conn.client.post(
|
|
7138
7371
|
f"{reporter_url}/progress",
|
|
7139
7372
|
json={"current_phase": "running", "current_step": "Running agent...", "progress_pct": 15},
|
|
@@ -7210,7 +7443,6 @@ class RuntimeDaemon:
|
|
|
7210
7443
|
# 4. Auto-commit if successful (always call _auto_commit on success:
|
|
7211
7444
|
# _auto_commit handles both uncommitted changes AND internally-committed
|
|
7212
7445
|
# changes that just need to be pushed — same as _execute_task).
|
|
7213
|
-
input_ctx = aj.get("input_context", {})
|
|
7214
7446
|
git_info = {}
|
|
7215
7447
|
if result.status == "success":
|
|
7216
7448
|
git_info = await self._auto_commit(workspace_path, fake_task)
|
|
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
|