forgexa-cli 1.20.4__tar.gz → 1.20.5__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.20.4
3
+ Version: 1.20.5
4
4
  Summary: Forgexa CLI — command-line client and AI agent runtime for the Forgexa platform
5
5
  Author-email: Jason Sun <dev.winds@gmail.com>
6
6
  License-Expression: MIT
@@ -1,2 +1,2 @@
1
1
  """forgexa-cli — Forgexa command-line client."""
2
- __version__ = "1.20.4"
2
+ __version__ = "1.20.5"
@@ -555,7 +555,7 @@ except (ImportError, ModuleNotFoundError):
555
555
  # DAEMON_VERSION is the protocol/logic version of the daemon code.
556
556
  # Kept in sync with pyproject.toml version via bump-version.sh.
557
557
  # CLIENT_TYPE identifies which packaging/distribution this daemon runs in.
558
- DAEMON_VERSION = "1.20.4"
558
+ DAEMON_VERSION = "1.20.5"
559
559
 
560
560
 
561
561
  def _detect_client_type() -> str:
@@ -773,6 +773,33 @@ class DiscoveredAgent:
773
773
  compatibility_level: str
774
774
 
775
775
 
776
+ def _prepare_agent_command(command: list[str]) -> list[str]:
777
+ """Make Windows script launchers executable through their shell hosts."""
778
+ if sys.platform != "win32" or not command:
779
+ return command
780
+
781
+ suffix = Path(command[0]).suffix.lower()
782
+ if suffix in {".cmd", ".bat"}:
783
+ return [
784
+ os.environ.get("COMSPEC", "cmd.exe"),
785
+ "/d",
786
+ "/s",
787
+ "/c",
788
+ subprocess.list2cmdline(command),
789
+ ]
790
+ if suffix == ".ps1":
791
+ return [
792
+ "powershell.exe",
793
+ "-NoProfile",
794
+ "-NonInteractive",
795
+ "-ExecutionPolicy",
796
+ "Bypass",
797
+ "-File",
798
+ *command,
799
+ ]
800
+ return command
801
+
802
+
776
803
  @dataclass
777
804
  class TaskInfo:
778
805
  task_id: str
@@ -1593,21 +1620,10 @@ class AgentDiscovery:
1593
1620
  import re
1594
1621
  try:
1595
1622
  parts = detect_cmd.split()
1596
- # On Windows, agent CLIs installed via npm create .cmd wrapper scripts
1597
- # (e.g. %APPDATA%\npm\copilot.cmd). Python's asyncio.create_subprocess_exec
1598
- # calls CreateProcess which cannot execute .cmd/.bat files directly —
1599
- # they need cmd.exe as the interpreter. Detect and wrap automatically.
1600
- if sys.platform == "win32":
1601
- resolved = shutil.which(parts[0])
1602
- if resolved:
1603
- ext = Path(resolved).suffix.lower()
1604
- if ext in ('.cmd', '.bat'):
1605
- parts = ['cmd', '/c', resolved] + parts[1:]
1606
- elif ext == '.ps1':
1607
- parts = [
1608
- 'powershell', '-NoProfile', '-NonInteractive',
1609
- '-Command', resolved,
1610
- ] + parts[1:]
1623
+ resolved = shutil.which(parts[0])
1624
+ if resolved:
1625
+ parts[0] = resolved
1626
+ parts = _prepare_agent_command(parts)
1611
1627
  proc = await asyncio.create_subprocess_exec(
1612
1628
  *parts,
1613
1629
  stdin=asyncio.subprocess.DEVNULL,
@@ -3920,6 +3936,22 @@ class ProcessManager:
3920
3936
  or any(p in error_text for p in ProcessManager.AGENT_UNAVAILABLE_PATTERNS)
3921
3937
  )
3922
3938
 
3939
+ @staticmethod
3940
+ def is_silent_idle_timeout(result: "TaskResult") -> bool:
3941
+ """Return whether an agent was killed before producing any evidence.
3942
+
3943
+ A timeout after output or workspace changes can represent completed work
3944
+ and must be handled by the normal recovery path. A completely silent
3945
+ timeout instead means this specific CLI is stuck before it can begin,
3946
+ so one alternate agent is safe to try.
3947
+ """
3948
+ return (
3949
+ result.failure_code == "agent_idle_timeout"
3950
+ and not result.stdout.strip()
3951
+ and not result.stderr.strip()
3952
+ and not result.files_changed
3953
+ )
3954
+
3923
3955
  @staticmethod
3924
3956
  def _detect_agent_output_failure(result: "TaskResult", agent_id: str) -> str | None:
3925
3957
  """Detect agent-level failures despite exit code 0.
@@ -4440,6 +4472,7 @@ class ProcessManager:
4440
4472
  "--no-session-persistence",
4441
4473
  "--dangerously-skip-permissions",
4442
4474
  ]
4475
+ cmd = _prepare_agent_command(cmd)
4443
4476
 
4444
4477
  env = os.environ.copy()
4445
4478
  for key in (
@@ -4539,6 +4572,7 @@ class ProcessManager:
4539
4572
  stdout=getattr(exc, "stdout", "")[-settings.AGENT_MAX_OUTPUT_SIZE:],
4540
4573
  stderr=getattr(exc, "stderr", "")[-10000:],
4541
4574
  error=_err,
4575
+ failure_code="agent_idle_timeout" if isinstance(exc, _IdleTimeoutError) else "",
4542
4576
  )
4543
4577
  except Exception as exc:
4544
4578
  logger.exception("Claude stream error for task %s", task_id)
@@ -5160,6 +5194,7 @@ class ProcessManager:
5160
5194
  _task_file = None
5161
5195
 
5162
5196
  cmd += ["-C", str(cwd), "-p", _effective_prompt]
5197
+ cmd = _prepare_agent_command(cmd)
5163
5198
 
5164
5199
  # Log the exact invocation (redact prompt body) so operators can
5165
5200
  # reproduce failures manually and diagnose CLI version incompatibilities.
@@ -5345,6 +5380,7 @@ class ProcessManager:
5345
5380
  stdout=getattr(exc, "stdout", "")[-settings.AGENT_MAX_OUTPUT_SIZE:],
5346
5381
  stderr=getattr(exc, "stderr", "")[-10000:],
5347
5382
  error=_err,
5383
+ failure_code="agent_idle_timeout" if isinstance(exc, _IdleTimeoutError) else "",
5348
5384
  )
5349
5385
  except Exception as exc:
5350
5386
  logger.exception("Copilot stream error for task %s", task_id)
@@ -5383,6 +5419,7 @@ class ProcessManager:
5383
5419
  stream_stderr: bool = False,
5384
5420
  ) -> TaskResult:
5385
5421
  try:
5422
+ cmd = _prepare_agent_command(cmd)
5386
5423
  proc = await asyncio.create_subprocess_exec(
5387
5424
  *cmd,
5388
5425
  stdout=asyncio.subprocess.PIPE,
@@ -5421,6 +5458,7 @@ class ProcessManager:
5421
5458
  stdout=getattr(exc, "stdout", "")[-settings.AGENT_MAX_OUTPUT_SIZE:],
5422
5459
  stderr=getattr(exc, "stderr", "")[-10000:],
5423
5460
  error=_err,
5461
+ failure_code="agent_idle_timeout" if isinstance(exc, _IdleTimeoutError) else "",
5424
5462
  )
5425
5463
  except Exception as exc:
5426
5464
  logger.exception("CLI stream error for task %s", task_id)
@@ -7786,12 +7824,21 @@ class RuntimeDaemon:
7786
7824
 
7787
7825
  tried_agents.add(agent.agent_id)
7788
7826
 
7789
- # ── Agent fallback: if agent hit rate limit or API is unavailable, try next agent ──
7827
+ # ── Agent fallback: try another CLI after a backend failure or a fully silent hang ──
7790
7828
  # Guard: if the agent already produced file changes in the workspace, it DID
7791
7829
  # meaningful work — don't trigger fallback even if it crashed after completing.
7792
7830
  # Let the recovery logic (step 4.1) handle non-zero exit with committed work.
7831
+ is_rate_limited = self.process_manager.is_rate_limited(result)
7832
+ is_silent_idle_timeout = self.process_manager.is_silent_idle_timeout(result)
7833
+ fallback_reason = (
7834
+ "rate_limit"
7835
+ if is_rate_limited
7836
+ else "agent_idle_timeout"
7837
+ if is_silent_idle_timeout
7838
+ else ""
7839
+ )
7793
7840
  _skip_fallback = False
7794
- if self.process_manager.is_rate_limited(result):
7841
+ if fallback_reason:
7795
7842
  _pre_fallback_git = await self.process_manager._collect_git_info(workspace_path)
7796
7843
  _pre_fallback_committed = await self.process_manager._collect_git_info_vs_parent(
7797
7844
  workspace_path,
@@ -7810,10 +7857,10 @@ class RuntimeDaemon:
7810
7857
  )
7811
7858
  _skip_fallback = True
7812
7859
 
7813
- if self.process_manager.is_rate_limited(result) and not _skip_fallback:
7860
+ if fallback_reason and not _skip_fallback:
7814
7861
  logger.warning(
7815
- "Agent '%s' unavailable/rate-limited for task %s, attempting fallback",
7816
- agent.agent_id, task.task_id,
7862
+ "Agent '%s' failed for task %s (%s), attempting fallback",
7863
+ agent.agent_id, task.task_id, fallback_reason,
7817
7864
  )
7818
7865
  fallback_agent = self._select_fallback_agent(
7819
7866
  agent.agent_id, task.fallback_chain, tried_agents
@@ -7830,7 +7877,7 @@ class RuntimeDaemon:
7830
7877
  await reporter.report_progress(
7831
7878
  task.task_id, 10,
7832
7879
  f"agent_fallback: retrying with {agent.agent_id}",
7833
- output_lines=[f"[daemon] Agent unavailable/rate-limited, switching to {agent.agent_id}"],
7880
+ output_lines=[f"[daemon] Agent {fallback_reason}, switching to {agent.agent_id}"],
7834
7881
  )
7835
7882
 
7836
7883
  # Re-run with fallback agent
@@ -7877,6 +7924,9 @@ class RuntimeDaemon:
7877
7924
  )
7878
7925
  _line_buffer.clear()
7879
7926
 
7927
+ # A silent hang gets exactly one alternate-agent attempt.
7928
+ # Unlike quota failures, do not serially spend the full idle
7929
+ # timeout on every installed CLI when the host is offline.
7880
7930
  if not self.process_manager.is_rate_limited(result):
7881
7931
  break # Success or non-retriable failure
7882
7932
 
@@ -8260,7 +8310,7 @@ class RuntimeDaemon:
8260
8310
  result.metrics["actual_agent"] = agent.agent_id
8261
8311
  if agent.agent_id != task.agent_type:
8262
8312
  result.metrics["original_agent"] = task.agent_type
8263
- result.metrics["fallback_reason"] = "rate_limit"
8313
+ result.metrics["fallback_reason"] = fallback_reason
8264
8314
  await reporter.report_progress(task.task_id, 100, "completed" if result.status == "success" else "failed")
8265
8315
  await reporter.report_complete(task.task_id, result)
8266
8316
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: forgexa-cli
3
- Version: 1.20.4
3
+ Version: 1.20.5
4
4
  Summary: Forgexa CLI — command-line client and AI agent runtime for the Forgexa platform
5
5
  Author-email: Jason Sun <dev.winds@gmail.com>
6
6
  License-Expression: MIT
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "forgexa-cli"
3
- version = "1.20.4"
3
+ version = "1.20.5"
4
4
  description = "Forgexa CLI — command-line client and AI agent runtime for the Forgexa platform"
5
5
  requires-python = ">=3.9"
6
6
  license = "MIT"
@@ -59,6 +59,26 @@ def test_login_persists_refresh_token(tmp_path: Path, monkeypatch: pytest.Monkey
59
59
  assert (tmp_path / ".forgexa" / "token").read_text() == "access-1"
60
60
 
61
61
 
62
+ @pytest.mark.parametrize("suffix", [".cmd", ".bat"])
63
+ def test_windows_batch_agent_commands_use_cmd_exe(
64
+ suffix: str, monkeypatch: pytest.MonkeyPatch,
65
+ ) -> None:
66
+ launcher = r"C:\Windows\System32\cmd.exe"
67
+ command = [rf"C:\Users\agent\AppData\Roaming\npm\copilot{suffix}", "--version"]
68
+ monkeypatch.setattr(daemon.sys, "platform", "win32")
69
+ monkeypatch.setenv("COMSPEC", launcher)
70
+
71
+ result = daemon._prepare_agent_command(command)
72
+
73
+ assert result == [
74
+ launcher,
75
+ "/d",
76
+ "/s",
77
+ "/c",
78
+ daemon.subprocess.list2cmdline(command),
79
+ ]
80
+
81
+
62
82
  def test_get_retries_once_after_refresh_on_401(
63
83
  tmp_path: Path,
64
84
  monkeypatch: pytest.MonkeyPatch,
File without changes
File without changes