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.
- {forgexa_cli-1.6.1 → forgexa_cli-1.7.2}/PKG-INFO +1 -1
- {forgexa_cli-1.6.1 → forgexa_cli-1.7.2}/forgexa_cli/__init__.py +1 -1
- {forgexa_cli-1.6.1 → forgexa_cli-1.7.2}/forgexa_cli/daemon.py +192 -1
- {forgexa_cli-1.6.1 → forgexa_cli-1.7.2}/forgexa_cli.egg-info/PKG-INFO +1 -1
- {forgexa_cli-1.6.1 → forgexa_cli-1.7.2}/pyproject.toml +1 -1
- {forgexa_cli-1.6.1 → forgexa_cli-1.7.2}/README.md +0 -0
- {forgexa_cli-1.6.1 → forgexa_cli-1.7.2}/forgexa_cli/_build_config.py +0 -0
- {forgexa_cli-1.6.1 → forgexa_cli-1.7.2}/forgexa_cli/main.py +0 -0
- {forgexa_cli-1.6.1 → forgexa_cli-1.7.2}/forgexa_cli/py.typed +0 -0
- {forgexa_cli-1.6.1 → forgexa_cli-1.7.2}/forgexa_cli.egg-info/SOURCES.txt +0 -0
- {forgexa_cli-1.6.1 → forgexa_cli-1.7.2}/forgexa_cli.egg-info/dependency_links.txt +0 -0
- {forgexa_cli-1.6.1 → forgexa_cli-1.7.2}/forgexa_cli.egg-info/entry_points.txt +0 -0
- {forgexa_cli-1.6.1 → forgexa_cli-1.7.2}/forgexa_cli.egg-info/requires.txt +0 -0
- {forgexa_cli-1.6.1 → forgexa_cli-1.7.2}/forgexa_cli.egg-info/top_level.txt +0 -0
- {forgexa_cli-1.6.1 → forgexa_cli-1.7.2}/setup.cfg +0 -0
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
"""forgexa-cli — Forgexa command-line client."""
|
|
2
|
-
__version__ = "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.
|
|
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",
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|