forgexa-cli 1.12.2__tar.gz → 1.13.2__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.12.2 → forgexa_cli-1.13.2}/PKG-INFO +28 -2
- {forgexa_cli-1.12.2 → forgexa_cli-1.13.2}/README.md +26 -1
- {forgexa_cli-1.12.2 → forgexa_cli-1.13.2}/forgexa_cli/__init__.py +1 -1
- {forgexa_cli-1.12.2 → forgexa_cli-1.13.2}/forgexa_cli/daemon.py +329 -67
- {forgexa_cli-1.12.2 → forgexa_cli-1.13.2}/forgexa_cli/main.py +380 -20
- {forgexa_cli-1.12.2 → forgexa_cli-1.13.2}/forgexa_cli.egg-info/PKG-INFO +28 -2
- {forgexa_cli-1.12.2 → forgexa_cli-1.13.2}/forgexa_cli.egg-info/requires.txt +1 -0
- {forgexa_cli-1.12.2 → forgexa_cli-1.13.2}/pyproject.toml +5 -3
- forgexa_cli-1.13.2/tests/test_auth_and_runtime_commands.py +436 -0
- forgexa_cli-1.12.2/tests/test_auth_and_runtime_commands.py +0 -236
- {forgexa_cli-1.12.2 → forgexa_cli-1.13.2}/forgexa_cli/_build_config.py +0 -0
- {forgexa_cli-1.12.2 → forgexa_cli-1.13.2}/forgexa_cli/py.typed +0 -0
- {forgexa_cli-1.12.2 → forgexa_cli-1.13.2}/forgexa_cli.egg-info/SOURCES.txt +0 -0
- {forgexa_cli-1.12.2 → forgexa_cli-1.13.2}/forgexa_cli.egg-info/dependency_links.txt +0 -0
- {forgexa_cli-1.12.2 → forgexa_cli-1.13.2}/forgexa_cli.egg-info/entry_points.txt +0 -0
- {forgexa_cli-1.12.2 → forgexa_cli-1.13.2}/forgexa_cli.egg-info/top_level.txt +0 -0
- {forgexa_cli-1.12.2 → forgexa_cli-1.13.2}/setup.cfg +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: forgexa-cli
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.13.2
|
|
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: MIT
|
|
@@ -25,6 +25,7 @@ Classifier: Topic :: Software Development :: Build Tools
|
|
|
25
25
|
Classifier: Topic :: Software Development :: Quality Assurance
|
|
26
26
|
Requires-Python: >=3.9
|
|
27
27
|
Description-Content-Type: text/markdown
|
|
28
|
+
Requires-Dist: httpx>=0.24
|
|
28
29
|
Provides-Extra: daemon
|
|
29
30
|
Requires-Dist: httpx>=0.24; extra == "daemon"
|
|
30
31
|
|
|
@@ -32,7 +33,7 @@ Requires-Dist: httpx>=0.24; extra == "daemon"
|
|
|
32
33
|
|
|
33
34
|
Command-line client and agent runtime for the [Forgexa](https://forgexa.net) platform.
|
|
34
35
|
|
|
35
|
-
|
|
36
|
+
Communicates with the Forgexa server via REST API.
|
|
36
37
|
Includes a built-in **daemon** that discovers local AI agents (Claude Code, Codex, Gemini CLI, OpenCode, Kimi Code, etc.) and executes tasks on behalf of the server.
|
|
37
38
|
|
|
38
39
|
## Installation
|
|
@@ -44,6 +45,10 @@ pip install forgexa-cli
|
|
|
44
45
|
# Or with pipx (isolated environment)
|
|
45
46
|
pipx install forgexa-cli
|
|
46
47
|
|
|
48
|
+
# Upgrade later
|
|
49
|
+
forgexa upgrade
|
|
50
|
+
forgexa upgrade --target-version 1.12.3
|
|
51
|
+
|
|
47
52
|
# Verify installation
|
|
48
53
|
forgexa version
|
|
49
54
|
```
|
|
@@ -104,6 +109,27 @@ forgexa board --project <project-id>
|
|
|
104
109
|
| `forgexa daemon stop` | Stop local daemon |
|
|
105
110
|
| `forgexa runtimes list` | List your runtimes |
|
|
106
111
|
| `forgexa version` | Show CLI version |
|
|
112
|
+
| `forgexa upgrade` | Upgrade to the latest CLI release |
|
|
113
|
+
| `forgexa upgrade --target-version <version>` | Upgrade or roll forward to a specific release |
|
|
114
|
+
|
|
115
|
+
## Self Upgrade
|
|
116
|
+
|
|
117
|
+
```bash
|
|
118
|
+
# Upgrade to the latest published release
|
|
119
|
+
forgexa upgrade
|
|
120
|
+
|
|
121
|
+
# Upgrade to a specific published release
|
|
122
|
+
forgexa upgrade --target-version 1.12.3
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
The command detects how `forgexa-cli` was installed:
|
|
126
|
+
|
|
127
|
+
- `pipx` install: uses `pipx upgrade forgexa-cli`, or `pipx install --force forgexa-cli==<version>` for a pinned version
|
|
128
|
+
- `pip` install: uses `python -m pip install --upgrade forgexa-cli`
|
|
129
|
+
|
|
130
|
+
For safety, `forgexa upgrade` stops any running local daemon before upgrading. Restart it afterward with `forgexa daemon start`.
|
|
131
|
+
|
|
132
|
+
`forgexa upgrade` is intended for standard PyPI / pipx installs. Editable installs, local path installs, and VCS installs are rejected with a manual upgrade hint.
|
|
107
133
|
|
|
108
134
|
## Configuration
|
|
109
135
|
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
Command-line client and agent runtime for the [Forgexa](https://forgexa.net) platform.
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
Communicates with the Forgexa server via REST API.
|
|
6
6
|
Includes a built-in **daemon** that discovers local AI agents (Claude Code, Codex, Gemini CLI, OpenCode, Kimi Code, etc.) and executes tasks on behalf of the server.
|
|
7
7
|
|
|
8
8
|
## Installation
|
|
@@ -14,6 +14,10 @@ pip install forgexa-cli
|
|
|
14
14
|
# Or with pipx (isolated environment)
|
|
15
15
|
pipx install forgexa-cli
|
|
16
16
|
|
|
17
|
+
# Upgrade later
|
|
18
|
+
forgexa upgrade
|
|
19
|
+
forgexa upgrade --target-version 1.12.3
|
|
20
|
+
|
|
17
21
|
# Verify installation
|
|
18
22
|
forgexa version
|
|
19
23
|
```
|
|
@@ -74,6 +78,27 @@ forgexa board --project <project-id>
|
|
|
74
78
|
| `forgexa daemon stop` | Stop local daemon |
|
|
75
79
|
| `forgexa runtimes list` | List your runtimes |
|
|
76
80
|
| `forgexa version` | Show CLI version |
|
|
81
|
+
| `forgexa upgrade` | Upgrade to the latest CLI release |
|
|
82
|
+
| `forgexa upgrade --target-version <version>` | Upgrade or roll forward to a specific release |
|
|
83
|
+
|
|
84
|
+
## Self Upgrade
|
|
85
|
+
|
|
86
|
+
```bash
|
|
87
|
+
# Upgrade to the latest published release
|
|
88
|
+
forgexa upgrade
|
|
89
|
+
|
|
90
|
+
# Upgrade to a specific published release
|
|
91
|
+
forgexa upgrade --target-version 1.12.3
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
The command detects how `forgexa-cli` was installed:
|
|
95
|
+
|
|
96
|
+
- `pipx` install: uses `pipx upgrade forgexa-cli`, or `pipx install --force forgexa-cli==<version>` for a pinned version
|
|
97
|
+
- `pip` install: uses `python -m pip install --upgrade forgexa-cli`
|
|
98
|
+
|
|
99
|
+
For safety, `forgexa upgrade` stops any running local daemon before upgrading. Restart it afterward with `forgexa daemon start`.
|
|
100
|
+
|
|
101
|
+
`forgexa upgrade` is intended for standard PyPI / pipx installs. Editable installs, local path installs, and VCS installs are rejected with a manual upgrade hint.
|
|
77
102
|
|
|
78
103
|
## Configuration
|
|
79
104
|
|
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
"""forgexa-cli — Forgexa command-line client."""
|
|
2
|
-
__version__ = "1.
|
|
2
|
+
__version__ = "1.13.2"
|
|
@@ -104,6 +104,70 @@ def _save_cli_tokens(access_token: str, refresh_token: str | None = None) -> Non
|
|
|
104
104
|
token_path.chmod(0o600)
|
|
105
105
|
|
|
106
106
|
|
|
107
|
+
def _looks_like_pipx_environment(python_path: str | None = None) -> bool:
|
|
108
|
+
candidate = (python_path or sys.executable or "").replace("\\", "/").lower()
|
|
109
|
+
return "/pipx/venvs/" in candidate
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _httpx_install_commands(python: str, deps_dir: str) -> list[tuple[str, list[str]]]:
|
|
113
|
+
# Use one isolated install path across all OSes so the daemon avoids
|
|
114
|
+
# mutating system or user site-packages.
|
|
115
|
+
return [
|
|
116
|
+
(
|
|
117
|
+
"pip install --target (isolated deps)",
|
|
118
|
+
[
|
|
119
|
+
python,
|
|
120
|
+
"-m",
|
|
121
|
+
"pip",
|
|
122
|
+
"install",
|
|
123
|
+
"--target",
|
|
124
|
+
deps_dir,
|
|
125
|
+
"--quiet",
|
|
126
|
+
"--upgrade",
|
|
127
|
+
"httpx>=0.24",
|
|
128
|
+
"httpcore",
|
|
129
|
+
"certifi",
|
|
130
|
+
],
|
|
131
|
+
)
|
|
132
|
+
]
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _httpx_recovery_hints(os_name: str, python_path: str) -> list[str]:
|
|
136
|
+
if _looks_like_pipx_environment(python_path):
|
|
137
|
+
return [
|
|
138
|
+
"pipx upgrade forgexa-cli # preferred repair for pipx installs",
|
|
139
|
+
"pipx inject forgexa-cli httpx httpcore certifi",
|
|
140
|
+
"pipx reinstall forgexa-cli",
|
|
141
|
+
]
|
|
142
|
+
|
|
143
|
+
if os_name == "Linux":
|
|
144
|
+
return [
|
|
145
|
+
"python3 -m pip install --upgrade forgexa-cli",
|
|
146
|
+
"pip3 install --upgrade forgexa-cli",
|
|
147
|
+
"pipx install forgexa-cli # recommended clean install",
|
|
148
|
+
"pip3 install --user httpx httpcore certifi",
|
|
149
|
+
]
|
|
150
|
+
if os_name == "Darwin":
|
|
151
|
+
return [
|
|
152
|
+
"python3 -m pip install --upgrade forgexa-cli",
|
|
153
|
+
"pip3 install --upgrade forgexa-cli",
|
|
154
|
+
"pipx install forgexa-cli # recommended clean install",
|
|
155
|
+
"pip3 install httpx httpcore certifi",
|
|
156
|
+
]
|
|
157
|
+
if os_name == "Windows":
|
|
158
|
+
return [
|
|
159
|
+
"py -m pip install --upgrade forgexa-cli",
|
|
160
|
+
"pip install --upgrade forgexa-cli",
|
|
161
|
+
"pipx install forgexa-cli # recommended clean install",
|
|
162
|
+
"py -m pip install httpx httpcore certifi",
|
|
163
|
+
]
|
|
164
|
+
return [
|
|
165
|
+
"python -m pip install --upgrade forgexa-cli",
|
|
166
|
+
"pipx install forgexa-cli",
|
|
167
|
+
"python -m pip install httpx httpcore certifi",
|
|
168
|
+
]
|
|
169
|
+
|
|
170
|
+
|
|
107
171
|
def _try_install_httpx(deps_dir: str) -> tuple[bool, str]:
|
|
108
172
|
"""Try to install httpx to a user-writable directory.
|
|
109
173
|
|
|
@@ -113,37 +177,29 @@ def _try_install_httpx(deps_dir: str) -> tuple[bool, str]:
|
|
|
113
177
|
- macOS .app bundles (sandboxed Python)
|
|
114
178
|
- Windows portable installs
|
|
115
179
|
- Docker containers with read-only system dirs
|
|
180
|
+
- pipx isolated venvs (bootstrap pip via ensurepip first)
|
|
116
181
|
|
|
117
182
|
Returns (success, error_detail).
|
|
118
183
|
"""
|
|
119
184
|
os.makedirs(deps_dir, exist_ok=True)
|
|
120
185
|
python = sys.executable or "python3"
|
|
121
186
|
|
|
122
|
-
#
|
|
123
|
-
#
|
|
124
|
-
#
|
|
125
|
-
#
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
[python, "-m", "pip", "install", "--user", "--quiet",
|
|
139
|
-
"httpx>=0.24", "httpcore", "certifi"],
|
|
140
|
-
),
|
|
141
|
-
(
|
|
142
|
-
"pip install --break-system-packages",
|
|
143
|
-
[python, "-m", "pip", "install", "--quiet",
|
|
144
|
-
"--break-system-packages", "httpx>=0.24", "httpcore", "certifi"],
|
|
145
|
-
),
|
|
146
|
-
]
|
|
187
|
+
# Strategy 0: Bootstrap pip via ensurepip (stdlib).
|
|
188
|
+
# pipx venvs don't include pip by design, so `python -m pip` fails with
|
|
189
|
+
# "No module named pip". ensurepip.bootstrap() installs pip into the
|
|
190
|
+
# current environment without any external tools.
|
|
191
|
+
try:
|
|
192
|
+
import ensurepip as _ensurepip
|
|
193
|
+
_ensurepip.bootstrap(upgrade=True)
|
|
194
|
+
except Exception:
|
|
195
|
+
pass # non-fatal; pip strategies below will detect availability
|
|
196
|
+
|
|
197
|
+
# Use a private deps directory on every OS. This avoids distro-specific
|
|
198
|
+
# package manager behavior, user-site visibility issues inside virtualenvs,
|
|
199
|
+
# and system-package mutations. We explicitly install httpcore and certifi
|
|
200
|
+
# because pip may otherwise reuse system copies that are not importable from
|
|
201
|
+
# the isolated target directory.
|
|
202
|
+
strategies = _httpx_install_commands(python, deps_dir)
|
|
147
203
|
|
|
148
204
|
last_error = ""
|
|
149
205
|
for label, cmd in strategies:
|
|
@@ -177,30 +233,7 @@ def _die_missing_httpx(detail: str) -> None:
|
|
|
177
233
|
"""Print a clear, actionable error and exit when httpx cannot be loaded."""
|
|
178
234
|
os_name = platform.system()
|
|
179
235
|
python_path = sys.executable or "(unknown)"
|
|
180
|
-
|
|
181
|
-
if os_name == "Linux":
|
|
182
|
-
hints = [
|
|
183
|
-
"pip3 install --user httpx",
|
|
184
|
-
"sudo apt install python3-httpx # Debian/Ubuntu",
|
|
185
|
-
"sudo dnf install python3-httpx # Fedora/RHEL",
|
|
186
|
-
"pip3 install forgexa-cli[daemon]",
|
|
187
|
-
]
|
|
188
|
-
elif os_name == "Darwin":
|
|
189
|
-
hints = [
|
|
190
|
-
"pip3 install httpx",
|
|
191
|
-
"brew install python3 && pip3 install httpx",
|
|
192
|
-
"pip3 install forgexa-cli[daemon]",
|
|
193
|
-
]
|
|
194
|
-
elif os_name == "Windows":
|
|
195
|
-
hints = [
|
|
196
|
-
"pip install httpx",
|
|
197
|
-
"pip install forgexa-cli[daemon]",
|
|
198
|
-
]
|
|
199
|
-
else:
|
|
200
|
-
hints = [
|
|
201
|
-
"pip3 install httpx",
|
|
202
|
-
"pip3 install forgexa-cli[daemon]",
|
|
203
|
-
]
|
|
236
|
+
hints = _httpx_recovery_hints(os_name, python_path)
|
|
204
237
|
|
|
205
238
|
hint_lines = "\n".join(f" {h}" for h in hints)
|
|
206
239
|
msg = (
|
|
@@ -216,7 +249,7 @@ def _die_missing_httpx(detail: str) -> None:
|
|
|
216
249
|
f" Platform: {os_name} ({platform.machine()})\n"
|
|
217
250
|
f" Detail: {detail}\n"
|
|
218
251
|
"\n"
|
|
219
|
-
" Please
|
|
252
|
+
" Please repair the installation with one of these commands:\n"
|
|
220
253
|
"\n"
|
|
221
254
|
f"{hint_lines}\n"
|
|
222
255
|
"\n"
|
|
@@ -392,7 +425,7 @@ except (ImportError, ModuleNotFoundError):
|
|
|
392
425
|
|
|
393
426
|
@property
|
|
394
427
|
def AGENT_IDLE_TIMEOUT(self) -> int:
|
|
395
|
-
return int(os.environ.get("AGENT_IDLE_TIMEOUT", "
|
|
428
|
+
return int(os.environ.get("AGENT_IDLE_TIMEOUT", "1200")) # 20-min idle (stdout+fs) = hung agent — matches backend config.py default
|
|
396
429
|
|
|
397
430
|
@property
|
|
398
431
|
def GIT_CLONE_TIMEOUT(self) -> int:
|
|
@@ -420,6 +453,22 @@ except (ImportError, ModuleNotFoundError):
|
|
|
420
453
|
return int(env_clone)
|
|
421
454
|
return 1800
|
|
422
455
|
|
|
456
|
+
@property
|
|
457
|
+
def GIT_PUSH_TIMEOUT(self) -> int:
|
|
458
|
+
"""Timeout (seconds) for git push operations.
|
|
459
|
+
|
|
460
|
+
Standalone runtimes use the same precedence model as fetch: allow an
|
|
461
|
+
explicit push timeout, otherwise inherit the clone timeout override,
|
|
462
|
+
then fall back to the 30-minute safe default used by the backend.
|
|
463
|
+
"""
|
|
464
|
+
env_push = os.environ.get("GIT_PUSH_TIMEOUT")
|
|
465
|
+
if env_push:
|
|
466
|
+
return int(env_push)
|
|
467
|
+
env_clone = os.environ.get("GIT_CLONE_TIMEOUT")
|
|
468
|
+
if env_clone:
|
|
469
|
+
return int(env_clone)
|
|
470
|
+
return 1800
|
|
471
|
+
|
|
423
472
|
@property
|
|
424
473
|
def GIT_CLONE_FILTER(self) -> str:
|
|
425
474
|
"""Optional partial-clone filter, e.g. 'blob:none' for blobless clones.
|
|
@@ -474,7 +523,7 @@ except (ImportError, ModuleNotFoundError):
|
|
|
474
523
|
# DAEMON_VERSION is the protocol/logic version of the daemon code.
|
|
475
524
|
# Kept in sync with pyproject.toml version via bump-version.sh.
|
|
476
525
|
# CLIENT_TYPE identifies which packaging/distribution this daemon runs in.
|
|
477
|
-
DAEMON_VERSION = "1.
|
|
526
|
+
DAEMON_VERSION = "1.13.2"
|
|
478
527
|
|
|
479
528
|
|
|
480
529
|
def _detect_client_type() -> str:
|
|
@@ -1315,6 +1364,70 @@ class WorkspaceManager:
|
|
|
1315
1364
|
args.extend(["--branch", branch])
|
|
1316
1365
|
return tuple(args)
|
|
1317
1366
|
|
|
1367
|
+
@staticmethod
|
|
1368
|
+
def _is_retryable_git_transport_error(exc: Exception | str) -> bool:
|
|
1369
|
+
err = str(exc).lower()
|
|
1370
|
+
retry_markers = (
|
|
1371
|
+
"timeout",
|
|
1372
|
+
"timed out",
|
|
1373
|
+
"server not responding",
|
|
1374
|
+
"unexpected disconnect",
|
|
1375
|
+
"early eof",
|
|
1376
|
+
"invalid index-pack output",
|
|
1377
|
+
"fetch-pack",
|
|
1378
|
+
"remote hung up unexpectedly",
|
|
1379
|
+
"connection reset",
|
|
1380
|
+
"connection closed",
|
|
1381
|
+
"connection refused",
|
|
1382
|
+
"network is unreachable",
|
|
1383
|
+
)
|
|
1384
|
+
return any(marker in err for marker in retry_markers)
|
|
1385
|
+
|
|
1386
|
+
async def _clone_repo(
|
|
1387
|
+
self,
|
|
1388
|
+
repo_url: str,
|
|
1389
|
+
dest: Path,
|
|
1390
|
+
*,
|
|
1391
|
+
branch: str | None = None,
|
|
1392
|
+
project_key: str = "default",
|
|
1393
|
+
max_attempts: int = 3,
|
|
1394
|
+
) -> None:
|
|
1395
|
+
import shutil
|
|
1396
|
+
|
|
1397
|
+
last_exc: RuntimeError | None = None
|
|
1398
|
+
for attempt in range(1, max_attempts + 1):
|
|
1399
|
+
try:
|
|
1400
|
+
await self._git(
|
|
1401
|
+
*self._clone_args(branch=branch),
|
|
1402
|
+
repo_url,
|
|
1403
|
+
str(dest),
|
|
1404
|
+
timeout=settings.GIT_CLONE_TIMEOUT,
|
|
1405
|
+
project_key=project_key,
|
|
1406
|
+
)
|
|
1407
|
+
return
|
|
1408
|
+
except RuntimeError as exc:
|
|
1409
|
+
last_exc = exc
|
|
1410
|
+
if attempt >= max_attempts or not self._is_retryable_git_transport_error(exc):
|
|
1411
|
+
# Clean up any partial directory git may have created before giving up.
|
|
1412
|
+
# If not cleaned up now, _is_healthy_worktree will detect it as broken
|
|
1413
|
+
# on the next call and remove it, but cleaning here gives a faster path.
|
|
1414
|
+
shutil.rmtree(dest, ignore_errors=True)
|
|
1415
|
+
raise
|
|
1416
|
+
|
|
1417
|
+
logger.warning(
|
|
1418
|
+
"Clone attempt %d/%d failed for %s -> %s: %s — retrying",
|
|
1419
|
+
attempt,
|
|
1420
|
+
max_attempts,
|
|
1421
|
+
repo_url,
|
|
1422
|
+
dest,
|
|
1423
|
+
exc,
|
|
1424
|
+
)
|
|
1425
|
+
shutil.rmtree(dest, ignore_errors=True)
|
|
1426
|
+
await asyncio.sleep(attempt * 5)
|
|
1427
|
+
|
|
1428
|
+
if last_exc is not None:
|
|
1429
|
+
raise last_exc
|
|
1430
|
+
|
|
1318
1431
|
async def prepare_workspace(self, project: dict, task: TaskInfo) -> Path:
|
|
1319
1432
|
"""Create or reuse a workspace for the given task.
|
|
1320
1433
|
|
|
@@ -2059,9 +2172,11 @@ class WorkspaceManager:
|
|
|
2059
2172
|
|
|
2060
2173
|
# Ensure _main repo is present and up-to-date
|
|
2061
2174
|
if not main_repo.exists():
|
|
2062
|
-
await self.
|
|
2063
|
-
|
|
2064
|
-
|
|
2175
|
+
await self._clone_repo(
|
|
2176
|
+
repo_url,
|
|
2177
|
+
main_repo,
|
|
2178
|
+
branch=default_branch,
|
|
2179
|
+
project_key=project_key,
|
|
2065
2180
|
)
|
|
2066
2181
|
else:
|
|
2067
2182
|
# Before fetching, verify _main has a valid HEAD and at least one ref.
|
|
@@ -2089,9 +2204,11 @@ class WorkspaceManager:
|
|
|
2089
2204
|
)
|
|
2090
2205
|
await self._safe_rmtree_main(main_repo)
|
|
2091
2206
|
try:
|
|
2092
|
-
await self.
|
|
2093
|
-
|
|
2094
|
-
|
|
2207
|
+
await self._clone_repo(
|
|
2208
|
+
repo_url,
|
|
2209
|
+
main_repo,
|
|
2210
|
+
branch=default_branch,
|
|
2211
|
+
project_key=project_key,
|
|
2095
2212
|
)
|
|
2096
2213
|
except Exception:
|
|
2097
2214
|
shutil.rmtree(main_repo, ignore_errors=True)
|
|
@@ -2121,9 +2238,10 @@ class WorkspaceManager:
|
|
|
2121
2238
|
)
|
|
2122
2239
|
await self._safe_rmtree_main(main_repo)
|
|
2123
2240
|
try:
|
|
2124
|
-
await self.
|
|
2125
|
-
|
|
2126
|
-
|
|
2241
|
+
await self._clone_repo(
|
|
2242
|
+
repo_url,
|
|
2243
|
+
main_repo,
|
|
2244
|
+
branch=default_branch,
|
|
2127
2245
|
project_key=project_key,
|
|
2128
2246
|
)
|
|
2129
2247
|
except Exception:
|
|
@@ -2233,9 +2351,10 @@ class WorkspaceManager:
|
|
|
2233
2351
|
)
|
|
2234
2352
|
except Exception:
|
|
2235
2353
|
ws_path.mkdir(parents=True, exist_ok=True)
|
|
2236
|
-
await self.
|
|
2237
|
-
|
|
2238
|
-
|
|
2354
|
+
await self._clone_repo(
|
|
2355
|
+
repo_url,
|
|
2356
|
+
ws_path,
|
|
2357
|
+
project_key=project_key,
|
|
2239
2358
|
)
|
|
2240
2359
|
# Ensure we're on the correct branch after clone
|
|
2241
2360
|
try:
|
|
@@ -2258,9 +2377,10 @@ class WorkspaceManager:
|
|
|
2258
2377
|
except Exception:
|
|
2259
2378
|
# Fallback to simple clone
|
|
2260
2379
|
ws_path.mkdir(parents=True, exist_ok=True)
|
|
2261
|
-
await self.
|
|
2262
|
-
|
|
2263
|
-
|
|
2380
|
+
await self._clone_repo(
|
|
2381
|
+
repo_url,
|
|
2382
|
+
ws_path,
|
|
2383
|
+
project_key=project_key,
|
|
2264
2384
|
)
|
|
2265
2385
|
# Ensure we're on the correct branch after clone
|
|
2266
2386
|
try:
|
|
@@ -2672,8 +2792,11 @@ class ProcessManager:
|
|
|
2672
2792
|
msg_data = data.get("data") or {}
|
|
2673
2793
|
if isinstance(msg_data.get("content"), str) and msg_data["content"].strip():
|
|
2674
2794
|
has_result = True
|
|
2795
|
+
elif ev_type == "assistant.message_start":
|
|
2796
|
+
has_assistant_events = True
|
|
2675
2797
|
elif ev_type == "assistant.message_delta":
|
|
2676
2798
|
has_meaningful_content = True
|
|
2799
|
+
has_assistant_events = True
|
|
2677
2800
|
# ── Generic / Claude "result" event ────────────────────────────
|
|
2678
2801
|
elif ev_type == "result":
|
|
2679
2802
|
# Copilot result format: {"type":"result","exitCode":0,"usage":{...}}
|
|
@@ -2796,6 +2919,19 @@ class ProcessManager:
|
|
|
2796
2919
|
or signals["has_meaningful_content"]
|
|
2797
2920
|
)
|
|
2798
2921
|
|
|
2922
|
+
@staticmethod
|
|
2923
|
+
def _copilot_completed_without_result(stdout: str) -> bool:
|
|
2924
|
+
"""Detect Copilot runs that completed a turn but omitted the final result event."""
|
|
2925
|
+
signals = ProcessManager._extract_output_signals(stdout)
|
|
2926
|
+
return bool(
|
|
2927
|
+
signals["has_turn_completed"]
|
|
2928
|
+
and signals["has_assistant_events"]
|
|
2929
|
+
and signals["has_meaningful_content"]
|
|
2930
|
+
and not signals["has_result"]
|
|
2931
|
+
and not signals["has_turn_failed"]
|
|
2932
|
+
and not signals["error_messages"]
|
|
2933
|
+
)
|
|
2934
|
+
|
|
2799
2935
|
@staticmethod
|
|
2800
2936
|
def is_rate_limited(result: "TaskResult") -> bool:
|
|
2801
2937
|
"""Check if an agent failure warrants trying a different agent.
|
|
@@ -3810,6 +3946,7 @@ class ProcessManager:
|
|
|
3810
3946
|
# Copilot always exits 0 on normal completion; check result.exitCode
|
|
3811
3947
|
# from the JSONL "result" event for a true success signal.
|
|
3812
3948
|
copilot_exit = self._extract_copilot_exit_code(stdout)
|
|
3949
|
+
completed_without_result = self._copilot_completed_without_result(stdout)
|
|
3813
3950
|
effective_rc = copilot_exit if copilot_exit is not None else returncode
|
|
3814
3951
|
|
|
3815
3952
|
if effective_rc == 0 and returncode == 0:
|
|
@@ -3820,6 +3957,21 @@ class ProcessManager:
|
|
|
3820
3957
|
stderr=stderr[-10000:],
|
|
3821
3958
|
metrics=metrics,
|
|
3822
3959
|
)
|
|
3960
|
+
elif copilot_exit is None and returncode != 0 and completed_without_result:
|
|
3961
|
+
logger.warning(
|
|
3962
|
+
"Copilot exited with return code %s for task %s despite a completed assistant turn and no result event; recovering to validation path",
|
|
3963
|
+
returncode,
|
|
3964
|
+
task_id,
|
|
3965
|
+
)
|
|
3966
|
+
metrics["recovered_missing_result_event"] = True
|
|
3967
|
+
metrics["recovered_process_returncode"] = returncode
|
|
3968
|
+
return TaskResult(
|
|
3969
|
+
status="success",
|
|
3970
|
+
exit_code=0,
|
|
3971
|
+
stdout=stdout[-settings.AGENT_MAX_OUTPUT_SIZE:],
|
|
3972
|
+
stderr=stderr[-10000:],
|
|
3973
|
+
metrics=metrics,
|
|
3974
|
+
)
|
|
3823
3975
|
else:
|
|
3824
3976
|
return TaskResult(
|
|
3825
3977
|
status="failed",
|
|
@@ -5097,6 +5249,8 @@ class RuntimeDaemon:
|
|
|
5097
5249
|
self.active_tasks: dict[str, asyncio.Task] = {}
|
|
5098
5250
|
# Track which ServerConnection owns each task (for reporting)
|
|
5099
5251
|
self._task_connections: dict[str, ServerConnection] = {}
|
|
5252
|
+
self._project_execution_locks: dict[str, asyncio.Lock] = {}
|
|
5253
|
+
self._project_execution_locks_mu: asyncio.Lock = asyncio.Lock()
|
|
5100
5254
|
self._shutdown = False
|
|
5101
5255
|
|
|
5102
5256
|
self.connections: list[ServerConnection] = []
|
|
@@ -5105,6 +5259,76 @@ class RuntimeDaemon:
|
|
|
5105
5259
|
self.process_manager = ProcessManager()
|
|
5106
5260
|
self._lock_file = None # File lock to prevent multiple daemon instances
|
|
5107
5261
|
|
|
5262
|
+
@staticmethod
|
|
5263
|
+
def _project_execution_key(project: dict | None, task_hint: str) -> str:
|
|
5264
|
+
project_info = project or {}
|
|
5265
|
+
for candidate in (
|
|
5266
|
+
project_info.get("project_key"),
|
|
5267
|
+
project_info.get("id"),
|
|
5268
|
+
project_info.get("repo_url"),
|
|
5269
|
+
project_info.get("name"),
|
|
5270
|
+
task_hint,
|
|
5271
|
+
):
|
|
5272
|
+
if candidate:
|
|
5273
|
+
return str(candidate)
|
|
5274
|
+
return task_hint or "default"
|
|
5275
|
+
|
|
5276
|
+
async def _get_project_execution_lock(self, project_key: str) -> asyncio.Lock:
|
|
5277
|
+
async with self._project_execution_locks_mu:
|
|
5278
|
+
if project_key not in self._project_execution_locks:
|
|
5279
|
+
self._project_execution_locks[project_key] = asyncio.Lock()
|
|
5280
|
+
return self._project_execution_locks[project_key]
|
|
5281
|
+
|
|
5282
|
+
@staticmethod
|
|
5283
|
+
def _resolve_task_output_dir(task: TaskInfo) -> str:
|
|
5284
|
+
input_data = task.input_data or {}
|
|
5285
|
+
context = input_data.get("context") or {}
|
|
5286
|
+
|
|
5287
|
+
if task.node_type == "analysis":
|
|
5288
|
+
raw_dir = (
|
|
5289
|
+
input_data.get("analysis_output_dir")
|
|
5290
|
+
or context.get("analysis_output_dir")
|
|
5291
|
+
or input_data.get("output_dir")
|
|
5292
|
+
or context.get("output_dir")
|
|
5293
|
+
or ""
|
|
5294
|
+
)
|
|
5295
|
+
elif task.node_type in {"design", "coding", "testing", "review", "delivery"}:
|
|
5296
|
+
raw_dir = (
|
|
5297
|
+
input_data.get("output_dir")
|
|
5298
|
+
or context.get("output_dir")
|
|
5299
|
+
or ""
|
|
5300
|
+
)
|
|
5301
|
+
else:
|
|
5302
|
+
raw_dir = ""
|
|
5303
|
+
|
|
5304
|
+
return str(raw_dir).replace("\\", "/").lstrip("./").rstrip("/")
|
|
5305
|
+
|
|
5306
|
+
def _ensure_task_output_dir(self, workspace_path: Path, task: TaskInfo) -> None:
|
|
5307
|
+
output_dir = self._resolve_task_output_dir(task)
|
|
5308
|
+
if not output_dir:
|
|
5309
|
+
return
|
|
5310
|
+
|
|
5311
|
+
target_dir = workspace_path / output_dir
|
|
5312
|
+
try:
|
|
5313
|
+
if not target_dir.resolve().is_relative_to(workspace_path.resolve()):
|
|
5314
|
+
logger.warning(
|
|
5315
|
+
"Task %s output dir %r resolves outside workspace %s — skipping mkdir",
|
|
5316
|
+
task.task_id,
|
|
5317
|
+
output_dir,
|
|
5318
|
+
workspace_path,
|
|
5319
|
+
)
|
|
5320
|
+
return
|
|
5321
|
+
except Exception:
|
|
5322
|
+
logger.warning(
|
|
5323
|
+
"Task %s output dir %r could not be resolved safely under %s",
|
|
5324
|
+
task.task_id,
|
|
5325
|
+
output_dir,
|
|
5326
|
+
workspace_path,
|
|
5327
|
+
)
|
|
5328
|
+
return
|
|
5329
|
+
|
|
5330
|
+
target_dir.mkdir(parents=True, exist_ok=True)
|
|
5331
|
+
|
|
5108
5332
|
@staticmethod
|
|
5109
5333
|
async def _mint_local_dev_token() -> str:
|
|
5110
5334
|
"""Mint a JWT for local development when no explicit token is configured.
|
|
@@ -5468,6 +5692,22 @@ class RuntimeDaemon:
|
|
|
5468
5692
|
async def _execute_task(self, task: TaskInfo, conn: ServerConnection):
|
|
5469
5693
|
"""Execute a single task, reporting to the originating server connection."""
|
|
5470
5694
|
reporter = conn.reporter
|
|
5695
|
+
# Use workspace-level granularity (requirement_workflow_id or graph_id) so that
|
|
5696
|
+
# tasks for DIFFERENT requirements can run in parallel on the same daemon, while
|
|
5697
|
+
# tasks for the SAME requirement (e.g. analysis + retry) are still serialized to
|
|
5698
|
+
# prevent non-fast-forward push conflicts. Prefixed with project_key to avoid
|
|
5699
|
+
# cross-project UUID collisions.
|
|
5700
|
+
_proj_key = (task.project or {}).get("project_key") or str((task.project or {}).get("id") or "default")
|
|
5701
|
+
_ws_key = task.requirement_workflow_id or str(task.graph_id or task.task_id)
|
|
5702
|
+
project_lock_key = f"{_proj_key}:{_ws_key}"
|
|
5703
|
+
project_lock = await self._get_project_execution_lock(project_lock_key)
|
|
5704
|
+
if project_lock.locked():
|
|
5705
|
+
logger.info(
|
|
5706
|
+
"Task %s waiting for project execution lock %s",
|
|
5707
|
+
task.task_id,
|
|
5708
|
+
project_lock_key,
|
|
5709
|
+
)
|
|
5710
|
+
await project_lock.acquire()
|
|
5471
5711
|
try:
|
|
5472
5712
|
# 1. Find the right agent
|
|
5473
5713
|
agent = self._select_agent(task.agent_type, task.fallback_chain)
|
|
@@ -5499,7 +5739,13 @@ class RuntimeDaemon:
|
|
|
5499
5739
|
workspace_path = await self.workspace_manager.prepare_workspace(
|
|
5500
5740
|
task.project, task
|
|
5501
5741
|
)
|
|
5742
|
+
if not workspace_path.exists():
|
|
5743
|
+
raise RuntimeError(
|
|
5744
|
+
f"Workspace directory does not exist after prepare_workspace: {workspace_path}. "
|
|
5745
|
+
"This indicates a git clone or worktree setup failure (check SSH/network)."
|
|
5746
|
+
)
|
|
5502
5747
|
logger.info("Workspace ready: %s", workspace_path)
|
|
5748
|
+
self._ensure_task_output_dir(workspace_path, task)
|
|
5503
5749
|
|
|
5504
5750
|
# 2.1 Workspace health check: detect broken checkout (Windows filename-
|
|
5505
5751
|
# too-long or other git checkout failure that leaves the working tree
|
|
@@ -6073,6 +6319,8 @@ class RuntimeDaemon:
|
|
|
6073
6319
|
status="failed", exit_code=-1, stdout="", stderr="",
|
|
6074
6320
|
error=str(e),
|
|
6075
6321
|
))
|
|
6322
|
+
finally:
|
|
6323
|
+
project_lock.release()
|
|
6076
6324
|
|
|
6077
6325
|
def _select_fallback_agent(
|
|
6078
6326
|
self, current_agent_id: str, fallback_chain: list[str] | None, tried: set[str]
|
|
@@ -6359,6 +6607,18 @@ class RuntimeDaemon:
|
|
|
6359
6607
|
user_prompt = aj.get("user_prompt", "")
|
|
6360
6608
|
|
|
6361
6609
|
reporter_url = f"{conn.server_url.rstrip('/')}/api/v1/runtimes/{conn.runtime_id}/ai-jobs/{job_id}"
|
|
6610
|
+
# Use workspace-level key (project:requirement or project:job_id) so AI jobs
|
|
6611
|
+
# for different requirements run in parallel while same-requirement jobs serialize.
|
|
6612
|
+
_aj_proj_key = project_info.get("project_key") or str(project_info.get("id") or "default")
|
|
6613
|
+
project_lock_key = f"{_aj_proj_key}:{requirement_key or job_id}"
|
|
6614
|
+
project_lock = await self._get_project_execution_lock(project_lock_key)
|
|
6615
|
+
if project_lock.locked():
|
|
6616
|
+
logger.info(
|
|
6617
|
+
"AIJob %s waiting for project execution lock %s",
|
|
6618
|
+
job_id,
|
|
6619
|
+
project_lock_key,
|
|
6620
|
+
)
|
|
6621
|
+
await project_lock.acquire()
|
|
6362
6622
|
|
|
6363
6623
|
try:
|
|
6364
6624
|
# Report progress: starting
|
|
@@ -6568,6 +6828,8 @@ class RuntimeDaemon:
|
|
|
6568
6828
|
)
|
|
6569
6829
|
except Exception:
|
|
6570
6830
|
pass
|
|
6831
|
+
finally:
|
|
6832
|
+
project_lock.release()
|
|
6571
6833
|
|
|
6572
6834
|
async def _validate_and_retry(
|
|
6573
6835
|
self,
|