forgexa-cli 1.3.4__tar.gz → 1.4.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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: forgexa-cli
3
- Version: 1.3.4
3
+ Version: 1.4.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
@@ -1,2 +1,2 @@
1
1
  """forgexa-cli — Forgexa command-line client."""
2
- __version__ = "1.3.4"
2
+ __version__ = "1.4.2"
@@ -37,16 +37,198 @@ try:
37
37
  except ImportError:
38
38
  fcntl = None # type: ignore[assignment]
39
39
 
40
- try:
41
- import httpx
42
- except ImportError:
43
- # Auto-install httpx when running standalone (e.g., bundled with desktop app)
44
- subprocess.check_call(
45
- [sys.executable, "-m", "pip", "install", "--quiet", "httpx>=0.24"],
46
- stdout=subprocess.DEVNULL,
47
- stderr=subprocess.PIPE,
40
+ # ── httpx dependency — robust auto-install for standalone environments ──
41
+ # When running inside the backend package, httpx is a declared dependency and
42
+ # always available. In standalone contexts (desktop AppImage/DMG/MSI, CLI
43
+ # without [daemon] extra), httpx may be missing. We try multiple strategies:
44
+ #
45
+ # 1. Direct import (works for backend & CLI[daemon])
46
+ # 2. Import from cached deps dir (~/.forgexa/daemon/deps)
47
+ # 3. Auto-install via pip --target to the cached deps dir
48
+ # (bypasses PEP 668 / externally-managed-environment on modern distros)
49
+ # 4. Friendly error with OS-specific instructions if all else fails
50
+ _HTTPX_DEPS_DIR = os.path.join(str(Path.home()), ".forgexa", "daemon", "deps")
51
+
52
+
53
+ def _try_install_httpx(deps_dir: str) -> tuple[bool, str]:
54
+ """Try to install httpx to a user-writable directory.
55
+
56
+ Uses pip --target which works on:
57
+ - AppImage (read-only squashfs, system Python)
58
+ - PEP 668 systems (Ubuntu 23.04+, Fedora 38+) — bypasses externally-managed check
59
+ - macOS .app bundles (sandboxed Python)
60
+ - Windows portable installs
61
+ - Docker containers with read-only system dirs
62
+
63
+ Returns (success, error_detail).
64
+ """
65
+ os.makedirs(deps_dir, exist_ok=True)
66
+ python = sys.executable or "python3"
67
+
68
+ # Try pip --target first (most universally compatible).
69
+ # Falls back to --user, then --break-system-packages as last resort.
70
+ # We explicitly list httpcore alongside httpx because pip --target may
71
+ # skip transitive deps it finds in system site-packages, even though
72
+ # they won't be importable from the isolated deps directory.
73
+ strategies: list[tuple[str, list[str]]] = [
74
+ (
75
+ "pip install --target (isolated deps)",
76
+ [python, "-m", "pip", "install", "--target", deps_dir,
77
+ "--quiet", "--upgrade", "httpx>=0.24", "httpcore"],
78
+ ),
79
+ (
80
+ "pip install --user",
81
+ [python, "-m", "pip", "install", "--user", "--quiet",
82
+ "httpx>=0.24", "httpcore"],
83
+ ),
84
+ (
85
+ "pip install --break-system-packages",
86
+ [python, "-m", "pip", "install", "--quiet",
87
+ "--break-system-packages", "httpx>=0.24", "httpcore"],
88
+ ),
89
+ ]
90
+
91
+ last_error = ""
92
+ for label, cmd in strategies:
93
+ try:
94
+ result = subprocess.run(
95
+ cmd,
96
+ stdout=subprocess.DEVNULL,
97
+ stderr=subprocess.PIPE,
98
+ text=True,
99
+ timeout=120,
100
+ )
101
+ if result.returncode == 0:
102
+ return True, ""
103
+ last_error = f"[{label}] exit code {result.returncode}"
104
+ stderr_text = (result.stderr or "").strip()
105
+ if stderr_text:
106
+ # Keep last 5 lines of stderr for diagnostics
107
+ stderr_lines = stderr_text.splitlines()[-5:]
108
+ last_error += ": " + " | ".join(stderr_lines)
109
+ except FileNotFoundError:
110
+ last_error = f"[{label}] Python not found: {cmd[0]}"
111
+ except subprocess.TimeoutExpired:
112
+ last_error = f"[{label}] timed out after 120s"
113
+ except Exception as exc:
114
+ last_error = f"[{label}] {type(exc).__name__}: {exc}"
115
+
116
+ return False, last_error
117
+
118
+
119
+ def _die_missing_httpx(detail: str) -> None:
120
+ """Print a clear, actionable error and exit when httpx cannot be loaded."""
121
+ os_name = platform.system()
122
+ python_path = sys.executable or "(unknown)"
123
+
124
+ if os_name == "Linux":
125
+ hints = [
126
+ "pip3 install --user httpx",
127
+ "sudo apt install python3-httpx # Debian/Ubuntu",
128
+ "sudo dnf install python3-httpx # Fedora/RHEL",
129
+ "pip3 install forgexa-cli[daemon]",
130
+ ]
131
+ elif os_name == "Darwin":
132
+ hints = [
133
+ "pip3 install httpx",
134
+ "brew install python3 && pip3 install httpx",
135
+ "pip3 install forgexa-cli[daemon]",
136
+ ]
137
+ elif os_name == "Windows":
138
+ hints = [
139
+ "pip install httpx",
140
+ "pip install forgexa-cli[daemon]",
141
+ ]
142
+ else:
143
+ hints = [
144
+ "pip3 install httpx",
145
+ "pip3 install forgexa-cli[daemon]",
146
+ ]
147
+
148
+ hint_lines = "\n".join(f" {h}" for h in hints)
149
+ msg = (
150
+ "\n"
151
+ "┌─────────────────────────────────────────────────────────────────────┐\n"
152
+ "│ Forgexa Daemon: missing required dependency 'httpx' │\n"
153
+ "└─────────────────────────────────────────────────────────────────────┘\n"
154
+ "\n"
155
+ " The daemon requires the 'httpx' HTTP client library but it could\n"
156
+ " not be imported, and automatic installation failed.\n"
157
+ "\n"
158
+ f" Python: {python_path}\n"
159
+ f" Platform: {os_name} ({platform.machine()})\n"
160
+ f" Detail: {detail}\n"
161
+ "\n"
162
+ " Please install it manually with one of these commands:\n"
163
+ "\n"
164
+ f"{hint_lines}\n"
165
+ "\n"
166
+ " Then restart the daemon.\n"
167
+ "─────────────────────────────────────────────────────────────────────\n"
48
168
  )
49
- import httpx
169
+ print(msg, file=sys.stderr)
170
+ # Machine-readable summary for the desktop app to parse and show as a toast.
171
+ print(f"DAEMON_ERROR: Missing required Python package 'httpx'. {detail}", file=sys.stderr)
172
+ sys.exit(1)
173
+
174
+
175
+ def _validate_httpx_imports() -> tuple[bool, str]:
176
+ """Validate that httpx and its critical transitive deps are importable.
177
+
178
+ A bare ``import httpx`` can succeed even when httpcore is missing,
179
+ because httpx lazily imports its transport layer. We eagerly check
180
+ the full chain so the daemon fails fast with a clear message instead
181
+ of crashing mid-operation when ``httpx.AsyncClient()`` tries to load
182
+ the transport.
183
+
184
+ Returns (ok, missing_module_name).
185
+ """
186
+ for mod_name in ("httpx", "httpcore"):
187
+ try:
188
+ __import__(mod_name)
189
+ except ImportError:
190
+ return False, mod_name
191
+ return True, ""
192
+
193
+
194
+ # Actual import sequence
195
+ _httpx_ok, _httpx_missing = _validate_httpx_imports()
196
+
197
+ if not _httpx_ok:
198
+ # Check cached deps directory (previous auto-install)
199
+ if _HTTPX_DEPS_DIR not in sys.path:
200
+ sys.path.insert(0, _HTTPX_DEPS_DIR)
201
+ _httpx_ok, _httpx_missing = _validate_httpx_imports()
202
+
203
+ if not _httpx_ok:
204
+ # If httpx is present but a sub-dependency (httpcore) is missing,
205
+ # the deps directory has a partial/stale installation. Clear it and
206
+ # purge cached modules so pip does a clean install with all transitive
207
+ # dependencies.
208
+ if _httpx_missing != "httpx":
209
+ shutil.rmtree(_HTTPX_DEPS_DIR, ignore_errors=True)
210
+ for _mod_key in list(sys.modules):
211
+ if _mod_key in ("httpx", "httpcore") or \
212
+ _mod_key.startswith(("httpx.", "httpcore.")):
213
+ del sys.modules[_mod_key]
214
+
215
+ # Attempt auto-install to user-writable deps directory
216
+ _ok, _err = _try_install_httpx(_HTTPX_DEPS_DIR)
217
+ if _ok:
218
+ if _HTTPX_DEPS_DIR not in sys.path:
219
+ sys.path.insert(0, _HTTPX_DEPS_DIR)
220
+ _httpx_ok, _httpx_missing = _validate_httpx_imports()
221
+ if not _httpx_ok:
222
+ _die_missing_httpx(
223
+ f"pip install succeeded but '{_httpx_missing}' still cannot "
224
+ "be imported — check Python version compatibility"
225
+ )
226
+ else:
227
+ _die_missing_httpx(_err)
228
+
229
+ import httpx # noqa: E402 — guaranteed available after validation above
230
+
231
+ del _httpx_ok, _httpx_missing
50
232
 
51
233
  # ── Settings: graceful fallback when running standalone (outside backend package) ──
52
234
  try:
@@ -117,6 +299,36 @@ except (ImportError, ModuleNotFoundError):
117
299
 
118
300
  settings = _StandaloneSettings() # type: ignore[assignment]
119
301
 
302
+ # ── Daemon version and client type ────────────────────────────────────────
303
+ # DAEMON_VERSION is the protocol/logic version of the daemon code.
304
+ # Kept in sync with pyproject.toml version via bump-version.sh.
305
+ # CLIENT_TYPE identifies which packaging/distribution this daemon runs in.
306
+ DAEMON_VERSION = "1.4.2"
307
+
308
+
309
+ def _detect_client_type() -> str:
310
+ """Auto-detect client type from runtime context.
311
+
312
+ Priority:
313
+ 1. FORGEXA_CLIENT_TYPE env var (set by desktop Tauri launcher)
314
+ 2. Import context: app.config importable → "server"
315
+ 3. Default: "cli" (standalone pip-installed daemon)
316
+
317
+ This allows a single daemon.py source to work correctly regardless
318
+ of deployment context, making the bundle-daemon.sh copy safe.
319
+ """
320
+ env_type = os.environ.get("FORGEXA_CLIENT_TYPE", "").strip().lower()
321
+ if env_type in ("server", "cli", "desktop"):
322
+ return env_type
323
+ # Server: app.config was successfully imported at module level above
324
+ if "app.config" in sys.modules:
325
+ return "server"
326
+ # Default: standalone execution = CLI
327
+ return "cli"
328
+
329
+
330
+ _CLIENT_TYPE = _detect_client_type()
331
+
120
332
  # ── Logging — self-managed file handler ────────────────────────────────
121
333
  # The daemon configures its own FileHandler so logs are written to
122
334
  # ~/.forgexa/daemon/daemon.log regardless of how the daemon was launched
@@ -331,6 +543,31 @@ class TaskResult:
331
543
  git: dict = field(default_factory=dict)
332
544
 
333
545
 
546
+ # ── Type-aware analysis outputs (inline fallback for standalone daemons) ──
547
+ # Mirrors type_workflow_profiles.py — used when import is unavailable (CLI/Desktop).
548
+ _ANALYSIS_OUTPUTS_BY_TYPE: dict[str, list[str]] = {
549
+ "feature": ["PRD.md", "SDD.md", "TASKS.md", "analysis.json", "test-intent.json"],
550
+ "bugfix": ["diagnosis.md", "TASKS.md", "analysis.json", "test-intent.json"],
551
+ "refactor": ["refactor-plan.md", "TASKS.md", "analysis.json"],
552
+ "documentation": ["outline.md", "analysis.json"],
553
+ "improvement": ["improvement-spec.md", "TASKS.md", "analysis.json", "test-intent.json"],
554
+ "task": ["task-plan.md", "analysis.json"],
555
+ }
556
+
557
+
558
+ def _get_analysis_outputs_for_type(req_type: str) -> list[str]:
559
+ """Get expected analysis output files for a requirement type.
560
+
561
+ Tries to use type_workflow_profiles (available in backend context),
562
+ falls back to inline mapping for standalone daemon execution.
563
+ """
564
+ try:
565
+ from app.services.type_workflow_profiles import get_profile
566
+ return list(get_profile(req_type).analysis_outputs)
567
+ except Exception:
568
+ return _ANALYSIS_OUTPUTS_BY_TYPE.get(req_type, _ANALYSIS_OUTPUTS_BY_TYPE["feature"])
569
+
570
+
334
571
  # ── Agent Discovery ──
335
572
 
336
573
 
@@ -1066,15 +1303,11 @@ class ProcessManager:
1066
1303
  "usage limit",
1067
1304
  "rate limit",
1068
1305
  "rate_limit",
1069
- "429",
1070
1306
  "quota exceeded",
1071
1307
  "too many requests",
1072
1308
  "overloaded",
1073
- "capacity",
1074
- "try again",
1075
- "credit",
1076
1309
  "insufficient_quota",
1077
- "billing",
1310
+ "billing hard limit",
1078
1311
  ]
1079
1312
 
1080
1313
  # Patterns indicating the agent's API is unreachable/misconfigured —
@@ -1086,9 +1319,11 @@ class ProcessManager:
1086
1319
  "connection refused",
1087
1320
  "connection reset",
1088
1321
  "connection timed out",
1322
+ "connection error",
1089
1323
  "name or service not known",
1090
1324
  "no such host",
1091
1325
  "network is unreachable",
1326
+ "api error",
1092
1327
  ]
1093
1328
 
1094
1329
  def __init__(self):
@@ -1139,8 +1374,12 @@ class ProcessManager:
1139
1374
  elif isinstance(err, str):
1140
1375
  error_messages.append(err)
1141
1376
  elif ev_type == "result":
1142
- has_result = True
1143
- has_meaningful_content = True
1377
+ if data.get("is_error"):
1378
+ err_text = str(data.get("result", "") or data.get("error", "") or "result marked as error")
1379
+ error_messages.append(err_text)
1380
+ else:
1381
+ has_result = True
1382
+ has_meaningful_content = True
1144
1383
  elif ev_type == "error":
1145
1384
  msg = data.get("message", "")
1146
1385
  if msg:
@@ -1182,13 +1421,25 @@ class ProcessManager:
1182
1421
 
1183
1422
  Returns True for rate/quota limits AND API unavailability errors,
1184
1423
  since a different agent (using a different API backend) may succeed.
1424
+
1425
+ IMPORTANT: Only checks stderr, error message, and the tail of stdout.
1426
+ The full stdout contains the agent's work output (e.g., analysis text
1427
+ about APIs, retry logic, HTTP status codes) which naturally contains
1428
+ patterns like "429", "try again", "capacity" — these are NOT indicators
1429
+ of the agent CLI itself being rate-limited.
1185
1430
  """
1186
1431
  if result.status == "success":
1187
1432
  return False
1188
- combined = (result.stdout + result.stderr + result.error).lower()
1433
+ # Search error channels: stderr (CLI errors) + error message + tail of stdout
1434
+ # (last 3000 chars catches any CLI-level error at the end of output)
1435
+ error_text = (
1436
+ (result.stderr or "")
1437
+ + "\n" + (result.error or "")
1438
+ + "\n" + (result.stdout or "")[-3000:]
1439
+ ).lower()
1189
1440
  return (
1190
- any(p in combined for p in ProcessManager.RATE_LIMIT_PATTERNS)
1191
- or any(p in combined for p in ProcessManager.AGENT_UNAVAILABLE_PATTERNS)
1441
+ any(p in error_text for p in ProcessManager.RATE_LIMIT_PATTERNS)
1442
+ or any(p in error_text for p in ProcessManager.AGENT_UNAVAILABLE_PATTERNS)
1192
1443
  )
1193
1444
 
1194
1445
  @staticmethod
@@ -1205,8 +1456,16 @@ class ProcessManager:
1205
1456
  if result.status != "success":
1206
1457
  return None
1207
1458
 
1208
- combined = "\n".join(part for part in (result.stdout, result.stderr, result.error) if part)
1209
- pattern_failure = ProcessManager._has_failure_pattern(combined)
1459
+ # For rate/unavailability pattern detection, only check error channels
1460
+ # (stderr, error field) plus the TAIL of stdout. The full stdout contains
1461
+ # the agent's work output (analysis text, generated docs) which naturally
1462
+ # mentions terms like "rate limit", "429", "capacity", "credit" etc.
1463
+ error_channels = (
1464
+ (result.stderr or "")
1465
+ + "\n" + (result.error or "")
1466
+ + "\n" + (result.stdout or "")[-3000:]
1467
+ )
1468
+ pattern_failure = ProcessManager._has_failure_pattern(error_channels)
1210
1469
  if pattern_failure:
1211
1470
  return pattern_failure
1212
1471
 
@@ -1320,18 +1579,23 @@ class ProcessManager:
1320
1579
  return normalized
1321
1580
 
1322
1581
  def _required_deliverable_paths(self, task: TaskInfo) -> set[str]:
1323
- output_dir = str((task.input_data or {}).get("output_dir", "") or "")
1582
+ # For analysis nodes, deliverables live in analysis_output_dir (docs/requirements/...)
1583
+ # For other nodes, use output_dir (docs/implements/...)
1584
+ if task.node_type == "analysis":
1585
+ output_dir = str(
1586
+ (task.input_data or {}).get("analysis_output_dir", "")
1587
+ or (task.input_data or {}).get("output_dir", "")
1588
+ or ""
1589
+ )
1590
+ else:
1591
+ output_dir = str((task.input_data or {}).get("output_dir", "") or "")
1324
1592
  output_dir = output_dir.replace("\\", "/").lstrip("./").rstrip("/")
1325
1593
  if not output_dir:
1326
1594
  return set()
1327
1595
 
1328
1596
  if task.node_type == "analysis":
1329
1597
  req_type = (task.input_data or {}).get("requirement_type", "feature")
1330
- try:
1331
- from app.services.type_workflow_profiles import get_profile
1332
- required_files = list(get_profile(req_type).analysis_outputs)
1333
- except Exception:
1334
- required_files = ["PRD.md", "SDD.md", "TASKS.md", "analysis.json", "test-intent.json"]
1598
+ required_files = _get_analysis_outputs_for_type(req_type)
1335
1599
  elif task.node_type == "design":
1336
1600
  required_files = ["design.md"]
1337
1601
  else:
@@ -1342,7 +1606,8 @@ class ProcessManager:
1342
1606
  def _has_required_deliverable_updates(self, task: TaskInfo, *path_lists: list[str] | None) -> bool:
1343
1607
  required_paths = self._required_deliverable_paths(task)
1344
1608
  if not required_paths:
1345
- return False
1609
+ # Cannot determine required deliverables — skip check (don't fail)
1610
+ return True
1346
1611
 
1347
1612
  changed_paths: set[str] = set()
1348
1613
  for paths in path_lists:
@@ -1502,7 +1767,7 @@ class ProcessManager:
1502
1767
  cmd = [
1503
1768
  agent.command,
1504
1769
  "-p",
1505
- "--output-format", "json",
1770
+ "--output-format", "stream-json",
1506
1771
  "--verbose",
1507
1772
  "--dangerously-skip-permissions",
1508
1773
  ]
@@ -1706,6 +1971,9 @@ class ProcessManager:
1706
1971
  data = json.loads(stdout.strip())
1707
1972
  if isinstance(data, dict):
1708
1973
  parsed.append(data)
1974
+ elif isinstance(data, list):
1975
+ # Handle JSON array (from --output-format json)
1976
+ parsed.extend(d for d in data if isinstance(d, dict))
1709
1977
  except (json.JSONDecodeError, ValueError):
1710
1978
  pass
1711
1979
 
@@ -2182,6 +2450,8 @@ class HeartbeatService:
2182
2450
  "available_agents": self._agents,
2183
2451
  "system_metrics": self._collect_system_metrics(),
2184
2452
  "os_info": get_os_info(),
2453
+ "daemon_version": DAEMON_VERSION,
2454
+ "client_type": _CLIENT_TYPE,
2185
2455
  },
2186
2456
  timeout=10,
2187
2457
  )
@@ -2210,6 +2480,76 @@ class HeartbeatService:
2210
2480
  return {}
2211
2481
 
2212
2482
 
2483
+ # ── Log Uploader ──
2484
+
2485
+
2486
+ class LogUploader:
2487
+ """Periodically uploads daemon log tail to the server for remote viewing."""
2488
+
2489
+ LOG_UPLOAD_INTERVAL = 300 # Upload every 5 minutes
2490
+ LOG_TAIL_LINES = 500 # Last N lines to upload
2491
+
2492
+ def __init__(
2493
+ self,
2494
+ client: httpx.AsyncClient,
2495
+ server_url: str,
2496
+ runtime_id: str,
2497
+ ):
2498
+ self.client = client
2499
+ self.server_url = server_url.rstrip("/")
2500
+ self.runtime_id = runtime_id
2501
+ self._task: asyncio.Task | None = None
2502
+
2503
+ async def start(self):
2504
+ self._task = asyncio.create_task(self._loop())
2505
+
2506
+ async def stop(self):
2507
+ if self._task:
2508
+ self._task.cancel()
2509
+ try:
2510
+ await self._task
2511
+ except asyncio.CancelledError:
2512
+ pass
2513
+
2514
+ async def _loop(self):
2515
+ # Initial upload after 30s delay (let daemon stabilize first)
2516
+ await asyncio.sleep(30)
2517
+ while True:
2518
+ try:
2519
+ await self._upload()
2520
+ except asyncio.CancelledError:
2521
+ raise
2522
+ except Exception as e:
2523
+ logger.warning("Log upload error: %s", e)
2524
+ await asyncio.sleep(self.LOG_UPLOAD_INTERVAL)
2525
+
2526
+ async def _upload(self):
2527
+ """Read daemon log tail and upload to server."""
2528
+ try:
2529
+ if not DAEMON_LOG_PATH.exists():
2530
+ return
2531
+ # Read last N lines efficiently
2532
+ with open(DAEMON_LOG_PATH, "rb") as f:
2533
+ # Seek from end to find last N lines
2534
+ f.seek(0, 2)
2535
+ file_size = f.tell()
2536
+ # Read at most 100KB from end
2537
+ read_size = min(file_size, 100 * 1024)
2538
+ f.seek(file_size - read_size)
2539
+ content = f.read().decode("utf-8", errors="replace")
2540
+
2541
+ # Take last N lines
2542
+ lines = content.split("\n")
2543
+ tail = "\n".join(lines[-self.LOG_TAIL_LINES:])
2544
+
2545
+ await self.client.post(
2546
+ f"{self.server_url}/api/v1/runtimes/{self.runtime_id}/logs",
2547
+ json={"log_tail": tail, "log_lines": self.LOG_TAIL_LINES},
2548
+ timeout=15,
2549
+ )
2550
+ except Exception as e:
2551
+ logger.warning("Failed to upload daemon log: %s", e)
2552
+
2213
2553
  # ── Task Poller ──
2214
2554
 
2215
2555
 
@@ -2296,6 +2636,7 @@ class ServerConnection:
2296
2636
  self.heartbeat: HeartbeatService | None = None
2297
2637
  self.poller: TaskPoller | None = None
2298
2638
  self.reporter: ProgressReporter | None = None
2639
+ self.log_uploader: LogUploader | None = None
2299
2640
  self._auth_failures = 0 # Consecutive auth failure count
2300
2641
  self._max_auth_failures = 3 # Trigger re-registration after this many
2301
2642
  # Short label for logging
@@ -2338,6 +2679,8 @@ class ServerConnection:
2338
2679
  self.poller.runtime_id = self.runtime_id
2339
2680
  if self.reporter and self.runtime_id:
2340
2681
  self.reporter.runtime_id = self.runtime_id
2682
+ if self.log_uploader and self.runtime_id:
2683
+ self.log_uploader.runtime_id = self.runtime_id
2341
2684
  self._auth_failures = 0
2342
2685
  logger.info("[%s] Re-registered successfully after token refresh", self.label)
2343
2686
  except Exception as e:
@@ -2364,6 +2707,8 @@ class ServerConnection:
2364
2707
  "hardware_id": self.hardware_id,
2365
2708
  "device_name": platform.node(),
2366
2709
  "os_info": get_os_info(),
2710
+ "daemon_version": DAEMON_VERSION,
2711
+ "client_type": _CLIENT_TYPE,
2367
2712
  "available_agents": agent_dicts,
2368
2713
  "max_concurrent_tasks": max_concurrent,
2369
2714
  "capabilities": {
@@ -2434,15 +2779,22 @@ class ServerConnection:
2434
2779
  self.reporter = ProgressReporter(
2435
2780
  self.client, self.server_url, self.runtime_id,
2436
2781
  )
2782
+ self.log_uploader = LogUploader(
2783
+ self.client, self.server_url, self.runtime_id,
2784
+ )
2437
2785
 
2438
2786
  async def start_heartbeat(self):
2439
2787
  if self.heartbeat:
2440
2788
  await self.heartbeat.start()
2789
+ if self.log_uploader:
2790
+ await self.log_uploader.start()
2441
2791
 
2442
2792
  async def stop(self):
2443
2793
  """Stop heartbeat and unregister."""
2444
2794
  if self.heartbeat:
2445
2795
  await self.heartbeat.stop()
2796
+ if self.log_uploader:
2797
+ await self.log_uploader.stop()
2446
2798
  if self.runtime_id:
2447
2799
  try:
2448
2800
  # Use deregister endpoint (no admin required) instead of DELETE
@@ -2874,15 +3226,15 @@ class RuntimeDaemon:
2874
3226
  _line_buffer.extend(lines)
2875
3227
 
2876
3228
  async def _progress_ticker():
2877
- """Flush buffered output lines + update progress % every 10 s."""
3229
+ """Flush buffered output lines + update progress % every 5 s."""
2878
3230
  import math as _math
2879
3231
  tick = 0
2880
3232
  while not progress_stop.is_set():
2881
- await asyncio.sleep(10)
3233
+ await asyncio.sleep(5)
2882
3234
  if progress_stop.is_set():
2883
3235
  break
2884
3236
  tick += 1
2885
- pct = min(int(10 + 80 * (1 - 1 / (1 + tick / 8))), 90)
3237
+ pct = min(int(10 + 80 * (1 - 1 / (1 + tick / 16))), 90)
2886
3238
  pid = self.process_manager.active_processes.get(task.task_id)
2887
3239
  step = "running_agent"
2888
3240
  if pid:
@@ -2919,7 +3271,26 @@ class RuntimeDaemon:
2919
3271
  tried_agents.add(agent.agent_id)
2920
3272
 
2921
3273
  # ── Agent fallback: if agent hit rate limit or API is unavailable, try next agent ──
3274
+ # Guard: if the agent already produced file changes in the workspace, it DID
3275
+ # meaningful work — don't trigger fallback even if it crashed after completing.
3276
+ # Let the recovery logic (step 4.1) handle non-zero exit with committed work.
3277
+ _skip_fallback = False
2922
3278
  if self.process_manager.is_rate_limited(result):
3279
+ _pre_fallback_git = await self.process_manager._collect_git_info(workspace_path)
3280
+ _pre_fallback_committed = await self.process_manager._collect_git_info_vs_parent(workspace_path)
3281
+ has_workspace_changes = (
3282
+ bool(_pre_fallback_git.get("files_changed"))
3283
+ or bool(_pre_fallback_committed.get("files_changed"))
3284
+ )
3285
+ if has_workspace_changes:
3286
+ logger.info(
3287
+ "Agent '%s' exited non-zero for task %s but workspace has changes — "
3288
+ "skipping fallback, proceeding to recovery",
3289
+ agent.agent_id, task.task_id,
3290
+ )
3291
+ _skip_fallback = True
3292
+
3293
+ if self.process_manager.is_rate_limited(result) and not _skip_fallback:
2923
3294
  logger.warning(
2924
3295
  "Agent '%s' unavailable/rate-limited for task %s, attempting fallback",
2925
3296
  agent.agent_id, task.task_id,
@@ -2949,11 +3320,11 @@ class RuntimeDaemon:
2949
3320
  async def _progress_ticker2():
2950
3321
  tick = 0
2951
3322
  while not progress_stop2.is_set():
2952
- await asyncio.sleep(10)
3323
+ await asyncio.sleep(5)
2953
3324
  if progress_stop2.is_set():
2954
3325
  break
2955
3326
  tick += 1
2956
- pct = min(int(10 + 80 * (1 - 1 / (1 + tick / 8))), 90)
3327
+ pct = min(int(10 + 80 * (1 - 1 / (1 + tick / 16))), 90)
2957
3328
  pid = self.process_manager.active_processes.get(task.task_id)
2958
3329
  step = f"running_agent:{agent.agent_id}"
2959
3330
  if pid:
@@ -3066,13 +3437,33 @@ class RuntimeDaemon:
3066
3437
  # Existing files from a prior iteration are not sufficient evidence.
3067
3438
  if result.status == "success" and task.node_type in ("analysis", "design"):
3068
3439
  committed_git = await self.process_manager._collect_git_info_vs_parent(workspace_path)
3069
- if not self._has_required_deliverable_updates(
3440
+ git_check_passed = self.process_manager._has_required_deliverable_updates(
3070
3441
  task,
3071
3442
  pre_commit_git.get("files_changed"),
3072
3443
  committed_git.get("files_changed"),
3073
3444
  result.files_changed,
3074
3445
  (result.git or {}).get("files_changed"),
3075
- ):
3446
+ )
3447
+ # Fallback: if git-based check fails (e.g., agent auto-committed and
3448
+ # merge-base detection failed), verify files physically exist on disk.
3449
+ # This prevents false failures when git state is unusual but files
3450
+ # are actually present.
3451
+ if not git_check_passed:
3452
+ required_paths = self.process_manager._required_deliverable_paths(task)
3453
+ if required_paths:
3454
+ files_exist = all(
3455
+ (workspace_path / p).exists() and (workspace_path / p).stat().st_size > 0
3456
+ for p in required_paths
3457
+ )
3458
+ if files_exist:
3459
+ logger.info(
3460
+ "Task %s (%s): git diff did not show deliverables but all %d "
3461
+ "files exist on disk — accepting as success",
3462
+ task.task_id, task.node_type, len(required_paths),
3463
+ )
3464
+ git_check_passed = True
3465
+
3466
+ if not git_check_passed:
3076
3467
  logger.warning(
3077
3468
  "Task %s (%s) reported success but did not update required deliverables",
3078
3469
  task.task_id, task.node_type,
@@ -3099,6 +3490,16 @@ class RuntimeDaemon:
3099
3490
  if commit_result:
3100
3491
  # Propagate push/commit errors in metrics so they're visible
3101
3492
  result.metrics.update(commit_result)
3493
+ # Push failure is a real problem for downstream nodes — mark
3494
+ # as failed so the orchestrator can retry (transient network).
3495
+ if commit_result.get("push_error"):
3496
+ push_err = commit_result["push_error"]
3497
+ logger.error(
3498
+ "Task %s: push failed, marking as failed so retry can attempt push again: %s",
3499
+ task.task_id, push_err,
3500
+ )
3501
+ result.status = "failed"
3502
+ result.error = f"Git push failed: {push_err}"
3102
3503
  # Re-collect git info after commit (compare with parent)
3103
3504
  post_commit_git = await self.process_manager._collect_git_info_vs_parent(workspace_path)
3104
3505
  # Merge: use the pre-commit file list if post-commit is empty
@@ -3201,15 +3602,13 @@ class RuntimeDaemon:
3201
3602
 
3202
3603
  if node_type == "analysis":
3203
3604
  # Use type profile to determine required analysis outputs
3204
- try:
3205
- from app.services.type_workflow_profiles import get_profile
3206
- profile = get_profile(req_type)
3207
- required_files = profile.analysis_outputs
3208
- except Exception:
3209
- # Fallback to full set if profile import fails
3210
- required_files = ["PRD.md", "SDD.md", "TASKS.md", "analysis.json", "test-intent.json"]
3605
+ required_files = _get_analysis_outputs_for_type(req_type)
3211
3606
 
3212
- doc_dir = (task.input_data or {}).get("output_dir", "")
3607
+ # Analysis deliverables live in analysis_output_dir (docs/requirements/...)
3608
+ doc_dir = (
3609
+ (task.input_data or {}).get("analysis_output_dir", "")
3610
+ or (task.input_data or {}).get("output_dir", "")
3611
+ )
3213
3612
  if doc_dir:
3214
3613
  base = workspace_path / doc_dir
3215
3614
  else:
@@ -3444,20 +3843,25 @@ class RuntimeDaemon:
3444
3843
  always receives the file contents via the completion report and gate
3445
3844
  reviewers can see the analysis documents immediately.
3446
3845
  """
3447
- doc_dir = (task.input_data or {}).get("output_dir", "")
3846
+ # Analysis deliverables live in analysis_output_dir (docs/requirements/...)
3847
+ doc_dir = (
3848
+ (task.input_data or {}).get("analysis_output_dir", "")
3849
+ or (task.input_data or {}).get("output_dir", "")
3850
+ )
3448
3851
  if not doc_dir:
3449
3852
  return
3450
3853
 
3451
3854
  base = workspace_path / doc_dir.lstrip("./")
3452
- _ANALYSIS_FILES = ("PRD.md", "SDD.md", "TASKS.md", "analysis.json", "test-intent.json")
3453
- existing_artifact_paths = {a.get("path", "") for a in result.artifacts}
3855
+ req_type = (task.input_data or {}).get("requirement_type", "feature")
3856
+ _ANALYSIS_FILES = _get_analysis_outputs_for_type(req_type)
3857
+ existing_artifact_paths = {a.get("path", "").replace("\\", "/") for a in result.artifacts}
3454
3858
 
3455
3859
  for fname in _ANALYSIS_FILES:
3456
3860
  fpath = base / fname
3457
3861
  if not fpath.exists() or fpath.stat().st_size == 0:
3458
3862
  continue
3459
3863
  try:
3460
- rel_path = str(fpath.relative_to(workspace_path))
3864
+ rel_path = str(fpath.relative_to(workspace_path)).replace("\\", "/")
3461
3865
  if rel_path in existing_artifact_paths:
3462
3866
  continue # already attached
3463
3867
  content = fpath.read_text(encoding="utf-8", errors="replace")
@@ -3487,13 +3891,13 @@ class RuntimeDaemon:
3487
3891
  return
3488
3892
 
3489
3893
  base = workspace_path / doc_dir.lstrip("./")
3490
- existing_artifact_paths = {a.get("path", "") for a in result.artifacts}
3894
+ existing_artifact_paths = {a.get("path", "").replace("\\", "/") for a in result.artifacts}
3491
3895
 
3492
3896
  design_path = base / "design.md"
3493
3897
  if not design_path.exists() or design_path.stat().st_size == 0:
3494
3898
  return
3495
3899
  try:
3496
- rel_path = str(design_path.relative_to(workspace_path))
3900
+ rel_path = str(design_path.relative_to(workspace_path)).replace("\\", "/")
3497
3901
  if rel_path in existing_artifact_paths:
3498
3902
  return
3499
3903
  content = design_path.read_text(encoding="utf-8", errors="replace")
@@ -3555,6 +3959,8 @@ class RuntimeDaemon:
3555
3959
  or task.input_data.get("title")
3556
3960
  or ""
3557
3961
  )
3962
+ if not isinstance(wi_title, str):
3963
+ wi_title = str(wi_title)
3558
3964
  req_key = task.requirement_key or task.work_item.get("requirement_key") or ""
3559
3965
  if req_key and wi_title:
3560
3966
  display_title = f"{req_key}: {wi_title}"
@@ -3565,11 +3971,15 @@ class RuntimeDaemon:
3565
3971
  else:
3566
3972
  display_title = task.task_id
3567
3973
 
3568
- commit_msg = await self._build_auto_commit_message(
3569
- display_title, task.task_id, task.node_type,
3570
- task.agent_type, change_summary,
3571
- workspace_path=workspace_path,
3572
- )
3974
+ try:
3975
+ commit_msg = await self._build_auto_commit_message(
3976
+ display_title, task.task_id, task.node_type,
3977
+ task.agent_type, change_summary,
3978
+ workspace_path=workspace_path,
3979
+ )
3980
+ except Exception as msg_err:
3981
+ logger.warning("Failed to build rich commit message: %s — using fallback", msg_err)
3982
+ commit_msg = f"{task.node_type}({task.requirement_key or task.task_id}): {display_title}"
3573
3983
  proc = await asyncio.create_subprocess_exec(
3574
3984
  "git", "commit", "-m", commit_msg,
3575
3985
  cwd=str(workspace_path),
@@ -3766,7 +4176,22 @@ class RuntimeDaemon:
3766
4176
  lines: list[str] = []
3767
4177
 
3768
4178
  # Summary — word-wrap at 78 chars
3769
- summary = (data.get("summary") or "").strip()
4179
+ raw_summary = data.get("summary")
4180
+ if isinstance(raw_summary, dict):
4181
+ # Some agents produce summary as a structured object; extract description
4182
+ summary = (
4183
+ raw_summary.get("description")
4184
+ or raw_summary.get("title")
4185
+ or raw_summary.get("summary")
4186
+ or ""
4187
+ )
4188
+ if not isinstance(summary, str):
4189
+ summary = str(summary) if summary else ""
4190
+ elif isinstance(raw_summary, str):
4191
+ summary = raw_summary
4192
+ else:
4193
+ summary = str(raw_summary) if raw_summary else ""
4194
+ summary = summary.strip()
3770
4195
  if summary:
3771
4196
  words = summary.split()
3772
4197
  current = ""
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: forgexa-cli
3
- Version: 1.3.4
3
+ Version: 1.4.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
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "forgexa-cli"
3
- version = "1.3.4"
3
+ version = "1.4.2"
4
4
  description = "Forgexa CLI — command-line client and AI agent runtime for the Forgexa platform"
5
5
  requires-python = ">=3.9"
6
6
  license = { text = "MIT" }
File without changes
File without changes