forgexa-cli 1.22.6__tar.gz → 1.23.0__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.22.6 → forgexa_cli-1.23.0}/PKG-INFO +1 -1
- {forgexa_cli-1.22.6 → forgexa_cli-1.23.0}/forgexa_cli/__init__.py +1 -1
- {forgexa_cli-1.22.6 → forgexa_cli-1.23.0}/forgexa_cli/daemon.py +638 -1
- {forgexa_cli-1.22.6 → forgexa_cli-1.23.0}/forgexa_cli.egg-info/PKG-INFO +1 -1
- {forgexa_cli-1.22.6 → forgexa_cli-1.23.0}/pyproject.toml +1 -1
- {forgexa_cli-1.22.6 → forgexa_cli-1.23.0}/README.md +0 -0
- {forgexa_cli-1.22.6 → forgexa_cli-1.23.0}/forgexa_cli/_build_config.py +0 -0
- {forgexa_cli-1.22.6 → forgexa_cli-1.23.0}/forgexa_cli/autoupgrade.py +0 -0
- {forgexa_cli-1.22.6 → forgexa_cli-1.23.0}/forgexa_cli/main.py +0 -0
- {forgexa_cli-1.22.6 → forgexa_cli-1.23.0}/forgexa_cli/py.typed +0 -0
- {forgexa_cli-1.22.6 → forgexa_cli-1.23.0}/forgexa_cli.egg-info/SOURCES.txt +0 -0
- {forgexa_cli-1.22.6 → forgexa_cli-1.23.0}/forgexa_cli.egg-info/dependency_links.txt +0 -0
- {forgexa_cli-1.22.6 → forgexa_cli-1.23.0}/forgexa_cli.egg-info/entry_points.txt +0 -0
- {forgexa_cli-1.22.6 → forgexa_cli-1.23.0}/forgexa_cli.egg-info/requires.txt +0 -0
- {forgexa_cli-1.22.6 → forgexa_cli-1.23.0}/forgexa_cli.egg-info/top_level.txt +0 -0
- {forgexa_cli-1.22.6 → forgexa_cli-1.23.0}/setup.cfg +0 -0
- {forgexa_cli-1.22.6 → forgexa_cli-1.23.0}/tests/test_auth_and_runtime_commands.py +0 -0
- {forgexa_cli-1.22.6 → forgexa_cli-1.23.0}/tests/test_autoupgrade.py +0 -0
- {forgexa_cli-1.22.6 → forgexa_cli-1.23.0}/tests/test_check_command.py +0 -0
- {forgexa_cli-1.22.6 → forgexa_cli-1.23.0}/tests/test_silent_install.py +0 -0
- {forgexa_cli-1.22.6 → forgexa_cli-1.23.0}/tests/test_upgrade_observability.py +0 -0
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
"""forgexa-cli — Forgexa command-line client."""
|
|
2
|
-
__version__ = "1.
|
|
2
|
+
__version__ = "1.23.0"
|
|
@@ -30,6 +30,7 @@ if sys.version_info < (3, 9):
|
|
|
30
30
|
import asyncio
|
|
31
31
|
import base64
|
|
32
32
|
import errno
|
|
33
|
+
import fnmatch
|
|
33
34
|
import hashlib
|
|
34
35
|
import json
|
|
35
36
|
import logging
|
|
@@ -556,7 +557,7 @@ except (ImportError, ModuleNotFoundError):
|
|
|
556
557
|
# DAEMON_VERSION is the protocol/logic version of the daemon code.
|
|
557
558
|
# Kept in sync with pyproject.toml version via bump-version.sh.
|
|
558
559
|
# CLIENT_TYPE identifies which packaging/distribution this daemon runs in.
|
|
559
|
-
DAEMON_VERSION = "1.
|
|
560
|
+
DAEMON_VERSION = "1.23.0"
|
|
560
561
|
|
|
561
562
|
|
|
562
563
|
def _detect_client_type() -> str:
|
|
@@ -8053,6 +8054,10 @@ class RuntimeDaemon:
|
|
|
8053
8054
|
self._project_execution_locks_mu: asyncio.Lock = asyncio.Lock()
|
|
8054
8055
|
self._shutdown = False
|
|
8055
8056
|
|
|
8057
|
+
# Deployment merge worktree GC: runs at most every 6 hours.
|
|
8058
|
+
self._last_merge_gc: float = 0.0
|
|
8059
|
+
self._MERGE_GC_INTERVAL = 6 * 3600 # seconds
|
|
8060
|
+
|
|
8056
8061
|
self.connections: list[ServerConnection] = []
|
|
8057
8062
|
self.agent_discovery = AgentDiscovery()
|
|
8058
8063
|
self.workspace_manager = WorkspaceManager(self.workspaces_root)
|
|
@@ -8515,6 +8520,14 @@ class RuntimeDaemon:
|
|
|
8515
8520
|
"""Poll for tasks from all connected servers and execute them."""
|
|
8516
8521
|
had_work = False
|
|
8517
8522
|
|
|
8523
|
+
# Opportunistic GC: remove old deploy-merge worktrees (throttled internally).
|
|
8524
|
+
try:
|
|
8525
|
+
await self._cleanup_old_merge_worktrees(
|
|
8526
|
+
self.connections[0] if self.connections else None
|
|
8527
|
+
)
|
|
8528
|
+
except Exception:
|
|
8529
|
+
pass # GC failure must never block the poll loop
|
|
8530
|
+
|
|
8518
8531
|
def _cancel_server_requested_ai_jobs(conn: ServerConnection) -> None:
|
|
8519
8532
|
for job_id in conn.poller.cancelled_ai_job_ids:
|
|
8520
8533
|
ai_task_key = f"aijob_{job_id}"
|
|
@@ -9973,6 +9986,626 @@ class RuntimeDaemon:
|
|
|
9973
9986
|
|
|
9974
9987
|
return issues
|
|
9975
9988
|
|
|
9989
|
+
def _merge_job_index_path(self) -> Path:
|
|
9990
|
+
return Path(self.workspaces_root) / "deploy-merge-jobs.json"
|
|
9991
|
+
|
|
9992
|
+
def _load_merge_job_index(self) -> dict:
|
|
9993
|
+
"""Load the merge_job_id → project_id index recorded by merge jobs."""
|
|
9994
|
+
try:
|
|
9995
|
+
p = self._merge_job_index_path()
|
|
9996
|
+
if p.exists():
|
|
9997
|
+
data = json.loads(p.read_text())
|
|
9998
|
+
if isinstance(data, dict):
|
|
9999
|
+
return data
|
|
10000
|
+
except Exception:
|
|
10001
|
+
pass
|
|
10002
|
+
return {}
|
|
10003
|
+
|
|
10004
|
+
def _record_merge_job(self, merge_job_id: str, project_id: str) -> None:
|
|
10005
|
+
"""Persist merge_job_id → project_id so GC can query eligibility later."""
|
|
10006
|
+
if not merge_job_id or not project_id:
|
|
10007
|
+
return
|
|
10008
|
+
try:
|
|
10009
|
+
index = self._load_merge_job_index()
|
|
10010
|
+
index[merge_job_id] = {"project_id": project_id, "recorded_at": time.time()}
|
|
10011
|
+
# Bound the index size by dropping the oldest entries.
|
|
10012
|
+
if len(index) > 500:
|
|
10013
|
+
oldest = sorted(index, key=lambda k: index[k].get("recorded_at", 0))
|
|
10014
|
+
for k in oldest[: len(index) - 500]:
|
|
10015
|
+
index.pop(k, None)
|
|
10016
|
+
self._merge_job_index_path().write_text(json.dumps(index))
|
|
10017
|
+
except Exception as exc:
|
|
10018
|
+
logger.debug("deployment_merge: failed to record merge job index: %s", exc)
|
|
10019
|
+
|
|
10020
|
+
async def _cleanup_old_merge_worktrees(self, conn: "ServerConnection | None" = None) -> int:
|
|
10021
|
+
"""Remove deploy-merge-* worktrees the server marks as GC-eligible.
|
|
10022
|
+
|
|
10023
|
+
Retention policy (design 5.6): worktrees of terminal jobs (succeeded /
|
|
10024
|
+
failed / cancelled / superseded) become eligible after 7 days, while
|
|
10025
|
+
conflict_needs_human / precheck_failed jobs are retained permanently
|
|
10026
|
+
until a human finishes handling them. The daemon never decides
|
|
10027
|
+
eligibility from mtime alone — it asks the server which job IDs are
|
|
10028
|
+
safe to collect. If the server is unreachable the whole round is
|
|
10029
|
+
skipped and nothing is deleted. A 30-day orphan fallback guards
|
|
10030
|
+
against disk leaks from worktrees the server no longer knows about.
|
|
10031
|
+
"""
|
|
10032
|
+
import time as _time
|
|
10033
|
+
now = _time.time()
|
|
10034
|
+
if now - self._last_merge_gc < self._MERGE_GC_INTERVAL:
|
|
10035
|
+
return 0
|
|
10036
|
+
self._last_merge_gc = now
|
|
10037
|
+
|
|
10038
|
+
# Discover candidate worktrees: {workspaces_root}/{project}/deploy-merge-{job_id}
|
|
10039
|
+
candidates: list[tuple[Path, Path, str, float]] = [] # (project_dir, wt_dir, job_id, mtime)
|
|
10040
|
+
try:
|
|
10041
|
+
ws_root = Path(self.workspaces_root)
|
|
10042
|
+
if not ws_root.exists():
|
|
10043
|
+
return 0
|
|
10044
|
+
for project_dir in ws_root.iterdir():
|
|
10045
|
+
if not project_dir.is_dir():
|
|
10046
|
+
continue
|
|
10047
|
+
for wt_dir in project_dir.iterdir():
|
|
10048
|
+
if not wt_dir.is_dir():
|
|
10049
|
+
continue
|
|
10050
|
+
name = wt_dir.name
|
|
10051
|
+
# Match deploy-merge-{job_id} pattern
|
|
10052
|
+
if not name.startswith("deploy-merge-"):
|
|
10053
|
+
continue
|
|
10054
|
+
try:
|
|
10055
|
+
mtime = wt_dir.stat().st_mtime
|
|
10056
|
+
except Exception:
|
|
10057
|
+
mtime = now
|
|
10058
|
+
candidates.append((project_dir, wt_dir, name[len("deploy-merge-"):], mtime))
|
|
10059
|
+
except Exception as exc:
|
|
10060
|
+
logger.warning("deployment_merge GC: scan failed: %s", exc)
|
|
10061
|
+
return 0
|
|
10062
|
+
if not candidates:
|
|
10063
|
+
return 0
|
|
10064
|
+
|
|
10065
|
+
if conn is None:
|
|
10066
|
+
logger.warning("deployment_merge GC: no server connection — skipping this round")
|
|
10067
|
+
return 0
|
|
10068
|
+
|
|
10069
|
+
# Ask the server which jobs are safe to collect. Any request failure
|
|
10070
|
+
# aborts the whole round: never delete while eligibility is unknown.
|
|
10071
|
+
index = self._load_merge_job_index()
|
|
10072
|
+
project_ids = {
|
|
10073
|
+
str(index[jid].get("project_id"))
|
|
10074
|
+
for _, _, jid, _ in candidates
|
|
10075
|
+
if jid in index and index[jid].get("project_id")
|
|
10076
|
+
}
|
|
10077
|
+
eligible: set[str] = set()
|
|
10078
|
+
for pid in sorted(project_ids):
|
|
10079
|
+
try:
|
|
10080
|
+
resp = await conn.client.get(
|
|
10081
|
+
f"{conn.server_url}/api/v1/projects/{pid}/deployment-trains/jobs/gc-eligible",
|
|
10082
|
+
timeout=10,
|
|
10083
|
+
)
|
|
10084
|
+
if resp.status_code != 200:
|
|
10085
|
+
logger.warning(
|
|
10086
|
+
"deployment_merge GC: gc-eligible query for project %s returned %s — skipping this round",
|
|
10087
|
+
pid, resp.status_code,
|
|
10088
|
+
)
|
|
10089
|
+
return 0
|
|
10090
|
+
eligible.update(str(j) for j in resp.json().get("eligible_job_ids", []))
|
|
10091
|
+
except Exception as exc:
|
|
10092
|
+
logger.warning(
|
|
10093
|
+
"deployment_merge GC: server unreachable (%s) — skipping this round", exc,
|
|
10094
|
+
)
|
|
10095
|
+
return 0
|
|
10096
|
+
|
|
10097
|
+
orphan_cutoff = now - (30 * 24 * 3600) # 30 days ago
|
|
10098
|
+
removed = 0
|
|
10099
|
+
for project_dir, wt_dir, job_id, mtime in candidates:
|
|
10100
|
+
is_eligible = job_id in eligible
|
|
10101
|
+
is_orphan = not is_eligible and mtime < orphan_cutoff
|
|
10102
|
+
if not is_eligible and not is_orphan:
|
|
10103
|
+
continue
|
|
10104
|
+
main_repo = project_dir / "_main"
|
|
10105
|
+
try:
|
|
10106
|
+
removed_ok = False
|
|
10107
|
+
if main_repo.exists():
|
|
10108
|
+
try:
|
|
10109
|
+
await self.workspace_manager._git(
|
|
10110
|
+
"worktree", "remove", "--force", str(wt_dir), cwd=main_repo,
|
|
10111
|
+
)
|
|
10112
|
+
removed_ok = True
|
|
10113
|
+
except RuntimeError:
|
|
10114
|
+
removed_ok = False
|
|
10115
|
+
if not removed_ok:
|
|
10116
|
+
shutil.rmtree(wt_dir, ignore_errors=True)
|
|
10117
|
+
if main_repo.exists():
|
|
10118
|
+
try:
|
|
10119
|
+
await self.workspace_manager._git("worktree", "prune", cwd=main_repo)
|
|
10120
|
+
except RuntimeError:
|
|
10121
|
+
pass
|
|
10122
|
+
removed += 1
|
|
10123
|
+
index.pop(job_id, None)
|
|
10124
|
+
logger.info(
|
|
10125
|
+
"deployment_merge GC: removed worktree %s (eligible=%s, orphan=%s, age=%.0f days)",
|
|
10126
|
+
wt_dir, is_eligible, is_orphan, (now - mtime) / 86400,
|
|
10127
|
+
)
|
|
10128
|
+
except Exception as exc:
|
|
10129
|
+
logger.debug("deployment_merge GC: skip %s: %s", wt_dir, exc)
|
|
10130
|
+
# Best-effort remote candidate branch cleanup (non-fatal).
|
|
10131
|
+
if main_repo.exists():
|
|
10132
|
+
try:
|
|
10133
|
+
await self.workspace_manager._git(
|
|
10134
|
+
"push", "origin", "--delete", f"merge-candidate/{job_id}", cwd=main_repo,
|
|
10135
|
+
)
|
|
10136
|
+
except RuntimeError as exc:
|
|
10137
|
+
logger.debug(
|
|
10138
|
+
"deployment_merge GC: remote branch delete failed for %s: %s", job_id, exc,
|
|
10139
|
+
)
|
|
10140
|
+
if removed:
|
|
10141
|
+
try:
|
|
10142
|
+
self._merge_job_index_path().write_text(json.dumps(index))
|
|
10143
|
+
except Exception:
|
|
10144
|
+
pass
|
|
10145
|
+
logger.info("deployment_merge GC: removed %d worktree(s)", removed)
|
|
10146
|
+
return removed
|
|
10147
|
+
|
|
10148
|
+
async def _execute_deployment_merge_job(self, aj: dict, conn: "ServerConnection"):
|
|
10149
|
+
"""Execute a deployment_merge AIJob: git merge + conflict resolution.
|
|
10150
|
+
|
|
10151
|
+
Phase 2: merges the requirement's frozen source SHA onto the integration
|
|
10152
|
+
branch candidate ref, detects conflicts, and attempts batched AI
|
|
10153
|
+
conflict resolution (10 files per batch, up to 3 batches). Conflicts
|
|
10154
|
+
touching sensitive paths, or left unresolved after all batches, are
|
|
10155
|
+
aborted and reported as conflict_needs_human. Resolved/clean candidates
|
|
10156
|
+
pass a deterministic precheck (no residual conflict markers, clean
|
|
10157
|
+
working tree), are pushed to origin as merge-candidate/{merge_job_id},
|
|
10158
|
+
and the structured result is reported back.
|
|
10159
|
+
"""
|
|
10160
|
+
job_id = aj.get("job_id", "")
|
|
10161
|
+
input_ctx = aj.get("input_context", {}) or {}
|
|
10162
|
+
project_info = aj.get("project", {}) or {}
|
|
10163
|
+
reporter_url = f"{conn.server_url.rstrip('/')}/api/v1/runtimes/{conn.runtime_id}/ai-jobs/{job_id}"
|
|
10164
|
+
|
|
10165
|
+
merge_job_id = input_ctx.get("deployment_merge_job_id", "")
|
|
10166
|
+
integration_branch = input_ctx.get("integration_branch", "")
|
|
10167
|
+
source_commit_sha = input_ctx.get("source_commit_sha", "")
|
|
10168
|
+
source_branch = input_ctx.get("source_branch", "")
|
|
10169
|
+
base_integration_sha = input_ctx.get("base_integration_sha")
|
|
10170
|
+
default_branch = input_ctx.get("default_branch", "main")
|
|
10171
|
+
agent_type = input_ctx.get("agent_type", "claude")
|
|
10172
|
+
fallback_chain = input_ctx.get("fallback_chain", [])
|
|
10173
|
+
timeout_seconds = min(int(input_ctx.get("timeout_seconds", 300)), 300)
|
|
10174
|
+
sensitive_patterns = input_ctx.get("sensitive_path_patterns") or []
|
|
10175
|
+
candidate_ref = f"refs/heads/merge-candidate/{merge_job_id}"
|
|
10176
|
+
|
|
10177
|
+
# Record merge_job_id → project_id so GC can query eligibility later.
|
|
10178
|
+
self._record_merge_job(merge_job_id, str(input_ctx.get("project_id") or ""))
|
|
10179
|
+
|
|
10180
|
+
async def _report(result: dict, status: str = "completed", error: str = ""):
|
|
10181
|
+
payload = {"status": status, "output_result": result}
|
|
10182
|
+
if error:
|
|
10183
|
+
payload["error"] = error[:1000]
|
|
10184
|
+
payload["failure_code"] = result.get("merge_outcome", "merge_failed")
|
|
10185
|
+
try:
|
|
10186
|
+
await conn.client.post(f"{reporter_url}/complete", json=payload, timeout=15)
|
|
10187
|
+
except Exception:
|
|
10188
|
+
logger.warning("deployment_merge: failed to report result for job %s", job_id)
|
|
10189
|
+
|
|
10190
|
+
await conn.client.post(
|
|
10191
|
+
f"{reporter_url}/progress",
|
|
10192
|
+
json={"current_phase": "preparing", "current_step": "Preparing merge workspace...", "progress_pct": 5},
|
|
10193
|
+
timeout=10,
|
|
10194
|
+
)
|
|
10195
|
+
|
|
10196
|
+
fake_task = TaskInfo(
|
|
10197
|
+
task_id=job_id,
|
|
10198
|
+
graph_id=f"deploy-merge-{merge_job_id}",
|
|
10199
|
+
node_type="deployment_merge",
|
|
10200
|
+
agent_type=agent_type,
|
|
10201
|
+
input_prompt="",
|
|
10202
|
+
input_data={},
|
|
10203
|
+
timeout_seconds=timeout_seconds,
|
|
10204
|
+
max_retries=0,
|
|
10205
|
+
retry_count=0,
|
|
10206
|
+
project=project_info,
|
|
10207
|
+
work_item={},
|
|
10208
|
+
fallback_chain=fallback_chain or [],
|
|
10209
|
+
)
|
|
10210
|
+
|
|
10211
|
+
_proj_key = project_info.get("project_key") or str(project_info.get("id") or "default")
|
|
10212
|
+
project_lock = await self._get_project_execution_lock(f"{_proj_key}:deploy-merge-{merge_job_id}")
|
|
10213
|
+
await project_lock.acquire()
|
|
10214
|
+
|
|
10215
|
+
try:
|
|
10216
|
+
workspace_path = await self.workspace_manager.prepare_workspace(project_info, fake_task)
|
|
10217
|
+
git = self.workspace_manager._git
|
|
10218
|
+
_git_name, _git_email = _resolve_git_author(project_info)
|
|
10219
|
+
|
|
10220
|
+
await conn.client.post(
|
|
10221
|
+
f"{reporter_url}/progress",
|
|
10222
|
+
json={"current_phase": "merging", "current_step": f"Merging into {integration_branch}...", "progress_pct": 25},
|
|
10223
|
+
timeout=10,
|
|
10224
|
+
)
|
|
10225
|
+
|
|
10226
|
+
# Fetch latest refs.
|
|
10227
|
+
await git("fetch", "origin", cwd=workspace_path)
|
|
10228
|
+
|
|
10229
|
+
# Determine base SHA: use recorded base, else default branch HEAD.
|
|
10230
|
+
if base_integration_sha:
|
|
10231
|
+
base_ref = base_integration_sha
|
|
10232
|
+
else:
|
|
10233
|
+
try:
|
|
10234
|
+
base_ref = (await git("rev-parse", f"origin/{default_branch}", cwd=workspace_path)).strip()
|
|
10235
|
+
except RuntimeError:
|
|
10236
|
+
base_ref = "HEAD"
|
|
10237
|
+
|
|
10238
|
+
# Detach HEAD to the base, creating a clean starting point for the candidate.
|
|
10239
|
+
await git("checkout", "--detach", base_ref, cwd=workspace_path)
|
|
10240
|
+
|
|
10241
|
+
# Resolve source SHA from branch name if not already a SHA.
|
|
10242
|
+
if not source_commit_sha and source_branch:
|
|
10243
|
+
try:
|
|
10244
|
+
source_commit_sha = (await git("rev-parse", f"origin/{source_branch}", cwd=workspace_path)).strip()
|
|
10245
|
+
except RuntimeError:
|
|
10246
|
+
pass
|
|
10247
|
+
if not source_commit_sha:
|
|
10248
|
+
await _report({"merge_outcome": "conflict_needs_human"}, status="failed", error="Cannot resolve source commit SHA")
|
|
10249
|
+
return
|
|
10250
|
+
|
|
10251
|
+
# Attempt the merge.
|
|
10252
|
+
try:
|
|
10253
|
+
await git(
|
|
10254
|
+
"-c", f"user.name={_git_name}",
|
|
10255
|
+
"-c", f"user.email={_git_email}",
|
|
10256
|
+
"merge", "--no-ff", "--no-edit", "-m", f"deploy-merge: {source_branch or source_commit_sha[:8]}",
|
|
10257
|
+
source_commit_sha,
|
|
10258
|
+
cwd=workspace_path,
|
|
10259
|
+
)
|
|
10260
|
+
merge_conflicted = False
|
|
10261
|
+
except RuntimeError:
|
|
10262
|
+
merge_conflicted = True
|
|
10263
|
+
|
|
10264
|
+
# Check for unresolved conflicts.
|
|
10265
|
+
try:
|
|
10266
|
+
diff_u = await git("diff", "--name-only", "--diff-filter=U", cwd=workspace_path)
|
|
10267
|
+
except RuntimeError:
|
|
10268
|
+
diff_u = ""
|
|
10269
|
+
conflicted_files = [f.strip() for f in diff_u.strip().splitlines() if f.strip()]
|
|
10270
|
+
|
|
10271
|
+
resolved_agent_id: str | None = None
|
|
10272
|
+
if merge_conflicted or conflicted_files:
|
|
10273
|
+
# Sensitive path policy (design 5.5.3): any conflicted file
|
|
10274
|
+
# matching a sensitive path pattern must go to a human — AI
|
|
10275
|
+
# resolution is disallowed by project policy.
|
|
10276
|
+
sensitive_hits = [
|
|
10277
|
+
(f, p)
|
|
10278
|
+
for f in conflicted_files
|
|
10279
|
+
for p in sensitive_patterns
|
|
10280
|
+
if fnmatch.fnmatchcase(f, p)
|
|
10281
|
+
]
|
|
10282
|
+
if sensitive_hits:
|
|
10283
|
+
hit_notes = "; ".join(f"{f} matches '{p}'" for f, p in sensitive_hits[:20])
|
|
10284
|
+
logger.warning(
|
|
10285
|
+
"deployment_merge: job %s hit sensitive path(s) — forcing human review: %s",
|
|
10286
|
+
job_id, hit_notes,
|
|
10287
|
+
)
|
|
10288
|
+
try:
|
|
10289
|
+
await git("merge", "--abort", cwd=workspace_path)
|
|
10290
|
+
except RuntimeError:
|
|
10291
|
+
await git("reset", "--hard", "HEAD", cwd=workspace_path)
|
|
10292
|
+
await _report({
|
|
10293
|
+
"merge_outcome": "conflict_needs_human",
|
|
10294
|
+
"conflicted_files": conflicted_files[:30],
|
|
10295
|
+
"candidate_ref": candidate_ref,
|
|
10296
|
+
"base_integration_sha": base_ref,
|
|
10297
|
+
"ai_resolution_notes": (
|
|
10298
|
+
"sensitive path(s) matched — AI resolution disallowed "
|
|
10299
|
+
f"by project policy: {hit_notes}"
|
|
10300
|
+
),
|
|
10301
|
+
})
|
|
10302
|
+
return
|
|
10303
|
+
|
|
10304
|
+
# Conflicts detected — attempt AI resolution before falling back to human.
|
|
10305
|
+
logger.info(
|
|
10306
|
+
"deployment_merge: job %s has %d conflict(s), attempting AI resolution: %s",
|
|
10307
|
+
job_id, len(conflicted_files), ", ".join(conflicted_files[:10]),
|
|
10308
|
+
)
|
|
10309
|
+
|
|
10310
|
+
# Requirement context prefix shared by all batch prompts
|
|
10311
|
+
# (design 5.4.2 contract (d)).
|
|
10312
|
+
req_context_lines: list[str] = []
|
|
10313
|
+
req_key = input_ctx.get("requirement_key") or ""
|
|
10314
|
+
req_title = input_ctx.get("requirement_title") or ""
|
|
10315
|
+
req_summary = input_ctx.get("requirement_summary") or ""
|
|
10316
|
+
if req_key or req_title:
|
|
10317
|
+
req_context_lines.append(
|
|
10318
|
+
f"Source requirement: {req_key} — {req_title}".rstrip(" —")
|
|
10319
|
+
)
|
|
10320
|
+
if req_summary:
|
|
10321
|
+
req_context_lines.append(f"Requirement summary: {req_summary}")
|
|
10322
|
+
req_context_lines.append(
|
|
10323
|
+
f"Candidate baseline SHA: {base_integration_sha or base_ref}"
|
|
10324
|
+
)
|
|
10325
|
+
member_ledger = input_ctx.get("member_ledger") or []
|
|
10326
|
+
if member_ledger:
|
|
10327
|
+
req_context_lines.append(
|
|
10328
|
+
"Independent requirements already on the integration branch:\n"
|
|
10329
|
+
+ "\n".join(f"- {m}" for m in member_ledger)
|
|
10330
|
+
)
|
|
10331
|
+
req_context = "\n".join(req_context_lines) + "\n\n"
|
|
10332
|
+
|
|
10333
|
+
# Batched AI resolution: up to 3 batches of 10 files each,
|
|
10334
|
+
# re-checking remaining conflicts after every batch.
|
|
10335
|
+
MAX_SNIPPET_CHARS = 3000
|
|
10336
|
+
MAX_BATCH_FILES = 10
|
|
10337
|
+
MAX_BATCHES = 3
|
|
10338
|
+
remaining = list(conflicted_files)
|
|
10339
|
+
for batch_no in range(1, MAX_BATCHES + 1):
|
|
10340
|
+
if not remaining:
|
|
10341
|
+
break
|
|
10342
|
+
|
|
10343
|
+
await conn.client.post(
|
|
10344
|
+
f"{reporter_url}/progress",
|
|
10345
|
+
json={
|
|
10346
|
+
"current_phase": "ai_resolving",
|
|
10347
|
+
"current_step": (
|
|
10348
|
+
f"AI resolving conflicts (batch {batch_no}/{MAX_BATCHES}, "
|
|
10349
|
+
f"{len(remaining)} remaining)..."
|
|
10350
|
+
),
|
|
10351
|
+
"progress_pct": 40,
|
|
10352
|
+
},
|
|
10353
|
+
timeout=10,
|
|
10354
|
+
)
|
|
10355
|
+
|
|
10356
|
+
# Read conflict file contents for this batch (3000 chars each).
|
|
10357
|
+
files_to_read = remaining[:MAX_BATCH_FILES]
|
|
10358
|
+
conflict_snippets: list[str] = []
|
|
10359
|
+
for fpath in files_to_read:
|
|
10360
|
+
full_path = workspace_path / fpath
|
|
10361
|
+
if full_path.exists():
|
|
10362
|
+
try:
|
|
10363
|
+
content = full_path.read_text(errors="replace")
|
|
10364
|
+
if len(content) > MAX_SNIPPET_CHARS:
|
|
10365
|
+
content = content[:MAX_SNIPPET_CHARS] + "\n... (truncated)"
|
|
10366
|
+
conflict_snippets.append(f"### {fpath}\n```\n{content}\n```")
|
|
10367
|
+
except Exception:
|
|
10368
|
+
conflict_snippets.append(f"### {fpath}\n(could not read)")
|
|
10369
|
+
else:
|
|
10370
|
+
conflict_snippets.append(f"### {fpath}\n(file not found)")
|
|
10371
|
+
|
|
10372
|
+
# Build the deployment-merge conflict resolution prompt.
|
|
10373
|
+
# Key difference from the existing _ai_resolve_conflicts prompt:
|
|
10374
|
+
# this is NOT "your branch behind main" — both sides are equally
|
|
10375
|
+
# valid independent requirements that must be composed, not chosen.
|
|
10376
|
+
resolve_prompt = (
|
|
10377
|
+
req_context
|
|
10378
|
+
+ f"CONTEXT: You are merging a frozen change for an independent "
|
|
10379
|
+
f"requirement (source: {source_branch or source_commit_sha[:8]}) "
|
|
10380
|
+
f"into a candidate integration version that already contains "
|
|
10381
|
+
f"other independent requirements. Both sides are equally valid "
|
|
10382
|
+
f"product changes.\n\n"
|
|
10383
|
+
f"GOAL: Preserve and correctly compose both requirements. "
|
|
10384
|
+
f"Do NOT choose one side merely because it is newer or shorter. "
|
|
10385
|
+
f"Do not add unrelated features. Remove ALL conflict markers "
|
|
10386
|
+
f"(<<<<<<< HEAD, =======, >>>>>>>).\n\n"
|
|
10387
|
+
f"FAILURE CONTRACT: If the intents cannot safely coexist, leave "
|
|
10388
|
+
f"the Git conflict unresolved. Do not replace a conflict with a "
|
|
10389
|
+
f"TODO, comment, stub, or guessed behavior.\n\n"
|
|
10390
|
+
f"Conflicted files (batch {batch_no}, "
|
|
10391
|
+
f"{len(files_to_read)} of {len(remaining)}):\n"
|
|
10392
|
+
+ "\n".join(f"- {f}" for f in files_to_read)
|
|
10393
|
+
+ "\n\n"
|
|
10394
|
+
+ "\n\n".join(conflict_snippets)
|
|
10395
|
+
)
|
|
10396
|
+
|
|
10397
|
+
# Select agent and run conflict resolution for this batch.
|
|
10398
|
+
cr_agent = self._select_agent(agent_type, fallback_chain)
|
|
10399
|
+
if not cr_agent:
|
|
10400
|
+
logger.warning(
|
|
10401
|
+
"deployment_merge: no agent available for conflict resolution (job %s)",
|
|
10402
|
+
job_id,
|
|
10403
|
+
)
|
|
10404
|
+
break
|
|
10405
|
+
_cr_tried: set[str] = {cr_agent.agent_id}
|
|
10406
|
+
_cr_task = TaskInfo(
|
|
10407
|
+
task_id=f"{job_id}-conflict-resolve-{batch_no}",
|
|
10408
|
+
graph_id=f"deploy-merge-{merge_job_id}",
|
|
10409
|
+
node_type="conflict_resolution",
|
|
10410
|
+
agent_type=cr_agent.agent_id,
|
|
10411
|
+
input_prompt=resolve_prompt,
|
|
10412
|
+
input_data={},
|
|
10413
|
+
timeout_seconds=timeout_seconds,
|
|
10414
|
+
max_retries=0,
|
|
10415
|
+
retry_count=0,
|
|
10416
|
+
project=project_info,
|
|
10417
|
+
work_item={},
|
|
10418
|
+
fallback_chain=fallback_chain or [],
|
|
10419
|
+
)
|
|
10420
|
+
try:
|
|
10421
|
+
cr_result = await self.process_manager.run_agent(
|
|
10422
|
+
cr_agent, _cr_task, workspace_path,
|
|
10423
|
+
)
|
|
10424
|
+
# Rate-limit fallback loop.
|
|
10425
|
+
while self.process_manager.is_rate_limited(cr_result):
|
|
10426
|
+
_cr_fallback = self._select_fallback_agent(
|
|
10427
|
+
cr_agent.agent_id, fallback_chain, _cr_tried,
|
|
10428
|
+
)
|
|
10429
|
+
if not _cr_fallback:
|
|
10430
|
+
logger.warning(
|
|
10431
|
+
"deployment_merge: all agents rate-limited for job %s",
|
|
10432
|
+
job_id,
|
|
10433
|
+
)
|
|
10434
|
+
break
|
|
10435
|
+
logger.warning(
|
|
10436
|
+
"deployment_merge: agent '%s' rate-limited, falling back to '%s'",
|
|
10437
|
+
cr_agent.agent_id, _cr_fallback.agent_id,
|
|
10438
|
+
)
|
|
10439
|
+
cr_agent = _cr_fallback
|
|
10440
|
+
_cr_tried.add(cr_agent.agent_id)
|
|
10441
|
+
_cr_task.agent_type = cr_agent.agent_id
|
|
10442
|
+
cr_result = await self.process_manager.run_agent(
|
|
10443
|
+
cr_agent, _cr_task, workspace_path,
|
|
10444
|
+
)
|
|
10445
|
+
if not self.process_manager.is_rate_limited(cr_result):
|
|
10446
|
+
# Remember the last agent that actually ran to completion.
|
|
10447
|
+
resolved_agent_id = cr_agent.agent_id
|
|
10448
|
+
except Exception as exc:
|
|
10449
|
+
logger.warning(
|
|
10450
|
+
"deployment_merge: AI resolution exception for job %s: %s",
|
|
10451
|
+
job_id, exc,
|
|
10452
|
+
)
|
|
10453
|
+
break
|
|
10454
|
+
|
|
10455
|
+
# Re-check remaining conflicts after this batch.
|
|
10456
|
+
try:
|
|
10457
|
+
diff_u = await git("diff", "--name-only", "--diff-filter=U", cwd=workspace_path)
|
|
10458
|
+
remaining = [
|
|
10459
|
+
f.strip() for f in diff_u.strip().splitlines() if f.strip()
|
|
10460
|
+
]
|
|
10461
|
+
except RuntimeError:
|
|
10462
|
+
pass # keep the previous remaining list
|
|
10463
|
+
|
|
10464
|
+
resolution_succeeded = not remaining
|
|
10465
|
+
if not resolution_succeeded:
|
|
10466
|
+
logger.warning(
|
|
10467
|
+
"deployment_merge: AI could not resolve all conflicts "
|
|
10468
|
+
"(%d remaining after %d batch(es)) for job %s",
|
|
10469
|
+
len(remaining), MAX_BATCHES, job_id,
|
|
10470
|
+
)
|
|
10471
|
+
# AI failed or unavailable — abort and report for human.
|
|
10472
|
+
try:
|
|
10473
|
+
await git("merge", "--abort", cwd=workspace_path)
|
|
10474
|
+
except RuntimeError:
|
|
10475
|
+
await git("reset", "--hard", "HEAD", cwd=workspace_path)
|
|
10476
|
+
await _report({
|
|
10477
|
+
"merge_outcome": "conflict_needs_human",
|
|
10478
|
+
"conflicted_files": (remaining or conflicted_files)[:30],
|
|
10479
|
+
"candidate_ref": candidate_ref,
|
|
10480
|
+
"base_integration_sha": base_ref,
|
|
10481
|
+
})
|
|
10482
|
+
return
|
|
10483
|
+
|
|
10484
|
+
# All conflicts resolved — stage resolved files and complete the merge.
|
|
10485
|
+
await git("add", "-A", cwd=workspace_path)
|
|
10486
|
+
try:
|
|
10487
|
+
await git(
|
|
10488
|
+
"-c", f"user.name={_git_name}",
|
|
10489
|
+
"-c", f"user.email={_git_email}",
|
|
10490
|
+
"commit", "--no-edit",
|
|
10491
|
+
cwd=workspace_path,
|
|
10492
|
+
)
|
|
10493
|
+
except RuntimeError:
|
|
10494
|
+
pass # merge may have already committed
|
|
10495
|
+
logger.info(
|
|
10496
|
+
"deployment_merge: AI resolution succeeded for job %s via %s",
|
|
10497
|
+
job_id, resolved_agent_id,
|
|
10498
|
+
)
|
|
10499
|
+
|
|
10500
|
+
# AI resolution succeeded — falls through to the shared
|
|
10501
|
+
# deterministic precheck + reporting block below.
|
|
10502
|
+
|
|
10503
|
+
# Deterministic precheck: scan for residual conflict markers.
|
|
10504
|
+
# Applies to both clean merges and AI-resolved merges.
|
|
10505
|
+
candidate_sha = (await git("rev-parse", "HEAD", cwd=workspace_path)).strip()
|
|
10506
|
+
marker_proc = await asyncio.create_subprocess_exec(
|
|
10507
|
+
"git", "grep", "-l", "-E", r"^(<<<<<<<|=======|>>>>>>>)",
|
|
10508
|
+
cwd=str(workspace_path),
|
|
10509
|
+
stdout=asyncio.subprocess.PIPE,
|
|
10510
|
+
stderr=asyncio.subprocess.DEVNULL,
|
|
10511
|
+
)
|
|
10512
|
+
marker_stdout, _ = await marker_proc.communicate()
|
|
10513
|
+
marker_files = [f.strip() for f in marker_stdout.decode().strip().splitlines() if f.strip()]
|
|
10514
|
+
|
|
10515
|
+
if marker_files:
|
|
10516
|
+
logger.warning(
|
|
10517
|
+
"deployment_merge: job %s precheck failed — residual markers in %d file(s)",
|
|
10518
|
+
job_id, len(marker_files),
|
|
10519
|
+
)
|
|
10520
|
+
await _report({
|
|
10521
|
+
"merge_outcome": "precheck_failed",
|
|
10522
|
+
"conflicted_files": marker_files[:30],
|
|
10523
|
+
"candidate_commit_sha": candidate_sha,
|
|
10524
|
+
})
|
|
10525
|
+
return
|
|
10526
|
+
|
|
10527
|
+
# Deterministic precheck: the working tree must be clean (no
|
|
10528
|
+
# unstaged/staged leftovers) after merge/commit. Untracked files
|
|
10529
|
+
# are ignored — worktrees may contain unrelated artifacts.
|
|
10530
|
+
try:
|
|
10531
|
+
status_out = await git(
|
|
10532
|
+
"status", "--porcelain", "--untracked-files=no", cwd=workspace_path,
|
|
10533
|
+
)
|
|
10534
|
+
except RuntimeError:
|
|
10535
|
+
status_out = ""
|
|
10536
|
+
dirty_files = [
|
|
10537
|
+
(line[3:].strip() if len(line) > 3 else line.strip())
|
|
10538
|
+
for line in status_out.strip().splitlines()
|
|
10539
|
+
if line.strip()
|
|
10540
|
+
]
|
|
10541
|
+
if dirty_files:
|
|
10542
|
+
logger.warning(
|
|
10543
|
+
"deployment_merge: job %s precheck failed — working tree not clean (%d file(s))",
|
|
10544
|
+
job_id, len(dirty_files),
|
|
10545
|
+
)
|
|
10546
|
+
await _report({
|
|
10547
|
+
"merge_outcome": "precheck_failed",
|
|
10548
|
+
"conflicted_files": dirty_files[:30],
|
|
10549
|
+
"candidate_commit_sha": candidate_sha,
|
|
10550
|
+
})
|
|
10551
|
+
return
|
|
10552
|
+
|
|
10553
|
+
# Push the candidate commit to origin so external CI can check it
|
|
10554
|
+
# out and subsequent merges can fetch it as a base.
|
|
10555
|
+
try:
|
|
10556
|
+
await git("push", "origin", f"HEAD:{candidate_ref}", cwd=workspace_path)
|
|
10557
|
+
except RuntimeError as exc:
|
|
10558
|
+
logger.warning(
|
|
10559
|
+
"deployment_merge: job %s candidate push failed: %s", job_id, exc,
|
|
10560
|
+
)
|
|
10561
|
+
await _report(
|
|
10562
|
+
{
|
|
10563
|
+
"merge_outcome": "precheck_failed",
|
|
10564
|
+
"candidate_commit_sha": candidate_sha,
|
|
10565
|
+
"candidate_ref": candidate_ref,
|
|
10566
|
+
},
|
|
10567
|
+
status="failed",
|
|
10568
|
+
error=f"candidate push failed: {exc}",
|
|
10569
|
+
)
|
|
10570
|
+
return
|
|
10571
|
+
|
|
10572
|
+
# Success — determine outcome label.
|
|
10573
|
+
if resolved_agent_id:
|
|
10574
|
+
outcome = "ai_resolved"
|
|
10575
|
+
step_msg = f"AI resolved merge via {resolved_agent_id}"
|
|
10576
|
+
else:
|
|
10577
|
+
outcome = "clean_merged"
|
|
10578
|
+
step_msg = "Merge completed cleanly"
|
|
10579
|
+
|
|
10580
|
+
await conn.client.post(
|
|
10581
|
+
f"{reporter_url}/progress",
|
|
10582
|
+
json={"current_phase": "completed", "current_step": step_msg, "progress_pct": 100},
|
|
10583
|
+
timeout=10,
|
|
10584
|
+
)
|
|
10585
|
+
logger.info(
|
|
10586
|
+
"deployment_merge: job %s %s → %s",
|
|
10587
|
+
job_id, outcome, candidate_sha[:12],
|
|
10588
|
+
)
|
|
10589
|
+
await _report({
|
|
10590
|
+
"merge_outcome": outcome,
|
|
10591
|
+
"resolved_agent": resolved_agent_id,
|
|
10592
|
+
"candidate_commit_sha": candidate_sha,
|
|
10593
|
+
"candidate_ref": candidate_ref,
|
|
10594
|
+
"merge_commit_sha": candidate_sha,
|
|
10595
|
+
"base_integration_sha": base_ref,
|
|
10596
|
+
"source_commit_sha": source_commit_sha,
|
|
10597
|
+
})
|
|
10598
|
+
|
|
10599
|
+
except Exception as exc:
|
|
10600
|
+
logger.error("deployment_merge: job %s exception: %s", job_id, exc)
|
|
10601
|
+
await _report(
|
|
10602
|
+
{"merge_outcome": "conflict_needs_human"},
|
|
10603
|
+
status="failed",
|
|
10604
|
+
error=str(exc),
|
|
10605
|
+
)
|
|
10606
|
+
finally:
|
|
10607
|
+
project_lock.release()
|
|
10608
|
+
|
|
9976
10609
|
async def _execute_ai_job(self, aj: dict, conn: "ServerConnection"):
|
|
9977
10610
|
"""Execute an AIJob in daemon workspace and report results back.
|
|
9978
10611
|
|
|
@@ -9981,6 +10614,10 @@ class RuntimeDaemon:
|
|
|
9981
10614
|
"""
|
|
9982
10615
|
job_id = aj.get("job_id", "")
|
|
9983
10616
|
task_type = aj.get("task_type", "unknown")
|
|
10617
|
+
# Deployment merge has a specialized flow (git merge + conflict detection).
|
|
10618
|
+
if task_type == "deployment_merge":
|
|
10619
|
+
await self._execute_deployment_merge_job(aj, conn)
|
|
10620
|
+
return
|
|
9984
10621
|
project_info = aj.get("project", {})
|
|
9985
10622
|
requirement_key = aj.get("requirement_key")
|
|
9986
10623
|
agent_override = aj.get("agent_override")
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|