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.
- {forgexa_cli-1.5.2 → forgexa_cli-1.7.2}/PKG-INFO +1 -1
- {forgexa_cli-1.5.2 → forgexa_cli-1.7.2}/forgexa_cli/__init__.py +1 -1
- {forgexa_cli-1.5.2 → forgexa_cli-1.7.2}/forgexa_cli/daemon.py +512 -27
- {forgexa_cli-1.5.2 → forgexa_cli-1.7.2}/forgexa_cli.egg-info/PKG-INFO +1 -1
- {forgexa_cli-1.5.2 → forgexa_cli-1.7.2}/pyproject.toml +1 -1
- {forgexa_cli-1.5.2 → forgexa_cli-1.7.2}/README.md +0 -0
- {forgexa_cli-1.5.2 → forgexa_cli-1.7.2}/forgexa_cli/_build_config.py +0 -0
- {forgexa_cli-1.5.2 → forgexa_cli-1.7.2}/forgexa_cli/main.py +0 -0
- {forgexa_cli-1.5.2 → forgexa_cli-1.7.2}/forgexa_cli/py.typed +0 -0
- {forgexa_cli-1.5.2 → forgexa_cli-1.7.2}/forgexa_cli.egg-info/SOURCES.txt +0 -0
- {forgexa_cli-1.5.2 → forgexa_cli-1.7.2}/forgexa_cli.egg-info/dependency_links.txt +0 -0
- {forgexa_cli-1.5.2 → forgexa_cli-1.7.2}/forgexa_cli.egg-info/entry_points.txt +0 -0
- {forgexa_cli-1.5.2 → forgexa_cli-1.7.2}/forgexa_cli.egg-info/requires.txt +0 -0
- {forgexa_cli-1.5.2 → forgexa_cli-1.7.2}/forgexa_cli.egg-info/top_level.txt +0 -0
- {forgexa_cli-1.5.2 → 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:
|
|
@@ -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
|
-
#
|
|
477
|
-
|
|
478
|
-
|
|
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.
|
|
873
|
-
#
|
|
874
|
-
|
|
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
|
|
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",
|
|
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",
|
|
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(
|
|
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
|
-
|
|
1288
|
-
|
|
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 =
|
|
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
|
-
|
|
1395
|
-
|
|
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(
|
|
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
|
-
|
|
3637
|
-
or (
|
|
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
|
-
|
|
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
|
-
|
|
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", "
|
|
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)
|
|
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
|