forgexa-cli 1.20.4__tar.gz → 1.21.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.
- {forgexa_cli-1.20.4 → forgexa_cli-1.21.0}/PKG-INFO +29 -1
- {forgexa_cli-1.20.4 → forgexa_cli-1.21.0}/README.md +28 -0
- {forgexa_cli-1.20.4 → forgexa_cli-1.21.0}/forgexa_cli/__init__.py +1 -1
- forgexa_cli-1.21.0/forgexa_cli/autoupgrade.py +219 -0
- {forgexa_cli-1.20.4 → forgexa_cli-1.21.0}/forgexa_cli/daemon.py +73 -23
- {forgexa_cli-1.20.4 → forgexa_cli-1.21.0}/forgexa_cli/main.py +266 -15
- {forgexa_cli-1.20.4 → forgexa_cli-1.21.0}/forgexa_cli.egg-info/PKG-INFO +29 -1
- {forgexa_cli-1.20.4 → forgexa_cli-1.21.0}/forgexa_cli.egg-info/SOURCES.txt +5 -1
- {forgexa_cli-1.20.4 → forgexa_cli-1.21.0}/pyproject.toml +1 -1
- {forgexa_cli-1.20.4 → forgexa_cli-1.21.0}/tests/test_auth_and_runtime_commands.py +36 -4
- forgexa_cli-1.21.0/tests/test_autoupgrade.py +211 -0
- forgexa_cli-1.21.0/tests/test_silent_install.py +332 -0
- forgexa_cli-1.21.0/tests/test_upgrade_observability.py +129 -0
- {forgexa_cli-1.20.4 → forgexa_cli-1.21.0}/forgexa_cli/_build_config.py +0 -0
- {forgexa_cli-1.20.4 → forgexa_cli-1.21.0}/forgexa_cli/py.typed +0 -0
- {forgexa_cli-1.20.4 → forgexa_cli-1.21.0}/forgexa_cli.egg-info/dependency_links.txt +0 -0
- {forgexa_cli-1.20.4 → forgexa_cli-1.21.0}/forgexa_cli.egg-info/entry_points.txt +0 -0
- {forgexa_cli-1.20.4 → forgexa_cli-1.21.0}/forgexa_cli.egg-info/requires.txt +0 -0
- {forgexa_cli-1.20.4 → forgexa_cli-1.21.0}/forgexa_cli.egg-info/top_level.txt +0 -0
- {forgexa_cli-1.20.4 → forgexa_cli-1.21.0}/setup.cfg +0 -0
- {forgexa_cli-1.20.4 → forgexa_cli-1.21.0}/tests/test_check_command.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: forgexa-cli
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.21.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
|
|
@@ -113,6 +113,7 @@ forgexa board --project <project-id>
|
|
|
113
113
|
| `forgexa check --agent <name>` | Check a single agent (e.g. `claude`, `codex`, `kimi`) |
|
|
114
114
|
| `forgexa runtimes list` | List your runtimes |
|
|
115
115
|
| `forgexa version` | Show CLI version |
|
|
116
|
+
| `forgexa version --status` | Show auto-upgrade status (mode, active/last success/last failure) |
|
|
116
117
|
| `forgexa upgrade` | Upgrade to the latest CLI release |
|
|
117
118
|
| `forgexa upgrade --target-version <version>` | Upgrade or roll forward to a specific release |
|
|
118
119
|
|
|
@@ -136,12 +137,39 @@ For safety, `forgexa upgrade` stops any running local daemon before upgrading. I
|
|
|
136
137
|
|
|
137
138
|
`forgexa upgrade` supports standard PyPI / pipx installs and non-editable local path installs. Editable installs, VCS installs, and other direct URL installs are rejected with a manual upgrade hint.
|
|
138
139
|
|
|
140
|
+
### Automatic Background Upgrade
|
|
141
|
+
|
|
142
|
+
By default (`auto_upgrade=silent`), forgexa checks for updates in the background (once every 24h) and silently installs new releases — no need to run `forgexa upgrade` yourself, the same way GitHub Copilot CLI / Claude Code / Kimi Code work (see [docs/designs/cli-auto-upgrade-design.md](../docs/designs/cli-auto-upgrade-design.md) for the full design). `forgexa upgrade` remains fully available at any time for an immediate manual upgrade or to pin a specific `--target-version` — the two are independent and coexist.
|
|
143
|
+
|
|
144
|
+
```bash
|
|
145
|
+
# Back to notify-only: check and print a footer notice, but never install automatically
|
|
146
|
+
forgexa config set auto-upgrade notify
|
|
147
|
+
|
|
148
|
+
# Disable update checks entirely
|
|
149
|
+
forgexa config set auto-upgrade off
|
|
150
|
+
|
|
151
|
+
# Re-enable silent background installs (the default)
|
|
152
|
+
forgexa config set auto-upgrade silent
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
| Mode | Check | Silent background install | Footer notice |
|
|
156
|
+
|------|-------|----------------------------|----------------|
|
|
157
|
+
| `silent` (default) | ✅ | ✅ | ✅ (one-time "updated to vX" notice next run) |
|
|
158
|
+
| `notify` | ✅ | ❌ | ✅ |
|
|
159
|
+
| `off` | ❌ | ❌ | ❌ |
|
|
160
|
+
|
|
161
|
+
A silent install only ever runs for a safe, already-detected install source (pip/pipx — the same sources `forgexa upgrade` supports), never while a local daemon is running (it's skipped and retried on a later invocation instead), never in CI or a non-interactive/`--format json` session, and gives up automatically after 2 consecutive failures for the same target version. Check `forgexa version --status` to see the current mode and the last install attempt's outcome, or `~/.forgexa/upgrade.log` for the raw install output.
|
|
162
|
+
|
|
163
|
+
`FORGEXA_AUTO_UPGRADE=<silent|notify|off>` overrides the config file for the current shell session; the legacy `FORGEXA_NO_UPDATE_CHECK=1` remains a supported alias for `off`.
|
|
164
|
+
|
|
139
165
|
## Configuration
|
|
140
166
|
|
|
141
167
|
| Variable | Default | Description |
|
|
142
168
|
|----------|---------|-------------|
|
|
143
169
|
| `FORGEXA_SERVER_URL` | `http://localhost:8000` | `Server base URL |
|
|
144
170
|
| `FORGEXA_TOKEN` | — | Bearer token (overrides `~/.forgexa/token`) |
|
|
171
|
+
| `FORGEXA_AUTO_UPGRADE` | — | Override `auto_upgrade` (`silent`\|`notify`\|`off`) for this session |
|
|
172
|
+
| `FORGEXA_NO_UPDATE_CHECK` | — | Legacy alias for `FORGEXA_AUTO_UPGRADE=off` |
|
|
145
173
|
|
|
146
174
|
## Output Format
|
|
147
175
|
|
|
@@ -81,6 +81,7 @@ forgexa board --project <project-id>
|
|
|
81
81
|
| `forgexa check --agent <name>` | Check a single agent (e.g. `claude`, `codex`, `kimi`) |
|
|
82
82
|
| `forgexa runtimes list` | List your runtimes |
|
|
83
83
|
| `forgexa version` | Show CLI version |
|
|
84
|
+
| `forgexa version --status` | Show auto-upgrade status (mode, active/last success/last failure) |
|
|
84
85
|
| `forgexa upgrade` | Upgrade to the latest CLI release |
|
|
85
86
|
| `forgexa upgrade --target-version <version>` | Upgrade or roll forward to a specific release |
|
|
86
87
|
|
|
@@ -104,12 +105,39 @@ For safety, `forgexa upgrade` stops any running local daemon before upgrading. I
|
|
|
104
105
|
|
|
105
106
|
`forgexa upgrade` supports standard PyPI / pipx installs and non-editable local path installs. Editable installs, VCS installs, and other direct URL installs are rejected with a manual upgrade hint.
|
|
106
107
|
|
|
108
|
+
### Automatic Background Upgrade
|
|
109
|
+
|
|
110
|
+
By default (`auto_upgrade=silent`), forgexa checks for updates in the background (once every 24h) and silently installs new releases — no need to run `forgexa upgrade` yourself, the same way GitHub Copilot CLI / Claude Code / Kimi Code work (see [docs/designs/cli-auto-upgrade-design.md](../docs/designs/cli-auto-upgrade-design.md) for the full design). `forgexa upgrade` remains fully available at any time for an immediate manual upgrade or to pin a specific `--target-version` — the two are independent and coexist.
|
|
111
|
+
|
|
112
|
+
```bash
|
|
113
|
+
# Back to notify-only: check and print a footer notice, but never install automatically
|
|
114
|
+
forgexa config set auto-upgrade notify
|
|
115
|
+
|
|
116
|
+
# Disable update checks entirely
|
|
117
|
+
forgexa config set auto-upgrade off
|
|
118
|
+
|
|
119
|
+
# Re-enable silent background installs (the default)
|
|
120
|
+
forgexa config set auto-upgrade silent
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
| Mode | Check | Silent background install | Footer notice |
|
|
124
|
+
|------|-------|----------------------------|----------------|
|
|
125
|
+
| `silent` (default) | ✅ | ✅ | ✅ (one-time "updated to vX" notice next run) |
|
|
126
|
+
| `notify` | ✅ | ❌ | ✅ |
|
|
127
|
+
| `off` | ❌ | ❌ | ❌ |
|
|
128
|
+
|
|
129
|
+
A silent install only ever runs for a safe, already-detected install source (pip/pipx — the same sources `forgexa upgrade` supports), never while a local daemon is running (it's skipped and retried on a later invocation instead), never in CI or a non-interactive/`--format json` session, and gives up automatically after 2 consecutive failures for the same target version. Check `forgexa version --status` to see the current mode and the last install attempt's outcome, or `~/.forgexa/upgrade.log` for the raw install output.
|
|
130
|
+
|
|
131
|
+
`FORGEXA_AUTO_UPGRADE=<silent|notify|off>` overrides the config file for the current shell session; the legacy `FORGEXA_NO_UPDATE_CHECK=1` remains a supported alias for `off`.
|
|
132
|
+
|
|
107
133
|
## Configuration
|
|
108
134
|
|
|
109
135
|
| Variable | Default | Description |
|
|
110
136
|
|----------|---------|-------------|
|
|
111
137
|
| `FORGEXA_SERVER_URL` | `http://localhost:8000` | `Server base URL |
|
|
112
138
|
| `FORGEXA_TOKEN` | — | Bearer token (overrides `~/.forgexa/token`) |
|
|
139
|
+
| `FORGEXA_AUTO_UPGRADE` | — | Override `auto_upgrade` (`silent`\|`notify`\|`off`) for this session |
|
|
140
|
+
| `FORGEXA_NO_UPDATE_CHECK` | — | Legacy alias for `FORGEXA_AUTO_UPGRADE=off` |
|
|
113
141
|
|
|
114
142
|
## Output Format
|
|
115
143
|
|
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
"""forgexa-cli — Forgexa command-line client."""
|
|
2
|
-
__version__ = "1.
|
|
2
|
+
__version__ = "1.21.0"
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
"""Auto-upgrade infrastructure for forgexa-cli.
|
|
2
|
+
|
|
3
|
+
Holds the pieces that are reusable/testable in isolation: resolving the
|
|
4
|
+
effective ``auto_upgrade`` mode (env var > ``~/.forgexa/config`` > built-in
|
|
5
|
+
default), and the on-disk state/lock files used to coordinate silent
|
|
6
|
+
background installs across concurrent CLI invocations.
|
|
7
|
+
|
|
8
|
+
The actual "check + maybe install" orchestration lives in ``main.py`` (it
|
|
9
|
+
needs to call ``_build_upgrade_plan()``, daemon helpers, etc. which live
|
|
10
|
+
there); this module only provides the primitives.
|
|
11
|
+
|
|
12
|
+
See docs/designs/cli-auto-upgrade-design.md for the full design and rationale.
|
|
13
|
+
"""
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import json
|
|
17
|
+
import os
|
|
18
|
+
import time
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
|
|
21
|
+
# ── auto_upgrade mode resolution ──────────────────────────────────────────────
|
|
22
|
+
|
|
23
|
+
VALID_MODES = ("silent", "notify", "off")
|
|
24
|
+
DEFAULT_MODE = "silent"
|
|
25
|
+
|
|
26
|
+
# Failure/staleness tuning (see docs/designs/cli-auto-upgrade-design.md §4.7).
|
|
27
|
+
FAILURE_BACKOFF_LIMIT = 2
|
|
28
|
+
ACTIVE_INSTALL_STALE_SECONDS = 30 * 60
|
|
29
|
+
LOCK_STALE_SECONDS = 30 * 60
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def resolve_auto_upgrade_mode(config: dict) -> str:
|
|
33
|
+
"""Resolve the effective ``auto_upgrade`` mode.
|
|
34
|
+
|
|
35
|
+
Precedence: ``FORGEXA_NO_UPDATE_CHECK`` (legacy total kill-switch, wins
|
|
36
|
+
over everything) > ``FORGEXA_AUTO_UPGRADE`` env var >
|
|
37
|
+
``~/.forgexa/config``'s ``auto_upgrade`` key > built-in default
|
|
38
|
+
(``"notify"``).
|
|
39
|
+
"""
|
|
40
|
+
if os.environ.get("FORGEXA_NO_UPDATE_CHECK"):
|
|
41
|
+
return "off"
|
|
42
|
+
env = os.environ.get("FORGEXA_AUTO_UPGRADE", "").strip().lower()
|
|
43
|
+
if env in VALID_MODES:
|
|
44
|
+
return env
|
|
45
|
+
configured = str(config.get("auto_upgrade") or "").strip().lower()
|
|
46
|
+
if configured in VALID_MODES:
|
|
47
|
+
return configured
|
|
48
|
+
return DEFAULT_MODE
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def describe_auto_upgrade_source(config: dict) -> str:
|
|
52
|
+
"""Human-readable description of where the effective mode came from.
|
|
53
|
+
|
|
54
|
+
Mirrors the precedence order in ``resolve_auto_upgrade_mode`` for
|
|
55
|
+
display purposes (``forgexa config show``).
|
|
56
|
+
"""
|
|
57
|
+
if os.environ.get("FORGEXA_NO_UPDATE_CHECK"):
|
|
58
|
+
return "FORGEXA_NO_UPDATE_CHECK env var"
|
|
59
|
+
env = os.environ.get("FORGEXA_AUTO_UPGRADE", "").strip().lower()
|
|
60
|
+
if env in VALID_MODES:
|
|
61
|
+
return "FORGEXA_AUTO_UPGRADE env var"
|
|
62
|
+
configured = str(config.get("auto_upgrade") or "").strip().lower()
|
|
63
|
+
if configured in VALID_MODES:
|
|
64
|
+
return "~/.forgexa/config"
|
|
65
|
+
return "built-in default"
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
# ── State file (~/.forgexa/upgrade-state.json) ────────────────────────────────
|
|
69
|
+
#
|
|
70
|
+
# Tracks the outcome of silent background install attempts:
|
|
71
|
+
# active — an install currently in progress: {version, started_at}
|
|
72
|
+
# last_failure — most recent failure: {version, failed_at, attempts}
|
|
73
|
+
# last_success — most recent success: {version, installed_at, notified}
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _state_path() -> Path:
|
|
77
|
+
return Path.home() / ".forgexa" / "upgrade-state.json"
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def empty_state() -> dict:
|
|
81
|
+
return {"active": None, "last_failure": None, "last_success": None}
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def read_state() -> dict:
|
|
85
|
+
"""Return the upgrade state dict. Never raises; missing/corrupt -> empty shape."""
|
|
86
|
+
try:
|
|
87
|
+
data = json.loads(_state_path().read_text())
|
|
88
|
+
if isinstance(data, dict):
|
|
89
|
+
state = empty_state()
|
|
90
|
+
state.update({k: data.get(k) for k in state if k in data})
|
|
91
|
+
return state
|
|
92
|
+
except Exception:
|
|
93
|
+
pass
|
|
94
|
+
return empty_state()
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def write_state(state: dict) -> None:
|
|
98
|
+
"""Atomically write the upgrade state. Never raises."""
|
|
99
|
+
try:
|
|
100
|
+
path = _state_path()
|
|
101
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
102
|
+
tmp = path.with_suffix(".json.tmp")
|
|
103
|
+
tmp.write_text(json.dumps(state, indent=2))
|
|
104
|
+
tmp.replace(path)
|
|
105
|
+
path.chmod(0o600)
|
|
106
|
+
except Exception:
|
|
107
|
+
pass
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def failure_attempts_for(state: dict, target_version: str) -> int:
|
|
111
|
+
"""How many consecutive failures are recorded for `target_version`.
|
|
112
|
+
|
|
113
|
+
Resets to 0 as soon as a different (e.g. newer) target version is being
|
|
114
|
+
considered, so a fixed release doesn't stay permanently backed off.
|
|
115
|
+
"""
|
|
116
|
+
failure = state.get("last_failure") or {}
|
|
117
|
+
if failure.get("version") == target_version:
|
|
118
|
+
return int(failure.get("attempts") or 0)
|
|
119
|
+
return 0
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def has_fresh_active_install(state: dict, target_version: str) -> bool:
|
|
123
|
+
"""Whether an install for `target_version` is already in progress and not stale."""
|
|
124
|
+
active = state.get("active") or {}
|
|
125
|
+
if active.get("version") != target_version:
|
|
126
|
+
return False
|
|
127
|
+
started_at = active.get("started_at")
|
|
128
|
+
if not isinstance(started_at, (int, float)):
|
|
129
|
+
return False
|
|
130
|
+
return (time.time() - started_at) < ACTIVE_INSTALL_STALE_SECONDS
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
# ── Lock file (~/.forgexa/upgrade.lock) ────────────────────────────────────────
|
|
134
|
+
#
|
|
135
|
+
# Prevents two concurrent CLI invocations from both starting a background
|
|
136
|
+
# install. Content is `{version, pid, started_at}`; staleness is judged by
|
|
137
|
+
# age first, and by whether `pid` is still alive as a secondary signal.
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _lock_path() -> Path:
|
|
141
|
+
return Path.home() / ".forgexa" / "upgrade.lock"
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _pid_alive(pid: int) -> bool:
|
|
145
|
+
try:
|
|
146
|
+
os.kill(pid, 0)
|
|
147
|
+
except ProcessLookupError:
|
|
148
|
+
return False
|
|
149
|
+
except PermissionError:
|
|
150
|
+
return True
|
|
151
|
+
except OSError:
|
|
152
|
+
return False
|
|
153
|
+
return True
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _lock_is_stale(path: Path) -> bool:
|
|
157
|
+
try:
|
|
158
|
+
payload = json.loads(path.read_text())
|
|
159
|
+
except Exception:
|
|
160
|
+
return True # Unparseable lock content — safe to reclaim.
|
|
161
|
+
|
|
162
|
+
pid = payload.get("pid")
|
|
163
|
+
if isinstance(pid, int) and not _pid_alive(pid):
|
|
164
|
+
return True
|
|
165
|
+
|
|
166
|
+
started_at = payload.get("started_at")
|
|
167
|
+
if not isinstance(started_at, (int, float)):
|
|
168
|
+
return True
|
|
169
|
+
return (time.time() - started_at) >= LOCK_STALE_SECONDS
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _write_lock_atomically(path: Path, target_version: str) -> bool:
|
|
173
|
+
payload = json.dumps(
|
|
174
|
+
{"version": target_version, "pid": os.getpid(), "started_at": time.time()}
|
|
175
|
+
).encode("utf-8")
|
|
176
|
+
try:
|
|
177
|
+
fd = os.open(str(path), os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600)
|
|
178
|
+
except OSError:
|
|
179
|
+
return False
|
|
180
|
+
try:
|
|
181
|
+
os.write(fd, payload)
|
|
182
|
+
finally:
|
|
183
|
+
os.close(fd)
|
|
184
|
+
return True
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def try_acquire_lock(target_version: str) -> bool:
|
|
188
|
+
"""Attempt to atomically create the upgrade lock file.
|
|
189
|
+
|
|
190
|
+
Returns True on success (caller must call ``release_lock()`` when done),
|
|
191
|
+
or False if another (non-stale) install is already in progress. A stale
|
|
192
|
+
lock is reclaimed automatically. Never raises.
|
|
193
|
+
"""
|
|
194
|
+
path = _lock_path()
|
|
195
|
+
try:
|
|
196
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
197
|
+
except OSError:
|
|
198
|
+
return False
|
|
199
|
+
|
|
200
|
+
if _write_lock_atomically(path, target_version):
|
|
201
|
+
return True
|
|
202
|
+
|
|
203
|
+
if not path.exists() or not _lock_is_stale(path):
|
|
204
|
+
return False
|
|
205
|
+
|
|
206
|
+
# Stale lock — reclaim it. A concurrent racer may win this instead; that's
|
|
207
|
+
# fine, at most one of us proceeds and the other simply skips this round.
|
|
208
|
+
try:
|
|
209
|
+
path.unlink()
|
|
210
|
+
except OSError:
|
|
211
|
+
pass
|
|
212
|
+
return _write_lock_atomically(path, target_version)
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def release_lock() -> None:
|
|
216
|
+
try:
|
|
217
|
+
_lock_path().unlink(missing_ok=True)
|
|
218
|
+
except OSError:
|
|
219
|
+
pass
|
|
@@ -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.
|
|
558
|
+
DAEMON_VERSION = "1.21.0"
|
|
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
|
-
|
|
1597
|
-
|
|
1598
|
-
|
|
1599
|
-
|
|
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:
|
|
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
|
|
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
|
|
7860
|
+
if fallback_reason and not _skip_fallback:
|
|
7814
7861
|
logger.warning(
|
|
7815
|
-
"Agent '%s'
|
|
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
|
|
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"] =
|
|
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
|
|