forgexa-cli 1.22.5__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.5 → forgexa_cli-1.23.0}/PKG-INFO +1 -1
- {forgexa_cli-1.22.5 → forgexa_cli-1.23.0}/forgexa_cli/__init__.py +1 -1
- {forgexa_cli-1.22.5 → forgexa_cli-1.23.0}/forgexa_cli/daemon.py +795 -18
- {forgexa_cli-1.22.5 → forgexa_cli-1.23.0}/forgexa_cli.egg-info/PKG-INFO +1 -1
- {forgexa_cli-1.22.5 → forgexa_cli-1.23.0}/pyproject.toml +1 -1
- {forgexa_cli-1.22.5 → forgexa_cli-1.23.0}/README.md +0 -0
- {forgexa_cli-1.22.5 → forgexa_cli-1.23.0}/forgexa_cli/_build_config.py +0 -0
- {forgexa_cli-1.22.5 → forgexa_cli-1.23.0}/forgexa_cli/autoupgrade.py +0 -0
- {forgexa_cli-1.22.5 → forgexa_cli-1.23.0}/forgexa_cli/main.py +0 -0
- {forgexa_cli-1.22.5 → forgexa_cli-1.23.0}/forgexa_cli/py.typed +0 -0
- {forgexa_cli-1.22.5 → forgexa_cli-1.23.0}/forgexa_cli.egg-info/SOURCES.txt +0 -0
- {forgexa_cli-1.22.5 → forgexa_cli-1.23.0}/forgexa_cli.egg-info/dependency_links.txt +0 -0
- {forgexa_cli-1.22.5 → forgexa_cli-1.23.0}/forgexa_cli.egg-info/entry_points.txt +0 -0
- {forgexa_cli-1.22.5 → forgexa_cli-1.23.0}/forgexa_cli.egg-info/requires.txt +0 -0
- {forgexa_cli-1.22.5 → forgexa_cli-1.23.0}/forgexa_cli.egg-info/top_level.txt +0 -0
- {forgexa_cli-1.22.5 → forgexa_cli-1.23.0}/setup.cfg +0 -0
- {forgexa_cli-1.22.5 → forgexa_cli-1.23.0}/tests/test_auth_and_runtime_commands.py +0 -0
- {forgexa_cli-1.22.5 → forgexa_cli-1.23.0}/tests/test_autoupgrade.py +0 -0
- {forgexa_cli-1.22.5 → forgexa_cli-1.23.0}/tests/test_check_command.py +0 -0
- {forgexa_cli-1.22.5 → forgexa_cli-1.23.0}/tests/test_silent_install.py +0 -0
- {forgexa_cli-1.22.5 → 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"
|
|
@@ -29,6 +29,8 @@ if sys.version_info < (3, 9):
|
|
|
29
29
|
|
|
30
30
|
import asyncio
|
|
31
31
|
import base64
|
|
32
|
+
import errno
|
|
33
|
+
import fnmatch
|
|
32
34
|
import hashlib
|
|
33
35
|
import json
|
|
34
36
|
import logging
|
|
@@ -555,7 +557,7 @@ except (ImportError, ModuleNotFoundError):
|
|
|
555
557
|
# DAEMON_VERSION is the protocol/logic version of the daemon code.
|
|
556
558
|
# Kept in sync with pyproject.toml version via bump-version.sh.
|
|
557
559
|
# CLIENT_TYPE identifies which packaging/distribution this daemon runs in.
|
|
558
|
-
DAEMON_VERSION = "1.
|
|
560
|
+
DAEMON_VERSION = "1.23.0"
|
|
559
561
|
|
|
560
562
|
|
|
561
563
|
def _detect_client_type() -> str:
|
|
@@ -1948,6 +1950,66 @@ class AgentDiscovery:
|
|
|
1948
1950
|
logger.warning("bwrap probe error: %s", exc)
|
|
1949
1951
|
|
|
1950
1952
|
|
|
1953
|
+
class _WorkspaceFileLock:
|
|
1954
|
+
"""Advisory lock shared by daemon processes using one workspace root."""
|
|
1955
|
+
|
|
1956
|
+
def __init__(self, path: Path):
|
|
1957
|
+
self.path = path
|
|
1958
|
+
self._handle: Any | None = None
|
|
1959
|
+
|
|
1960
|
+
async def acquire(self) -> None:
|
|
1961
|
+
self.path.parent.mkdir(parents=True, exist_ok=True)
|
|
1962
|
+
self._handle = open(self.path, "a+b")
|
|
1963
|
+
waiting = False
|
|
1964
|
+
|
|
1965
|
+
try:
|
|
1966
|
+
if sys.platform == "win32":
|
|
1967
|
+
self._handle.seek(0)
|
|
1968
|
+
if not self._handle.read(1):
|
|
1969
|
+
self._handle.write(b"\0")
|
|
1970
|
+
self._handle.flush()
|
|
1971
|
+
|
|
1972
|
+
while True:
|
|
1973
|
+
try:
|
|
1974
|
+
self._handle.seek(0)
|
|
1975
|
+
if sys.platform == "win32":
|
|
1976
|
+
import msvcrt
|
|
1977
|
+
|
|
1978
|
+
msvcrt.locking(self._handle.fileno(), msvcrt.LK_NBLCK, 1)
|
|
1979
|
+
elif fcntl is not None:
|
|
1980
|
+
fcntl.flock(self._handle.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
1981
|
+
else:
|
|
1982
|
+
raise RuntimeError("Workspace file locking is unavailable on this platform")
|
|
1983
|
+
return
|
|
1984
|
+
except OSError as exc:
|
|
1985
|
+
if exc.errno not in (errno.EACCES, errno.EAGAIN):
|
|
1986
|
+
raise
|
|
1987
|
+
if not waiting:
|
|
1988
|
+
logger.info("Waiting for workspace lock at %s", self.path)
|
|
1989
|
+
waiting = True
|
|
1990
|
+
await asyncio.sleep(0.1)
|
|
1991
|
+
except BaseException:
|
|
1992
|
+
self.release()
|
|
1993
|
+
raise
|
|
1994
|
+
|
|
1995
|
+
def release(self) -> None:
|
|
1996
|
+
handle, self._handle = self._handle, None
|
|
1997
|
+
if handle is None:
|
|
1998
|
+
return
|
|
1999
|
+
try:
|
|
2000
|
+
handle.seek(0)
|
|
2001
|
+
if sys.platform == "win32":
|
|
2002
|
+
import msvcrt
|
|
2003
|
+
|
|
2004
|
+
msvcrt.locking(handle.fileno(), msvcrt.LK_UNLCK, 1)
|
|
2005
|
+
elif fcntl is not None:
|
|
2006
|
+
fcntl.flock(handle.fileno(), fcntl.LOCK_UN)
|
|
2007
|
+
except OSError:
|
|
2008
|
+
pass
|
|
2009
|
+
finally:
|
|
2010
|
+
handle.close()
|
|
2011
|
+
|
|
2012
|
+
|
|
1951
2013
|
class WorkspaceManager:
|
|
1952
2014
|
"""Manages isolated workspaces per task using git worktrees."""
|
|
1953
2015
|
|
|
@@ -1966,6 +2028,32 @@ class WorkspaceManager:
|
|
|
1966
2028
|
|
|
1967
2029
|
self._dirs_ready = False
|
|
1968
2030
|
|
|
2031
|
+
@staticmethod
|
|
2032
|
+
def _workspace_key(task: TaskInfo) -> str:
|
|
2033
|
+
if task.requirement_workflow_id:
|
|
2034
|
+
return re.sub(r"[^a-zA-Z0-9_-]", "_", task.requirement_workflow_id)
|
|
2035
|
+
return re.sub(r"[^a-zA-Z0-9_-]", "_", str(task.graph_id))
|
|
2036
|
+
|
|
2037
|
+
async def _acquire_file_lock(self, lock_path: Path) -> _WorkspaceFileLock:
|
|
2038
|
+
lock = _WorkspaceFileLock(lock_path)
|
|
2039
|
+
await lock.acquire()
|
|
2040
|
+
return lock
|
|
2041
|
+
|
|
2042
|
+
async def _acquire_project_worktree_lock(self, project_dir: Path) -> _WorkspaceFileLock:
|
|
2043
|
+
return await self._acquire_file_lock(project_dir / ".forgexa-worktree.lock")
|
|
2044
|
+
|
|
2045
|
+
async def acquire_workspace_execution_lock(
|
|
2046
|
+
self,
|
|
2047
|
+
project: dict,
|
|
2048
|
+
task: TaskInfo,
|
|
2049
|
+
) -> _WorkspaceFileLock:
|
|
2050
|
+
self._ensure_dirs()
|
|
2051
|
+
project_key = str(project.get("project_key") or "default")
|
|
2052
|
+
project_dir = self.projects_root / project_key
|
|
2053
|
+
return await self._acquire_file_lock(
|
|
2054
|
+
project_dir / ".forgexa-workspace-locks" / f"{self._workspace_key(task)}.lock"
|
|
2055
|
+
)
|
|
2056
|
+
|
|
1969
2057
|
def _ensure_dirs(self) -> None:
|
|
1970
2058
|
"""Lazy initialization of workspace directories.
|
|
1971
2059
|
|
|
@@ -2376,10 +2464,7 @@ class WorkspaceManager:
|
|
|
2376
2464
|
# analysis, execution, and fix graphs for the same requirement share
|
|
2377
2465
|
# one directory and git worktree (C-2 / D-1 workspace reuse).
|
|
2378
2466
|
# Fall back to graph_id for backward compatibility.
|
|
2379
|
-
|
|
2380
|
-
workspace_key = re.sub(r"[^a-zA-Z0-9_-]", "_", task.requirement_workflow_id)
|
|
2381
|
-
else:
|
|
2382
|
-
workspace_key = re.sub(r"[^a-zA-Z0-9_-]", "_", str(task.graph_id))
|
|
2467
|
+
workspace_key = self._workspace_key(task)
|
|
2383
2468
|
|
|
2384
2469
|
# Use human-readable branch name feature/{requirement_key}
|
|
2385
2470
|
# when requirement_key is available; fallback to feature/{workspace_key}
|
|
@@ -2554,7 +2639,13 @@ class WorkspaceManager:
|
|
|
2554
2639
|
await self._git("config", "core.filemode", "false", cwd=ws_path)
|
|
2555
2640
|
return ws_path
|
|
2556
2641
|
|
|
2557
|
-
async def _is_healthy_worktree(
|
|
2642
|
+
async def _is_healthy_worktree(
|
|
2643
|
+
self,
|
|
2644
|
+
ws_path: Path,
|
|
2645
|
+
main_repo: Path | None = None,
|
|
2646
|
+
*,
|
|
2647
|
+
require_unlocked: bool = False,
|
|
2648
|
+
) -> bool:
|
|
2558
2649
|
"""Check if a git worktree directory is a valid *linked* worktree.
|
|
2559
2650
|
|
|
2560
2651
|
A linked git worktree has .git as a FILE containing a gitdir pointer to
|
|
@@ -2610,6 +2701,8 @@ class WorkspaceManager:
|
|
|
2610
2701
|
gitdir.resolve().relative_to(expected_wt_root)
|
|
2611
2702
|
except ValueError:
|
|
2612
2703
|
return False
|
|
2704
|
+
if require_unlocked and (gitdir / "index.lock").exists():
|
|
2705
|
+
return False
|
|
2613
2706
|
return True
|
|
2614
2707
|
except OSError:
|
|
2615
2708
|
pass
|
|
@@ -2686,6 +2779,7 @@ class WorkspaceManager:
|
|
|
2686
2779
|
branch_name: str,
|
|
2687
2780
|
*,
|
|
2688
2781
|
healthy_only: bool = True,
|
|
2782
|
+
require_unlocked: bool = False,
|
|
2689
2783
|
) -> Path | None:
|
|
2690
2784
|
"""Return a linked worktree already on branch_name."""
|
|
2691
2785
|
try:
|
|
@@ -2715,7 +2809,11 @@ class WorkspaceManager:
|
|
|
2715
2809
|
continue
|
|
2716
2810
|
if not healthy_only:
|
|
2717
2811
|
return candidate_path
|
|
2718
|
-
if candidate_path.exists() and await self._is_healthy_worktree(
|
|
2812
|
+
if candidate_path.exists() and await self._is_healthy_worktree(
|
|
2813
|
+
candidate_path,
|
|
2814
|
+
main_repo=main_repo,
|
|
2815
|
+
require_unlocked=require_unlocked,
|
|
2816
|
+
):
|
|
2719
2817
|
return candidate_path
|
|
2720
2818
|
|
|
2721
2819
|
return None
|
|
@@ -3100,14 +3198,18 @@ class WorkspaceManager:
|
|
|
3100
3198
|
# concurrent tasks from racing on _main (shutil.rmtree / git clone / worktree add).
|
|
3101
3199
|
project_lock = await self._get_project_lock(project_dir)
|
|
3102
3200
|
async with project_lock:
|
|
3103
|
-
|
|
3104
|
-
|
|
3105
|
-
|
|
3106
|
-
|
|
3107
|
-
|
|
3108
|
-
|
|
3109
|
-
|
|
3110
|
-
|
|
3201
|
+
file_lock = await self._acquire_project_worktree_lock(project_dir)
|
|
3202
|
+
try:
|
|
3203
|
+
return await self._create_worktree_impl(
|
|
3204
|
+
project_dir, repo_url, default_branch,
|
|
3205
|
+
workspace_key, branch_name,
|
|
3206
|
+
fresh_start=fresh_start,
|
|
3207
|
+
project_key=project_key,
|
|
3208
|
+
expect_branch=expect_branch,
|
|
3209
|
+
detach_head=detach_head,
|
|
3210
|
+
)
|
|
3211
|
+
finally:
|
|
3212
|
+
file_lock.release()
|
|
3111
3213
|
|
|
3112
3214
|
async def _create_worktree_impl(
|
|
3113
3215
|
self, project_dir: Path, repo_url: str, default_branch: str,
|
|
@@ -3130,7 +3232,11 @@ class WorkspaceManager:
|
|
|
3130
3232
|
if ws_path.exists():
|
|
3131
3233
|
# Validate that this worktree is healthy (its .git file points to a
|
|
3132
3234
|
# valid main repo). Worktrees break when directories are moved.
|
|
3133
|
-
if not await self._is_healthy_worktree(
|
|
3235
|
+
if not await self._is_healthy_worktree(
|
|
3236
|
+
ws_path,
|
|
3237
|
+
main_repo=main_repo,
|
|
3238
|
+
require_unlocked=fresh_start,
|
|
3239
|
+
):
|
|
3134
3240
|
logger.warning("Broken worktree detected at %s — removing and recreating", ws_path)
|
|
3135
3241
|
await self._remove_broken_worktree(main_repo, ws_path, workspace_key)
|
|
3136
3242
|
else:
|
|
@@ -3605,6 +3711,19 @@ class WorkspaceManager:
|
|
|
3605
3711
|
# that `git worktree add ... {default_branch}` uses latest code.
|
|
3606
3712
|
# We fetch first, then update the local ref directly (avoids
|
|
3607
3713
|
# needing to checkout default_branch which may conflict with worktrees).
|
|
3714
|
+
try:
|
|
3715
|
+
await self._git(
|
|
3716
|
+
"fetch", "origin",
|
|
3717
|
+
f"{default_branch}:refs/remotes/origin/{default_branch}",
|
|
3718
|
+
cwd=main_repo,
|
|
3719
|
+
timeout=settings.GIT_FETCH_TIMEOUT,
|
|
3720
|
+
project_key=project_key,
|
|
3721
|
+
)
|
|
3722
|
+
except RuntimeError as exc:
|
|
3723
|
+
raise RuntimeError(
|
|
3724
|
+
f"Failed to fetch default branch '{default_branch}' from remote: {exc}"
|
|
3725
|
+
) from exc
|
|
3726
|
+
|
|
3608
3727
|
try:
|
|
3609
3728
|
await self._git(
|
|
3610
3729
|
"update-ref", f"refs/heads/{default_branch}",
|
|
@@ -3815,7 +3934,7 @@ class WorkspaceManager:
|
|
|
3815
3934
|
# feature ref behind. Attaching that ref would resurrect stale
|
|
3816
3935
|
# requirement history, so reset it explicitly.
|
|
3817
3936
|
existing_branch_worktree = await self._find_existing_worktree_for_branch(
|
|
3818
|
-
main_repo, branch_name,
|
|
3937
|
+
main_repo, branch_name, require_unlocked=True,
|
|
3819
3938
|
)
|
|
3820
3939
|
if existing_branch_worktree is not None:
|
|
3821
3940
|
logger.info(
|
|
@@ -3839,7 +3958,7 @@ class WorkspaceManager:
|
|
|
3839
3958
|
)
|
|
3840
3959
|
except Exception as exc:
|
|
3841
3960
|
existing_branch_worktree = await self._find_existing_worktree_for_branch(
|
|
3842
|
-
main_repo, branch_name,
|
|
3961
|
+
main_repo, branch_name, require_unlocked=True,
|
|
3843
3962
|
)
|
|
3844
3963
|
if existing_branch_worktree is not None:
|
|
3845
3964
|
logger.info(
|
|
@@ -4087,6 +4206,13 @@ class WorkspaceManager:
|
|
|
4087
4206
|
)
|
|
4088
4207
|
try:
|
|
4089
4208
|
stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=timeout)
|
|
4209
|
+
except asyncio.CancelledError:
|
|
4210
|
+
_kill_proc(proc)
|
|
4211
|
+
try:
|
|
4212
|
+
await asyncio.shield(asyncio.wait_for(proc.wait(), timeout=5.0))
|
|
4213
|
+
except Exception:
|
|
4214
|
+
pass
|
|
4215
|
+
raise
|
|
4090
4216
|
except (TimeoutError, asyncio.TimeoutError):
|
|
4091
4217
|
# Kill the entire process group so that SSH (and any other
|
|
4092
4218
|
# child processes git may have spawned) are also terminated.
|
|
@@ -7928,6 +8054,10 @@ class RuntimeDaemon:
|
|
|
7928
8054
|
self._project_execution_locks_mu: asyncio.Lock = asyncio.Lock()
|
|
7929
8055
|
self._shutdown = False
|
|
7930
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
|
+
|
|
7931
8061
|
self.connections: list[ServerConnection] = []
|
|
7932
8062
|
self.agent_discovery = AgentDiscovery()
|
|
7933
8063
|
self.workspace_manager = WorkspaceManager(self.workspaces_root)
|
|
@@ -8390,6 +8520,14 @@ class RuntimeDaemon:
|
|
|
8390
8520
|
"""Poll for tasks from all connected servers and execute them."""
|
|
8391
8521
|
had_work = False
|
|
8392
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
|
+
|
|
8393
8531
|
def _cancel_server_requested_ai_jobs(conn: ServerConnection) -> None:
|
|
8394
8532
|
for job_id in conn.poller.cancelled_ai_job_ids:
|
|
8395
8533
|
ai_task_key = f"aijob_{job_id}"
|
|
@@ -8601,6 +8739,7 @@ class RuntimeDaemon:
|
|
|
8601
8739
|
project_lock_key,
|
|
8602
8740
|
)
|
|
8603
8741
|
await project_lock.acquire()
|
|
8742
|
+
workspace_execution_lock: _WorkspaceFileLock | None = None
|
|
8604
8743
|
try:
|
|
8605
8744
|
# 1. Find the right agent
|
|
8606
8745
|
agent = self._select_agent(task.agent_type, task.fallback_chain)
|
|
@@ -8624,6 +8763,11 @@ class RuntimeDaemon:
|
|
|
8624
8763
|
))
|
|
8625
8764
|
return
|
|
8626
8765
|
|
|
8766
|
+
workspace_execution_lock = await self.workspace_manager.acquire_workspace_execution_lock(
|
|
8767
|
+
task.project,
|
|
8768
|
+
task,
|
|
8769
|
+
)
|
|
8770
|
+
|
|
8627
8771
|
# Track which agents we've already tried (for rate-limit fallback)
|
|
8628
8772
|
tried_agents: set[str] = set()
|
|
8629
8773
|
|
|
@@ -9362,6 +9506,8 @@ class RuntimeDaemon:
|
|
|
9362
9506
|
error=str(e),
|
|
9363
9507
|
))
|
|
9364
9508
|
finally:
|
|
9509
|
+
if workspace_execution_lock is not None:
|
|
9510
|
+
workspace_execution_lock.release()
|
|
9365
9511
|
project_lock.release()
|
|
9366
9512
|
|
|
9367
9513
|
def _select_fallback_agent(
|
|
@@ -9840,6 +9986,626 @@ class RuntimeDaemon:
|
|
|
9840
9986
|
|
|
9841
9987
|
return issues
|
|
9842
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
|
+
|
|
9843
10609
|
async def _execute_ai_job(self, aj: dict, conn: "ServerConnection"):
|
|
9844
10610
|
"""Execute an AIJob in daemon workspace and report results back.
|
|
9845
10611
|
|
|
@@ -9848,6 +10614,10 @@ class RuntimeDaemon:
|
|
|
9848
10614
|
"""
|
|
9849
10615
|
job_id = aj.get("job_id", "")
|
|
9850
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
|
|
9851
10621
|
project_info = aj.get("project", {})
|
|
9852
10622
|
requirement_key = aj.get("requirement_key")
|
|
9853
10623
|
agent_override = aj.get("agent_override")
|
|
@@ -9876,6 +10646,7 @@ class RuntimeDaemon:
|
|
|
9876
10646
|
project_lock_key,
|
|
9877
10647
|
)
|
|
9878
10648
|
await project_lock.acquire()
|
|
10649
|
+
workspace_execution_lock: _WorkspaceFileLock | None = None
|
|
9879
10650
|
|
|
9880
10651
|
try:
|
|
9881
10652
|
# Report progress: starting
|
|
@@ -9940,6 +10711,10 @@ class RuntimeDaemon:
|
|
|
9940
10711
|
workspace_source_mode=qa_ws_source.get("mode"),
|
|
9941
10712
|
workspace_branch=qa_ws_source.get("branch"),
|
|
9942
10713
|
)
|
|
10714
|
+
workspace_execution_lock = await self.workspace_manager.acquire_workspace_execution_lock(
|
|
10715
|
+
project_info,
|
|
10716
|
+
fake_task,
|
|
10717
|
+
)
|
|
9943
10718
|
workspace_path = await self.workspace_manager.prepare_workspace(
|
|
9944
10719
|
project_info, fake_task,
|
|
9945
10720
|
)
|
|
@@ -10163,6 +10938,8 @@ class RuntimeDaemon:
|
|
|
10163
10938
|
except Exception:
|
|
10164
10939
|
pass
|
|
10165
10940
|
finally:
|
|
10941
|
+
if workspace_execution_lock is not None:
|
|
10942
|
+
workspace_execution_lock.release()
|
|
10166
10943
|
project_lock.release()
|
|
10167
10944
|
|
|
10168
10945
|
async def _collect_workspace_git_info(self, workspace_path: Path) -> dict:
|
|
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
|