forgexa-cli 1.64.0__tar.gz → 1.65.0__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 (30) hide show
  1. {forgexa_cli-1.64.0 → forgexa_cli-1.65.0}/PKG-INFO +1 -1
  2. {forgexa_cli-1.64.0 → forgexa_cli-1.65.0}/forgexa_cli/__init__.py +1 -1
  3. {forgexa_cli-1.64.0 → forgexa_cli-1.65.0}/forgexa_cli/daemon.py +133 -33
  4. {forgexa_cli-1.64.0 → forgexa_cli-1.65.0}/forgexa_cli.egg-info/PKG-INFO +1 -1
  5. {forgexa_cli-1.64.0 → forgexa_cli-1.65.0}/pyproject.toml +1 -1
  6. {forgexa_cli-1.64.0 → forgexa_cli-1.65.0}/README.md +0 -0
  7. {forgexa_cli-1.64.0 → forgexa_cli-1.65.0}/forgexa_cli/_build_config.py +0 -0
  8. {forgexa_cli-1.64.0 → forgexa_cli-1.65.0}/forgexa_cli/_local_bind.py +0 -0
  9. {forgexa_cli-1.64.0 → forgexa_cli-1.65.0}/forgexa_cli/agent_core.py +0 -0
  10. {forgexa_cli-1.64.0 → forgexa_cli-1.65.0}/forgexa_cli/autoupgrade.py +0 -0
  11. {forgexa_cli-1.64.0 → forgexa_cli-1.65.0}/forgexa_cli/aws_evidence.py +0 -0
  12. {forgexa_cli-1.64.0 → forgexa_cli-1.65.0}/forgexa_cli/main.py +0 -0
  13. {forgexa_cli-1.64.0 → forgexa_cli-1.65.0}/forgexa_cli/py.typed +0 -0
  14. {forgexa_cli-1.64.0 → forgexa_cli-1.65.0}/forgexa_cli/runtime_evidence.py +0 -0
  15. {forgexa_cli-1.64.0 → forgexa_cli-1.65.0}/forgexa_cli/zcode_protocol.py +0 -0
  16. {forgexa_cli-1.64.0 → forgexa_cli-1.65.0}/forgexa_cli.egg-info/SOURCES.txt +0 -0
  17. {forgexa_cli-1.64.0 → forgexa_cli-1.65.0}/forgexa_cli.egg-info/dependency_links.txt +0 -0
  18. {forgexa_cli-1.64.0 → forgexa_cli-1.65.0}/forgexa_cli.egg-info/entry_points.txt +0 -0
  19. {forgexa_cli-1.64.0 → forgexa_cli-1.65.0}/forgexa_cli.egg-info/requires.txt +0 -0
  20. {forgexa_cli-1.64.0 → forgexa_cli-1.65.0}/forgexa_cli.egg-info/top_level.txt +0 -0
  21. {forgexa_cli-1.64.0 → forgexa_cli-1.65.0}/setup.cfg +0 -0
  22. {forgexa_cli-1.64.0 → forgexa_cli-1.65.0}/tests/test_auth_and_runtime_commands.py +0 -0
  23. {forgexa_cli-1.64.0 → forgexa_cli-1.65.0}/tests/test_autoupgrade.py +0 -0
  24. {forgexa_cli-1.64.0 → forgexa_cli-1.65.0}/tests/test_check_command.py +0 -0
  25. {forgexa_cli-1.64.0 → forgexa_cli-1.65.0}/tests/test_expiry_warnings_and_revoke.py +0 -0
  26. {forgexa_cli-1.64.0 → forgexa_cli-1.65.0}/tests/test_local_bind_commands.py +0 -0
  27. {forgexa_cli-1.64.0 → forgexa_cli-1.65.0}/tests/test_runtime_credentials.py +0 -0
  28. {forgexa_cli-1.64.0 → forgexa_cli-1.65.0}/tests/test_session_credentials.py +0 -0
  29. {forgexa_cli-1.64.0 → forgexa_cli-1.65.0}/tests/test_silent_install.py +0 -0
  30. {forgexa_cli-1.64.0 → forgexa_cli-1.65.0}/tests/test_upgrade_observability.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: forgexa-cli
3
- Version: 1.64.0
3
+ Version: 1.65.0
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.64.0"
2
+ __version__ = "1.65.0"
@@ -133,6 +133,9 @@ _MANAGED_WSL_POLICY = getattr(_wsl_policy_module, "ACTIVE_POLICY", None)
133
133
  async def _create_subprocess_exec(*command, **kwargs):
134
134
  if _MANAGED_WSL_POLICY is not None:
135
135
  return await _MANAGED_WSL_POLICY.spawn(*command, **kwargs)
136
+ # Desktop stdin is its parent-liveness control pipe, never child input.
137
+ # Agent callers that need prompt input explicitly pass subprocess.PIPE.
138
+ kwargs.setdefault("stdin", asyncio.subprocess.DEVNULL)
136
139
  return await asyncio.create_subprocess_exec(*command, **kwargs)
137
140
 
138
141
 
@@ -1231,7 +1234,7 @@ except (ImportError, ModuleNotFoundError):
1231
1234
  # DAEMON_VERSION is the protocol/logic version of the daemon code.
1232
1235
  # Kept in sync with pyproject.toml version via bump-version.sh.
1233
1236
  # CLIENT_TYPE identifies which packaging/distribution this daemon runs in.
1234
- DAEMON_VERSION = "1.64.0"
1237
+ DAEMON_VERSION = "1.65.0"
1235
1238
 
1236
1239
 
1237
1240
  def _detect_client_type() -> str:
@@ -2767,6 +2770,27 @@ def _normalize_repo_identity(repo_url: str) -> str | None:
2767
2770
  return f"{host}/{path}"
2768
2771
 
2769
2772
 
2773
+ _LOCAL_BINDING_GIT_CLEANUP_TIMEOUT_SECONDS = 2.0
2774
+ _LOCAL_BINDING_VERIFICATION_TIMEOUT_SECONDS = 25.0
2775
+ _LOCAL_BINDING_VERIFICATION_CONCURRENCY = 4
2776
+
2777
+
2778
+ async def _stop_local_binding_git_probe(proc) -> None:
2779
+ """Bound cleanup of this read-only probe, including Windows pipe teardown."""
2780
+ try:
2781
+ proc.kill()
2782
+ except ProcessLookupError:
2783
+ pass
2784
+ except OSError as exc:
2785
+ logger.warning("Local workspace Git probe termination failed (%s)", type(exc).__name__)
2786
+ try:
2787
+ # wait() can remain pending after Windows process exit until its pipe
2788
+ # transport closes. Drain it, but never wait indefinitely for cleanup.
2789
+ await asyncio.wait_for(proc.communicate(), timeout=_LOCAL_BINDING_GIT_CLEANUP_TIMEOUT_SECONDS)
2790
+ except (asyncio.TimeoutError, OSError) as exc:
2791
+ logger.warning("Local workspace Git probe cleanup did not complete (%s)", type(exc).__name__)
2792
+
2793
+
2770
2794
  async def _validate_local_workspace_binding(binding: dict[str, Any], runtime_id: str) -> str | None:
2771
2795
  if _MANAGED_WSL_POLICY is not None:
2772
2796
  _MANAGED_WSL_POLICY.check_mapping()
@@ -2803,6 +2827,7 @@ async def _validate_local_workspace_binding(binding: dict[str, Any], runtime_id:
2803
2827
  proc = await _create_subprocess_exec(
2804
2828
  _resolve_git_command() if _MANAGED_WSL_POLICY is not None else "git", "remote", "get-url", "origin",
2805
2829
  cwd=str(local_path),
2830
+ stdin=asyncio.subprocess.DEVNULL,
2806
2831
  stdout=asyncio.subprocess.PIPE,
2807
2832
  stderr=asyncio.subprocess.DEVNULL,
2808
2833
  **_agent_process_group_kwargs(),
@@ -2810,10 +2835,15 @@ async def _validate_local_workspace_binding(binding: dict[str, Any], runtime_id:
2810
2835
  stdout, _ = await asyncio.wait_for(proc.communicate(), timeout=10)
2811
2836
  except asyncio.TimeoutError:
2812
2837
  if proc is not None:
2813
- proc.kill()
2814
- await proc.wait()
2838
+ await _stop_local_binding_git_probe(proc)
2815
2839
  return "remote_unreadable"
2840
+ except asyncio.CancelledError:
2841
+ if proc is not None:
2842
+ await _stop_local_binding_git_probe(proc)
2843
+ raise
2816
2844
  except OSError:
2845
+ if proc is not None:
2846
+ await _stop_local_binding_git_probe(proc)
2817
2847
  return "remote_unreadable"
2818
2848
  if proc.returncode != 0:
2819
2849
  return "remote_unreadable"
@@ -9067,6 +9097,8 @@ class HeartbeatService:
9067
9097
  self.daemon_id = daemon_id
9068
9098
  self.interval = interval
9069
9099
  self._task: asyncio.Task | None = None
9100
+ self._verification_task: asyncio.Task | None = None
9101
+ self._verification_requested: asyncio.Event | None = None
9070
9102
  self._active_tasks = 0
9071
9103
  self._agents: list[dict] = []
9072
9104
  self._preflight_snapshot_provider = preflight_snapshot_provider or (lambda: None)
@@ -9081,10 +9113,10 @@ class HeartbeatService:
9081
9113
  if _MANAGED_WSL_POLICY is not None and _MANAGED_WSL_POLICY.task is not None:
9082
9114
  return # The task already retains mapping admission and the physical lock.
9083
9115
  try:
9084
- response = await self.client.get(
9116
+ response = await asyncio.wait_for(self.client.get(
9085
9117
  f"{self.server_url}/api/v1/runtimes/{self.runtime_id}/local-workspace-bindings/verification",
9086
9118
  timeout=10,
9087
- )
9119
+ ), timeout=10)
9088
9120
  if response.status_code != 200:
9089
9121
  detail = _safe_http_error_detail(response)
9090
9122
  logger.warning(
@@ -9096,40 +9128,78 @@ class HeartbeatService:
9096
9128
  bindings = response.json()
9097
9129
  if not isinstance(bindings, list):
9098
9130
  return
9099
- for binding in bindings:
9100
- if not isinstance(binding, dict) or not binding.get("id"):
9101
- continue
9102
- if _MANAGED_WSL_POLICY is not None:
9103
- await _MANAGED_WSL_POLICY.sync_binding(self, binding)
9104
- continue
9105
- failure_code = await _validate_local_workspace_binding(binding, self.runtime_id)
9106
- if failure_code is None and binding.get("status") == "active":
9107
- continue
9108
- response = await self.client.post(
9109
- f"{self.server_url}/api/v1/runtimes/{self.runtime_id}/local-workspace-bindings/{binding['id']}/activate",
9110
- json={"success": failure_code is None, "reason": failure_code},
9111
- timeout=10,
9112
- )
9113
- if response.status_code != 200:
9114
- detail = _safe_http_error_detail(response)
9115
- logger.warning(
9116
- "Local workspace binding verification update failed: %s%s",
9117
- response.status_code,
9118
- f" ({detail})" if detail else "",
9119
- )
9131
+ # Prioritize fresh bindings and isolate each Native validation. A
9132
+ # slow/broken old directory must not starve another pending one.
9133
+ bindings = sorted(
9134
+ (binding for binding in bindings if isinstance(binding, dict) and binding.get("id")),
9135
+ key=lambda binding: binding.get("status") != "pending",
9136
+ )
9137
+ limit = 1 if _MANAGED_WSL_POLICY is not None else _LOCAL_BINDING_VERIFICATION_CONCURRENCY
9138
+ semaphore = asyncio.Semaphore(limit)
9139
+
9140
+ async def verify_one(binding):
9141
+ async with semaphore:
9142
+ try:
9143
+ await asyncio.wait_for(
9144
+ self._verify_local_workspace_binding(binding),
9145
+ timeout=_LOCAL_BINDING_VERIFICATION_TIMEOUT_SECONDS,
9146
+ )
9147
+ except Exception as exc:
9148
+ # Local exceptions can contain paths; only their type
9149
+ # may enter the daemon log that is uploaded remotely.
9150
+ logger.warning("Local workspace binding verification failed (%s)", type(exc).__name__)
9151
+
9152
+ await asyncio.gather(*(verify_one(binding) for binding in bindings))
9120
9153
  except Exception as exc:
9121
- logger.warning("Local workspace binding verification failed: %s", exc)
9154
+ logger.warning("Local workspace binding verification request failed (%s)", type(exc).__name__)
9155
+
9156
+ async def _verify_local_workspace_binding(self, binding: dict[str, Any]) -> None:
9157
+ if _MANAGED_WSL_POLICY is not None:
9158
+ await _MANAGED_WSL_POLICY.sync_binding(self, binding)
9159
+ return
9160
+ failure_code = await _validate_local_workspace_binding(binding, self.runtime_id)
9161
+ if failure_code is None and binding.get("status") == "active":
9162
+ return
9163
+ response = await asyncio.wait_for(self.client.post(
9164
+ f"{self.server_url}/api/v1/runtimes/{self.runtime_id}/local-workspace-bindings/{binding['id']}/activate",
9165
+ json={"success": failure_code is None, "reason": failure_code},
9166
+ timeout=10,
9167
+ ), timeout=10)
9168
+ if response.status_code != 200:
9169
+ detail = _safe_http_error_detail(response)
9170
+ logger.warning(
9171
+ "Local workspace binding verification update failed: %s%s",
9172
+ response.status_code,
9173
+ f" ({detail})" if detail else "",
9174
+ )
9122
9175
 
9123
9176
  async def start(self):
9177
+ if self._task is not None and not self._task.done():
9178
+ return
9179
+ self._verification_requested = asyncio.Event()
9180
+ self._verification_task = asyncio.create_task(self._verification_loop())
9124
9181
  self._task = asyncio.create_task(self._loop())
9125
9182
 
9126
9183
  async def stop(self):
9127
- if self._task:
9128
- self._task.cancel()
9184
+ tasks = [task for task in (self._task, self._verification_task) if task is not None]
9185
+ for task in tasks:
9186
+ task.cancel()
9187
+ if tasks:
9188
+ await asyncio.gather(*tasks, return_exceptions=True)
9189
+ self._task = self._verification_task = None
9190
+ self._verification_requested = None
9191
+
9192
+ async def _verification_loop(self):
9193
+ requested = self._verification_requested
9194
+ if requested is None:
9195
+ return
9196
+ while True:
9197
+ await requested.wait()
9198
+ requested.clear()
9129
9199
  try:
9130
- await self._task
9131
- except asyncio.CancelledError:
9132
- pass
9200
+ await self._sync_local_workspace_bindings()
9201
+ except Exception as exc:
9202
+ logger.warning("Local workspace binding verification cycle failed (%s)", type(exc).__name__)
9133
9203
 
9134
9204
  async def _loop(self):
9135
9205
  while True:
@@ -9157,7 +9227,10 @@ class HeartbeatService:
9157
9227
  )
9158
9228
  if resp.status_code == 200:
9159
9229
  self._on_success()
9160
- await self._sync_local_workspace_bindings()
9230
+ # Verification has its own single-flight worker. Never
9231
+ # await local Git/activation on the heartbeat's lifetime.
9232
+ if self._verification_requested is not None:
9233
+ self._verification_requested.set()
9161
9234
  elif resp.status_code in (401, 403):
9162
9235
  logger.warning("Heartbeat auth failed: %s %s", resp.status_code, resp.text)
9163
9236
  self._on_auth_failure()
@@ -18681,10 +18754,37 @@ class RuntimeDaemon:
18681
18754
  self._shutdown_event.set()
18682
18755
 
18683
18756
 
18757
+ def _start_desktop_parent_monitor(daemon, loop):
18758
+ """Stop only an explicitly Desktop-owned daemon when its parent pipe closes."""
18759
+ if sys.platform != "win32" or os.environ.pop("FORGEXA_DESKTOP_PARENT_PIPE", None) != "1":
18760
+ return None
18761
+ stream = sys.stdin
18762
+ if stream is None:
18763
+ raise RuntimeError("Desktop parent pipe is unavailable")
18764
+ import threading
18765
+
18766
+ def monitor():
18767
+ try:
18768
+ while stream.read(1):
18769
+ pass
18770
+ except (OSError, ValueError):
18771
+ pass
18772
+ try:
18773
+ # Capture the running loop here, not in this background thread.
18774
+ loop.call_soon_threadsafe(daemon.handle_signal, signal.SIGTERM)
18775
+ except RuntimeError:
18776
+ pass # The daemon has already closed its loop.
18777
+
18778
+ thread = threading.Thread(target=monitor, name="desktop-parent-pipe", daemon=True)
18779
+ thread.start()
18780
+ return thread
18781
+
18782
+
18684
18783
  async def main():
18685
18784
  daemon = RuntimeDaemon()
18686
18785
 
18687
18786
  loop = asyncio.get_event_loop()
18787
+ _start_desktop_parent_monitor(daemon, loop)
18688
18788
  if sys.platform != "win32":
18689
18789
  for sig in (signal.SIGINT, signal.SIGTERM):
18690
18790
  loop.add_signal_handler(sig, daemon.handle_signal, sig)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: forgexa-cli
3
- Version: 1.64.0
3
+ Version: 1.65.0
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.64.0"
3
+ version = "1.65.0"
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"
File without changes
File without changes