forgexa-cli 1.12.2__tar.gz → 1.13.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.
- {forgexa_cli-1.12.2 → forgexa_cli-1.13.1}/PKG-INFO +28 -2
- {forgexa_cli-1.12.2 → forgexa_cli-1.13.1}/README.md +26 -1
- {forgexa_cli-1.12.2 → forgexa_cli-1.13.1}/forgexa_cli/__init__.py +1 -1
- {forgexa_cli-1.12.2 → forgexa_cli-1.13.1}/forgexa_cli/daemon.py +297 -67
- {forgexa_cli-1.12.2 → forgexa_cli-1.13.1}/forgexa_cli/main.py +349 -20
- {forgexa_cli-1.12.2 → forgexa_cli-1.13.1}/forgexa_cli.egg-info/PKG-INFO +28 -2
- {forgexa_cli-1.12.2 → forgexa_cli-1.13.1}/forgexa_cli.egg-info/requires.txt +1 -0
- {forgexa_cli-1.12.2 → forgexa_cli-1.13.1}/pyproject.toml +5 -3
- forgexa_cli-1.13.1/tests/test_auth_and_runtime_commands.py +400 -0
- forgexa_cli-1.12.2/tests/test_auth_and_runtime_commands.py +0 -236
- {forgexa_cli-1.12.2 → forgexa_cli-1.13.1}/forgexa_cli/_build_config.py +0 -0
- {forgexa_cli-1.12.2 → forgexa_cli-1.13.1}/forgexa_cli/py.typed +0 -0
- {forgexa_cli-1.12.2 → forgexa_cli-1.13.1}/forgexa_cli.egg-info/SOURCES.txt +0 -0
- {forgexa_cli-1.12.2 → forgexa_cli-1.13.1}/forgexa_cli.egg-info/dependency_links.txt +0 -0
- {forgexa_cli-1.12.2 → forgexa_cli-1.13.1}/forgexa_cli.egg-info/entry_points.txt +0 -0
- {forgexa_cli-1.12.2 → forgexa_cli-1.13.1}/forgexa_cli.egg-info/top_level.txt +0 -0
- {forgexa_cli-1.12.2 → forgexa_cli-1.13.1}/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.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: 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.1"
|
|
@@ -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.1"
|
|
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:
|
|
@@ -5097,6 +5217,8 @@ class RuntimeDaemon:
|
|
|
5097
5217
|
self.active_tasks: dict[str, asyncio.Task] = {}
|
|
5098
5218
|
# Track which ServerConnection owns each task (for reporting)
|
|
5099
5219
|
self._task_connections: dict[str, ServerConnection] = {}
|
|
5220
|
+
self._project_execution_locks: dict[str, asyncio.Lock] = {}
|
|
5221
|
+
self._project_execution_locks_mu: asyncio.Lock = asyncio.Lock()
|
|
5100
5222
|
self._shutdown = False
|
|
5101
5223
|
|
|
5102
5224
|
self.connections: list[ServerConnection] = []
|
|
@@ -5105,6 +5227,76 @@ class RuntimeDaemon:
|
|
|
5105
5227
|
self.process_manager = ProcessManager()
|
|
5106
5228
|
self._lock_file = None # File lock to prevent multiple daemon instances
|
|
5107
5229
|
|
|
5230
|
+
@staticmethod
|
|
5231
|
+
def _project_execution_key(project: dict | None, task_hint: str) -> str:
|
|
5232
|
+
project_info = project or {}
|
|
5233
|
+
for candidate in (
|
|
5234
|
+
project_info.get("project_key"),
|
|
5235
|
+
project_info.get("id"),
|
|
5236
|
+
project_info.get("repo_url"),
|
|
5237
|
+
project_info.get("name"),
|
|
5238
|
+
task_hint,
|
|
5239
|
+
):
|
|
5240
|
+
if candidate:
|
|
5241
|
+
return str(candidate)
|
|
5242
|
+
return task_hint or "default"
|
|
5243
|
+
|
|
5244
|
+
async def _get_project_execution_lock(self, project_key: str) -> asyncio.Lock:
|
|
5245
|
+
async with self._project_execution_locks_mu:
|
|
5246
|
+
if project_key not in self._project_execution_locks:
|
|
5247
|
+
self._project_execution_locks[project_key] = asyncio.Lock()
|
|
5248
|
+
return self._project_execution_locks[project_key]
|
|
5249
|
+
|
|
5250
|
+
@staticmethod
|
|
5251
|
+
def _resolve_task_output_dir(task: TaskInfo) -> str:
|
|
5252
|
+
input_data = task.input_data or {}
|
|
5253
|
+
context = input_data.get("context") or {}
|
|
5254
|
+
|
|
5255
|
+
if task.node_type == "analysis":
|
|
5256
|
+
raw_dir = (
|
|
5257
|
+
input_data.get("analysis_output_dir")
|
|
5258
|
+
or context.get("analysis_output_dir")
|
|
5259
|
+
or input_data.get("output_dir")
|
|
5260
|
+
or context.get("output_dir")
|
|
5261
|
+
or ""
|
|
5262
|
+
)
|
|
5263
|
+
elif task.node_type in {"design", "coding", "testing", "review", "delivery"}:
|
|
5264
|
+
raw_dir = (
|
|
5265
|
+
input_data.get("output_dir")
|
|
5266
|
+
or context.get("output_dir")
|
|
5267
|
+
or ""
|
|
5268
|
+
)
|
|
5269
|
+
else:
|
|
5270
|
+
raw_dir = ""
|
|
5271
|
+
|
|
5272
|
+
return str(raw_dir).replace("\\", "/").lstrip("./").rstrip("/")
|
|
5273
|
+
|
|
5274
|
+
def _ensure_task_output_dir(self, workspace_path: Path, task: TaskInfo) -> None:
|
|
5275
|
+
output_dir = self._resolve_task_output_dir(task)
|
|
5276
|
+
if not output_dir:
|
|
5277
|
+
return
|
|
5278
|
+
|
|
5279
|
+
target_dir = workspace_path / output_dir
|
|
5280
|
+
try:
|
|
5281
|
+
if not target_dir.resolve().is_relative_to(workspace_path.resolve()):
|
|
5282
|
+
logger.warning(
|
|
5283
|
+
"Task %s output dir %r resolves outside workspace %s — skipping mkdir",
|
|
5284
|
+
task.task_id,
|
|
5285
|
+
output_dir,
|
|
5286
|
+
workspace_path,
|
|
5287
|
+
)
|
|
5288
|
+
return
|
|
5289
|
+
except Exception:
|
|
5290
|
+
logger.warning(
|
|
5291
|
+
"Task %s output dir %r could not be resolved safely under %s",
|
|
5292
|
+
task.task_id,
|
|
5293
|
+
output_dir,
|
|
5294
|
+
workspace_path,
|
|
5295
|
+
)
|
|
5296
|
+
return
|
|
5297
|
+
|
|
5298
|
+
target_dir.mkdir(parents=True, exist_ok=True)
|
|
5299
|
+
|
|
5108
5300
|
@staticmethod
|
|
5109
5301
|
async def _mint_local_dev_token() -> str:
|
|
5110
5302
|
"""Mint a JWT for local development when no explicit token is configured.
|
|
@@ -5468,6 +5660,22 @@ class RuntimeDaemon:
|
|
|
5468
5660
|
async def _execute_task(self, task: TaskInfo, conn: ServerConnection):
|
|
5469
5661
|
"""Execute a single task, reporting to the originating server connection."""
|
|
5470
5662
|
reporter = conn.reporter
|
|
5663
|
+
# Use workspace-level granularity (requirement_workflow_id or graph_id) so that
|
|
5664
|
+
# tasks for DIFFERENT requirements can run in parallel on the same daemon, while
|
|
5665
|
+
# tasks for the SAME requirement (e.g. analysis + retry) are still serialized to
|
|
5666
|
+
# prevent non-fast-forward push conflicts. Prefixed with project_key to avoid
|
|
5667
|
+
# cross-project UUID collisions.
|
|
5668
|
+
_proj_key = (task.project or {}).get("project_key") or str((task.project or {}).get("id") or "default")
|
|
5669
|
+
_ws_key = task.requirement_workflow_id or str(task.graph_id or task.task_id)
|
|
5670
|
+
project_lock_key = f"{_proj_key}:{_ws_key}"
|
|
5671
|
+
project_lock = await self._get_project_execution_lock(project_lock_key)
|
|
5672
|
+
if project_lock.locked():
|
|
5673
|
+
logger.info(
|
|
5674
|
+
"Task %s waiting for project execution lock %s",
|
|
5675
|
+
task.task_id,
|
|
5676
|
+
project_lock_key,
|
|
5677
|
+
)
|
|
5678
|
+
await project_lock.acquire()
|
|
5471
5679
|
try:
|
|
5472
5680
|
# 1. Find the right agent
|
|
5473
5681
|
agent = self._select_agent(task.agent_type, task.fallback_chain)
|
|
@@ -5499,7 +5707,13 @@ class RuntimeDaemon:
|
|
|
5499
5707
|
workspace_path = await self.workspace_manager.prepare_workspace(
|
|
5500
5708
|
task.project, task
|
|
5501
5709
|
)
|
|
5710
|
+
if not workspace_path.exists():
|
|
5711
|
+
raise RuntimeError(
|
|
5712
|
+
f"Workspace directory does not exist after prepare_workspace: {workspace_path}. "
|
|
5713
|
+
"This indicates a git clone or worktree setup failure (check SSH/network)."
|
|
5714
|
+
)
|
|
5502
5715
|
logger.info("Workspace ready: %s", workspace_path)
|
|
5716
|
+
self._ensure_task_output_dir(workspace_path, task)
|
|
5503
5717
|
|
|
5504
5718
|
# 2.1 Workspace health check: detect broken checkout (Windows filename-
|
|
5505
5719
|
# too-long or other git checkout failure that leaves the working tree
|
|
@@ -6073,6 +6287,8 @@ class RuntimeDaemon:
|
|
|
6073
6287
|
status="failed", exit_code=-1, stdout="", stderr="",
|
|
6074
6288
|
error=str(e),
|
|
6075
6289
|
))
|
|
6290
|
+
finally:
|
|
6291
|
+
project_lock.release()
|
|
6076
6292
|
|
|
6077
6293
|
def _select_fallback_agent(
|
|
6078
6294
|
self, current_agent_id: str, fallback_chain: list[str] | None, tried: set[str]
|
|
@@ -6359,6 +6575,18 @@ class RuntimeDaemon:
|
|
|
6359
6575
|
user_prompt = aj.get("user_prompt", "")
|
|
6360
6576
|
|
|
6361
6577
|
reporter_url = f"{conn.server_url.rstrip('/')}/api/v1/runtimes/{conn.runtime_id}/ai-jobs/{job_id}"
|
|
6578
|
+
# Use workspace-level key (project:requirement or project:job_id) so AI jobs
|
|
6579
|
+
# for different requirements run in parallel while same-requirement jobs serialize.
|
|
6580
|
+
_aj_proj_key = project_info.get("project_key") or str(project_info.get("id") or "default")
|
|
6581
|
+
project_lock_key = f"{_aj_proj_key}:{requirement_key or job_id}"
|
|
6582
|
+
project_lock = await self._get_project_execution_lock(project_lock_key)
|
|
6583
|
+
if project_lock.locked():
|
|
6584
|
+
logger.info(
|
|
6585
|
+
"AIJob %s waiting for project execution lock %s",
|
|
6586
|
+
job_id,
|
|
6587
|
+
project_lock_key,
|
|
6588
|
+
)
|
|
6589
|
+
await project_lock.acquire()
|
|
6362
6590
|
|
|
6363
6591
|
try:
|
|
6364
6592
|
# Report progress: starting
|
|
@@ -6568,6 +6796,8 @@ class RuntimeDaemon:
|
|
|
6568
6796
|
)
|
|
6569
6797
|
except Exception:
|
|
6570
6798
|
pass
|
|
6799
|
+
finally:
|
|
6800
|
+
project_lock.release()
|
|
6571
6801
|
|
|
6572
6802
|
async def _validate_and_retry(
|
|
6573
6803
|
self,
|