forgexa-cli 1.20.5__tar.gz → 1.21.1__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.20.5 → forgexa_cli-1.21.1}/PKG-INFO +29 -1
  2. {forgexa_cli-1.20.5 → forgexa_cli-1.21.1}/README.md +28 -0
  3. {forgexa_cli-1.20.5 → forgexa_cli-1.21.1}/forgexa_cli/__init__.py +1 -1
  4. forgexa_cli-1.21.1/forgexa_cli/autoupgrade.py +219 -0
  5. {forgexa_cli-1.20.5 → forgexa_cli-1.21.1}/forgexa_cli/daemon.py +56 -5
  6. {forgexa_cli-1.20.5 → forgexa_cli-1.21.1}/forgexa_cli/main.py +266 -15
  7. {forgexa_cli-1.20.5 → forgexa_cli-1.21.1}/forgexa_cli.egg-info/PKG-INFO +29 -1
  8. {forgexa_cli-1.20.5 → forgexa_cli-1.21.1}/forgexa_cli.egg-info/SOURCES.txt +5 -1
  9. {forgexa_cli-1.20.5 → forgexa_cli-1.21.1}/pyproject.toml +1 -1
  10. {forgexa_cli-1.20.5 → forgexa_cli-1.21.1}/tests/test_auth_and_runtime_commands.py +16 -4
  11. forgexa_cli-1.21.1/tests/test_autoupgrade.py +211 -0
  12. forgexa_cli-1.21.1/tests/test_silent_install.py +332 -0
  13. forgexa_cli-1.21.1/tests/test_upgrade_observability.py +129 -0
  14. {forgexa_cli-1.20.5 → forgexa_cli-1.21.1}/forgexa_cli/_build_config.py +0 -0
  15. {forgexa_cli-1.20.5 → forgexa_cli-1.21.1}/forgexa_cli/py.typed +0 -0
  16. {forgexa_cli-1.20.5 → forgexa_cli-1.21.1}/forgexa_cli.egg-info/dependency_links.txt +0 -0
  17. {forgexa_cli-1.20.5 → forgexa_cli-1.21.1}/forgexa_cli.egg-info/entry_points.txt +0 -0
  18. {forgexa_cli-1.20.5 → forgexa_cli-1.21.1}/forgexa_cli.egg-info/requires.txt +0 -0
  19. {forgexa_cli-1.20.5 → forgexa_cli-1.21.1}/forgexa_cli.egg-info/top_level.txt +0 -0
  20. {forgexa_cli-1.20.5 → forgexa_cli-1.21.1}/setup.cfg +0 -0
  21. {forgexa_cli-1.20.5 → forgexa_cli-1.21.1}/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.20.5
3
+ Version: 1.21.1
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.20.5"
2
+ __version__ = "1.21.1"
@@ -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.20.5"
558
+ DAEMON_VERSION = "1.21.1"
559
559
 
560
560
 
561
561
  def _detect_client_type() -> str:
@@ -780,8 +780,19 @@ def _prepare_agent_command(command: list[str]) -> list[str]:
780
780
 
781
781
  suffix = Path(command[0]).suffix.lower()
782
782
  if suffix in {".cmd", ".bat"}:
783
+ comspec = os.environ.get("COMSPEC", "")
784
+ if comspec and Path(comspec).is_file():
785
+ command_processor = comspec
786
+ else:
787
+ system_root = os.environ.get("SystemRoot") or os.environ.get("WINDIR", "")
788
+ system_cmd = Path(system_root) / "System32" / "cmd.exe" if system_root else None
789
+ command_processor = (
790
+ str(system_cmd)
791
+ if system_cmd and system_cmd.is_file()
792
+ else shutil.which("cmd.exe") or comspec or "cmd.exe"
793
+ )
783
794
  return [
784
- os.environ.get("COMSPEC", "cmd.exe"),
795
+ command_processor,
785
796
  "/d",
786
797
  "/s",
787
798
  "/c",
@@ -800,6 +811,13 @@ def _prepare_agent_command(command: list[str]) -> list[str]:
800
811
  return command
801
812
 
802
813
 
814
+ def _agent_process_group_kwargs() -> dict[str, bool]:
815
+ """Isolate agent process groups where the platform supports POSIX sessions."""
816
+ # Windows cleanup uses taskkill /T, and starting cmd.exe with a new session
817
+ # differs from the command path proven during agent discovery.
818
+ return {"start_new_session": True} if sys.platform != "win32" else {}
819
+
820
+
803
821
  @dataclass
804
822
  class TaskInfo:
805
823
  task_id: str
@@ -4502,7 +4520,7 @@ class ProcessManager:
4502
4520
  cwd=str(cwd),
4503
4521
  env=env,
4504
4522
  limit=100 * 1024 * 1024, # 100MB line buffer for large JSON output from long sessions
4505
- start_new_session=True, # own process group → killpg on timeout kills all children
4523
+ **_agent_process_group_kwargs(),
4506
4524
  )
4507
4525
  self.active_processes[task_id] = proc
4508
4526
  stdout, stderr, returncode = await self._stream_process(
@@ -4559,6 +4577,16 @@ class ProcessManager:
4559
4577
  error=f"Claude exited with code {returncode}: {stderr[-500:]}",
4560
4578
  metrics=metrics,
4561
4579
  )
4580
+ except FileNotFoundError as exc:
4581
+ logger.exception("Claude executable was not found for task %s", task_id)
4582
+ return TaskResult(
4583
+ status="failed",
4584
+ exit_code=-1,
4585
+ stdout="",
4586
+ stderr="",
4587
+ error=f"Claude launch failed: executable not found: {cmd[0]!r} ({exc})",
4588
+ failure_code="agent_launch_not_found",
4589
+ )
4562
4590
  except asyncio.TimeoutError as exc:
4563
4591
  _kill_proc(self.active_processes.pop(task_id, None) or proc)
4564
4592
  _err = (
@@ -5237,7 +5265,7 @@ class ProcessManager:
5237
5265
  cwd=str(cwd),
5238
5266
  env=env,
5239
5267
  limit=100 * 1024 * 1024,
5240
- start_new_session=True,
5268
+ **_agent_process_group_kwargs(),
5241
5269
  **_win_flags,
5242
5270
  )
5243
5271
  self.active_processes[task_id] = proc
@@ -5367,6 +5395,19 @@ class ProcessManager:
5367
5395
  error=failure_error,
5368
5396
  metrics=metrics,
5369
5397
  )
5398
+ except FileNotFoundError as exc:
5399
+ logger.exception("Copilot executable was not found for task %s", task_id)
5400
+ return TaskResult(
5401
+ status="failed",
5402
+ exit_code=-1,
5403
+ stdout="",
5404
+ stderr="",
5405
+ error=(
5406
+ f"Copilot launch failed: executable not found: {cmd[0]!r}; "
5407
+ f"configured agent path: {agent.command!r} ({exc})"
5408
+ ),
5409
+ failure_code="agent_launch_not_found",
5410
+ )
5370
5411
  except asyncio.TimeoutError as exc:
5371
5412
  _kill_proc(self.active_processes.pop(task_id, None) or proc)
5372
5413
  _err = (
@@ -5428,7 +5469,7 @@ class ProcessManager:
5428
5469
  cwd=str(cwd),
5429
5470
  env=env,
5430
5471
  limit=100 * 1024 * 1024, # 100MB line buffer for large agent output
5431
- start_new_session=True, # own process group → killpg on timeout kills all children
5472
+ **_agent_process_group_kwargs(),
5432
5473
  )
5433
5474
  self.active_processes[task_id] = proc
5434
5475
  stdin_bytes = stdin_input.encode() if stdin_input else None
@@ -5445,6 +5486,16 @@ class ProcessManager:
5445
5486
  stderr=stderr[-10000:],
5446
5487
  error="" if status == "success" else f"Exited with code {returncode}",
5447
5488
  )
5489
+ except FileNotFoundError as exc:
5490
+ logger.exception("Agent executable was not found for task %s", task_id)
5491
+ return TaskResult(
5492
+ status="failed",
5493
+ exit_code=-1,
5494
+ stdout="",
5495
+ stderr="",
5496
+ error=f"Agent launch failed: executable not found: {cmd[0]!r} ({exc})",
5497
+ failure_code="agent_launch_not_found",
5498
+ )
5448
5499
  except asyncio.TimeoutError as exc:
5449
5500
  _kill_proc(self.active_processes.pop(task_id, None) or proc)
5450
5501
  _err = (