forgexa-cli 1.5.2__tar.gz → 1.7.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.5.2
3
+ Version: 1.7.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.5.2"
2
+ __version__ = "1.7.2"
@@ -10,6 +10,20 @@ Usage:
10
10
 
11
11
  from __future__ import annotations
12
12
 
13
+ import sys
14
+
15
+ # ── Python version gate — must run before any other imports ──────────────────
16
+ # Emit a machine-readable DAEMON_ERROR so the desktop app shows a clear
17
+ # message instead of a cryptic traceback.
18
+ if sys.version_info < (3, 9):
19
+ _ver = f"{sys.version_info.major}.{sys.version_info.minor}"
20
+ print(
21
+ f"DAEMON_ERROR: Python {_ver} is too old. Forgexa Daemon requires Python 3.9 or "
22
+ f"newer. Please upgrade Python from https://www.python.org/downloads/",
23
+ file=sys.stderr,
24
+ )
25
+ sys.exit(1)
26
+
13
27
  import asyncio
14
28
  import base64
15
29
  import hashlib
@@ -307,7 +321,7 @@ except (ImportError, ModuleNotFoundError):
307
321
  # DAEMON_VERSION is the protocol/logic version of the daemon code.
308
322
  # Kept in sync with pyproject.toml version via bump-version.sh.
309
323
  # CLIENT_TYPE identifies which packaging/distribution this daemon runs in.
310
- DAEMON_VERSION = "1.5.2"
324
+ DAEMON_VERSION = "1.7.2"
311
325
 
312
326
 
313
327
  def _detect_client_type() -> str:
@@ -473,9 +487,12 @@ def get_os_info() -> str:
473
487
  return f"{distro} {machine}"
474
488
  return f"Linux {platform.release().split('-')[0]} {machine}"
475
489
  elif system == "Windows":
476
- # e.g. "Windows 10.0 AMD64"
477
- win_ver = platform.version().split('.')[0:2]
478
- return f"Windows {'.'.join(win_ver)} {machine}"
490
+ # platform.version() returns the NT kernel version (10.0.x) for BOTH
491
+ # Windows 10 and Windows 11, so it cannot distinguish them.
492
+ # platform.win32_ver()[0] returns the marketing release: "10" or "11".
493
+ win_release = platform.win32_ver()[0] or platform.version().split('.')[0]
494
+ build = platform.version() # e.g. "10.0.26200"
495
+ return f"Windows {win_release} ({build}) {machine}"
479
496
  else:
480
497
  return f"{system} {platform.release()} {machine}"
481
498
 
@@ -869,12 +886,19 @@ class WorkspaceManager:
869
886
  branch_name = f"feature/{workspace_key}"
870
887
 
871
888
  # Determine whether this node is the "first" in a new graph that needs
872
- # a fresh base from the default branch. Analysis nodes always start
873
- # fresh unless using "refine" mode which preserves the existing branch.
874
- analysis_mode = task.input_data.get("analysis_mode", "fresh")
889
+ # a fresh base from the default branch. Only analysis nodes with
890
+ # explicit analysis_mode="fresh" should start fresh. All other modes
891
+ # (including the default when no mode is specified) preserve the
892
+ # existing branch to avoid destroying prior implementation commits.
893
+ #
894
+ # SAFETY: Defaulting to "refine" ensures that any code path which
895
+ # forgets to set analysis_mode will safely preserve the branch.
896
+ # Only the initial requirement analysis endpoint (requirements.py)
897
+ # explicitly passes "fresh" when creating a brand-new requirement branch.
898
+ analysis_mode = task.input_data.get("analysis_mode", "refine")
875
899
  is_fresh_start = (
876
900
  task.node_type == "analysis"
877
- and analysis_mode != "refine"
901
+ and analysis_mode == "fresh"
878
902
  )
879
903
 
880
904
  if repo_url:
@@ -977,11 +1001,52 @@ class WorkspaceManager:
977
1001
  logger.warning("Broken worktree detected at %s — removing and recreating", ws_path)
978
1002
  await self._remove_broken_worktree(main_repo, ws_path, workspace_key)
979
1003
  else:
980
- # Healthy worktree — fetch and optionally reset
1004
+ # Healthy worktree — fetch and optionally reset.
1005
+ # The _main repo uses --single-branch, so `git fetch origin`
1006
+ # only fetches the default branch. Explicitly fetch the
1007
+ # feature branch with a full refspec so that
1008
+ # origin/{branch_name} is available for checkout/reset.
981
1009
  try:
982
1010
  await self._git("fetch", "origin", cwd=ws_path, project_key=project_key)
983
1011
  except RuntimeError:
984
1012
  pass
1013
+ try:
1014
+ await self._git(
1015
+ "fetch", "origin",
1016
+ f"{branch_name}:refs/remotes/origin/{branch_name}",
1017
+ cwd=ws_path, project_key=project_key,
1018
+ )
1019
+ except RuntimeError:
1020
+ pass # Branch may not exist on remote yet
1021
+
1022
+ if fresh_start:
1023
+ # Safety check: if the branch already exists on remote with
1024
+ # commits beyond the default branch, do NOT reset to
1025
+ # origin/default_branch — that would destroy prior work.
1026
+ # This guards against accidental branch destruction when
1027
+ # analysis_mode is incorrectly set to "fresh" for
1028
+ # verification or iterative nodes.
1029
+ branch_has_remote_commits = False
1030
+ try:
1031
+ result = await self._git(
1032
+ "rev-list", "--count",
1033
+ f"origin/{default_branch}..origin/{branch_name}",
1034
+ cwd=ws_path,
1035
+ )
1036
+ ahead_count = int(result.strip()) if result.strip() else 0
1037
+ branch_has_remote_commits = ahead_count > 0
1038
+ except (RuntimeError, ValueError):
1039
+ pass # Branch may not exist on remote yet
1040
+
1041
+ if branch_has_remote_commits:
1042
+ logger.warning(
1043
+ "Fresh start requested for %s but remote branch has %d commit(s) "
1044
+ "ahead of %s — switching to safe sync instead of resetting to "
1045
+ "avoid destroying prior work",
1046
+ branch_name, ahead_count, default_branch,
1047
+ )
1048
+ # Override: fall through to the non-fresh sync path
1049
+ fresh_start = False
985
1050
 
986
1051
  if fresh_start:
987
1052
  logger.info("Fresh start: resetting %s to origin/%s", ws_path, default_branch)
@@ -1036,7 +1101,9 @@ class WorkspaceManager:
1036
1101
  await asyncio.sleep(2 * (_sync_attempt + 1))
1037
1102
  try:
1038
1103
  await self._git(
1039
- "fetch", "origin", cwd=ws_path,
1104
+ "fetch", "origin",
1105
+ f"{branch_name}:refs/remotes/origin/{branch_name}",
1106
+ cwd=ws_path,
1040
1107
  project_key=project_key,
1041
1108
  )
1042
1109
  except RuntimeError:
@@ -1061,7 +1128,9 @@ class WorkspaceManager:
1061
1128
  await asyncio.sleep(2 * (_sync_attempt + 1))
1062
1129
  try:
1063
1130
  await self._git(
1064
- "fetch", "origin", cwd=ws_path,
1131
+ "fetch", "origin",
1132
+ f"{branch_name}:refs/remotes/origin/{branch_name}",
1133
+ cwd=ws_path,
1065
1134
  project_key=project_key,
1066
1135
  )
1067
1136
  except RuntimeError:
@@ -1096,6 +1165,19 @@ class WorkspaceManager:
1096
1165
  else:
1097
1166
  await self._git("fetch", "--all", cwd=main_repo, timeout=300, project_key=project_key)
1098
1167
 
1168
+ # --single-branch clone only fetches the default branch.
1169
+ # Explicitly fetch the feature branch so origin/{branch_name}
1170
+ # is available for worktree creation and checkout.
1171
+ if not fresh_start:
1172
+ try:
1173
+ await self._git(
1174
+ "fetch", "origin",
1175
+ f"{branch_name}:refs/remotes/origin/{branch_name}",
1176
+ cwd=main_repo, timeout=60, project_key=project_key,
1177
+ )
1178
+ except RuntimeError:
1179
+ pass # Branch may not exist on remote yet (first analysis)
1180
+
1099
1181
  # Prune stale worktree references (e.g. directories deleted externally
1100
1182
  # when simulating cross-runtime or after disk cleanup). Without this,
1101
1183
  # `git worktree add` refuses to create a branch that is "already checked out"
@@ -1144,7 +1226,11 @@ class WorkspaceManager:
1144
1226
  )
1145
1227
  await asyncio.sleep(2 * (_check_attempt + 1))
1146
1228
  try:
1147
- await self._git("fetch", "--all", cwd=main_repo, timeout=60, project_key=project_key)
1229
+ await self._git(
1230
+ "fetch", "origin",
1231
+ f"{branch_name}:refs/remotes/origin/{branch_name}",
1232
+ cwd=main_repo, timeout=60, project_key=project_key,
1233
+ )
1148
1234
  except RuntimeError:
1149
1235
  pass
1150
1236
 
@@ -1227,6 +1313,18 @@ class WorkspaceManager:
1227
1313
 
1228
1314
  If a project SSH key is registered, remote-touching commands
1229
1315
  (clone, fetch, pull, push) will use GIT_SSH_COMMAND.
1316
+
1317
+ Process-group isolation: git is started in its own session so that
1318
+ SIGKILL on timeout propagates to all of git's children (especially
1319
+ the ssh subprocess that git forks for remote operations). Without
1320
+ this, killing git leaves orphaned ssh processes that hold the stderr
1321
+ pipe open — preventing asyncio from detecting EOF and causing the
1322
+ parent to hang until the GIT_CLONE_TIMEOUT fires.
1323
+
1324
+ SSH keepalives: GIT_SSH_COMMAND now includes ServerAliveInterval=30
1325
+ and ServerAliveCountMax=3 so that a stalled TCP connection (server
1326
+ accepts but never sends the git protocol banner) is detected within
1327
+ ~90 s rather than waiting forever.
1230
1328
  """
1231
1329
  env = None
1232
1330
  git_prefix_args: list[str] = []
@@ -1255,6 +1353,14 @@ class WorkspaceManager:
1255
1353
  f" -o StrictHostKeyChecking=accept-new"
1256
1354
  f" -o UserKnownHostsFile=/dev/null"
1257
1355
  f" -o IdentitiesOnly=yes"
1356
+ # Detect a stalled TCP connection (server accepts but
1357
+ # never sends the git protocol banner). After 30 s of
1358
+ # silence the client sends a keepalive; after 3 missed
1359
+ # responses (≤ 90 s total) SSH exits, closes the pipes,
1360
+ # and lets asyncio's communicate() return.
1361
+ f" -o ConnectTimeout=30"
1362
+ f" -o ServerAliveInterval=30"
1363
+ f" -o ServerAliveCountMax=3"
1258
1364
  ),
1259
1365
  }
1260
1366
  except Exception:
@@ -1274,18 +1380,53 @@ class WorkspaceManager:
1274
1380
  if git_prefix_args:
1275
1381
  env = {**(env or os.environ), "GIT_TERMINAL_PROMPT": "0"}
1276
1382
 
1383
+ # start_new_session=True puts git in its own process group.
1384
+ # On timeout we send SIGKILL to the entire group, which includes
1385
+ # any ssh/gpg/credential-helper children that git forked — preventing
1386
+ # orphaned processes from keeping pipes alive.
1387
+ # Windows note: start_new_session creates a new console process group;
1388
+ # we use taskkill /T there instead of killpg.
1277
1389
  proc = await asyncio.create_subprocess_exec(
1278
1390
  "git", *git_prefix_args, *args,
1279
1391
  stdout=asyncio.subprocess.PIPE,
1280
1392
  stderr=asyncio.subprocess.PIPE,
1281
1393
  cwd=str(cwd) if cwd else None,
1282
1394
  env=env,
1395
+ start_new_session=True,
1283
1396
  )
1284
1397
  try:
1285
1398
  stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=timeout)
1286
1399
  except (TimeoutError, asyncio.TimeoutError):
1287
- proc.kill()
1288
- await proc.wait()
1400
+ # Kill the entire process group so that SSH (and any other
1401
+ # child processes git may have spawned) are also terminated.
1402
+ # A plain proc.kill() only kills the direct child (git); the
1403
+ # ssh grandchild becomes orphaned, keeps the stderr pipe open,
1404
+ # and proc.communicate() can never return EOF.
1405
+ try:
1406
+ if sys.platform != "win32":
1407
+ import signal as _signal
1408
+ try:
1409
+ os.killpg(os.getpgid(proc.pid), _signal.SIGKILL)
1410
+ except (ProcessLookupError, PermissionError, OSError):
1411
+ pass
1412
+ else:
1413
+ # Windows: taskkill /F /T kills the process tree
1414
+ import subprocess as _subprocess
1415
+ _subprocess.run(
1416
+ ["taskkill", "/F", "/T", "/PID", str(proc.pid)],
1417
+ capture_output=True,
1418
+ )
1419
+ except Exception:
1420
+ pass
1421
+ finally:
1422
+ try:
1423
+ proc.kill()
1424
+ except Exception:
1425
+ pass
1426
+ try:
1427
+ await asyncio.wait_for(proc.wait(), timeout=5.0)
1428
+ except Exception:
1429
+ pass
1289
1430
  raise RuntimeError(f"git {' '.join(args)} timed out after {timeout}s")
1290
1431
  finally:
1291
1432
  # Clean up temp SSH key file if created
@@ -1353,11 +1494,27 @@ class ProcessManager:
1353
1494
 
1354
1495
  @staticmethod
1355
1496
  def _extract_output_signals(text: str) -> dict[str, Any]:
1356
- """Parse stdout/stderr-like streams for success and failure signals."""
1497
+ """Parse stdout/stderr-like streams for success and failure signals.
1498
+
1499
+ Uses structural signal detection — NOT keyword matching on result text.
1500
+
1501
+ The key invariant for Claude stream-json:
1502
+ - A genuine execution always emits at least one 'assistant' event AND
1503
+ has total_input_tokens > 0 in the result event, because the CLI made
1504
+ a real API call.
1505
+ - A pre-call failure (API Error, Connection error, auth failure) exits
1506
+ immediately: no 'assistant' events, and total_input_tokens == 0 in
1507
+ the result event. The result text contains the CLI error message.
1508
+
1509
+ This avoids false positives from agent work output that legitimately
1510
+ contains words like 'rate limit', 'connection error', 'API error', etc.
1511
+ (e.g. implementing a rate-limiter feature, or documenting error codes).
1512
+ """
1357
1513
  has_turn_completed = False
1358
1514
  has_turn_failed = False
1359
1515
  has_result = False
1360
1516
  has_meaningful_content = False
1517
+ has_assistant_events = False # True once any 'assistant' event is seen
1361
1518
  error_messages: list[str] = []
1362
1519
  json_line_count = 0
1363
1520
 
@@ -1387,18 +1544,31 @@ class ProcessManager:
1387
1544
  elif isinstance(err, str):
1388
1545
  error_messages.append(err)
1389
1546
  elif ev_type == "result":
1547
+ result_text = str(data.get("result", "") or "")
1390
1548
  if data.get("is_error"):
1391
- err_text = str(data.get("result", "") or data.get("error", "") or "result marked as error")
1549
+ err_text = result_text or str(data.get("error", "") or "result marked as error")
1392
1550
  error_messages.append(err_text)
1393
1551
  else:
1394
- has_result = True
1395
- has_meaningful_content = True
1552
+ # Structural check: if no tokens were consumed AND no assistant
1553
+ # events appeared, the CLI never made an API call. The result
1554
+ # text is a CLI-level error (e.g. "API Error: Connection error.")
1555
+ # rather than the agent's actual work output.
1556
+ tok_in = int(data.get("total_input_tokens", 0) or 0)
1557
+ tok_out = int(data.get("total_output_tokens", 0) or 0)
1558
+ no_api_call = (tok_in + tok_out == 0) and not has_assistant_events
1559
+ if no_api_call and result_text:
1560
+ error_messages.append(result_text)
1561
+ else:
1562
+ has_result = True
1563
+ has_meaningful_content = True
1396
1564
  elif ev_type == "error":
1397
1565
  msg = data.get("message", "")
1398
1566
  if msg:
1399
1567
  error_messages.append(msg)
1568
+ elif ev_type == "assistant":
1569
+ has_meaningful_content = True
1570
+ has_assistant_events = True
1400
1571
  elif ev_type in (
1401
- "assistant",
1402
1572
  "content_block_delta",
1403
1573
  "message_delta",
1404
1574
  "step_finish",
@@ -1557,7 +1727,10 @@ class ProcessManager:
1557
1727
  start_time = time.monotonic()
1558
1728
 
1559
1729
  if agent.agent_id == "claude-code":
1560
- result = await self._run_claude(agent, prompt, workspace_path, timeout, task.task_id, on_chunk)
1730
+ result = await self._run_claude(
1731
+ agent, prompt, workspace_path, timeout, task.task_id, on_chunk,
1732
+ node_type=task.node_type,
1733
+ )
1561
1734
  elif agent.agent_id == "codex":
1562
1735
  result = await self._run_codex(agent, prompt, workspace_path, timeout, task.task_id, on_chunk)
1563
1736
  elif agent.agent_id == "opencode":
@@ -1778,18 +1951,37 @@ class ProcessManager:
1778
1951
 
1779
1952
  async def _run_claude(
1780
1953
  self, agent: DiscoveredAgent, prompt: str, cwd: Path, timeout: int, task_id: str,
1781
- on_chunk: Any = None,
1954
+ on_chunk: Any = None, node_type: str = "",
1782
1955
  ) -> TaskResult:
1783
1956
  """Run Claude Code CLI in print mode with auto-approve permissions.
1784
1957
 
1785
1958
  Uses stdin pipe for prompt delivery to avoid ARG_MAX limits.
1786
1959
  Uses --dangerously-skip-permissions for autonomous file operations.
1960
+ Uses --max-turns to prevent context window overflow during long sessions.
1787
1961
  """
1962
+ # Determine max turns based on node type.
1963
+ # Analysis/coding nodes need more turns (read many files, write multiple outputs).
1964
+ # Review/testing nodes are typically shorter.
1965
+ # This prevents runaway context accumulation that leads to "Prompt is too long".
1966
+ max_turns_map = {
1967
+ "analysis": 80,
1968
+ "coding": 80,
1969
+ "design": 50,
1970
+ "testing": 60,
1971
+ "review": 40,
1972
+ "fix": 60,
1973
+ }
1974
+ max_turns = int(os.environ.get(
1975
+ "FACTORY_CLAUDE_MAX_TURNS",
1976
+ str(max_turns_map.get(node_type, 60)),
1977
+ ))
1978
+
1788
1979
  cmd = [
1789
1980
  agent.command,
1790
1981
  "-p",
1791
1982
  "--output-format", "stream-json",
1792
1983
  "--verbose",
1984
+ "--max-turns", str(max_turns),
1793
1985
  "--dangerously-skip-permissions",
1794
1986
  ]
1795
1987
 
@@ -1825,6 +2017,24 @@ class ProcessManager:
1825
2017
  metrics=metrics,
1826
2018
  )
1827
2019
  else:
2020
+ # Detect context overflow: Claude exits 1 with "Prompt is too long" in output
2021
+ _combined_output = stdout[-20000:] + stderr[-500:]
2022
+ if "Prompt is too long" in _combined_output or "prompt is too long" in _combined_output:
2023
+ logger.error(
2024
+ "Context overflow for task %s: Claude context window exceeded "
2025
+ "(%d stdout chars). Consider reducing prompt size.",
2026
+ task_id, len(stdout),
2027
+ )
2028
+ return TaskResult(
2029
+ status="failed",
2030
+ exit_code=returncode,
2031
+ stdout=stdout[-settings.AGENT_MAX_OUTPUT_SIZE:],
2032
+ stderr=stderr[-10000:],
2033
+ error="context_overflow: Claude's context window was exceeded during execution. "
2034
+ "The accumulated conversation history (initial prompt + tool call results) "
2035
+ "exceeded the model's limit. Reduce prompt size or shorten file reads.",
2036
+ metrics=metrics,
2037
+ )
1828
2038
  return TaskResult(
1829
2039
  status="failed",
1830
2040
  exit_code=returncode,
@@ -2638,6 +2848,23 @@ class TaskPoller:
2638
2848
  logger.warning("Task poll error: %s", e)
2639
2849
  return []
2640
2850
 
2851
+ async def poll_ai_jobs(self) -> list[dict]:
2852
+ """Poll for AIJobs dispatched to this daemon (workspace-mode)."""
2853
+ try:
2854
+ resp = await self.client.get(
2855
+ f"{self.server_url}/api/v1/runtimes/{self.runtime_id}/ai-jobs/poll",
2856
+ timeout=10,
2857
+ )
2858
+ if resp.status_code == 200:
2859
+ self._on_success()
2860
+ return resp.json().get("ai_jobs", [])
2861
+ elif resp.status_code in (401, 403):
2862
+ self._on_auth_failure()
2863
+ return []
2864
+ except Exception as e:
2865
+ logger.debug("AIJob poll error: %s", e)
2866
+ return []
2867
+
2641
2868
 
2642
2869
  # ── Server Connection ──
2643
2870
 
@@ -3018,6 +3245,11 @@ class RuntimeDaemon:
3018
3245
 
3019
3246
  if not acquired:
3020
3247
  logger.error("Cannot acquire daemon lock — another instance may still be running")
3248
+ print(
3249
+ "DAEMON_ERROR: Cannot acquire daemon lock — another daemon instance may "
3250
+ "still be running. Stop the existing daemon first or restart the machine.",
3251
+ file=sys.stderr,
3252
+ )
3021
3253
  raise SystemExit(1)
3022
3254
 
3023
3255
  # Write PID to lock file (for reference, though unreadable while locked)
@@ -3073,6 +3305,11 @@ class RuntimeDaemon:
3073
3305
  fcntl.flock(self._lock_file.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
3074
3306
  except (IOError, OSError):
3075
3307
  logger.error("Cannot acquire daemon lock — another instance may still be running")
3308
+ print(
3309
+ "DAEMON_ERROR: Cannot acquire daemon lock — another daemon instance may "
3310
+ "still be running. Stop the existing daemon first or restart the machine.",
3311
+ file=sys.stderr,
3312
+ )
3076
3313
  raise SystemExit(1)
3077
3314
 
3078
3315
  # Write our PID to the lock file for reference
@@ -3215,6 +3452,23 @@ class RuntimeDaemon:
3215
3452
  self._execute_task(task, conn)
3216
3453
  )
3217
3454
 
3455
+ # Poll for AIJobs (workspace-mode tasks)
3456
+ if len(self.active_tasks) < self.max_concurrent:
3457
+ ai_jobs = await conn.poller.poll_ai_jobs()
3458
+ for aj in ai_jobs:
3459
+ job_id = aj.get("job_id", "")
3460
+ ai_task_key = f"aijob_{job_id}"
3461
+ if ai_task_key in self.active_tasks:
3462
+ continue
3463
+ if len(self.active_tasks) >= self.max_concurrent:
3464
+ break
3465
+ logger.info("[%s] Starting AIJob %s (type=%s)",
3466
+ conn.label, job_id, aj.get("task_type"))
3467
+ self._task_connections[ai_task_key] = conn
3468
+ self.active_tasks[ai_task_key] = asyncio.create_task(
3469
+ self._execute_ai_job(aj, conn)
3470
+ )
3471
+
3218
3472
  async def _execute_task(self, task: TaskInfo, conn: ServerConnection):
3219
3473
  """Execute a single task, reporting to the originating server connection."""
3220
3474
  reporter = conn.reporter
@@ -3632,9 +3886,13 @@ class RuntimeDaemon:
3632
3886
  required_files = _get_analysis_outputs_for_type(req_type)
3633
3887
 
3634
3888
  # Analysis deliverables live in analysis_output_dir (docs/requirements/...)
3889
+ _input = task.input_data or {}
3635
3890
  doc_dir = (
3636
- (task.input_data or {}).get("analysis_output_dir", "")
3637
- or (task.input_data or {}).get("output_dir", "")
3891
+ _input.get("analysis_output_dir")
3892
+ or _input.get("context", {}).get("analysis_output_dir")
3893
+ or _input.get("output_dir")
3894
+ or _input.get("context", {}).get("output_dir")
3895
+ or ""
3638
3896
  )
3639
3897
  if doc_dir:
3640
3898
  base = workspace_path / doc_dir
@@ -3718,7 +3976,12 @@ class RuntimeDaemon:
3718
3976
  pass
3719
3977
 
3720
3978
  if not _skip_test_artifacts:
3721
- doc_dir = (task.input_data or {}).get("output_dir", "")
3979
+ _input = task.input_data or {}
3980
+ doc_dir = (
3981
+ _input.get("output_dir")
3982
+ or _input.get("context", {}).get("output_dir")
3983
+ or ""
3984
+ )
3722
3985
  if doc_dir:
3723
3986
  base = workspace_path / doc_dir
3724
3987
  else:
@@ -3770,6 +4033,139 @@ class RuntimeDaemon:
3770
4033
 
3771
4034
  return issues
3772
4035
 
4036
+ async def _execute_ai_job(self, aj: dict, conn: "ServerConnection"):
4037
+ """Execute an AIJob in daemon workspace and report results back.
4038
+
4039
+ Uses WorkspaceManager for branch-based isolation, runs the agent CLI
4040
+ with the job's prompt, auto-commits results, and reports back.
4041
+ """
4042
+ job_id = aj.get("job_id", "")
4043
+ task_type = aj.get("task_type", "unknown")
4044
+ project_info = aj.get("project", {})
4045
+ requirement_key = aj.get("requirement_key")
4046
+ agent_override = aj.get("agent_override")
4047
+ system_prompt = aj.get("system_prompt", "")
4048
+ user_prompt = aj.get("user_prompt", "")
4049
+
4050
+ reporter_url = f"{conn.server_url.rstrip('/')}/api/v1/runtimes/{conn.runtime_id}/ai-jobs/{job_id}"
4051
+
4052
+ try:
4053
+ # Report progress: starting
4054
+ await conn.client.post(
4055
+ f"{reporter_url}/progress",
4056
+ json={"current_phase": "preparing", "current_step": "Preparing workspace...", "progress_pct": 5},
4057
+ timeout=10,
4058
+ )
4059
+
4060
+ # 1. Select agent
4061
+ agent_type = agent_override or "claude-code"
4062
+ agent = self._select_agent(agent_type, [])
4063
+ if not agent:
4064
+ await conn.client.post(
4065
+ f"{reporter_url}/complete",
4066
+ json={"status": "failed", "error": f"No agent CLI for '{agent_type}'", "failure_code": "no_agent"},
4067
+ timeout=10,
4068
+ )
4069
+ return
4070
+
4071
+ # 2. Prepare workspace (using project info + requirement branch)
4072
+ full_prompt = f"{system_prompt}\n\n{user_prompt}" if system_prompt else user_prompt
4073
+ fake_task = TaskInfo(
4074
+ task_id=job_id,
4075
+ graph_id="",
4076
+ node_type="ai_job",
4077
+ agent_type=agent_type,
4078
+ input_prompt=full_prompt,
4079
+ input_data={},
4080
+ timeout_seconds=settings.AGENT_TIMEOUT,
4081
+ max_retries=0,
4082
+ retry_count=0,
4083
+ project=project_info,
4084
+ work_item={},
4085
+ fallback_chain=[],
4086
+ requirement_workflow_id=None,
4087
+ requirement_key=requirement_key,
4088
+ graph_type="ai_job",
4089
+ )
4090
+ workspace_path = await self.workspace_manager.prepare_workspace(
4091
+ project_info, fake_task,
4092
+ )
4093
+
4094
+ await conn.client.post(
4095
+ f"{reporter_url}/progress",
4096
+ json={"current_phase": "running", "current_step": "Running agent...", "progress_pct": 15},
4097
+ timeout=10,
4098
+ )
4099
+
4100
+ # 3. Run agent with prompt
4101
+ _line_buffer: list[str] = []
4102
+
4103
+ async def on_chunk(lines: list[str]):
4104
+ _line_buffer.extend(lines)
4105
+
4106
+ result = await self.process_manager.run_agent(
4107
+ agent, fake_task, workspace_path, on_chunk=on_chunk,
4108
+ )
4109
+
4110
+ # 4. Auto-commit if successful
4111
+ git_info = {}
4112
+ if result.status == "success" and result.files_changed:
4113
+ git_info = await self._auto_commit(workspace_path, fake_task)
4114
+
4115
+ # 5. Report completion
4116
+ output_content = result.stdout[-20000:] if result.stdout else ""
4117
+ scripts: dict = {}
4118
+
4119
+ # Try to extract per-scenario scripts from output
4120
+ scenario_ids = aj.get("input_context", {}).get("scenario_ids", [])
4121
+ if scenario_ids and output_content:
4122
+ # Simple heuristic: if output is a single script, map it to first scenario
4123
+ # Daemon-generated scripts may be multiple files in workspace
4124
+ for sid in scenario_ids:
4125
+ # Check if daemon wrote test files to workspace
4126
+ import glob
4127
+ test_files = glob.glob(str(workspace_path / "tests" / "**" / f"*{sid[:8]}*"), recursive=True)
4128
+ if test_files:
4129
+ try:
4130
+ with open(test_files[0], "r") as f:
4131
+ scripts[sid] = f.read()
4132
+ except Exception:
4133
+ pass
4134
+
4135
+ complete_payload = {
4136
+ "status": "success" if result.status == "success" else "failed",
4137
+ "output_content": output_content,
4138
+ "output_result": {
4139
+ "scripts": scripts,
4140
+ "files_changed": result.files_changed,
4141
+ "lines_added": result.lines_added,
4142
+ "lines_removed": result.lines_removed,
4143
+ },
4144
+ "tier_used": "agent_cli",
4145
+ "resolved_agent": agent.agent_id,
4146
+ "git_info": git_info,
4147
+ "error": result.error if result.status != "success" else "",
4148
+ "failure_code": "agent_error" if result.status != "success" else "",
4149
+ }
4150
+
4151
+ await conn.client.post(
4152
+ f"{reporter_url}/complete",
4153
+ json=complete_payload,
4154
+ timeout=30,
4155
+ )
4156
+ logger.info("AIJob %s completed: %s", job_id, result.status)
4157
+
4158
+ except Exception as e:
4159
+ logger.exception("AIJob %s execution error", job_id)
4160
+ try:
4161
+ await conn.client.post(
4162
+ f"{reporter_url}/complete",
4163
+ json={"status": "failed", "error": str(e)[:2000], "failure_code": "daemon_exception"},
4164
+ timeout=10,
4165
+ )
4166
+ except Exception:
4167
+ pass
4168
+
3773
4169
  async def _validate_and_retry(
3774
4170
  self,
3775
4171
  agent: "DiscoveredAgent",
@@ -3809,11 +4205,25 @@ class RuntimeDaemon:
3809
4205
  ],
3810
4206
  )
3811
4207
 
3812
- # Build a targeted fix prompt
4208
+ # Build a targeted fix prompt with output directory context
4209
+ _input = task.input_data or {}
4210
+ _fix_doc_dir = (
4211
+ _input.get("output_dir")
4212
+ or _input.get("context", {}).get("output_dir")
4213
+ or ""
4214
+ )
3813
4215
  fix_prompt = (
3814
4216
  "The previous execution produced output with validation errors.\n"
3815
4217
  "Please fix ALL of the following issues:\n\n"
3816
4218
  f"{issues_text}\n\n"
4219
+ )
4220
+ if _fix_doc_dir:
4221
+ fix_prompt += (
4222
+ f"IMPORTANT: All deliverable files (test-cases.json, coverage-matrix.json, "
4223
+ f"test-report.md, design.md, etc.) MUST be written to the `{_fix_doc_dir}/` "
4224
+ f"directory, NOT the workspace root.\n\n"
4225
+ )
4226
+ fix_prompt += (
3817
4227
  "Fix the issues in-place. Do NOT recreate files that are already correct.\n"
3818
4228
  "Only fix the specific problems listed above."
3819
4229
  )
@@ -4024,7 +4434,13 @@ class RuntimeDaemon:
4024
4434
  # ── Pre-push rebase onto latest default branch ──
4025
4435
  # This ensures the AI's branch incorporates the latest remote
4026
4436
  # changes, dramatically reducing PR merge conflicts.
4027
- await self._rebase_onto_latest(workspace_path, default_branch, task, project_key)
4437
+ # SKIP for analysis nodes: analysis produces documentation files
4438
+ # (PRD.md, SDD.md, etc.) that are independent of codebase changes.
4439
+ # Rebasing analysis commits onto default_branch is unnecessary
4440
+ # overhead and rewrites commit SHAs, which complicates pushes
4441
+ # on subsequent iterations of the same requirement.
4442
+ if task.node_type != "analysis":
4443
+ await self._rebase_onto_latest(workspace_path, default_branch, task, project_key)
4028
4444
 
4029
4445
  # ── Verify we're on the correct branch before pushing ──
4030
4446
  current_branch = ""
@@ -4550,10 +4966,79 @@ class RuntimeDaemon:
4550
4966
  unpushed = "first-push"
4551
4967
 
4552
4968
  if unpushed:
4969
+ # Check if remote has commits not in local history (diverged).
4970
+ try:
4971
+ remote_ahead = (await git(
4972
+ "log", f"HEAD..origin/{branch}", "--oneline", cwd=workspace_path,
4973
+ )).strip()
4974
+ except RuntimeError:
4975
+ remote_ahead = "" # Remote branch doesn't exist yet
4976
+
4977
+ if remote_ahead:
4978
+ remote_count = len(remote_ahead.splitlines())
4979
+ # Divergence detected — but this is expected after rebase.
4980
+ # Check if the remote commits are already incorporated
4981
+ # (i.e., their patches are empty against our branch, meaning
4982
+ # the rebase already applied them with new SHAs).
4983
+ # `git cherry HEAD origin/branch` lists commits from
4984
+ # origin/branch not in HEAD; lines starting with "-" are
4985
+ # already incorporated (equivalent patch exists).
4986
+ rebase_divergence = False
4987
+ try:
4988
+ cherry_out = (await git(
4989
+ "cherry", "HEAD", f"origin/{branch}",
4990
+ cwd=workspace_path,
4991
+ )).strip()
4992
+ if cherry_out:
4993
+ # All lines starting with "-" means all remote
4994
+ # commits are already in our branch (rebased).
4995
+ # Lines starting with "+" are truly missing.
4996
+ truly_missing = [
4997
+ line for line in cherry_out.splitlines()
4998
+ if line.startswith("+ ")
4999
+ ]
5000
+ rebase_divergence = len(truly_missing) == 0
5001
+ else:
5002
+ # No cherry output — remote commits are empty or
5003
+ # fully equivalent
5004
+ rebase_divergence = True
5005
+ except RuntimeError:
5006
+ pass
5007
+
5008
+ if rebase_divergence:
5009
+ logger.info(
5010
+ "Branch %s diverged from origin (%d remote commit(s)) "
5011
+ "but all are already incorporated (rebase). "
5012
+ "Using --force-with-lease to push safely.",
5013
+ branch, remote_count,
5014
+ )
5015
+ try:
5016
+ await git(
5017
+ "push", "--force-with-lease", "-u", "origin", branch,
5018
+ cwd=workspace_path, project_key=project_key,
5019
+ )
5020
+ logger.info("Force-pushed (with lease) branch %s to origin", branch)
5021
+ return None
5022
+ except RuntimeError as exc:
5023
+ logger.error("Force-push (with lease) failed for %s: %s", branch, exc)
5024
+ return f"Push failed: {exc}"
5025
+ else:
5026
+ logger.error(
5027
+ "SAFETY: Refusing to push %s — remote has %d commit(s) "
5028
+ "not in local branch. This would destroy prior work. "
5029
+ "Remote-only commits:\n%s",
5030
+ branch, remote_count, remote_ahead,
5031
+ )
5032
+ return (
5033
+ f"Push refused: remote branch '{branch}' has {remote_count} "
5034
+ f"commit(s) not in local history. Force-pushing would "
5035
+ f"destroy prior implementation work."
5036
+ )
5037
+
4553
5038
  logger.info("Found unpushed commits on %s, pushing...", branch)
4554
5039
  try:
4555
5040
  await git(
4556
- "push", "--force-with-lease", "-u", "origin", branch,
5041
+ "push", "-u", "origin", branch,
4557
5042
  cwd=workspace_path, project_key=project_key,
4558
5043
  )
4559
5044
  logger.info("Pushed branch %s to origin", branch)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: forgexa-cli
3
- Version: 1.5.2
3
+ Version: 1.7.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.5.2"
3
+ version = "1.7.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