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.
Files changed (21) hide show
  1. {forgexa_cli-1.22.6 → forgexa_cli-1.23.0}/PKG-INFO +1 -1
  2. {forgexa_cli-1.22.6 → forgexa_cli-1.23.0}/forgexa_cli/__init__.py +1 -1
  3. {forgexa_cli-1.22.6 → forgexa_cli-1.23.0}/forgexa_cli/daemon.py +638 -1
  4. {forgexa_cli-1.22.6 → forgexa_cli-1.23.0}/forgexa_cli.egg-info/PKG-INFO +1 -1
  5. {forgexa_cli-1.22.6 → forgexa_cli-1.23.0}/pyproject.toml +1 -1
  6. {forgexa_cli-1.22.6 → forgexa_cli-1.23.0}/README.md +0 -0
  7. {forgexa_cli-1.22.6 → forgexa_cli-1.23.0}/forgexa_cli/_build_config.py +0 -0
  8. {forgexa_cli-1.22.6 → forgexa_cli-1.23.0}/forgexa_cli/autoupgrade.py +0 -0
  9. {forgexa_cli-1.22.6 → forgexa_cli-1.23.0}/forgexa_cli/main.py +0 -0
  10. {forgexa_cli-1.22.6 → forgexa_cli-1.23.0}/forgexa_cli/py.typed +0 -0
  11. {forgexa_cli-1.22.6 → forgexa_cli-1.23.0}/forgexa_cli.egg-info/SOURCES.txt +0 -0
  12. {forgexa_cli-1.22.6 → forgexa_cli-1.23.0}/forgexa_cli.egg-info/dependency_links.txt +0 -0
  13. {forgexa_cli-1.22.6 → forgexa_cli-1.23.0}/forgexa_cli.egg-info/entry_points.txt +0 -0
  14. {forgexa_cli-1.22.6 → forgexa_cli-1.23.0}/forgexa_cli.egg-info/requires.txt +0 -0
  15. {forgexa_cli-1.22.6 → forgexa_cli-1.23.0}/forgexa_cli.egg-info/top_level.txt +0 -0
  16. {forgexa_cli-1.22.6 → forgexa_cli-1.23.0}/setup.cfg +0 -0
  17. {forgexa_cli-1.22.6 → forgexa_cli-1.23.0}/tests/test_auth_and_runtime_commands.py +0 -0
  18. {forgexa_cli-1.22.6 → forgexa_cli-1.23.0}/tests/test_autoupgrade.py +0 -0
  19. {forgexa_cli-1.22.6 → forgexa_cli-1.23.0}/tests/test_check_command.py +0 -0
  20. {forgexa_cli-1.22.6 → forgexa_cli-1.23.0}/tests/test_silent_install.py +0 -0
  21. {forgexa_cli-1.22.6 → forgexa_cli-1.23.0}/tests/test_upgrade_observability.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: forgexa-cli
3
- Version: 1.22.6
3
+ Version: 1.23.0
4
4
  Summary: Forgexa CLI — command-line client and AI agent runtime for the Forgexa platform
5
5
  Author-email: Jason Sun <dev.winds@gmail.com>
6
6
  License-Expression: MIT
@@ -1,2 +1,2 @@
1
1
  """forgexa-cli — Forgexa command-line client."""
2
- __version__ = "1.22.6"
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.22.6"
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")
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: forgexa-cli
3
- Version: 1.22.6
3
+ Version: 1.23.0
4
4
  Summary: Forgexa CLI — command-line client and AI agent runtime for the Forgexa platform
5
5
  Author-email: Jason Sun <dev.winds@gmail.com>
6
6
  License-Expression: MIT
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "forgexa-cli"
3
- version = "1.22.6"
3
+ version = "1.23.0"
4
4
  description = "Forgexa CLI — command-line client and AI agent runtime for the Forgexa platform"
5
5
  requires-python = ">=3.9"
6
6
  license = "MIT"
File without changes
File without changes