forgexa-cli 1.21.2__tar.gz → 1.21.3__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.
Files changed (21) hide show
  1. {forgexa_cli-1.21.2 → forgexa_cli-1.21.3}/PKG-INFO +1 -1
  2. {forgexa_cli-1.21.2 → forgexa_cli-1.21.3}/forgexa_cli/__init__.py +1 -1
  3. {forgexa_cli-1.21.2 → forgexa_cli-1.21.3}/forgexa_cli/autoupgrade.py +1 -1
  4. {forgexa_cli-1.21.2 → forgexa_cli-1.21.3}/forgexa_cli/daemon.py +1 -1
  5. {forgexa_cli-1.21.2 → forgexa_cli-1.21.3}/forgexa_cli/main.py +103 -26
  6. {forgexa_cli-1.21.2 → forgexa_cli-1.21.3}/forgexa_cli.egg-info/PKG-INFO +1 -1
  7. {forgexa_cli-1.21.2 → forgexa_cli-1.21.3}/pyproject.toml +1 -1
  8. {forgexa_cli-1.21.2 → forgexa_cli-1.21.3}/tests/test_auth_and_runtime_commands.py +1 -1
  9. {forgexa_cli-1.21.2 → forgexa_cli-1.21.3}/tests/test_check_command.py +64 -0
  10. {forgexa_cli-1.21.2 → forgexa_cli-1.21.3}/tests/test_silent_install.py +170 -2
  11. {forgexa_cli-1.21.2 → forgexa_cli-1.21.3}/tests/test_upgrade_observability.py +2 -0
  12. {forgexa_cli-1.21.2 → forgexa_cli-1.21.3}/README.md +0 -0
  13. {forgexa_cli-1.21.2 → forgexa_cli-1.21.3}/forgexa_cli/_build_config.py +0 -0
  14. {forgexa_cli-1.21.2 → forgexa_cli-1.21.3}/forgexa_cli/py.typed +0 -0
  15. {forgexa_cli-1.21.2 → forgexa_cli-1.21.3}/forgexa_cli.egg-info/SOURCES.txt +0 -0
  16. {forgexa_cli-1.21.2 → forgexa_cli-1.21.3}/forgexa_cli.egg-info/dependency_links.txt +0 -0
  17. {forgexa_cli-1.21.2 → forgexa_cli-1.21.3}/forgexa_cli.egg-info/entry_points.txt +0 -0
  18. {forgexa_cli-1.21.2 → forgexa_cli-1.21.3}/forgexa_cli.egg-info/requires.txt +0 -0
  19. {forgexa_cli-1.21.2 → forgexa_cli-1.21.3}/forgexa_cli.egg-info/top_level.txt +0 -0
  20. {forgexa_cli-1.21.2 → forgexa_cli-1.21.3}/setup.cfg +0 -0
  21. {forgexa_cli-1.21.2 → forgexa_cli-1.21.3}/tests/test_autoupgrade.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: forgexa-cli
3
- Version: 1.21.2
3
+ Version: 1.21.3
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.21.2"
2
+ __version__ = "1.21.3"
@@ -78,7 +78,7 @@ def _state_path() -> Path:
78
78
 
79
79
 
80
80
  def empty_state() -> dict:
81
- return {"active": None, "last_failure": None, "last_success": None}
81
+ return {"active": None, "last_failure": None, "last_success": None, "last_skip": None}
82
82
 
83
83
 
84
84
  def read_state() -> dict:
@@ -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.21.2"
558
+ DAEMON_VERSION = "1.21.3"
559
559
 
560
560
 
561
561
  def _detect_client_type() -> str:
@@ -557,6 +557,18 @@ def _upgrade_log_path() -> Path:
557
557
  return Path.home() / ".forgexa" / "upgrade.log"
558
558
 
559
559
 
560
+ def _record_install_skip(target_version: str, reason: str) -> None:
561
+ """Record why a silent install attempt was skipped, for `forgexa version --status`.
562
+
563
+ Covers the two "surprising" skip reasons (unsupported install source, a
564
+ running daemon) so `active`/`last_success`/`last_failure` all being "none"
565
+ isn't a silent mystery — see docs/designs/cli-auto-upgrade-design.md Appendix C.
566
+ """
567
+ state = autoupgrade.read_state()
568
+ state["last_skip"] = {"version": target_version, "reason": reason, "at": time.time()}
569
+ autoupgrade.write_state(state)
570
+
571
+
560
572
  def _maybe_install_update() -> None:
561
573
  """Attempt a single silent background install, if all safety conditions hold.
562
574
 
@@ -580,10 +592,14 @@ def _maybe_install_update() -> None:
580
592
  try:
581
593
  plan = _build_upgrade_plan(latest)
582
594
  except RuntimeError:
583
- return # Unsupported/unsafe install source — leave it to the passive notice.
595
+ # Unsupported/unsafe install source — leave it to the passive notice.
596
+ _record_install_skip(latest, "unsupported_source")
597
+ return
584
598
 
585
599
  if _find_running_daemon_pid() is not None:
586
- return # Don't touch a running daemon; retry on a later invocation.
600
+ # Don't touch a running daemon; retry on a later invocation.
601
+ _record_install_skip(latest, "daemon_running")
602
+ return
587
603
 
588
604
  state = autoupgrade.read_state()
589
605
  target_version = latest
@@ -596,6 +612,7 @@ def _maybe_install_update() -> None:
596
612
 
597
613
  try:
598
614
  state["active"] = {"version": target_version, "started_at": time.time()}
615
+ state["last_skip"] = None
599
616
  autoupgrade.write_state(state)
600
617
 
601
618
  log_path = _upgrade_log_path()
@@ -639,14 +656,23 @@ def _maybe_install_update() -> None:
639
656
  autoupgrade.release_lock()
640
657
 
641
658
 
642
- def _run_update_check_and_maybe_install(allow_install: bool) -> None:
643
- """Refresh the update cache and, if allowed, attempt a silent background install.
659
+ def _run_update_check_and_maybe_install(allow_install: bool, refresh_cache: bool = True) -> None:
660
+ """Refresh the update cache (if `refresh_cache`) and, if allowed, attempt
661
+ a silent background install.
644
662
 
645
663
  `allow_install` reflects the parent process's interactive-session judgment
646
664
  (TTY / CI / --format) at launch time — this worker's own stdio is redirected
647
665
  to DEVNULL, so it can never determine that for itself.
666
+
667
+ `refresh_cache=False` supports retrying an install using the already-cached
668
+ latest version, without an extra PyPI round-trip — see
669
+ `_launch_update_check_process`'s "retry-only" path, which exists precisely
670
+ so an install attempt isn't stuck waiting for the network-check TTL to
671
+ expire just because *something else* (interactivity, a running daemon)
672
+ blocked the previous attempt.
648
673
  """
649
- _run_update_check()
674
+ if refresh_cache:
675
+ _run_update_check()
650
676
  if not allow_install:
651
677
  return
652
678
  try:
@@ -666,22 +692,56 @@ def _is_interactive_session() -> bool:
666
692
  return bool(sys.stderr.isatty()) and not os.environ.get("CI") and _output_format != "json"
667
693
 
668
694
 
695
+ def _has_pending_silent_install(current_version: str) -> bool:
696
+ """Whether a known newer version exists that hasn't been installed yet,
697
+ isn't already exhausted on failure backoff, and isn't already being
698
+ installed by a still-fresh `active` install right now.
699
+
700
+ Purely local (no network) — used so an install can be *retried* on a
701
+ later invocation even while the network-check cache is still fresh,
702
+ since whatever blocked the earlier attempt (a running daemon, a
703
+ momentarily non-interactive session) can resolve well within the
704
+ network check's 24h TTL, and there's no reason to make the user wait
705
+ that long for a retry.
706
+ """
707
+ if not current_version:
708
+ return False
709
+ latest, _ = _read_update_cache()
710
+ if not latest or not _version_is_newer(latest, current_version):
711
+ return False
712
+ state = autoupgrade.read_state()
713
+ if autoupgrade.failure_attempts_for(state, latest) >= autoupgrade.FAILURE_BACKOFF_LIMIT:
714
+ return False
715
+ if autoupgrade.has_fresh_active_install(state, latest):
716
+ return False
717
+ return True
718
+
719
+
669
720
  def _launch_update_check_process(allow_install: bool) -> None:
670
- """Start a detached worker process that refreshes the update cache and,
721
+ """Start a detached worker process that refreshes the update cache and/or,
671
722
  if `allow_install`, attempts a silent background install.
672
723
 
673
724
  Short-lived commands like `forgexa version` can exit before a daemon thread
674
725
  finishes its PyPI request. A separate Python process survives long enough to
675
726
  populate the cache without blocking the foreground command.
676
727
 
728
+ The network check itself is rate-limited to once per `_UPDATE_CHECK_TTL` to
729
+ avoid hammering PyPI. Install *retries* are deliberately NOT tied to that
730
+ same window: when the cache is still fresh but a known newer version is
731
+ still pending (see `_has_pending_silent_install`), this launches a
732
+ network-free worker that just retries the local install steps.
733
+
677
734
  The worker is launched fully detached (new session on POSIX, no console
678
735
  window on Windows) so a Ctrl+C / signal aimed at the foreground command's
679
736
  process group can't take an in-progress install down with it and leave a
680
737
  half-installed package.
681
738
  """
682
739
  _, checked_at = _read_update_cache()
683
- if time.time() - checked_at <= _UPDATE_CHECK_TTL:
684
- return # Cache is fresh — nothing to do
740
+ cache_stale = time.time() - checked_at > _UPDATE_CHECK_TTL
741
+
742
+ if not cache_stale:
743
+ if not allow_install or not _has_pending_silent_install(_installed_cli_version() or ""):
744
+ return # Cache is fresh and there's nothing pending to retry.
685
745
 
686
746
  popen_kwargs: dict = {}
687
747
  if sys.platform == "win32":
@@ -697,7 +757,7 @@ def _launch_update_check_process(allow_install: bool) -> None:
697
757
  sys.executable,
698
758
  "-c",
699
759
  "from forgexa_cli.main import _run_update_check_and_maybe_install; "
700
- f"_run_update_check_and_maybe_install({bool(allow_install)!r})",
760
+ f"_run_update_check_and_maybe_install({bool(allow_install)!r}, {bool(cache_stale)!r})",
701
761
  ],
702
762
  stdin=subprocess.DEVNULL,
703
763
  stdout=subprocess.DEVNULL,
@@ -1683,11 +1743,15 @@ def _print_check_report(results: list[dict], quick: bool) -> None:
1683
1743
  print("Gemini CLI, Kimi Code, or GitHub Copilot CLI), then re-run: forgexa check")
1684
1744
 
1685
1745
 
1686
- def _run_environment_checks(narrate: bool) -> bool:
1746
+ def _run_environment_checks(narrate: bool) -> tuple[bool, list[dict]]:
1687
1747
  """Run environment checks (git, workspace, disk) using the daemon's check logic.
1688
1748
 
1689
- Returns True if any blocking check failed.
1690
- Prints a flutter-doctor-style listing in table mode.
1749
+ Always runs regardless of --quick/--environment/default mode: these checks
1750
+ are free (no network, no LLM tokens), so there is no cost reason to hide
1751
+ them behind a flag.
1752
+
1753
+ Returns (blocking_failed, checks). Prints a flutter-doctor-style listing
1754
+ in table mode.
1691
1755
  """
1692
1756
  import asyncio
1693
1757
  from forgexa_cli.daemon import RuntimePreflightMonitor, settings
@@ -1705,7 +1769,7 @@ def _run_environment_checks(narrate: bool) -> bool:
1705
1769
  except Exception as exc:
1706
1770
  if narrate:
1707
1771
  print(f" [ERROR] Environment check failed: {exc}")
1708
- return True
1772
+ return True, []
1709
1773
 
1710
1774
  blocking_failed = False
1711
1775
  for check in checks:
@@ -1739,7 +1803,7 @@ def _run_environment_checks(narrate: bool) -> bool:
1739
1803
  print("All environment checks passed.")
1740
1804
  print()
1741
1805
 
1742
- return blocking_failed
1806
+ return blocking_failed, checks
1743
1807
 
1744
1808
 
1745
1809
  def cmd_check(args: argparse.Namespace) -> None:
@@ -1761,19 +1825,23 @@ def cmd_check(args: argparse.Namespace) -> None:
1761
1825
  timeout = getattr(args, "timeout", None) or 90
1762
1826
 
1763
1827
  narrate = _fmt() == "table"
1764
- env_failed = False
1765
1828
 
1766
1829
  # ── Environment checks (shared with daemon's RuntimePreflightMonitor) ──
1767
- # Run when --environment or --quick is set. These are free (no API calls,
1768
- # no network, no LLM tokens) and reuse the exact same check logic the
1769
- # daemon uses for its periodic preflight.
1770
- if environment_only or quick:
1771
- env_failed = _run_environment_checks(narrate)
1772
- if environment_only:
1773
- # --environment: only env checks, then exit
1774
- if env_failed:
1775
- sys.exit(1)
1776
- return
1830
+ # Always run, in every mode (default/--quick/--environment): these are
1831
+ # free (no API calls, no network, no LLM tokens) and reuse the exact same
1832
+ # check logic the daemon uses for its periodic preflight, so there is no
1833
+ # cost reason to hide them behind a flag.
1834
+ env_failed, env_checks = _run_environment_checks(narrate)
1835
+ if environment_only:
1836
+ fmt = _fmt()
1837
+ if fmt == "json":
1838
+ print(json.dumps(env_checks, indent=2, ensure_ascii=False))
1839
+ elif fmt == "quiet":
1840
+ for c in env_checks:
1841
+ print(f"{c.get('id', '?')}\t{c.get('status', 'unknown')}")
1842
+ if env_failed:
1843
+ sys.exit(1)
1844
+ return
1777
1845
 
1778
1846
  known_ids = list(AgentDiscovery.AGENT_REGISTRY.keys())
1779
1847
  if only_agent:
@@ -2023,6 +2091,15 @@ def _print_auto_upgrade_status() -> None:
2023
2091
  else:
2024
2092
  print("Last failure : none")
2025
2093
 
2094
+ skip = state.get("last_skip")
2095
+ if skip:
2096
+ print(
2097
+ f"Last skip : {skip.get('version')} "
2098
+ f"(reason={skip.get('reason')}, at {_format_epoch(skip.get('at'))})"
2099
+ )
2100
+ else:
2101
+ print("Last skip : none")
2102
+
2026
2103
  print(f"Upgrade log : {_upgrade_log_path()}")
2027
2104
 
2028
2105
 
@@ -2277,7 +2354,7 @@ def main() -> None:
2277
2354
  check_p.add_argument("--agent", default=None, help="Check only this agent (e.g. claude, codex, kimi)")
2278
2355
  check_p.add_argument(
2279
2356
  "--quick", action="store_true",
2280
- help="Discovery + environment checks — skip the live task execution test (no API calls)",
2357
+ help="Skip the live task execution test (agent discovery + environment checks only, no API calls)",
2281
2358
  )
2282
2359
  check_p.add_argument(
2283
2360
  "--environment", action="store_true",
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: forgexa-cli
3
- Version: 1.21.2
3
+ Version: 1.21.3
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.21.2"
3
+ version = "1.21.3"
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"
@@ -325,7 +325,7 @@ def test_launch_update_check_process_spawns_background_python_when_cache_is_stal
325
325
  assert cmd[:2] == ["/usr/bin/python3", "-c"]
326
326
  assert cmd[2] == (
327
327
  "from forgexa_cli.main import _run_update_check_and_maybe_install; "
328
- "_run_update_check_and_maybe_install(True)"
328
+ "_run_update_check_and_maybe_install(True, True)"
329
329
  )
330
330
  assert kwargs["stdin"] is main.subprocess.DEVNULL
331
331
  assert kwargs["stdout"] is main.subprocess.DEVNULL
@@ -140,3 +140,67 @@ def test_check_json_output_is_parseable(
140
140
  "available": True,
141
141
  "reason": "",
142
142
  }]
143
+
144
+
145
+ def _mock_environment_checks(monkeypatch: pytest.MonkeyPatch, checks: list[dict]) -> None:
146
+ monkeypatch.setattr(
147
+ daemon.RuntimePreflightMonitor, "_run_checks",
148
+ AsyncMock(return_value=checks),
149
+ )
150
+
151
+
152
+ def test_check_default_mode_also_shows_environment_checks(
153
+ monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str],
154
+ ) -> None:
155
+ """Environment checks (git/workspace/disk) are free (no network, no LLM
156
+ tokens) and must be visible by default, not just behind --quick/--environment.
157
+ """
158
+ monkeypatch.setattr(daemon.AgentDiscovery, "AGENT_REGISTRY", _small_registry())
159
+ monkeypatch.setattr(
160
+ daemon.AgentDiscovery, "discover",
161
+ AsyncMock(return_value=[_agent("claude")]),
162
+ )
163
+ monkeypatch.setattr(
164
+ daemon.ProcessManager, "run_agent",
165
+ AsyncMock(return_value=daemon.TaskResult(status="success", exit_code=0, stdout="OK", stderr="")),
166
+ )
167
+ _mock_environment_checks(monkeypatch, [
168
+ {"id": "git_executable", "status": "pass", "blocking": True, "detail": "git 2.50", "remediation": ""},
169
+ ])
170
+
171
+ main.cmd_check(SimpleNamespace(agent="claude", quick=False, environment=False, timeout=5))
172
+
173
+ out = capsys.readouterr().out
174
+ assert "Environment Checks" in out
175
+ assert "git_executable" in out
176
+
177
+
178
+ def test_check_environment_only_json_output_lists_checks(
179
+ monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str],
180
+ ) -> None:
181
+ """--environment previously printed nothing at all under --format json;
182
+ it must return the structured checks array like every other subcommand."""
183
+ monkeypatch.setattr(main, "_output_format", "json")
184
+ _mock_environment_checks(monkeypatch, [
185
+ {"id": "git_executable", "status": "pass", "blocking": True, "detail": "git 2.50", "remediation": ""},
186
+ {"id": "workspace_root", "status": "pass", "blocking": True, "detail": "/tmp/ws", "remediation": ""},
187
+ ])
188
+
189
+ main.cmd_check(SimpleNamespace(agent=None, quick=False, environment=True, timeout=90))
190
+
191
+ out = capsys.readouterr().out
192
+ payload = json.loads(out)
193
+ assert [c["id"] for c in payload] == ["git_executable", "workspace_root"]
194
+
195
+
196
+ def test_check_environment_only_exits_nonzero_on_blocking_failure(
197
+ monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str],
198
+ ) -> None:
199
+ _mock_environment_checks(monkeypatch, [
200
+ {"id": "git_executable", "status": "fail", "blocking": True, "detail": "not found", "remediation": "install git"},
201
+ ])
202
+
203
+ with pytest.raises(SystemExit) as exc_info:
204
+ main.cmd_check(SimpleNamespace(agent=None, quick=False, environment=True, timeout=90))
205
+
206
+ assert exc_info.value.code == 1
@@ -60,6 +60,9 @@ def test_skips_when_upgrade_plan_unsupported(monkeypatch: pytest.MonkeyPatch) ->
60
60
  main._maybe_install_update() # must not raise
61
61
 
62
62
  assert run_mock.called is False
63
+ state = autoupgrade.read_state()
64
+ assert state["last_skip"]["reason"] == "unsupported_source"
65
+ assert state["last_skip"]["version"] == "1.1.0"
63
66
 
64
67
 
65
68
  def test_skips_when_daemon_is_running(monkeypatch: pytest.MonkeyPatch) -> None:
@@ -73,8 +76,14 @@ def test_skips_when_daemon_is_running(monkeypatch: pytest.MonkeyPatch) -> None:
73
76
  main._maybe_install_update()
74
77
 
75
78
  assert run_mock.called is False
76
- # Must not touch the daemon or leave any install-in-progress state behind.
77
- assert autoupgrade.read_state() == autoupgrade.empty_state()
79
+ # Must not touch the daemon or leave any install-in-progress state behind,
80
+ # but the skip reason IS recorded (see docs/designs/cli-auto-upgrade-design.md
81
+ # Appendix C) so `forgexa version --status` can explain what happened.
82
+ state = autoupgrade.read_state()
83
+ assert state["active"] is None
84
+ assert state["last_success"] is None
85
+ assert state["last_failure"] is None
86
+ assert state["last_skip"] == {"version": "1.1.0", "reason": "daemon_running", "at": state["last_skip"]["at"]}
78
87
 
79
88
 
80
89
  def test_skips_when_failure_backoff_exhausted(monkeypatch: pytest.MonkeyPatch) -> None:
@@ -156,6 +165,27 @@ def test_success_writes_last_success_and_releases_lock(monkeypatch: pytest.Monke
156
165
  assert (Path.home() / ".forgexa" / "upgrade.lock").exists() is False
157
166
 
158
167
 
168
+ def test_success_clears_a_previously_recorded_skip(monkeypatch: pytest.MonkeyPatch) -> None:
169
+ """A stale 'skipped: daemon running' note shouldn't linger once an install actually runs."""
170
+ _versions(monkeypatch, ["1.0.0", "1.1.0"])
171
+ monkeypatch.setattr(main, "_read_update_cache", lambda: ("1.1.0", 0.0))
172
+ monkeypatch.setattr(main, "_build_upgrade_plan", lambda target: _fake_plan(target))
173
+ _no_daemon(monkeypatch)
174
+ monkeypatch.setattr(main.subprocess, "run", lambda cmd, **k: SimpleNamespace(returncode=0))
175
+ autoupgrade.write_state(
176
+ {
177
+ "active": None,
178
+ "last_failure": None,
179
+ "last_success": None,
180
+ "last_skip": {"version": "1.1.0", "reason": "daemon_running", "at": 0},
181
+ }
182
+ )
183
+
184
+ main._maybe_install_update()
185
+
186
+ assert autoupgrade.read_state()["last_skip"] is None
187
+
188
+
159
189
  def test_returncode_zero_but_wrong_version_counts_as_failure(
160
190
  monkeypatch: pytest.MonkeyPatch,
161
191
  ) -> None:
@@ -247,6 +277,20 @@ def test_run_update_check_and_maybe_install_swallows_unexpected_errors(
247
277
  main._run_update_check_and_maybe_install(True) # must not raise
248
278
 
249
279
 
280
+ def test_run_update_check_and_maybe_install_skips_network_refresh_when_retrying(
281
+ monkeypatch: pytest.MonkeyPatch,
282
+ ) -> None:
283
+ refresh_called = SimpleNamespace(value=False)
284
+ monkeypatch.setattr(main, "_run_update_check", lambda: refresh_called.__setattr__("value", True))
285
+ install_called = SimpleNamespace(value=False)
286
+ monkeypatch.setattr(main, "_maybe_install_update", lambda: install_called.__setattr__("value", True))
287
+
288
+ main._run_update_check_and_maybe_install(True, refresh_cache=False)
289
+
290
+ assert refresh_called.value is False
291
+ assert install_called.value is True
292
+
293
+
250
294
  # ── _is_interactive_session ─────────────────────────────────────────────────
251
295
 
252
296
  def test_is_interactive_session_true_when_tty_and_no_ci_and_not_json(
@@ -330,3 +374,127 @@ def test_main_skips_worker_entirely_when_auto_upgrade_off(
330
374
 
331
375
  assert launched.value is False
332
376
  assert notified.value is False
377
+
378
+
379
+ # ── _has_pending_silent_install ─────────────────────────────────────────────
380
+
381
+ def test_pending_install_true_when_newer_cached_and_not_backed_off(
382
+ monkeypatch: pytest.MonkeyPatch,
383
+ ) -> None:
384
+ monkeypatch.setattr(main, "_read_update_cache", lambda: ("1.1.0", 0.0))
385
+ assert main._has_pending_silent_install("1.0.0") is True
386
+
387
+
388
+ def test_pending_install_false_when_no_current_version() -> None:
389
+ assert main._has_pending_silent_install("") is False
390
+
391
+
392
+ def test_pending_install_false_when_cache_has_no_newer_version(
393
+ monkeypatch: pytest.MonkeyPatch,
394
+ ) -> None:
395
+ monkeypatch.setattr(main, "_read_update_cache", lambda: ("1.0.0", 0.0))
396
+ assert main._has_pending_silent_install("1.0.0") is False
397
+
398
+
399
+ def test_pending_install_false_when_backoff_exhausted(monkeypatch: pytest.MonkeyPatch) -> None:
400
+ monkeypatch.setattr(main, "_read_update_cache", lambda: ("1.1.0", 0.0))
401
+ autoupgrade.write_state(
402
+ {
403
+ "active": None,
404
+ "last_failure": {"version": "1.1.0", "failed_at": 0, "attempts": 2},
405
+ "last_success": None,
406
+ }
407
+ )
408
+ assert main._has_pending_silent_install("1.0.0") is False
409
+
410
+
411
+ def test_pending_install_false_when_already_actively_installing(
412
+ monkeypatch: pytest.MonkeyPatch,
413
+ ) -> None:
414
+ import time as time_module
415
+
416
+ monkeypatch.setattr(main, "_read_update_cache", lambda: ("1.1.0", 0.0))
417
+ autoupgrade.write_state(
418
+ {
419
+ "active": {"version": "1.1.0", "started_at": time_module.time()},
420
+ "last_failure": None,
421
+ "last_success": None,
422
+ }
423
+ )
424
+ assert main._has_pending_silent_install("1.0.0") is False
425
+
426
+
427
+ # ── _launch_update_check_process: retry an install even when the cache is fresh ──
428
+ #
429
+ # Regression coverage for a real bug found via manual testing (2026-07-23):
430
+ # the install retry was tied to the same 24h TTL as the network check, so once
431
+ # an install attempt was blocked for any reason (daemon running, a momentarily
432
+ # non-interactive session, ...), the CLI would keep showing the same cached
433
+ # "update available" notice without ever retrying the install until the cache
434
+ # went stale up to 24h later.
435
+
436
+ def test_launch_process_retries_install_when_cache_fresh_and_install_pending(
437
+ monkeypatch: pytest.MonkeyPatch,
438
+ ) -> None:
439
+ monkeypatch.setattr(main, "_read_update_cache", lambda: ("1.1.0", main.time.time()))
440
+ monkeypatch.setattr(main, "_installed_cli_version", lambda: "1.0.0")
441
+ monkeypatch.setattr(main.sys, "executable", "/usr/bin/python3")
442
+
443
+ calls: list[tuple[list[str], dict]] = []
444
+ monkeypatch.setattr(main.subprocess, "Popen", lambda cmd, **kwargs: calls.append((cmd, kwargs)))
445
+
446
+ main._launch_update_check_process(True)
447
+
448
+ assert len(calls) == 1
449
+ cmd, _kwargs = calls[0]
450
+ # refresh_cache must be False: retry the install without hitting PyPI again.
451
+ assert cmd[2] == (
452
+ "from forgexa_cli.main import _run_update_check_and_maybe_install; "
453
+ "_run_update_check_and_maybe_install(True, False)"
454
+ )
455
+
456
+
457
+ def test_launch_process_does_nothing_when_cache_fresh_and_not_allowed_to_install(
458
+ monkeypatch: pytest.MonkeyPatch,
459
+ ) -> None:
460
+ monkeypatch.setattr(main, "_read_update_cache", lambda: ("1.1.0", main.time.time()))
461
+ monkeypatch.setattr(main, "_installed_cli_version", lambda: "1.0.0")
462
+ calls: list[tuple[list[str], dict]] = []
463
+ monkeypatch.setattr(main.subprocess, "Popen", lambda cmd, **kwargs: calls.append((cmd, kwargs)))
464
+
465
+ main._launch_update_check_process(False) # e.g. notify mode
466
+
467
+ assert calls == []
468
+
469
+
470
+ def test_launch_process_does_nothing_when_cache_fresh_and_nothing_pending(
471
+ monkeypatch: pytest.MonkeyPatch,
472
+ ) -> None:
473
+ monkeypatch.setattr(main, "_read_update_cache", lambda: ("1.0.0", main.time.time()))
474
+ monkeypatch.setattr(main, "_installed_cli_version", lambda: "1.0.0") # already up to date
475
+ calls: list[tuple[list[str], dict]] = []
476
+ monkeypatch.setattr(main.subprocess, "Popen", lambda cmd, **kwargs: calls.append((cmd, kwargs)))
477
+
478
+ main._launch_update_check_process(True)
479
+
480
+ assert calls == []
481
+
482
+
483
+ def test_launch_process_does_not_retry_once_backoff_exhausted(
484
+ monkeypatch: pytest.MonkeyPatch,
485
+ ) -> None:
486
+ monkeypatch.setattr(main, "_read_update_cache", lambda: ("1.1.0", main.time.time()))
487
+ monkeypatch.setattr(main, "_installed_cli_version", lambda: "1.0.0")
488
+ autoupgrade.write_state(
489
+ {
490
+ "active": None,
491
+ "last_failure": {"version": "1.1.0", "failed_at": 0, "attempts": 2},
492
+ "last_success": None,
493
+ }
494
+ )
495
+ calls: list[tuple[list[str], dict]] = []
496
+ monkeypatch.setattr(main.subprocess, "Popen", lambda cmd, **kwargs: calls.append((cmd, kwargs)))
497
+
498
+ main._launch_update_check_process(True)
499
+
500
+ assert calls == []
@@ -116,6 +116,7 @@ def test_version_status_shows_full_state(
116
116
  "active": {"version": "1.22.0", "started_at": 0},
117
117
  "last_failure": {"version": "1.21.0", "failed_at": 0, "attempts": 1},
118
118
  "last_success": {"version": "1.20.0", "installed_at": 0, "notified": True},
119
+ "last_skip": {"version": "1.22.0", "reason": "daemon_running", "at": 0},
119
120
  }
120
121
  )
121
122
 
@@ -126,4 +127,5 @@ def test_version_status_shows_full_state(
126
127
  assert "Active install : 1.22.0" in out
127
128
  assert "Last success : 1.20.0" in out
128
129
  assert "Last failure : 1.21.0" in out
130
+ assert "Last skip : 1.22.0 (reason=daemon_running" in out
129
131
  assert "Upgrade log :" in out
File without changes
File without changes