forgexa-cli 1.7.8__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.7.8 → forgexa_cli-1.8.4}/PKG-INFO +1 -1
- {forgexa_cli-1.7.8 → forgexa_cli-1.8.4}/forgexa_cli/__init__.py +1 -1
- {forgexa_cli-1.7.8 → forgexa_cli-1.8.4}/forgexa_cli/daemon.py +554 -86
- {forgexa_cli-1.7.8 → forgexa_cli-1.8.4}/forgexa_cli.egg-info/PKG-INFO +1 -1
- {forgexa_cli-1.7.8 → forgexa_cli-1.8.4}/pyproject.toml +1 -1
- {forgexa_cli-1.7.8 → forgexa_cli-1.8.4}/README.md +0 -0
- {forgexa_cli-1.7.8 → forgexa_cli-1.8.4}/forgexa_cli/_build_config.py +0 -0
- {forgexa_cli-1.7.8 → forgexa_cli-1.8.4}/forgexa_cli/main.py +0 -0
- {forgexa_cli-1.7.8 → forgexa_cli-1.8.4}/forgexa_cli/py.typed +0 -0
- {forgexa_cli-1.7.8 → forgexa_cli-1.8.4}/forgexa_cli.egg-info/SOURCES.txt +0 -0
- {forgexa_cli-1.7.8 → forgexa_cli-1.8.4}/forgexa_cli.egg-info/dependency_links.txt +0 -0
- {forgexa_cli-1.7.8 → forgexa_cli-1.8.4}/forgexa_cli.egg-info/entry_points.txt +0 -0
- {forgexa_cli-1.7.8 → forgexa_cli-1.8.4}/forgexa_cli.egg-info/requires.txt +0 -0
- {forgexa_cli-1.7.8 → forgexa_cli-1.8.4}/forgexa_cli.egg-info/top_level.txt +0 -0
- {forgexa_cli-1.7.8 → forgexa_cli-1.8.4}/setup.cfg +0 -0
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
"""forgexa-cli — Forgexa command-line client."""
|
|
2
|
-
__version__ = "1.
|
|
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.
|
|
335
|
+
DAEMON_VERSION = "1.8.4"
|
|
336
336
|
|
|
337
337
|
|
|
338
338
|
def _detect_client_type() -> str:
|
|
@@ -611,7 +611,7 @@ class AgentDiscovery:
|
|
|
611
611
|
"""Scans for locally installed Agent CLI tools."""
|
|
612
612
|
|
|
613
613
|
AGENT_REGISTRY = {
|
|
614
|
-
"claude
|
|
614
|
+
"claude": {
|
|
615
615
|
"commands": ["claude"],
|
|
616
616
|
"detect": "claude --version",
|
|
617
617
|
"invoke_modes": ["print", "app-server"],
|
|
@@ -642,19 +642,19 @@ class AgentDiscovery:
|
|
|
642
642
|
"env_path_override": "FACTORY_GEMINI_PATH",
|
|
643
643
|
"compatibility_level": "L1",
|
|
644
644
|
},
|
|
645
|
-
"kimi
|
|
645
|
+
"kimi": {
|
|
646
646
|
"commands": ["kimi"],
|
|
647
647
|
"detect": "kimi --version",
|
|
648
648
|
"invoke_modes": ["cli"],
|
|
649
649
|
"env_path_override": "FACTORY_KIMI_PATH",
|
|
650
650
|
"compatibility_level": "L2",
|
|
651
651
|
},
|
|
652
|
-
"
|
|
653
|
-
"commands": ["
|
|
654
|
-
"detect": "
|
|
652
|
+
"copilot": {
|
|
653
|
+
"commands": ["copilot"],
|
|
654
|
+
"detect": "copilot --version",
|
|
655
655
|
"invoke_modes": ["cli"],
|
|
656
|
-
"env_path_override": "
|
|
657
|
-
"compatibility_level": "
|
|
656
|
+
"env_path_override": "FACTORY_COPILOT_PATH",
|
|
657
|
+
"compatibility_level": "L3",
|
|
658
658
|
},
|
|
659
659
|
}
|
|
660
660
|
|
|
@@ -711,6 +711,24 @@ class AgentDiscovery:
|
|
|
711
711
|
Path("/usr/local/bin"),
|
|
712
712
|
Path("/opt/homebrew/bin"),
|
|
713
713
|
]
|
|
714
|
+
# GitHub Copilot CLI — installed via VS Code extension into globalStorage
|
|
715
|
+
vscode_copilot = (
|
|
716
|
+
home / "Library" / "Application Support" / "Code" / "User"
|
|
717
|
+
/ "globalStorage" / "github.copilot-chat" / "copilotCli"
|
|
718
|
+
)
|
|
719
|
+
extra_dirs.append(vscode_copilot)
|
|
720
|
+
for vs_variant in ("Code - Insiders", "VSCodium"):
|
|
721
|
+
extra_dirs.append(
|
|
722
|
+
home / "Library" / "Application Support" / vs_variant / "User"
|
|
723
|
+
/ "globalStorage" / "github.copilot-chat" / "copilotCli"
|
|
724
|
+
)
|
|
725
|
+
if sys.platform == "linux":
|
|
726
|
+
# GitHub Copilot CLI on Linux VSCode
|
|
727
|
+
for config_dir in (
|
|
728
|
+
home / ".config" / "Code" / "User" / "globalStorage" / "github.copilot-chat" / "copilotCli",
|
|
729
|
+
home / ".config" / "Code - Insiders" / "User" / "globalStorage" / "github.copilot-chat" / "copilotCli",
|
|
730
|
+
):
|
|
731
|
+
extra_dirs.append(config_dir)
|
|
714
732
|
# nvm (macOS + Linux)
|
|
715
733
|
nvm_dir = os.environ.get("NVM_DIR", str(home / ".nvm"))
|
|
716
734
|
nvm_path = Path(nvm_dir)
|
|
@@ -738,6 +756,14 @@ class AgentDiscovery:
|
|
|
738
756
|
resolved = shutil.which(cmd)
|
|
739
757
|
if resolved:
|
|
740
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
|
|
741
767
|
available.append(DiscoveredAgent(
|
|
742
768
|
agent_id=agent_id,
|
|
743
769
|
command=resolved,
|
|
@@ -748,18 +774,43 @@ class AgentDiscovery:
|
|
|
748
774
|
logger.info("Discovered agent: %s v%s (%s)", agent_id, version, resolved)
|
|
749
775
|
return available
|
|
750
776
|
|
|
751
|
-
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
|
|
752
786
|
try:
|
|
753
787
|
parts = detect_cmd.split()
|
|
754
788
|
proc = await asyncio.create_subprocess_exec(
|
|
755
789
|
*parts,
|
|
790
|
+
stdin=asyncio.subprocess.DEVNULL,
|
|
756
791
|
stdout=asyncio.subprocess.PIPE,
|
|
757
792
|
stderr=asyncio.subprocess.PIPE,
|
|
758
793
|
)
|
|
759
|
-
stdout,
|
|
760
|
-
|
|
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
|
|
761
812
|
except Exception:
|
|
762
|
-
return
|
|
813
|
+
return None
|
|
763
814
|
|
|
764
815
|
@staticmethod
|
|
765
816
|
async def _probe_bwrap_support() -> None:
|
|
@@ -1732,24 +1783,49 @@ class ProcessManager:
|
|
|
1732
1783
|
error_messages.append(err.get("message", "turn failed"))
|
|
1733
1784
|
elif isinstance(err, str):
|
|
1734
1785
|
error_messages.append(err)
|
|
1786
|
+
# ── GitHub Copilot CLI event types ──────────────────────────────
|
|
1787
|
+
elif ev_type == "assistant.turn_end":
|
|
1788
|
+
has_turn_completed = True
|
|
1789
|
+
elif ev_type == "assistant.turn_start":
|
|
1790
|
+
has_assistant_events = True
|
|
1791
|
+
elif ev_type == "assistant.message":
|
|
1792
|
+
has_meaningful_content = True
|
|
1793
|
+
has_assistant_events = True
|
|
1794
|
+
msg_data = data.get("data") or {}
|
|
1795
|
+
if isinstance(msg_data.get("content"), str) and msg_data["content"].strip():
|
|
1796
|
+
has_result = True
|
|
1797
|
+
elif ev_type == "assistant.message_delta":
|
|
1798
|
+
has_meaningful_content = True
|
|
1799
|
+
# ── Generic / Claude "result" event ────────────────────────────
|
|
1735
1800
|
elif ev_type == "result":
|
|
1736
|
-
|
|
1737
|
-
|
|
1738
|
-
|
|
1739
|
-
|
|
1740
|
-
|
|
1741
|
-
|
|
1742
|
-
# events appeared, the CLI never made an API call. The result
|
|
1743
|
-
# text is a CLI-level error (e.g. "API Error: Connection error.")
|
|
1744
|
-
# rather than the agent's actual work output.
|
|
1745
|
-
tok_in = int(data.get("total_input_tokens", 0) or 0)
|
|
1746
|
-
tok_out = int(data.get("total_output_tokens", 0) or 0)
|
|
1747
|
-
no_api_call = (tok_in + tok_out == 0) and not has_assistant_events
|
|
1748
|
-
if no_api_call and result_text:
|
|
1749
|
-
error_messages.append(result_text)
|
|
1750
|
-
else:
|
|
1801
|
+
# Copilot result format: {"type":"result","exitCode":0,"usage":{...}}
|
|
1802
|
+
# Claude result format: {"type":"result","result":"...","is_error":false,...}
|
|
1803
|
+
if "exitCode" in data:
|
|
1804
|
+
# Copilot JSONL result
|
|
1805
|
+
exit_code = int(data.get("exitCode") or 0)
|
|
1806
|
+
if exit_code == 0:
|
|
1751
1807
|
has_result = True
|
|
1752
1808
|
has_meaningful_content = True
|
|
1809
|
+
else:
|
|
1810
|
+
error_messages.append(f"Copilot exited with code {exit_code}")
|
|
1811
|
+
else:
|
|
1812
|
+
result_text = str(data.get("result", "") or "")
|
|
1813
|
+
if data.get("is_error"):
|
|
1814
|
+
err_text = result_text or str(data.get("error", "") or "result marked as error")
|
|
1815
|
+
error_messages.append(err_text)
|
|
1816
|
+
else:
|
|
1817
|
+
# Structural check: if no tokens were consumed AND no assistant
|
|
1818
|
+
# events appeared, the CLI never made an API call. The result
|
|
1819
|
+
# text is a CLI-level error (e.g. "API Error: Connection error.")
|
|
1820
|
+
# rather than the agent's actual work output.
|
|
1821
|
+
tok_in = int(data.get("total_input_tokens", 0) or 0)
|
|
1822
|
+
tok_out = int(data.get("total_output_tokens", 0) or 0)
|
|
1823
|
+
no_api_call = (tok_in + tok_out == 0) and not has_assistant_events
|
|
1824
|
+
if no_api_call and result_text:
|
|
1825
|
+
error_messages.append(result_text)
|
|
1826
|
+
else:
|
|
1827
|
+
has_result = True
|
|
1828
|
+
has_meaningful_content = True
|
|
1753
1829
|
elif ev_type == "error":
|
|
1754
1830
|
msg = data.get("message", "")
|
|
1755
1831
|
if msg:
|
|
@@ -1895,10 +1971,15 @@ class ProcessManager:
|
|
|
1895
1971
|
return f"Agent encountered errors without producing output: {error_messages[0]}"
|
|
1896
1972
|
|
|
1897
1973
|
# ── Claude: JSON output mode but no result object and no content ──
|
|
1898
|
-
if agent_id == "claude
|
|
1974
|
+
if agent_id == "claude" and json_line_count > 0:
|
|
1899
1975
|
if not has_result and not has_meaningful_content:
|
|
1900
1976
|
return "Claude produced no result output"
|
|
1901
1977
|
|
|
1978
|
+
# ── Copilot: JSONL mode but no turn completion and no content ──
|
|
1979
|
+
if agent_id == "copilot" and json_line_count > 0:
|
|
1980
|
+
if not has_result and not has_meaningful_content:
|
|
1981
|
+
return "Copilot produced no result output (check GitHub authentication: run 'gh auth login')"
|
|
1982
|
+
|
|
1902
1983
|
return None
|
|
1903
1984
|
|
|
1904
1985
|
async def run_agent(
|
|
@@ -1915,7 +1996,7 @@ class ProcessManager:
|
|
|
1915
1996
|
|
|
1916
1997
|
start_time = time.monotonic()
|
|
1917
1998
|
|
|
1918
|
-
if agent.agent_id == "claude
|
|
1999
|
+
if agent.agent_id == "claude":
|
|
1919
2000
|
result = await self._run_claude(
|
|
1920
2001
|
agent, prompt, workspace_path, timeout, task.task_id, on_chunk,
|
|
1921
2002
|
node_type=task.node_type,
|
|
@@ -1926,8 +2007,10 @@ class ProcessManager:
|
|
|
1926
2007
|
result = await self._run_opencode(agent, prompt, workspace_path, timeout, task.task_id, on_chunk)
|
|
1927
2008
|
elif agent.agent_id == "gemini":
|
|
1928
2009
|
result = await self._run_gemini(agent, prompt, workspace_path, timeout, task.task_id, on_chunk)
|
|
1929
|
-
elif agent.agent_id == "kimi
|
|
2010
|
+
elif agent.agent_id == "kimi":
|
|
1930
2011
|
result = await self._run_kimi_code(agent, prompt, workspace_path, timeout, task.task_id, on_chunk)
|
|
2012
|
+
elif agent.agent_id == "copilot":
|
|
2013
|
+
result = await self._run_copilot(agent, prompt, workspace_path, timeout, task.task_id, on_chunk)
|
|
1931
2014
|
else:
|
|
1932
2015
|
result = await self._run_generic(agent, prompt, workspace_path, timeout, task.task_id, on_chunk)
|
|
1933
2016
|
|
|
@@ -2386,6 +2469,101 @@ class ProcessManager:
|
|
|
2386
2469
|
result.metrics.update(parsed_metrics)
|
|
2387
2470
|
return result
|
|
2388
2471
|
|
|
2472
|
+
async def _run_copilot(
|
|
2473
|
+
self, agent: DiscoveredAgent, prompt: str, cwd: Path, timeout: int, task_id: str,
|
|
2474
|
+
on_chunk: Any = None,
|
|
2475
|
+
) -> TaskResult:
|
|
2476
|
+
"""Run GitHub Copilot CLI in non-interactive JSON-streaming mode.
|
|
2477
|
+
|
|
2478
|
+
Uses TERM=dumb to suppress TTY-detection (copilot suspends when it
|
|
2479
|
+
can't acquire a pseudo-terminal). Requires GitHub login:
|
|
2480
|
+
``gh auth login`` or GITHUB_TOKEN env var must be set.
|
|
2481
|
+
|
|
2482
|
+
Flags:
|
|
2483
|
+
-p / --prompt Non-interactive prompt (exits after completion).
|
|
2484
|
+
--output-format json JSONL stream of session events.
|
|
2485
|
+
--allow-all Grant all tool + path + URL permissions required
|
|
2486
|
+
for autonomous file-editing tasks.
|
|
2487
|
+
-C <dir> Change working directory before execution.
|
|
2488
|
+
"""
|
|
2489
|
+
env = os.environ.copy()
|
|
2490
|
+
env["TERM"] = "dumb" # suppress TTY-detection that suspends the process
|
|
2491
|
+
|
|
2492
|
+
model_override = os.environ.get("FACTORY_COPILOT_MODEL")
|
|
2493
|
+
reasoning = os.environ.get("FACTORY_COPILOT_REASONING", "medium")
|
|
2494
|
+
|
|
2495
|
+
cmd = [
|
|
2496
|
+
agent.command,
|
|
2497
|
+
"--output-format", "json",
|
|
2498
|
+
"--allow-all",
|
|
2499
|
+
"--effort", reasoning,
|
|
2500
|
+
"-C", str(cwd),
|
|
2501
|
+
"-p", prompt,
|
|
2502
|
+
]
|
|
2503
|
+
if model_override:
|
|
2504
|
+
cmd = [agent.command, "--model", model_override] + cmd[1:]
|
|
2505
|
+
|
|
2506
|
+
try:
|
|
2507
|
+
proc = await asyncio.create_subprocess_exec(
|
|
2508
|
+
*cmd,
|
|
2509
|
+
stdout=asyncio.subprocess.PIPE,
|
|
2510
|
+
stderr=asyncio.subprocess.PIPE,
|
|
2511
|
+
cwd=str(cwd),
|
|
2512
|
+
env=env,
|
|
2513
|
+
limit=100 * 1024 * 1024,
|
|
2514
|
+
start_new_session=True,
|
|
2515
|
+
)
|
|
2516
|
+
self.active_processes[task_id] = proc
|
|
2517
|
+
stdout, stderr, returncode = await self._stream_process(
|
|
2518
|
+
proc, None, timeout, task_id, on_chunk
|
|
2519
|
+
)
|
|
2520
|
+
|
|
2521
|
+
# Parse copilot JSONL output for metrics
|
|
2522
|
+
metrics = self._parse_copilot_output(stdout)
|
|
2523
|
+
|
|
2524
|
+
# Copilot always exits 0 on normal completion; check result.exitCode
|
|
2525
|
+
# from the JSONL "result" event for a true success signal.
|
|
2526
|
+
copilot_exit = self._extract_copilot_exit_code(stdout)
|
|
2527
|
+
effective_rc = copilot_exit if copilot_exit is not None else returncode
|
|
2528
|
+
|
|
2529
|
+
if effective_rc == 0 and returncode == 0:
|
|
2530
|
+
return TaskResult(
|
|
2531
|
+
status="success",
|
|
2532
|
+
exit_code=0,
|
|
2533
|
+
stdout=stdout[-settings.AGENT_MAX_OUTPUT_SIZE:],
|
|
2534
|
+
stderr=stderr[-10000:],
|
|
2535
|
+
metrics=metrics,
|
|
2536
|
+
)
|
|
2537
|
+
else:
|
|
2538
|
+
return TaskResult(
|
|
2539
|
+
status="failed",
|
|
2540
|
+
exit_code=effective_rc,
|
|
2541
|
+
stdout=stdout[-settings.AGENT_MAX_OUTPUT_SIZE:],
|
|
2542
|
+
stderr=stderr[-10000:],
|
|
2543
|
+
error=f"Copilot exited with code {effective_rc}: {stderr[-500:]}",
|
|
2544
|
+
metrics=metrics,
|
|
2545
|
+
)
|
|
2546
|
+
except asyncio.TimeoutError:
|
|
2547
|
+
if task_id in self.active_processes:
|
|
2548
|
+
self.active_processes[task_id].kill()
|
|
2549
|
+
return TaskResult(
|
|
2550
|
+
status="failed", exit_code=-1, stdout="", stderr="",
|
|
2551
|
+
error=f"Timed out after {timeout}s",
|
|
2552
|
+
)
|
|
2553
|
+
except Exception as exc:
|
|
2554
|
+
logger.exception("Copilot stream error for task %s", task_id)
|
|
2555
|
+
if task_id in self.active_processes:
|
|
2556
|
+
try:
|
|
2557
|
+
self.active_processes[task_id].kill()
|
|
2558
|
+
except Exception:
|
|
2559
|
+
pass
|
|
2560
|
+
return TaskResult(
|
|
2561
|
+
status="failed", exit_code=-1, stdout="", stderr="",
|
|
2562
|
+
error=f"Stream processing error: {exc}",
|
|
2563
|
+
)
|
|
2564
|
+
finally:
|
|
2565
|
+
self.active_processes.pop(task_id, None)
|
|
2566
|
+
|
|
2389
2567
|
async def _run_generic(
|
|
2390
2568
|
self, agent: DiscoveredAgent, prompt: str, cwd: Path, timeout: int, task_id: str,
|
|
2391
2569
|
on_chunk: Any = None,
|
|
@@ -2702,6 +2880,79 @@ class ProcessManager:
|
|
|
2702
2880
|
metrics["token_output"] = output
|
|
2703
2881
|
return metrics
|
|
2704
2882
|
|
|
2883
|
+
def _parse_copilot_output(self, stdout: str) -> dict:
|
|
2884
|
+
"""Extract metrics from GitHub Copilot CLI JSONL output.
|
|
2885
|
+
|
|
2886
|
+
Copilot CLI (--output-format json) emits one JSON object per line.
|
|
2887
|
+
The key event types:
|
|
2888
|
+
- ``assistant.message`` -> content + model + outputTokens per turn
|
|
2889
|
+
- ``result`` -> exitCode + usage (premiumRequests,
|
|
2890
|
+
totalApiDurationMs, codeChanges)
|
|
2891
|
+
|
|
2892
|
+
Copilot is subscription-based and does NOT report USD cost or input
|
|
2893
|
+
tokens, so those fields are intentionally omitted.
|
|
2894
|
+
"""
|
|
2895
|
+
metrics: dict[str, Any] = {}
|
|
2896
|
+
total_output_tokens = 0
|
|
2897
|
+
model_seen: str | None = None
|
|
2898
|
+
|
|
2899
|
+
for raw in stdout.strip().split("\n"):
|
|
2900
|
+
raw = raw.strip()
|
|
2901
|
+
if not raw:
|
|
2902
|
+
continue
|
|
2903
|
+
try:
|
|
2904
|
+
data = json.loads(raw)
|
|
2905
|
+
except json.JSONDecodeError:
|
|
2906
|
+
continue
|
|
2907
|
+
if not isinstance(data, dict):
|
|
2908
|
+
continue
|
|
2909
|
+
|
|
2910
|
+
ev_type = str(data.get("type", ""))
|
|
2911
|
+
|
|
2912
|
+
if ev_type == "assistant.message":
|
|
2913
|
+
msg_data = data.get("data") or {}
|
|
2914
|
+
out_tokens = msg_data.get("outputTokens")
|
|
2915
|
+
if out_tokens:
|
|
2916
|
+
total_output_tokens += int(out_tokens)
|
|
2917
|
+
if not model_seen and isinstance(msg_data.get("model"), str):
|
|
2918
|
+
model_seen = msg_data["model"]
|
|
2919
|
+
|
|
2920
|
+
elif ev_type == "result":
|
|
2921
|
+
usage = data.get("usage") or {}
|
|
2922
|
+
premium_reqs = usage.get("premiumRequests")
|
|
2923
|
+
if premium_reqs is not None:
|
|
2924
|
+
metrics["premium_requests"] = int(premium_reqs)
|
|
2925
|
+
api_ms = usage.get("totalApiDurationMs")
|
|
2926
|
+
if api_ms:
|
|
2927
|
+
metrics["api_duration_ms"] = int(api_ms)
|
|
2928
|
+
changes = usage.get("codeChanges") or {}
|
|
2929
|
+
if changes.get("linesAdded") or changes.get("linesRemoved"):
|
|
2930
|
+
metrics["lines_added"] = int(changes.get("linesAdded") or 0)
|
|
2931
|
+
metrics["lines_removed"] = int(changes.get("linesRemoved") or 0)
|
|
2932
|
+
|
|
2933
|
+
if total_output_tokens:
|
|
2934
|
+
metrics["token_output"] = total_output_tokens
|
|
2935
|
+
if model_seen:
|
|
2936
|
+
metrics["model"] = model_seen
|
|
2937
|
+
return metrics
|
|
2938
|
+
|
|
2939
|
+
@staticmethod
|
|
2940
|
+
def _extract_copilot_exit_code(stdout: str) -> int | None:
|
|
2941
|
+
"""Extract the exitCode from Copilot JSONL ``result`` event."""
|
|
2942
|
+
for raw in reversed(stdout.strip().split("\n")):
|
|
2943
|
+
raw = raw.strip()
|
|
2944
|
+
if not raw:
|
|
2945
|
+
continue
|
|
2946
|
+
try:
|
|
2947
|
+
data = json.loads(raw)
|
|
2948
|
+
except json.JSONDecodeError:
|
|
2949
|
+
continue
|
|
2950
|
+
if isinstance(data, dict) and data.get("type") == "result":
|
|
2951
|
+
ec = data.get("exitCode")
|
|
2952
|
+
if ec is not None:
|
|
2953
|
+
return int(ec)
|
|
2954
|
+
return None
|
|
2955
|
+
|
|
2705
2956
|
async def _collect_git_info(self, cwd: Path) -> dict:
|
|
2706
2957
|
"""Collect git diff stats from workspace."""
|
|
2707
2958
|
info: dict[str, Any] = {}
|
|
@@ -2779,7 +3030,7 @@ class ProcessManager:
|
|
|
2779
3030
|
"""Collect git diff stats comparing HEAD vs merge-base with default branch.
|
|
2780
3031
|
|
|
2781
3032
|
Uses merge-base to capture ALL changes on the feature branch, not just
|
|
2782
|
-
the last commit (agents like claude
|
|
3033
|
+
the last commit (agents like claude may create multiple commits).
|
|
2783
3034
|
Falls back to HEAD~1 if merge-base detection fails.
|
|
2784
3035
|
"""
|
|
2785
3036
|
info: dict[str, Any] = {}
|
|
@@ -3140,7 +3391,7 @@ class TaskPoller:
|
|
|
3140
3391
|
task_id=t["task_id"],
|
|
3141
3392
|
graph_id=t["graph_id"],
|
|
3142
3393
|
node_type=t["node_type"],
|
|
3143
|
-
agent_type=t.get("agent_type", "claude
|
|
3394
|
+
agent_type=t.get("agent_type", "claude"),
|
|
3144
3395
|
input_prompt=t.get("input_prompt", ""),
|
|
3145
3396
|
input_data=t.get("input_data", {}),
|
|
3146
3397
|
timeout_seconds=t.get("timeout_seconds", settings.AGENT_TIMEOUT),
|
|
@@ -3787,10 +4038,22 @@ class RuntimeDaemon:
|
|
|
3787
4038
|
# 1. Find the right agent
|
|
3788
4039
|
agent = self._select_agent(task.agent_type, task.fallback_chain)
|
|
3789
4040
|
if not agent:
|
|
3790
|
-
|
|
4041
|
+
_INSTALL_HINTS = {
|
|
4042
|
+
"claude": "npm install -g @anthropic-ai/claude-code",
|
|
4043
|
+
"codex": "npm install -g @openai/codex",
|
|
4044
|
+
"opencode": "curl -sSL https://opencode.ai/install | sh",
|
|
4045
|
+
"gemini": "npm install -g @google/gemini-cli",
|
|
4046
|
+
"kimi": "curl -LsSf https://code.kimi.com/install.sh | bash",
|
|
4047
|
+
"copilot": "Install VS Code GitHub Copilot Chat extension, or run: gh copilot. Then: gh auth login",
|
|
4048
|
+
}
|
|
4049
|
+
hint = _INSTALL_HINTS.get(task.agent_type, f"install the '{task.agent_type}' CLI tool")
|
|
4050
|
+
logger.error("No agent found for type '%s' on this runtime", task.agent_type)
|
|
3791
4051
|
await reporter.report_complete(task.task_id, TaskResult(
|
|
3792
4052
|
status="failed", exit_code=-1, stdout="", stderr="",
|
|
3793
|
-
error=
|
|
4053
|
+
error=(
|
|
4054
|
+
f"Agent '{task.agent_type}' is not available on this runtime. "
|
|
4055
|
+
f"Install it: {hint}"
|
|
4056
|
+
),
|
|
3794
4057
|
))
|
|
3795
4058
|
return
|
|
3796
4059
|
|
|
@@ -3818,15 +4081,22 @@ class RuntimeDaemon:
|
|
|
3818
4081
|
_line_buffer.extend(lines)
|
|
3819
4082
|
|
|
3820
4083
|
async def _progress_ticker():
|
|
3821
|
-
"""Flush buffered output lines + update progress % every
|
|
4084
|
+
"""Flush buffered output lines + update progress % every 1 s.
|
|
4085
|
+
|
|
4086
|
+
Using 1-second ticks keeps the UI responsive without flooding
|
|
4087
|
+
the backend. Empty ticks are skipped to reduce HTTP traffic.
|
|
4088
|
+
"""
|
|
3822
4089
|
import math as _math
|
|
3823
4090
|
tick = 0
|
|
3824
4091
|
while not progress_stop.is_set():
|
|
3825
|
-
await asyncio.sleep(
|
|
4092
|
+
await asyncio.sleep(1)
|
|
3826
4093
|
if progress_stop.is_set():
|
|
3827
4094
|
break
|
|
4095
|
+
if not _line_buffer and tick < 3:
|
|
4096
|
+
tick += 1
|
|
4097
|
+
continue
|
|
3828
4098
|
tick += 1
|
|
3829
|
-
pct = min(int(10 + 80 * (1 - 1 / (1 + tick /
|
|
4099
|
+
pct = min(int(10 + 80 * (1 - 1 / (1 + tick / 80))), 90)
|
|
3830
4100
|
pid = self.process_manager.active_processes.get(task.task_id)
|
|
3831
4101
|
step = "running_agent"
|
|
3832
4102
|
if pid:
|
|
@@ -3912,11 +4182,14 @@ class RuntimeDaemon:
|
|
|
3912
4182
|
async def _progress_ticker2():
|
|
3913
4183
|
tick = 0
|
|
3914
4184
|
while not progress_stop2.is_set():
|
|
3915
|
-
await asyncio.sleep(
|
|
4185
|
+
await asyncio.sleep(1)
|
|
3916
4186
|
if progress_stop2.is_set():
|
|
3917
4187
|
break
|
|
4188
|
+
if not _line_buffer and tick < 3:
|
|
4189
|
+
tick += 1
|
|
4190
|
+
continue
|
|
3918
4191
|
tick += 1
|
|
3919
|
-
pct = min(int(10 + 80 * (1 - 1 / (1 + tick /
|
|
4192
|
+
pct = min(int(10 + 80 * (1 - 1 / (1 + tick / 80))), 90)
|
|
3920
4193
|
pid = self.process_manager.active_processes.get(task.task_id)
|
|
3921
4194
|
step = f"running_agent:{agent.agent_id}"
|
|
3922
4195
|
if pid:
|
|
@@ -4136,8 +4409,8 @@ class RuntimeDaemon:
|
|
|
4136
4409
|
if agent.agent_id == fallback_id:
|
|
4137
4410
|
return agent
|
|
4138
4411
|
|
|
4139
|
-
# 2. Try any available agent not yet tried (prefer opencode > gemini > claude
|
|
4140
|
-
preferred_order = ["opencode", "gemini", "claude
|
|
4412
|
+
# 2. Try any available agent not yet tried (prefer opencode > copilot > gemini > claude)
|
|
4413
|
+
preferred_order = ["opencode", "copilot", "gemini", "claude"]
|
|
4141
4414
|
for preferred_id in preferred_order:
|
|
4142
4415
|
if preferred_id in tried:
|
|
4143
4416
|
continue
|
|
@@ -4152,7 +4425,17 @@ class RuntimeDaemon:
|
|
|
4152
4425
|
return None
|
|
4153
4426
|
|
|
4154
4427
|
def _select_agent(self, agent_type: str, fallback_chain: list[str] | None = None) -> DiscoveredAgent | None:
|
|
4155
|
-
"""Find best matching agent for the requested type with fallback chain support.
|
|
4428
|
+
"""Find best matching agent for the requested type with fallback chain support.
|
|
4429
|
+
|
|
4430
|
+
Returns ``None`` when the requested agent is a known canonical ID but is not
|
|
4431
|
+
currently installed/discovered on this machine — callers should report a clear
|
|
4432
|
+
error rather than silently running a different agent.
|
|
4433
|
+
|
|
4434
|
+
The "any available agent" fallbacks (steps 3–4) only apply when ``agent_type``
|
|
4435
|
+
is NOT a recognized canonical ID (e.g. empty string, "auto", or an unknown
|
|
4436
|
+
custom identifier). This prevents silent substitution when the user explicitly
|
|
4437
|
+
selected an agent that is just not installed locally.
|
|
4438
|
+
"""
|
|
4156
4439
|
# 1. Exact match
|
|
4157
4440
|
for agent in self.agents:
|
|
4158
4441
|
if agent.agent_id == agent_type:
|
|
@@ -4168,11 +4451,18 @@ class RuntimeDaemon:
|
|
|
4168
4451
|
logger.info("Using fallback agent '%s' (requested '%s')", fallback_id, agent_type)
|
|
4169
4452
|
return agent
|
|
4170
4453
|
|
|
4171
|
-
# 3.
|
|
4454
|
+
# 3. If the requested agent_type is a KNOWN canonical ID (registered in
|
|
4455
|
+
# AGENT_REGISTRY) but not discovered locally, return None so the caller
|
|
4456
|
+
# can surface a clear "agent not installed" error instead of silently
|
|
4457
|
+
# using whatever L3 agent happens to be available.
|
|
4458
|
+
if agent_type in AgentDiscovery.AGENT_REGISTRY:
|
|
4459
|
+
return None
|
|
4460
|
+
|
|
4461
|
+
# 4. Fallback: first available L3 agent (only for unrecognized/generic types)
|
|
4172
4462
|
for agent in self.agents:
|
|
4173
4463
|
if agent.compatibility_level == "L3":
|
|
4174
4464
|
return agent
|
|
4175
|
-
#
|
|
4465
|
+
# 5. Any agent (last resort for unrecognized types)
|
|
4176
4466
|
return self.agents[0] if self.agents else None
|
|
4177
4467
|
|
|
4178
4468
|
# ── Layer 2: Validation Gate ──
|
|
@@ -4390,13 +4680,33 @@ class RuntimeDaemon:
|
|
|
4390
4680
|
timeout=10,
|
|
4391
4681
|
)
|
|
4392
4682
|
|
|
4393
|
-
# 1. Select agent
|
|
4394
|
-
|
|
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.
|
|
4687
|
+
_AGENT_ALIASES = {"claude": "claude", "kimi": "kimi"}
|
|
4688
|
+
agent_type = _AGENT_ALIASES.get(agent_override or "", agent_override or "")
|
|
4395
4689
|
agent = self._select_agent(agent_type, [])
|
|
4396
4690
|
if not agent:
|
|
4691
|
+
_INSTALL_HINTS = {
|
|
4692
|
+
"claude": "npm install -g @anthropic-ai/claude-code",
|
|
4693
|
+
"codex": "npm install -g @openai/codex",
|
|
4694
|
+
"opencode": "curl -sSL https://opencode.ai/install | sh",
|
|
4695
|
+
"gemini": "npm install -g @google/gemini-cli",
|
|
4696
|
+
"kimi": "curl -LsSf https://code.kimi.com/install.sh | bash",
|
|
4697
|
+
"copilot": "Install VS Code GitHub Copilot Chat extension, or run: gh copilot. Then: gh auth login",
|
|
4698
|
+
}
|
|
4699
|
+
hint = _INSTALL_HINTS.get(agent_type, f"install the '{agent_type}' CLI tool")
|
|
4397
4700
|
await conn.client.post(
|
|
4398
4701
|
f"{reporter_url}/complete",
|
|
4399
|
-
json={
|
|
4702
|
+
json={
|
|
4703
|
+
"status": "failed",
|
|
4704
|
+
"error": (
|
|
4705
|
+
f"Agent '{agent_type}' is not available on this runtime. "
|
|
4706
|
+
f"Install it: {hint}"
|
|
4707
|
+
),
|
|
4708
|
+
"failure_code": "no_agent",
|
|
4709
|
+
},
|
|
4400
4710
|
timeout=10,
|
|
4401
4711
|
)
|
|
4402
4712
|
return
|
|
@@ -4405,7 +4715,11 @@ class RuntimeDaemon:
|
|
|
4405
4715
|
full_prompt = f"{system_prompt}\n\n{user_prompt}" if system_prompt else user_prompt
|
|
4406
4716
|
fake_task = TaskInfo(
|
|
4407
4717
|
task_id=job_id,
|
|
4408
|
-
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,
|
|
4409
4723
|
node_type="ai_job",
|
|
4410
4724
|
agent_type=agent_type,
|
|
4411
4725
|
input_prompt=full_prompt,
|
|
@@ -4430,40 +4744,86 @@ class RuntimeDaemon:
|
|
|
4430
4744
|
timeout=10,
|
|
4431
4745
|
)
|
|
4432
4746
|
|
|
4433
|
-
# 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.
|
|
4434
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()
|
|
4435
4767
|
|
|
4436
4768
|
async def on_chunk(lines: list[str]):
|
|
4437
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()
|
|
4438
4775
|
|
|
4439
4776
|
result = await self.process_manager.run_agent(
|
|
4440
4777
|
agent, fake_task, workspace_path, on_chunk=on_chunk,
|
|
4441
4778
|
)
|
|
4779
|
+
# Flush any remaining buffered lines after agent finishes
|
|
4780
|
+
await _flush_output_to_server()
|
|
4442
4781
|
|
|
4443
4782
|
# 4. Auto-commit if successful
|
|
4783
|
+
input_ctx = aj.get("input_context", {})
|
|
4444
4784
|
git_info = {}
|
|
4445
4785
|
if result.status == "success" and result.files_changed:
|
|
4446
4786
|
git_info = await self._auto_commit(workspace_path, fake_task)
|
|
4447
4787
|
|
|
4448
4788
|
# 5. Report completion
|
|
4449
|
-
|
|
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 ""
|
|
4450
4792
|
scripts: dict = {}
|
|
4451
4793
|
|
|
4452
|
-
|
|
4453
|
-
scenario_ids = aj.get("input_context", {}).get("scenario_ids", [])
|
|
4794
|
+
scenario_ids = input_ctx.get("scenario_ids", [])
|
|
4454
4795
|
if scenario_ids and output_content:
|
|
4455
|
-
#
|
|
4456
|
-
#
|
|
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
|
|
4457
4799
|
for sid in scenario_ids:
|
|
4458
|
-
|
|
4459
|
-
|
|
4460
|
-
|
|
4461
|
-
|
|
4462
|
-
|
|
4463
|
-
|
|
4464
|
-
|
|
4465
|
-
|
|
4466
|
-
|
|
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
|
|
4467
4827
|
|
|
4468
4828
|
complete_payload = {
|
|
4469
4829
|
"status": "success" if result.status == "success" else "failed",
|
|
@@ -4683,7 +5043,7 @@ class RuntimeDaemon:
|
|
|
4683
5043
|
async def _auto_commit(self, workspace_path: Path, task: TaskInfo) -> dict:
|
|
4684
5044
|
"""Auto-commit and push agent changes.
|
|
4685
5045
|
|
|
4686
|
-
Some agents (e.g. claude
|
|
5046
|
+
Some agents (e.g. claude) commit changes internally, so we must
|
|
4687
5047
|
also push even when the working directory is clean.
|
|
4688
5048
|
|
|
4689
5049
|
Before pushing, we rebase onto the latest ``origin/{default_branch}``
|
|
@@ -5075,12 +5435,18 @@ class RuntimeDaemon:
|
|
|
5075
5435
|
self, workspace_path: Path, default_branch: str, task: TaskInfo,
|
|
5076
5436
|
project_key: str = "default",
|
|
5077
5437
|
):
|
|
5078
|
-
"""
|
|
5079
|
-
|
|
5080
|
-
Strategy
|
|
5081
|
-
|
|
5082
|
-
|
|
5083
|
-
|
|
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.
|
|
5084
5450
|
"""
|
|
5085
5451
|
git = self.workspace_manager._git
|
|
5086
5452
|
target = f"origin/{default_branch}"
|
|
@@ -5089,10 +5455,10 @@ class RuntimeDaemon:
|
|
|
5089
5455
|
try:
|
|
5090
5456
|
await git("fetch", "origin", cwd=workspace_path, timeout=300, project_key=project_key)
|
|
5091
5457
|
except RuntimeError as exc:
|
|
5092
|
-
logger.warning("Pre-push fetch failed: %s — skipping
|
|
5458
|
+
logger.warning("Pre-push fetch failed: %s — skipping integration", exc)
|
|
5093
5459
|
return
|
|
5094
5460
|
|
|
5095
|
-
# Check if
|
|
5461
|
+
# Check if integration is needed (any commits on origin/default ahead of us?)
|
|
5096
5462
|
try:
|
|
5097
5463
|
behind = await git(
|
|
5098
5464
|
"rev-list", "--count", f"HEAD..{target}", cwd=workspace_path,
|
|
@@ -5100,27 +5466,56 @@ class RuntimeDaemon:
|
|
|
5100
5466
|
if behind.strip() == "0":
|
|
5101
5467
|
logger.info("Branch is already up-to-date with %s", target)
|
|
5102
5468
|
return
|
|
5103
|
-
logger.info("Branch is %s commit(s) behind %s —
|
|
5469
|
+
logger.info("Branch is %s commit(s) behind %s — integrating", behind.strip(), target)
|
|
5104
5470
|
except RuntimeError:
|
|
5105
|
-
# Can't determine — proceed
|
|
5471
|
+
# Can't determine — proceed anyway
|
|
5106
5472
|
pass
|
|
5107
5473
|
|
|
5108
|
-
#
|
|
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
|
|
5109
5480
|
try:
|
|
5110
|
-
await git(
|
|
5111
|
-
"-
|
|
5112
|
-
|
|
5113
|
-
"rebase", target,
|
|
5114
|
-
cwd=workspace_path, timeout=120,
|
|
5115
|
-
)
|
|
5116
|
-
logger.info("Rebase onto %s succeeded", target)
|
|
5117
|
-
return # done — clean linear history
|
|
5481
|
+
current_branch = (await git(
|
|
5482
|
+
"rev-parse", "--abbrev-ref", "HEAD", cwd=workspace_path,
|
|
5483
|
+
)).strip()
|
|
5118
5484
|
except RuntimeError:
|
|
5119
|
-
|
|
5485
|
+
pass
|
|
5486
|
+
if current_branch and current_branch != "HEAD":
|
|
5120
5487
|
try:
|
|
5121
|
-
await git(
|
|
5488
|
+
await git(
|
|
5489
|
+
"rev-parse", "--verify", f"origin/{current_branch}",
|
|
5490
|
+
cwd=workspace_path,
|
|
5491
|
+
)
|
|
5492
|
+
remote_branch_exists = True
|
|
5122
5493
|
except RuntimeError:
|
|
5123
|
-
|
|
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
|
|
5507
|
+
except RuntimeError:
|
|
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
|
+
)
|
|
5124
5519
|
|
|
5125
5520
|
# ── Tier 2: merge ──
|
|
5126
5521
|
try:
|
|
@@ -5354,6 +5749,79 @@ class RuntimeDaemon:
|
|
|
5354
5749
|
return None
|
|
5355
5750
|
except RuntimeError as exc:
|
|
5356
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
|
+
)
|
|
5357
5825
|
return f"Push failed: {exc}"
|
|
5358
5826
|
else:
|
|
5359
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
|