forgexa-cli 1.6.1__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.6.1
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.6.1"
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.6.1"
324
+ DAEMON_VERSION = "1.7.2"
311
325
 
312
326
 
313
327
  def _detect_client_type() -> str:
@@ -2834,6 +2848,23 @@ class TaskPoller:
2834
2848
  logger.warning("Task poll error: %s", e)
2835
2849
  return []
2836
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
+
2837
2868
 
2838
2869
  # ── Server Connection ──
2839
2870
 
@@ -3214,6 +3245,11 @@ class RuntimeDaemon:
3214
3245
 
3215
3246
  if not acquired:
3216
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
+ )
3217
3253
  raise SystemExit(1)
3218
3254
 
3219
3255
  # Write PID to lock file (for reference, though unreadable while locked)
@@ -3269,6 +3305,11 @@ class RuntimeDaemon:
3269
3305
  fcntl.flock(self._lock_file.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
3270
3306
  except (IOError, OSError):
3271
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
+ )
3272
3313
  raise SystemExit(1)
3273
3314
 
3274
3315
  # Write our PID to the lock file for reference
@@ -3411,6 +3452,23 @@ class RuntimeDaemon:
3411
3452
  self._execute_task(task, conn)
3412
3453
  )
3413
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
+
3414
3472
  async def _execute_task(self, task: TaskInfo, conn: ServerConnection):
3415
3473
  """Execute a single task, reporting to the originating server connection."""
3416
3474
  reporter = conn.reporter
@@ -3975,6 +4033,139 @@ class RuntimeDaemon:
3975
4033
 
3976
4034
  return issues
3977
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
+
3978
4169
  async def _validate_and_retry(
3979
4170
  self,
3980
4171
  agent: "DiscoveredAgent",
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: forgexa-cli
3
- Version: 1.6.1
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.6.1"
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