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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: forgexa-cli
3
- Version: 1.12.2
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
- Lightweight — communicates with the Forgexa server via REST API using only Python stdlib.
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
- Lightweight — communicates with the Forgexa server via REST API using only Python stdlib.
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.12.2"
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
- # Try pip --target first (most universally compatible).
123
- # Falls back to --user, then --break-system-packages as last resort.
124
- # We explicitly list httpcore and certifi alongside httpx because pip
125
- # --target may skip transitive deps it finds in system site-packages,
126
- # even though they won't be importable from the isolated deps directory.
127
- # certifi must be included so that its cacert.pem bundle is copied into
128
- # the deps dir; without it httpx raises FileNotFoundError when building
129
- # the default SSL context (observed on Python 3.14 standalone / Windows).
130
- strategies: list[tuple[str, list[str]]] = [
131
- (
132
- "pip install --target (isolated deps)",
133
- [python, "-m", "pip", "install", "--target", deps_dir,
134
- "--quiet", "--upgrade", "httpx>=0.24", "httpcore", "certifi"],
135
- ),
136
- (
137
- "pip install --user",
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 install it manually with one of these commands:\n"
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", "600")) # 10-min idle (stdout+fs) = hung agent
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.12.2"
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._git(
2063
- *self._clone_args(branch=default_branch),
2064
- repo_url, str(main_repo), timeout=settings.GIT_CLONE_TIMEOUT, project_key=project_key,
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._git(
2093
- *self._clone_args(branch=default_branch),
2094
- repo_url, str(main_repo), timeout=settings.GIT_CLONE_TIMEOUT, project_key=project_key,
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._git(
2125
- *self._clone_args(branch=default_branch),
2126
- repo_url, str(main_repo), timeout=settings.GIT_CLONE_TIMEOUT,
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._git(
2237
- *self._clone_args(),
2238
- repo_url, str(ws_path), timeout=settings.GIT_CLONE_TIMEOUT, project_key=project_key,
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._git(
2262
- *self._clone_args(),
2263
- repo_url, str(ws_path), timeout=settings.GIT_CLONE_TIMEOUT, project_key=project_key,
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,