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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: forgexa-cli
3
- Version: 1.8.3
3
+ Version: 1.8.4
4
4
  Summary: Forgexa CLI — command-line client and AI agent runtime for the Forgexa platform
5
5
  Author-email: Jason Sun <dev.winds@gmail.com>
6
6
  License: MIT
@@ -1,2 +1,2 @@
1
1
  """forgexa-cli — Forgexa command-line client."""
2
- __version__ = "1.8.3"
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.3"
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, _ = await asyncio.wait_for(proc.communicate(), timeout=10)
778
- return stdout.decode().strip().split("\n")[0][:100]
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 "unknown"
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 "claude")
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
- output_content = result.stdout[-20000:] if result.stdout else ""
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
- # Try to extract per-scenario scripts from output
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
- # Simple heuristic: if output is a single script, map it to first scenario
4730
- # Daemon-generated scripts may be multiple files in workspace
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
- # Check if daemon wrote test files to workspace
4733
- import glob
4734
- test_files = glob.glob(str(workspace_path / "tests" / "**" / f"*{sid[:8]}*"), recursive=True)
4735
- if test_files:
4736
- try:
4737
- with open(test_files[0], "r") as f:
4738
- scripts[sid] = f.read()
4739
- except Exception:
4740
- pass
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
- """Rebase the current branch onto ``origin/{default_branch}``.
5353
-
5354
- Strategy (3-tier):
5355
- 1. ``git rebase origin/{default_branch}`` cleanest; linear history.
5356
- 2. If rebase conflicts abort, try ``git merge`` instead.
5357
- 3. If merge conflicts use the AI agent to auto-resolve.
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 rebase", exc)
5458
+ logger.warning("Pre-push fetch failed: %s — skipping integration", exc)
5367
5459
  return
5368
5460
 
5369
- # Check if rebase is needed (any commits on origin/default ahead of us?)
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 — rebasing", behind.strip(), target)
5469
+ logger.info("Branch is %s commit(s) behind %s — integrating", behind.strip(), target)
5378
5470
  except RuntimeError:
5379
- # Can't determine — proceed with rebase anyway
5471
+ # Can't determine — proceed anyway
5380
5472
  pass
5381
5473
 
5382
- # ── Tier 1: rebase ──
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
- "-c", "user.name=Forgexa Agent",
5386
- "-c", "user.email=agent@forgexa.net",
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
- logger.info("Rebase onto %s had conflicts — aborting rebase", target)
5485
+ pass
5486
+ if current_branch and current_branch != "HEAD":
5394
5487
  try:
5395
- await git("rebase", "--abort", cwd=workspace_path)
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
- pass # already aborted or not in rebase state
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(
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: forgexa-cli
3
- Version: 1.8.3
3
+ Version: 1.8.4
4
4
  Summary: Forgexa CLI — command-line client and AI agent runtime for the Forgexa platform
5
5
  Author-email: Jason Sun <dev.winds@gmail.com>
6
6
  License: MIT
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "forgexa-cli"
3
- version = "1.8.3"
3
+ version = "1.8.4"
4
4
  description = "Forgexa CLI — command-line client and AI agent runtime for the Forgexa platform"
5
5
  requires-python = ">=3.9"
6
6
  license = { text = "MIT" }
File without changes
File without changes