forgexa-cli 1.8.3__tar.gz → 1.8.4__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {forgexa_cli-1.8.3 → forgexa_cli-1.8.4}/PKG-INFO +1 -1
- {forgexa_cli-1.8.3 → forgexa_cli-1.8.4}/forgexa_cli/__init__.py +1 -1
- {forgexa_cli-1.8.3 → forgexa_cli-1.8.4}/forgexa_cli/daemon.py +239 -45
- {forgexa_cli-1.8.3 → forgexa_cli-1.8.4}/forgexa_cli.egg-info/PKG-INFO +1 -1
- {forgexa_cli-1.8.3 → forgexa_cli-1.8.4}/pyproject.toml +1 -1
- {forgexa_cli-1.8.3 → forgexa_cli-1.8.4}/README.md +0 -0
- {forgexa_cli-1.8.3 → forgexa_cli-1.8.4}/forgexa_cli/_build_config.py +0 -0
- {forgexa_cli-1.8.3 → forgexa_cli-1.8.4}/forgexa_cli/main.py +0 -0
- {forgexa_cli-1.8.3 → forgexa_cli-1.8.4}/forgexa_cli/py.typed +0 -0
- {forgexa_cli-1.8.3 → forgexa_cli-1.8.4}/forgexa_cli.egg-info/SOURCES.txt +0 -0
- {forgexa_cli-1.8.3 → forgexa_cli-1.8.4}/forgexa_cli.egg-info/dependency_links.txt +0 -0
- {forgexa_cli-1.8.3 → forgexa_cli-1.8.4}/forgexa_cli.egg-info/entry_points.txt +0 -0
- {forgexa_cli-1.8.3 → forgexa_cli-1.8.4}/forgexa_cli.egg-info/requires.txt +0 -0
- {forgexa_cli-1.8.3 → forgexa_cli-1.8.4}/forgexa_cli.egg-info/top_level.txt +0 -0
- {forgexa_cli-1.8.3 → forgexa_cli-1.8.4}/setup.cfg +0 -0
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
"""forgexa-cli — Forgexa command-line client."""
|
|
2
|
-
__version__ = "1.8.
|
|
2
|
+
__version__ = "1.8.4"
|
|
@@ -332,7 +332,7 @@ except (ImportError, ModuleNotFoundError):
|
|
|
332
332
|
# DAEMON_VERSION is the protocol/logic version of the daemon code.
|
|
333
333
|
# Kept in sync with pyproject.toml version via bump-version.sh.
|
|
334
334
|
# CLIENT_TYPE identifies which packaging/distribution this daemon runs in.
|
|
335
|
-
DAEMON_VERSION = "1.8.
|
|
335
|
+
DAEMON_VERSION = "1.8.4"
|
|
336
336
|
|
|
337
337
|
|
|
338
338
|
def _detect_client_type() -> str:
|
|
@@ -756,6 +756,14 @@ class AgentDiscovery:
|
|
|
756
756
|
resolved = shutil.which(cmd)
|
|
757
757
|
if resolved:
|
|
758
758
|
version = await self._get_version(spec["detect"])
|
|
759
|
+
if version is None:
|
|
760
|
+
# Binary found but version check failed — it is a stub or
|
|
761
|
+
# not properly installed (e.g. copilot prompts to install).
|
|
762
|
+
logger.warning(
|
|
763
|
+
"Agent %s found at %s but version check failed — skipping",
|
|
764
|
+
agent_id, resolved,
|
|
765
|
+
)
|
|
766
|
+
continue
|
|
759
767
|
available.append(DiscoveredAgent(
|
|
760
768
|
agent_id=agent_id,
|
|
761
769
|
command=resolved,
|
|
@@ -766,18 +774,43 @@ class AgentDiscovery:
|
|
|
766
774
|
logger.info("Discovered agent: %s v%s (%s)", agent_id, version, resolved)
|
|
767
775
|
return available
|
|
768
776
|
|
|
769
|
-
async def _get_version(self, detect_cmd: str) -> str:
|
|
777
|
+
async def _get_version(self, detect_cmd: str) -> str | None:
|
|
778
|
+
"""Run <detect_cmd> and return the first line of output as a version string.
|
|
779
|
+
|
|
780
|
+
Returns ``None`` if the command exits with a non-zero code, times out,
|
|
781
|
+
or produces output that doesn't look like a version number (e.g. an
|
|
782
|
+
interactive install prompt). Callers should treat ``None`` as
|
|
783
|
+
"binary found but not functional".
|
|
784
|
+
"""
|
|
785
|
+
import re
|
|
770
786
|
try:
|
|
771
787
|
parts = detect_cmd.split()
|
|
772
788
|
proc = await asyncio.create_subprocess_exec(
|
|
773
789
|
*parts,
|
|
790
|
+
stdin=asyncio.subprocess.DEVNULL,
|
|
774
791
|
stdout=asyncio.subprocess.PIPE,
|
|
775
792
|
stderr=asyncio.subprocess.PIPE,
|
|
776
793
|
)
|
|
777
|
-
stdout,
|
|
778
|
-
|
|
794
|
+
stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=10)
|
|
795
|
+
if proc.returncode != 0:
|
|
796
|
+
return None
|
|
797
|
+
output = stdout.decode().strip().split("\n")[0][:100]
|
|
798
|
+
if not output:
|
|
799
|
+
# Some tools write their version to stderr (e.g. some Node CLIs)
|
|
800
|
+
output = stderr.decode().strip().split("\n")[0][:100]
|
|
801
|
+
# Reject non-version output such as interactive install prompts.
|
|
802
|
+
# A valid version string contains a digit sequence like 1.2.3 or v1.2.
|
|
803
|
+
# Use re.search so we match versions embedded in text like:
|
|
804
|
+
# 'Kimi Code 1.0.0', '@openai/codex 0.1.x', 'GitHub Copilot 1.2.3'
|
|
805
|
+
if not re.search(r'v?\d+[.\d]', output):
|
|
806
|
+
logger.warning(
|
|
807
|
+
"Version check for %r returned unexpected output: %r — treating as not available",
|
|
808
|
+
detect_cmd, output,
|
|
809
|
+
)
|
|
810
|
+
return None
|
|
811
|
+
return output
|
|
779
812
|
except Exception:
|
|
780
|
-
return
|
|
813
|
+
return None
|
|
781
814
|
|
|
782
815
|
@staticmethod
|
|
783
816
|
async def _probe_bwrap_support() -> None:
|
|
@@ -4647,9 +4680,12 @@ class RuntimeDaemon:
|
|
|
4647
4680
|
timeout=10,
|
|
4648
4681
|
)
|
|
4649
4682
|
|
|
4650
|
-
# 1. Select agent — normalize legacy aliases to canonical IDs
|
|
4683
|
+
# 1. Select agent — normalize legacy aliases to canonical IDs.
|
|
4684
|
+
# When no agent_override is specified (empty/None), pass an empty
|
|
4685
|
+
# string so _select_agent falls through to its "any available L3
|
|
4686
|
+
# agent" logic instead of hard-failing on the 'claude' default.
|
|
4651
4687
|
_AGENT_ALIASES = {"claude": "claude", "kimi": "kimi"}
|
|
4652
|
-
agent_type = _AGENT_ALIASES.get(agent_override or "", agent_override or "
|
|
4688
|
+
agent_type = _AGENT_ALIASES.get(agent_override or "", agent_override or "")
|
|
4653
4689
|
agent = self._select_agent(agent_type, [])
|
|
4654
4690
|
if not agent:
|
|
4655
4691
|
_INSTALL_HINTS = {
|
|
@@ -4679,7 +4715,11 @@ class RuntimeDaemon:
|
|
|
4679
4715
|
full_prompt = f"{system_prompt}\n\n{user_prompt}" if system_prompt else user_prompt
|
|
4680
4716
|
fake_task = TaskInfo(
|
|
4681
4717
|
task_id=job_id,
|
|
4682
|
-
graph_id
|
|
4718
|
+
# Use job_id as graph_id so workspace_key is non-empty.
|
|
4719
|
+
# If graph_id="" then workspace_key="" and ws_path == project_dir
|
|
4720
|
+
# (Python: Path("x") / "" == Path("x")), causing git clone to
|
|
4721
|
+
# fail with "destination path already exists" on the second run.
|
|
4722
|
+
graph_id=job_id,
|
|
4683
4723
|
node_type="ai_job",
|
|
4684
4724
|
agent_type=agent_type,
|
|
4685
4725
|
input_prompt=full_prompt,
|
|
@@ -4704,40 +4744,86 @@ class RuntimeDaemon:
|
|
|
4704
4744
|
timeout=10,
|
|
4705
4745
|
)
|
|
4706
4746
|
|
|
4707
|
-
# 3. Run agent with prompt
|
|
4747
|
+
# 3. Run agent with prompt — stream output lines back to server in
|
|
4748
|
+
# real-time so the UI black box shows agent activity instead of
|
|
4749
|
+
# staying empty for the entire (potentially long) run.
|
|
4708
4750
|
_line_buffer: list[str] = []
|
|
4751
|
+
_chunk_state = {"sent_count": 0, "last_flush": time.monotonic()}
|
|
4752
|
+
|
|
4753
|
+
async def _flush_output_to_server():
|
|
4754
|
+
pending = _line_buffer[_chunk_state["sent_count"]:]
|
|
4755
|
+
if not pending:
|
|
4756
|
+
return
|
|
4757
|
+
try:
|
|
4758
|
+
await conn.client.post(
|
|
4759
|
+
f"{reporter_url}/progress",
|
|
4760
|
+
json={"output_lines": pending, "agent_id": agent.agent_id},
|
|
4761
|
+
timeout=5,
|
|
4762
|
+
)
|
|
4763
|
+
except Exception:
|
|
4764
|
+
pass # never let streaming errors affect agent execution
|
|
4765
|
+
_chunk_state["sent_count"] += len(pending)
|
|
4766
|
+
_chunk_state["last_flush"] = time.monotonic()
|
|
4709
4767
|
|
|
4710
4768
|
async def on_chunk(lines: list[str]):
|
|
4711
4769
|
_line_buffer.extend(lines)
|
|
4770
|
+
now = time.monotonic()
|
|
4771
|
+
pending_count = len(_line_buffer) - _chunk_state["sent_count"]
|
|
4772
|
+
# Flush every 10 new lines or every 8 seconds, whichever first
|
|
4773
|
+
if pending_count >= 10 or (now - _chunk_state["last_flush"]) >= 8.0:
|
|
4774
|
+
await _flush_output_to_server()
|
|
4712
4775
|
|
|
4713
4776
|
result = await self.process_manager.run_agent(
|
|
4714
4777
|
agent, fake_task, workspace_path, on_chunk=on_chunk,
|
|
4715
4778
|
)
|
|
4779
|
+
# Flush any remaining buffered lines after agent finishes
|
|
4780
|
+
await _flush_output_to_server()
|
|
4716
4781
|
|
|
4717
4782
|
# 4. Auto-commit if successful
|
|
4783
|
+
input_ctx = aj.get("input_context", {})
|
|
4718
4784
|
git_info = {}
|
|
4719
4785
|
if result.status == "success" and result.files_changed:
|
|
4720
4786
|
git_info = await self._auto_commit(workspace_path, fake_task)
|
|
4721
4787
|
|
|
4722
4788
|
# 5. Report completion
|
|
4723
|
-
|
|
4789
|
+
# For deliverables: allow up to 200K chars (full document); others: last 20K
|
|
4790
|
+
max_content = 200000 if task_type == "deliverable_generate" else 20000
|
|
4791
|
+
output_content = (result.stdout or "")[-max_content:] if result.stdout else ""
|
|
4724
4792
|
scripts: dict = {}
|
|
4725
4793
|
|
|
4726
|
-
|
|
4727
|
-
scenario_ids = aj.get("input_context", {}).get("scenario_ids", [])
|
|
4794
|
+
scenario_ids = input_ctx.get("scenario_ids", [])
|
|
4728
4795
|
if scenario_ids and output_content:
|
|
4729
|
-
#
|
|
4730
|
-
#
|
|
4796
|
+
# Primary: extract scripts using structured SCRIPT_START/END markers
|
|
4797
|
+
# inserted by poll_ai_jobs into the multi-scenario prompt.
|
|
4798
|
+
import re as _re
|
|
4731
4799
|
for sid in scenario_ids:
|
|
4732
|
-
|
|
4733
|
-
|
|
4734
|
-
|
|
4735
|
-
|
|
4736
|
-
|
|
4737
|
-
|
|
4738
|
-
|
|
4739
|
-
|
|
4740
|
-
|
|
4800
|
+
pattern = (
|
|
4801
|
+
r"##\s*SCRIPT_START::" + _re.escape(sid)
|
|
4802
|
+
+ r"\s*\n(.*?)\n##\s*SCRIPT_END::" + _re.escape(sid)
|
|
4803
|
+
)
|
|
4804
|
+
m = _re.search(pattern, output_content, _re.DOTALL)
|
|
4805
|
+
if m:
|
|
4806
|
+
scripts[sid] = m.group(1).strip()
|
|
4807
|
+
|
|
4808
|
+
# Fallback: if no markers found but only one scenario, treat
|
|
4809
|
+
# the entire output as that scenario's script.
|
|
4810
|
+
if not scripts and len(scenario_ids) == 1:
|
|
4811
|
+
scripts[scenario_ids[0]] = output_content.strip()
|
|
4812
|
+
|
|
4813
|
+
# Fallback: check workspace for test files named after scenario
|
|
4814
|
+
if not scripts:
|
|
4815
|
+
import glob as _glob
|
|
4816
|
+
for sid in scenario_ids:
|
|
4817
|
+
test_files = _glob.glob(
|
|
4818
|
+
str(workspace_path / "tests" / "**" / f"*{sid[:8]}*"),
|
|
4819
|
+
recursive=True,
|
|
4820
|
+
)
|
|
4821
|
+
if test_files:
|
|
4822
|
+
try:
|
|
4823
|
+
with open(test_files[0], "r") as f:
|
|
4824
|
+
scripts[sid] = f.read()
|
|
4825
|
+
except Exception:
|
|
4826
|
+
pass
|
|
4741
4827
|
|
|
4742
4828
|
complete_payload = {
|
|
4743
4829
|
"status": "success" if result.status == "success" else "failed",
|
|
@@ -5349,12 +5435,18 @@ class RuntimeDaemon:
|
|
|
5349
5435
|
self, workspace_path: Path, default_branch: str, task: TaskInfo,
|
|
5350
5436
|
project_key: str = "default",
|
|
5351
5437
|
):
|
|
5352
|
-
"""
|
|
5353
|
-
|
|
5354
|
-
Strategy
|
|
5355
|
-
|
|
5356
|
-
|
|
5357
|
-
|
|
5438
|
+
"""Integrate the current branch with ``origin/{default_branch}``.
|
|
5439
|
+
|
|
5440
|
+
Strategy:
|
|
5441
|
+
- If the current branch already exists on remote (was previously pushed):
|
|
5442
|
+
Skip rebase entirely and use merge only. Rebase rewrites commit
|
|
5443
|
+
SHAs of already-published commits, which creates divergence and
|
|
5444
|
+
requires a force-push. Many servers (e.g. Bitbucket Server with
|
|
5445
|
+
branch protection) forbid force-push, so we must preserve the
|
|
5446
|
+
existing remote history by using merge instead.
|
|
5447
|
+
- If the branch is new (first push): try rebase first for a clean
|
|
5448
|
+
linear history; fall back to merge on conflicts.
|
|
5449
|
+
- 3-tier merge fallback: merge → AI-assisted conflict resolution.
|
|
5358
5450
|
"""
|
|
5359
5451
|
git = self.workspace_manager._git
|
|
5360
5452
|
target = f"origin/{default_branch}"
|
|
@@ -5363,10 +5455,10 @@ class RuntimeDaemon:
|
|
|
5363
5455
|
try:
|
|
5364
5456
|
await git("fetch", "origin", cwd=workspace_path, timeout=300, project_key=project_key)
|
|
5365
5457
|
except RuntimeError as exc:
|
|
5366
|
-
logger.warning("Pre-push fetch failed: %s — skipping
|
|
5458
|
+
logger.warning("Pre-push fetch failed: %s — skipping integration", exc)
|
|
5367
5459
|
return
|
|
5368
5460
|
|
|
5369
|
-
# Check if
|
|
5461
|
+
# Check if integration is needed (any commits on origin/default ahead of us?)
|
|
5370
5462
|
try:
|
|
5371
5463
|
behind = await git(
|
|
5372
5464
|
"rev-list", "--count", f"HEAD..{target}", cwd=workspace_path,
|
|
@@ -5374,27 +5466,56 @@ class RuntimeDaemon:
|
|
|
5374
5466
|
if behind.strip() == "0":
|
|
5375
5467
|
logger.info("Branch is already up-to-date with %s", target)
|
|
5376
5468
|
return
|
|
5377
|
-
logger.info("Branch is %s commit(s) behind %s —
|
|
5469
|
+
logger.info("Branch is %s commit(s) behind %s — integrating", behind.strip(), target)
|
|
5378
5470
|
except RuntimeError:
|
|
5379
|
-
# Can't determine — proceed
|
|
5471
|
+
# Can't determine — proceed anyway
|
|
5380
5472
|
pass
|
|
5381
5473
|
|
|
5382
|
-
#
|
|
5474
|
+
# Determine if the current branch already exists on remote.
|
|
5475
|
+
# If it does, a rebase would rewrite the SHAs of already-pushed commits,
|
|
5476
|
+
# causing divergence that requires a force-push (often blocked by server
|
|
5477
|
+
# branch-protection rules). Use merge-only in that case.
|
|
5478
|
+
current_branch = ""
|
|
5479
|
+
remote_branch_exists = False
|
|
5383
5480
|
try:
|
|
5384
|
-
await git(
|
|
5385
|
-
"-
|
|
5386
|
-
|
|
5387
|
-
"rebase", target,
|
|
5388
|
-
cwd=workspace_path, timeout=120,
|
|
5389
|
-
)
|
|
5390
|
-
logger.info("Rebase onto %s succeeded", target)
|
|
5391
|
-
return # done — clean linear history
|
|
5481
|
+
current_branch = (await git(
|
|
5482
|
+
"rev-parse", "--abbrev-ref", "HEAD", cwd=workspace_path,
|
|
5483
|
+
)).strip()
|
|
5392
5484
|
except RuntimeError:
|
|
5393
|
-
|
|
5485
|
+
pass
|
|
5486
|
+
if current_branch and current_branch != "HEAD":
|
|
5394
5487
|
try:
|
|
5395
|
-
await git(
|
|
5488
|
+
await git(
|
|
5489
|
+
"rev-parse", "--verify", f"origin/{current_branch}",
|
|
5490
|
+
cwd=workspace_path,
|
|
5491
|
+
)
|
|
5492
|
+
remote_branch_exists = True
|
|
5493
|
+
except RuntimeError:
|
|
5494
|
+
remote_branch_exists = False
|
|
5495
|
+
|
|
5496
|
+
if not remote_branch_exists:
|
|
5497
|
+
# ── Tier 1: rebase (safe — branch not yet on remote) ──
|
|
5498
|
+
try:
|
|
5499
|
+
await git(
|
|
5500
|
+
"-c", "user.name=Forgexa Agent",
|
|
5501
|
+
"-c", "user.email=agent@forgexa.net",
|
|
5502
|
+
"rebase", target,
|
|
5503
|
+
cwd=workspace_path, timeout=120,
|
|
5504
|
+
)
|
|
5505
|
+
logger.info("Rebase onto %s succeeded", target)
|
|
5506
|
+
return # done — clean linear history
|
|
5396
5507
|
except RuntimeError:
|
|
5397
|
-
|
|
5508
|
+
logger.info("Rebase onto %s had conflicts — aborting rebase", target)
|
|
5509
|
+
try:
|
|
5510
|
+
await git("rebase", "--abort", cwd=workspace_path)
|
|
5511
|
+
except RuntimeError:
|
|
5512
|
+
pass # already aborted or not in rebase state
|
|
5513
|
+
else:
|
|
5514
|
+
logger.info(
|
|
5515
|
+
"Branch %s already exists on remote — skipping rebase to preserve "
|
|
5516
|
+
"published commit SHAs (force-push not required)",
|
|
5517
|
+
current_branch,
|
|
5518
|
+
)
|
|
5398
5519
|
|
|
5399
5520
|
# ── Tier 2: merge ──
|
|
5400
5521
|
try:
|
|
@@ -5628,6 +5749,79 @@ class RuntimeDaemon:
|
|
|
5628
5749
|
return None
|
|
5629
5750
|
except RuntimeError as exc:
|
|
5630
5751
|
logger.error("Force-push (with lease) failed for %s: %s", branch, exc)
|
|
5752
|
+
exc_str = str(exc).lower()
|
|
5753
|
+
# Detect permanent server-side force-push prohibition
|
|
5754
|
+
# (e.g. Bitbucket Server branch-protection pre-receive hook).
|
|
5755
|
+
# In this case we must NOT retry with force — instead, recover
|
|
5756
|
+
# by resetting to the remote HEAD and cherry-picking only the
|
|
5757
|
+
# truly new commits, then doing a regular (non-force) push.
|
|
5758
|
+
force_push_blocked = (
|
|
5759
|
+
"force-pushing" in exc_str
|
|
5760
|
+
or "force pushing" in exc_str
|
|
5761
|
+
or "pre-receive hook declined" in exc_str
|
|
5762
|
+
)
|
|
5763
|
+
if force_push_blocked:
|
|
5764
|
+
logger.warning(
|
|
5765
|
+
"Remote has force-push disabled for branch %s — "
|
|
5766
|
+
"attempting cherry-pick recovery to avoid force-push",
|
|
5767
|
+
branch,
|
|
5768
|
+
)
|
|
5769
|
+
try:
|
|
5770
|
+
# Identify commits that are genuinely new (not
|
|
5771
|
+
# equivalent to any remote commit). git-cherry
|
|
5772
|
+
# lines prefixed with '+' are truly missing from
|
|
5773
|
+
# origin; '-' lines are already incorporated (same
|
|
5774
|
+
# patch, different SHA — result of prior rebase).
|
|
5775
|
+
cherry_out = (await git(
|
|
5776
|
+
"cherry", "HEAD", f"origin/{branch}",
|
|
5777
|
+
cwd=workspace_path,
|
|
5778
|
+
)).strip()
|
|
5779
|
+
new_shas = [
|
|
5780
|
+
line.split()[1]
|
|
5781
|
+
for line in cherry_out.splitlines()
|
|
5782
|
+
if line.startswith("+ ")
|
|
5783
|
+
]
|
|
5784
|
+
if not new_shas:
|
|
5785
|
+
# Nothing genuinely new — remote is already
|
|
5786
|
+
# up-to-date, treat as success.
|
|
5787
|
+
logger.info(
|
|
5788
|
+
"Recovery: no truly new commits on %s — "
|
|
5789
|
+
"remote already has equivalent content",
|
|
5790
|
+
branch,
|
|
5791
|
+
)
|
|
5792
|
+
return None
|
|
5793
|
+
# Reset local branch to match remote exactly,
|
|
5794
|
+
# then replay only the new commits on top.
|
|
5795
|
+
await git(
|
|
5796
|
+
"reset", "--hard", f"origin/{branch}",
|
|
5797
|
+
cwd=workspace_path,
|
|
5798
|
+
)
|
|
5799
|
+
await git(
|
|
5800
|
+
"-c", "user.name=Forgexa Agent",
|
|
5801
|
+
"-c", "user.email=agent@forgexa.net",
|
|
5802
|
+
"cherry-pick", *new_shas,
|
|
5803
|
+
cwd=workspace_path,
|
|
5804
|
+
)
|
|
5805
|
+
# Now a regular push should succeed.
|
|
5806
|
+
await git(
|
|
5807
|
+
"push", "-u", "origin", branch,
|
|
5808
|
+
cwd=workspace_path, project_key=project_key,
|
|
5809
|
+
)
|
|
5810
|
+
logger.info(
|
|
5811
|
+
"Recovery push succeeded for branch %s "
|
|
5812
|
+
"(%d new commit(s) cherry-picked)",
|
|
5813
|
+
branch, len(new_shas),
|
|
5814
|
+
)
|
|
5815
|
+
return None
|
|
5816
|
+
except RuntimeError as recovery_exc:
|
|
5817
|
+
logger.error(
|
|
5818
|
+
"Cherry-pick recovery also failed for %s: %s",
|
|
5819
|
+
branch, recovery_exc,
|
|
5820
|
+
)
|
|
5821
|
+
return (
|
|
5822
|
+
f"Push failed: remote has force-push disabled "
|
|
5823
|
+
f"and cherry-pick recovery failed: {recovery_exc}"
|
|
5824
|
+
)
|
|
5631
5825
|
return f"Push failed: {exc}"
|
|
5632
5826
|
else:
|
|
5633
5827
|
logger.error(
|
|
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
|