forgexa-cli 1.5.2__tar.gz → 1.6.1__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.6.1}/PKG-INFO +1 -1
- {forgexa_cli-1.5.2 → forgexa_cli-1.6.1}/forgexa_cli/__init__.py +1 -1
- {forgexa_cli-1.5.2 → forgexa_cli-1.6.1}/forgexa_cli/daemon.py +321 -27
- {forgexa_cli-1.5.2 → forgexa_cli-1.6.1}/forgexa_cli.egg-info/PKG-INFO +1 -1
- {forgexa_cli-1.5.2 → forgexa_cli-1.6.1}/pyproject.toml +1 -1
- {forgexa_cli-1.5.2 → forgexa_cli-1.6.1}/README.md +0 -0
- {forgexa_cli-1.5.2 → forgexa_cli-1.6.1}/forgexa_cli/_build_config.py +0 -0
- {forgexa_cli-1.5.2 → forgexa_cli-1.6.1}/forgexa_cli/main.py +0 -0
- {forgexa_cli-1.5.2 → forgexa_cli-1.6.1}/forgexa_cli/py.typed +0 -0
- {forgexa_cli-1.5.2 → forgexa_cli-1.6.1}/forgexa_cli.egg-info/SOURCES.txt +0 -0
- {forgexa_cli-1.5.2 → forgexa_cli-1.6.1}/forgexa_cli.egg-info/dependency_links.txt +0 -0
- {forgexa_cli-1.5.2 → forgexa_cli-1.6.1}/forgexa_cli.egg-info/entry_points.txt +0 -0
- {forgexa_cli-1.5.2 → forgexa_cli-1.6.1}/forgexa_cli.egg-info/requires.txt +0 -0
- {forgexa_cli-1.5.2 → forgexa_cli-1.6.1}/forgexa_cli.egg-info/top_level.txt +0 -0
- {forgexa_cli-1.5.2 → forgexa_cli-1.6.1}/setup.cfg +0 -0
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
"""forgexa-cli — Forgexa command-line client."""
|
|
2
|
-
__version__ = "1.
|
|
2
|
+
__version__ = "1.6.1"
|
|
@@ -307,7 +307,7 @@ except (ImportError, ModuleNotFoundError):
|
|
|
307
307
|
# DAEMON_VERSION is the protocol/logic version of the daemon code.
|
|
308
308
|
# Kept in sync with pyproject.toml version via bump-version.sh.
|
|
309
309
|
# CLIENT_TYPE identifies which packaging/distribution this daemon runs in.
|
|
310
|
-
DAEMON_VERSION = "1.
|
|
310
|
+
DAEMON_VERSION = "1.6.1"
|
|
311
311
|
|
|
312
312
|
|
|
313
313
|
def _detect_client_type() -> str:
|
|
@@ -473,9 +473,12 @@ def get_os_info() -> str:
|
|
|
473
473
|
return f"{distro} {machine}"
|
|
474
474
|
return f"Linux {platform.release().split('-')[0]} {machine}"
|
|
475
475
|
elif system == "Windows":
|
|
476
|
-
#
|
|
477
|
-
|
|
478
|
-
|
|
476
|
+
# platform.version() returns the NT kernel version (10.0.x) for BOTH
|
|
477
|
+
# Windows 10 and Windows 11, so it cannot distinguish them.
|
|
478
|
+
# platform.win32_ver()[0] returns the marketing release: "10" or "11".
|
|
479
|
+
win_release = platform.win32_ver()[0] or platform.version().split('.')[0]
|
|
480
|
+
build = platform.version() # e.g. "10.0.26200"
|
|
481
|
+
return f"Windows {win_release} ({build}) {machine}"
|
|
479
482
|
else:
|
|
480
483
|
return f"{system} {platform.release()} {machine}"
|
|
481
484
|
|
|
@@ -869,12 +872,19 @@ class WorkspaceManager:
|
|
|
869
872
|
branch_name = f"feature/{workspace_key}"
|
|
870
873
|
|
|
871
874
|
# Determine whether this node is the "first" in a new graph that needs
|
|
872
|
-
# a fresh base from the default branch.
|
|
873
|
-
#
|
|
874
|
-
|
|
875
|
+
# a fresh base from the default branch. Only analysis nodes with
|
|
876
|
+
# explicit analysis_mode="fresh" should start fresh. All other modes
|
|
877
|
+
# (including the default when no mode is specified) preserve the
|
|
878
|
+
# existing branch to avoid destroying prior implementation commits.
|
|
879
|
+
#
|
|
880
|
+
# SAFETY: Defaulting to "refine" ensures that any code path which
|
|
881
|
+
# forgets to set analysis_mode will safely preserve the branch.
|
|
882
|
+
# Only the initial requirement analysis endpoint (requirements.py)
|
|
883
|
+
# explicitly passes "fresh" when creating a brand-new requirement branch.
|
|
884
|
+
analysis_mode = task.input_data.get("analysis_mode", "refine")
|
|
875
885
|
is_fresh_start = (
|
|
876
886
|
task.node_type == "analysis"
|
|
877
|
-
and analysis_mode
|
|
887
|
+
and analysis_mode == "fresh"
|
|
878
888
|
)
|
|
879
889
|
|
|
880
890
|
if repo_url:
|
|
@@ -977,11 +987,52 @@ class WorkspaceManager:
|
|
|
977
987
|
logger.warning("Broken worktree detected at %s — removing and recreating", ws_path)
|
|
978
988
|
await self._remove_broken_worktree(main_repo, ws_path, workspace_key)
|
|
979
989
|
else:
|
|
980
|
-
# Healthy worktree — fetch and optionally reset
|
|
990
|
+
# Healthy worktree — fetch and optionally reset.
|
|
991
|
+
# The _main repo uses --single-branch, so `git fetch origin`
|
|
992
|
+
# only fetches the default branch. Explicitly fetch the
|
|
993
|
+
# feature branch with a full refspec so that
|
|
994
|
+
# origin/{branch_name} is available for checkout/reset.
|
|
981
995
|
try:
|
|
982
996
|
await self._git("fetch", "origin", cwd=ws_path, project_key=project_key)
|
|
983
997
|
except RuntimeError:
|
|
984
998
|
pass
|
|
999
|
+
try:
|
|
1000
|
+
await self._git(
|
|
1001
|
+
"fetch", "origin",
|
|
1002
|
+
f"{branch_name}:refs/remotes/origin/{branch_name}",
|
|
1003
|
+
cwd=ws_path, project_key=project_key,
|
|
1004
|
+
)
|
|
1005
|
+
except RuntimeError:
|
|
1006
|
+
pass # Branch may not exist on remote yet
|
|
1007
|
+
|
|
1008
|
+
if fresh_start:
|
|
1009
|
+
# Safety check: if the branch already exists on remote with
|
|
1010
|
+
# commits beyond the default branch, do NOT reset to
|
|
1011
|
+
# origin/default_branch — that would destroy prior work.
|
|
1012
|
+
# This guards against accidental branch destruction when
|
|
1013
|
+
# analysis_mode is incorrectly set to "fresh" for
|
|
1014
|
+
# verification or iterative nodes.
|
|
1015
|
+
branch_has_remote_commits = False
|
|
1016
|
+
try:
|
|
1017
|
+
result = await self._git(
|
|
1018
|
+
"rev-list", "--count",
|
|
1019
|
+
f"origin/{default_branch}..origin/{branch_name}",
|
|
1020
|
+
cwd=ws_path,
|
|
1021
|
+
)
|
|
1022
|
+
ahead_count = int(result.strip()) if result.strip() else 0
|
|
1023
|
+
branch_has_remote_commits = ahead_count > 0
|
|
1024
|
+
except (RuntimeError, ValueError):
|
|
1025
|
+
pass # Branch may not exist on remote yet
|
|
1026
|
+
|
|
1027
|
+
if branch_has_remote_commits:
|
|
1028
|
+
logger.warning(
|
|
1029
|
+
"Fresh start requested for %s but remote branch has %d commit(s) "
|
|
1030
|
+
"ahead of %s — switching to safe sync instead of resetting to "
|
|
1031
|
+
"avoid destroying prior work",
|
|
1032
|
+
branch_name, ahead_count, default_branch,
|
|
1033
|
+
)
|
|
1034
|
+
# Override: fall through to the non-fresh sync path
|
|
1035
|
+
fresh_start = False
|
|
985
1036
|
|
|
986
1037
|
if fresh_start:
|
|
987
1038
|
logger.info("Fresh start: resetting %s to origin/%s", ws_path, default_branch)
|
|
@@ -1036,7 +1087,9 @@ class WorkspaceManager:
|
|
|
1036
1087
|
await asyncio.sleep(2 * (_sync_attempt + 1))
|
|
1037
1088
|
try:
|
|
1038
1089
|
await self._git(
|
|
1039
|
-
"fetch", "origin",
|
|
1090
|
+
"fetch", "origin",
|
|
1091
|
+
f"{branch_name}:refs/remotes/origin/{branch_name}",
|
|
1092
|
+
cwd=ws_path,
|
|
1040
1093
|
project_key=project_key,
|
|
1041
1094
|
)
|
|
1042
1095
|
except RuntimeError:
|
|
@@ -1061,7 +1114,9 @@ class WorkspaceManager:
|
|
|
1061
1114
|
await asyncio.sleep(2 * (_sync_attempt + 1))
|
|
1062
1115
|
try:
|
|
1063
1116
|
await self._git(
|
|
1064
|
-
"fetch", "origin",
|
|
1117
|
+
"fetch", "origin",
|
|
1118
|
+
f"{branch_name}:refs/remotes/origin/{branch_name}",
|
|
1119
|
+
cwd=ws_path,
|
|
1065
1120
|
project_key=project_key,
|
|
1066
1121
|
)
|
|
1067
1122
|
except RuntimeError:
|
|
@@ -1096,6 +1151,19 @@ class WorkspaceManager:
|
|
|
1096
1151
|
else:
|
|
1097
1152
|
await self._git("fetch", "--all", cwd=main_repo, timeout=300, project_key=project_key)
|
|
1098
1153
|
|
|
1154
|
+
# --single-branch clone only fetches the default branch.
|
|
1155
|
+
# Explicitly fetch the feature branch so origin/{branch_name}
|
|
1156
|
+
# is available for worktree creation and checkout.
|
|
1157
|
+
if not fresh_start:
|
|
1158
|
+
try:
|
|
1159
|
+
await self._git(
|
|
1160
|
+
"fetch", "origin",
|
|
1161
|
+
f"{branch_name}:refs/remotes/origin/{branch_name}",
|
|
1162
|
+
cwd=main_repo, timeout=60, project_key=project_key,
|
|
1163
|
+
)
|
|
1164
|
+
except RuntimeError:
|
|
1165
|
+
pass # Branch may not exist on remote yet (first analysis)
|
|
1166
|
+
|
|
1099
1167
|
# Prune stale worktree references (e.g. directories deleted externally
|
|
1100
1168
|
# when simulating cross-runtime or after disk cleanup). Without this,
|
|
1101
1169
|
# `git worktree add` refuses to create a branch that is "already checked out"
|
|
@@ -1144,7 +1212,11 @@ class WorkspaceManager:
|
|
|
1144
1212
|
)
|
|
1145
1213
|
await asyncio.sleep(2 * (_check_attempt + 1))
|
|
1146
1214
|
try:
|
|
1147
|
-
await self._git(
|
|
1215
|
+
await self._git(
|
|
1216
|
+
"fetch", "origin",
|
|
1217
|
+
f"{branch_name}:refs/remotes/origin/{branch_name}",
|
|
1218
|
+
cwd=main_repo, timeout=60, project_key=project_key,
|
|
1219
|
+
)
|
|
1148
1220
|
except RuntimeError:
|
|
1149
1221
|
pass
|
|
1150
1222
|
|
|
@@ -1227,6 +1299,18 @@ class WorkspaceManager:
|
|
|
1227
1299
|
|
|
1228
1300
|
If a project SSH key is registered, remote-touching commands
|
|
1229
1301
|
(clone, fetch, pull, push) will use GIT_SSH_COMMAND.
|
|
1302
|
+
|
|
1303
|
+
Process-group isolation: git is started in its own session so that
|
|
1304
|
+
SIGKILL on timeout propagates to all of git's children (especially
|
|
1305
|
+
the ssh subprocess that git forks for remote operations). Without
|
|
1306
|
+
this, killing git leaves orphaned ssh processes that hold the stderr
|
|
1307
|
+
pipe open — preventing asyncio from detecting EOF and causing the
|
|
1308
|
+
parent to hang until the GIT_CLONE_TIMEOUT fires.
|
|
1309
|
+
|
|
1310
|
+
SSH keepalives: GIT_SSH_COMMAND now includes ServerAliveInterval=30
|
|
1311
|
+
and ServerAliveCountMax=3 so that a stalled TCP connection (server
|
|
1312
|
+
accepts but never sends the git protocol banner) is detected within
|
|
1313
|
+
~90 s rather than waiting forever.
|
|
1230
1314
|
"""
|
|
1231
1315
|
env = None
|
|
1232
1316
|
git_prefix_args: list[str] = []
|
|
@@ -1255,6 +1339,14 @@ class WorkspaceManager:
|
|
|
1255
1339
|
f" -o StrictHostKeyChecking=accept-new"
|
|
1256
1340
|
f" -o UserKnownHostsFile=/dev/null"
|
|
1257
1341
|
f" -o IdentitiesOnly=yes"
|
|
1342
|
+
# Detect a stalled TCP connection (server accepts but
|
|
1343
|
+
# never sends the git protocol banner). After 30 s of
|
|
1344
|
+
# silence the client sends a keepalive; after 3 missed
|
|
1345
|
+
# responses (≤ 90 s total) SSH exits, closes the pipes,
|
|
1346
|
+
# and lets asyncio's communicate() return.
|
|
1347
|
+
f" -o ConnectTimeout=30"
|
|
1348
|
+
f" -o ServerAliveInterval=30"
|
|
1349
|
+
f" -o ServerAliveCountMax=3"
|
|
1258
1350
|
),
|
|
1259
1351
|
}
|
|
1260
1352
|
except Exception:
|
|
@@ -1274,18 +1366,53 @@ class WorkspaceManager:
|
|
|
1274
1366
|
if git_prefix_args:
|
|
1275
1367
|
env = {**(env or os.environ), "GIT_TERMINAL_PROMPT": "0"}
|
|
1276
1368
|
|
|
1369
|
+
# start_new_session=True puts git in its own process group.
|
|
1370
|
+
# On timeout we send SIGKILL to the entire group, which includes
|
|
1371
|
+
# any ssh/gpg/credential-helper children that git forked — preventing
|
|
1372
|
+
# orphaned processes from keeping pipes alive.
|
|
1373
|
+
# Windows note: start_new_session creates a new console process group;
|
|
1374
|
+
# we use taskkill /T there instead of killpg.
|
|
1277
1375
|
proc = await asyncio.create_subprocess_exec(
|
|
1278
1376
|
"git", *git_prefix_args, *args,
|
|
1279
1377
|
stdout=asyncio.subprocess.PIPE,
|
|
1280
1378
|
stderr=asyncio.subprocess.PIPE,
|
|
1281
1379
|
cwd=str(cwd) if cwd else None,
|
|
1282
1380
|
env=env,
|
|
1381
|
+
start_new_session=True,
|
|
1283
1382
|
)
|
|
1284
1383
|
try:
|
|
1285
1384
|
stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=timeout)
|
|
1286
1385
|
except (TimeoutError, asyncio.TimeoutError):
|
|
1287
|
-
|
|
1288
|
-
|
|
1386
|
+
# Kill the entire process group so that SSH (and any other
|
|
1387
|
+
# child processes git may have spawned) are also terminated.
|
|
1388
|
+
# A plain proc.kill() only kills the direct child (git); the
|
|
1389
|
+
# ssh grandchild becomes orphaned, keeps the stderr pipe open,
|
|
1390
|
+
# and proc.communicate() can never return EOF.
|
|
1391
|
+
try:
|
|
1392
|
+
if sys.platform != "win32":
|
|
1393
|
+
import signal as _signal
|
|
1394
|
+
try:
|
|
1395
|
+
os.killpg(os.getpgid(proc.pid), _signal.SIGKILL)
|
|
1396
|
+
except (ProcessLookupError, PermissionError, OSError):
|
|
1397
|
+
pass
|
|
1398
|
+
else:
|
|
1399
|
+
# Windows: taskkill /F /T kills the process tree
|
|
1400
|
+
import subprocess as _subprocess
|
|
1401
|
+
_subprocess.run(
|
|
1402
|
+
["taskkill", "/F", "/T", "/PID", str(proc.pid)],
|
|
1403
|
+
capture_output=True,
|
|
1404
|
+
)
|
|
1405
|
+
except Exception:
|
|
1406
|
+
pass
|
|
1407
|
+
finally:
|
|
1408
|
+
try:
|
|
1409
|
+
proc.kill()
|
|
1410
|
+
except Exception:
|
|
1411
|
+
pass
|
|
1412
|
+
try:
|
|
1413
|
+
await asyncio.wait_for(proc.wait(), timeout=5.0)
|
|
1414
|
+
except Exception:
|
|
1415
|
+
pass
|
|
1289
1416
|
raise RuntimeError(f"git {' '.join(args)} timed out after {timeout}s")
|
|
1290
1417
|
finally:
|
|
1291
1418
|
# Clean up temp SSH key file if created
|
|
@@ -1353,11 +1480,27 @@ class ProcessManager:
|
|
|
1353
1480
|
|
|
1354
1481
|
@staticmethod
|
|
1355
1482
|
def _extract_output_signals(text: str) -> dict[str, Any]:
|
|
1356
|
-
"""Parse stdout/stderr-like streams for success and failure signals.
|
|
1483
|
+
"""Parse stdout/stderr-like streams for success and failure signals.
|
|
1484
|
+
|
|
1485
|
+
Uses structural signal detection — NOT keyword matching on result text.
|
|
1486
|
+
|
|
1487
|
+
The key invariant for Claude stream-json:
|
|
1488
|
+
- A genuine execution always emits at least one 'assistant' event AND
|
|
1489
|
+
has total_input_tokens > 0 in the result event, because the CLI made
|
|
1490
|
+
a real API call.
|
|
1491
|
+
- A pre-call failure (API Error, Connection error, auth failure) exits
|
|
1492
|
+
immediately: no 'assistant' events, and total_input_tokens == 0 in
|
|
1493
|
+
the result event. The result text contains the CLI error message.
|
|
1494
|
+
|
|
1495
|
+
This avoids false positives from agent work output that legitimately
|
|
1496
|
+
contains words like 'rate limit', 'connection error', 'API error', etc.
|
|
1497
|
+
(e.g. implementing a rate-limiter feature, or documenting error codes).
|
|
1498
|
+
"""
|
|
1357
1499
|
has_turn_completed = False
|
|
1358
1500
|
has_turn_failed = False
|
|
1359
1501
|
has_result = False
|
|
1360
1502
|
has_meaningful_content = False
|
|
1503
|
+
has_assistant_events = False # True once any 'assistant' event is seen
|
|
1361
1504
|
error_messages: list[str] = []
|
|
1362
1505
|
json_line_count = 0
|
|
1363
1506
|
|
|
@@ -1387,18 +1530,31 @@ class ProcessManager:
|
|
|
1387
1530
|
elif isinstance(err, str):
|
|
1388
1531
|
error_messages.append(err)
|
|
1389
1532
|
elif ev_type == "result":
|
|
1533
|
+
result_text = str(data.get("result", "") or "")
|
|
1390
1534
|
if data.get("is_error"):
|
|
1391
|
-
err_text =
|
|
1535
|
+
err_text = result_text or str(data.get("error", "") or "result marked as error")
|
|
1392
1536
|
error_messages.append(err_text)
|
|
1393
1537
|
else:
|
|
1394
|
-
|
|
1395
|
-
|
|
1538
|
+
# Structural check: if no tokens were consumed AND no assistant
|
|
1539
|
+
# events appeared, the CLI never made an API call. The result
|
|
1540
|
+
# text is a CLI-level error (e.g. "API Error: Connection error.")
|
|
1541
|
+
# rather than the agent's actual work output.
|
|
1542
|
+
tok_in = int(data.get("total_input_tokens", 0) or 0)
|
|
1543
|
+
tok_out = int(data.get("total_output_tokens", 0) or 0)
|
|
1544
|
+
no_api_call = (tok_in + tok_out == 0) and not has_assistant_events
|
|
1545
|
+
if no_api_call and result_text:
|
|
1546
|
+
error_messages.append(result_text)
|
|
1547
|
+
else:
|
|
1548
|
+
has_result = True
|
|
1549
|
+
has_meaningful_content = True
|
|
1396
1550
|
elif ev_type == "error":
|
|
1397
1551
|
msg = data.get("message", "")
|
|
1398
1552
|
if msg:
|
|
1399
1553
|
error_messages.append(msg)
|
|
1554
|
+
elif ev_type == "assistant":
|
|
1555
|
+
has_meaningful_content = True
|
|
1556
|
+
has_assistant_events = True
|
|
1400
1557
|
elif ev_type in (
|
|
1401
|
-
"assistant",
|
|
1402
1558
|
"content_block_delta",
|
|
1403
1559
|
"message_delta",
|
|
1404
1560
|
"step_finish",
|
|
@@ -1557,7 +1713,10 @@ class ProcessManager:
|
|
|
1557
1713
|
start_time = time.monotonic()
|
|
1558
1714
|
|
|
1559
1715
|
if agent.agent_id == "claude-code":
|
|
1560
|
-
result = await self._run_claude(
|
|
1716
|
+
result = await self._run_claude(
|
|
1717
|
+
agent, prompt, workspace_path, timeout, task.task_id, on_chunk,
|
|
1718
|
+
node_type=task.node_type,
|
|
1719
|
+
)
|
|
1561
1720
|
elif agent.agent_id == "codex":
|
|
1562
1721
|
result = await self._run_codex(agent, prompt, workspace_path, timeout, task.task_id, on_chunk)
|
|
1563
1722
|
elif agent.agent_id == "opencode":
|
|
@@ -1778,18 +1937,37 @@ class ProcessManager:
|
|
|
1778
1937
|
|
|
1779
1938
|
async def _run_claude(
|
|
1780
1939
|
self, agent: DiscoveredAgent, prompt: str, cwd: Path, timeout: int, task_id: str,
|
|
1781
|
-
on_chunk: Any = None,
|
|
1940
|
+
on_chunk: Any = None, node_type: str = "",
|
|
1782
1941
|
) -> TaskResult:
|
|
1783
1942
|
"""Run Claude Code CLI in print mode with auto-approve permissions.
|
|
1784
1943
|
|
|
1785
1944
|
Uses stdin pipe for prompt delivery to avoid ARG_MAX limits.
|
|
1786
1945
|
Uses --dangerously-skip-permissions for autonomous file operations.
|
|
1946
|
+
Uses --max-turns to prevent context window overflow during long sessions.
|
|
1787
1947
|
"""
|
|
1948
|
+
# Determine max turns based on node type.
|
|
1949
|
+
# Analysis/coding nodes need more turns (read many files, write multiple outputs).
|
|
1950
|
+
# Review/testing nodes are typically shorter.
|
|
1951
|
+
# This prevents runaway context accumulation that leads to "Prompt is too long".
|
|
1952
|
+
max_turns_map = {
|
|
1953
|
+
"analysis": 80,
|
|
1954
|
+
"coding": 80,
|
|
1955
|
+
"design": 50,
|
|
1956
|
+
"testing": 60,
|
|
1957
|
+
"review": 40,
|
|
1958
|
+
"fix": 60,
|
|
1959
|
+
}
|
|
1960
|
+
max_turns = int(os.environ.get(
|
|
1961
|
+
"FACTORY_CLAUDE_MAX_TURNS",
|
|
1962
|
+
str(max_turns_map.get(node_type, 60)),
|
|
1963
|
+
))
|
|
1964
|
+
|
|
1788
1965
|
cmd = [
|
|
1789
1966
|
agent.command,
|
|
1790
1967
|
"-p",
|
|
1791
1968
|
"--output-format", "stream-json",
|
|
1792
1969
|
"--verbose",
|
|
1970
|
+
"--max-turns", str(max_turns),
|
|
1793
1971
|
"--dangerously-skip-permissions",
|
|
1794
1972
|
]
|
|
1795
1973
|
|
|
@@ -1825,6 +2003,24 @@ class ProcessManager:
|
|
|
1825
2003
|
metrics=metrics,
|
|
1826
2004
|
)
|
|
1827
2005
|
else:
|
|
2006
|
+
# Detect context overflow: Claude exits 1 with "Prompt is too long" in output
|
|
2007
|
+
_combined_output = stdout[-20000:] + stderr[-500:]
|
|
2008
|
+
if "Prompt is too long" in _combined_output or "prompt is too long" in _combined_output:
|
|
2009
|
+
logger.error(
|
|
2010
|
+
"Context overflow for task %s: Claude context window exceeded "
|
|
2011
|
+
"(%d stdout chars). Consider reducing prompt size.",
|
|
2012
|
+
task_id, len(stdout),
|
|
2013
|
+
)
|
|
2014
|
+
return TaskResult(
|
|
2015
|
+
status="failed",
|
|
2016
|
+
exit_code=returncode,
|
|
2017
|
+
stdout=stdout[-settings.AGENT_MAX_OUTPUT_SIZE:],
|
|
2018
|
+
stderr=stderr[-10000:],
|
|
2019
|
+
error="context_overflow: Claude's context window was exceeded during execution. "
|
|
2020
|
+
"The accumulated conversation history (initial prompt + tool call results) "
|
|
2021
|
+
"exceeded the model's limit. Reduce prompt size or shorten file reads.",
|
|
2022
|
+
metrics=metrics,
|
|
2023
|
+
)
|
|
1828
2024
|
return TaskResult(
|
|
1829
2025
|
status="failed",
|
|
1830
2026
|
exit_code=returncode,
|
|
@@ -3632,9 +3828,13 @@ class RuntimeDaemon:
|
|
|
3632
3828
|
required_files = _get_analysis_outputs_for_type(req_type)
|
|
3633
3829
|
|
|
3634
3830
|
# Analysis deliverables live in analysis_output_dir (docs/requirements/...)
|
|
3831
|
+
_input = task.input_data or {}
|
|
3635
3832
|
doc_dir = (
|
|
3636
|
-
|
|
3637
|
-
or (
|
|
3833
|
+
_input.get("analysis_output_dir")
|
|
3834
|
+
or _input.get("context", {}).get("analysis_output_dir")
|
|
3835
|
+
or _input.get("output_dir")
|
|
3836
|
+
or _input.get("context", {}).get("output_dir")
|
|
3837
|
+
or ""
|
|
3638
3838
|
)
|
|
3639
3839
|
if doc_dir:
|
|
3640
3840
|
base = workspace_path / doc_dir
|
|
@@ -3718,7 +3918,12 @@ class RuntimeDaemon:
|
|
|
3718
3918
|
pass
|
|
3719
3919
|
|
|
3720
3920
|
if not _skip_test_artifacts:
|
|
3721
|
-
|
|
3921
|
+
_input = task.input_data or {}
|
|
3922
|
+
doc_dir = (
|
|
3923
|
+
_input.get("output_dir")
|
|
3924
|
+
or _input.get("context", {}).get("output_dir")
|
|
3925
|
+
or ""
|
|
3926
|
+
)
|
|
3722
3927
|
if doc_dir:
|
|
3723
3928
|
base = workspace_path / doc_dir
|
|
3724
3929
|
else:
|
|
@@ -3809,11 +4014,25 @@ class RuntimeDaemon:
|
|
|
3809
4014
|
],
|
|
3810
4015
|
)
|
|
3811
4016
|
|
|
3812
|
-
# Build a targeted fix prompt
|
|
4017
|
+
# Build a targeted fix prompt with output directory context
|
|
4018
|
+
_input = task.input_data or {}
|
|
4019
|
+
_fix_doc_dir = (
|
|
4020
|
+
_input.get("output_dir")
|
|
4021
|
+
or _input.get("context", {}).get("output_dir")
|
|
4022
|
+
or ""
|
|
4023
|
+
)
|
|
3813
4024
|
fix_prompt = (
|
|
3814
4025
|
"The previous execution produced output with validation errors.\n"
|
|
3815
4026
|
"Please fix ALL of the following issues:\n\n"
|
|
3816
4027
|
f"{issues_text}\n\n"
|
|
4028
|
+
)
|
|
4029
|
+
if _fix_doc_dir:
|
|
4030
|
+
fix_prompt += (
|
|
4031
|
+
f"IMPORTANT: All deliverable files (test-cases.json, coverage-matrix.json, "
|
|
4032
|
+
f"test-report.md, design.md, etc.) MUST be written to the `{_fix_doc_dir}/` "
|
|
4033
|
+
f"directory, NOT the workspace root.\n\n"
|
|
4034
|
+
)
|
|
4035
|
+
fix_prompt += (
|
|
3817
4036
|
"Fix the issues in-place. Do NOT recreate files that are already correct.\n"
|
|
3818
4037
|
"Only fix the specific problems listed above."
|
|
3819
4038
|
)
|
|
@@ -4024,7 +4243,13 @@ class RuntimeDaemon:
|
|
|
4024
4243
|
# ── Pre-push rebase onto latest default branch ──
|
|
4025
4244
|
# This ensures the AI's branch incorporates the latest remote
|
|
4026
4245
|
# changes, dramatically reducing PR merge conflicts.
|
|
4027
|
-
|
|
4246
|
+
# SKIP for analysis nodes: analysis produces documentation files
|
|
4247
|
+
# (PRD.md, SDD.md, etc.) that are independent of codebase changes.
|
|
4248
|
+
# Rebasing analysis commits onto default_branch is unnecessary
|
|
4249
|
+
# overhead and rewrites commit SHAs, which complicates pushes
|
|
4250
|
+
# on subsequent iterations of the same requirement.
|
|
4251
|
+
if task.node_type != "analysis":
|
|
4252
|
+
await self._rebase_onto_latest(workspace_path, default_branch, task, project_key)
|
|
4028
4253
|
|
|
4029
4254
|
# ── Verify we're on the correct branch before pushing ──
|
|
4030
4255
|
current_branch = ""
|
|
@@ -4550,10 +4775,79 @@ class RuntimeDaemon:
|
|
|
4550
4775
|
unpushed = "first-push"
|
|
4551
4776
|
|
|
4552
4777
|
if unpushed:
|
|
4778
|
+
# Check if remote has commits not in local history (diverged).
|
|
4779
|
+
try:
|
|
4780
|
+
remote_ahead = (await git(
|
|
4781
|
+
"log", f"HEAD..origin/{branch}", "--oneline", cwd=workspace_path,
|
|
4782
|
+
)).strip()
|
|
4783
|
+
except RuntimeError:
|
|
4784
|
+
remote_ahead = "" # Remote branch doesn't exist yet
|
|
4785
|
+
|
|
4786
|
+
if remote_ahead:
|
|
4787
|
+
remote_count = len(remote_ahead.splitlines())
|
|
4788
|
+
# Divergence detected — but this is expected after rebase.
|
|
4789
|
+
# Check if the remote commits are already incorporated
|
|
4790
|
+
# (i.e., their patches are empty against our branch, meaning
|
|
4791
|
+
# the rebase already applied them with new SHAs).
|
|
4792
|
+
# `git cherry HEAD origin/branch` lists commits from
|
|
4793
|
+
# origin/branch not in HEAD; lines starting with "-" are
|
|
4794
|
+
# already incorporated (equivalent patch exists).
|
|
4795
|
+
rebase_divergence = False
|
|
4796
|
+
try:
|
|
4797
|
+
cherry_out = (await git(
|
|
4798
|
+
"cherry", "HEAD", f"origin/{branch}",
|
|
4799
|
+
cwd=workspace_path,
|
|
4800
|
+
)).strip()
|
|
4801
|
+
if cherry_out:
|
|
4802
|
+
# All lines starting with "-" means all remote
|
|
4803
|
+
# commits are already in our branch (rebased).
|
|
4804
|
+
# Lines starting with "+" are truly missing.
|
|
4805
|
+
truly_missing = [
|
|
4806
|
+
line for line in cherry_out.splitlines()
|
|
4807
|
+
if line.startswith("+ ")
|
|
4808
|
+
]
|
|
4809
|
+
rebase_divergence = len(truly_missing) == 0
|
|
4810
|
+
else:
|
|
4811
|
+
# No cherry output — remote commits are empty or
|
|
4812
|
+
# fully equivalent
|
|
4813
|
+
rebase_divergence = True
|
|
4814
|
+
except RuntimeError:
|
|
4815
|
+
pass
|
|
4816
|
+
|
|
4817
|
+
if rebase_divergence:
|
|
4818
|
+
logger.info(
|
|
4819
|
+
"Branch %s diverged from origin (%d remote commit(s)) "
|
|
4820
|
+
"but all are already incorporated (rebase). "
|
|
4821
|
+
"Using --force-with-lease to push safely.",
|
|
4822
|
+
branch, remote_count,
|
|
4823
|
+
)
|
|
4824
|
+
try:
|
|
4825
|
+
await git(
|
|
4826
|
+
"push", "--force-with-lease", "-u", "origin", branch,
|
|
4827
|
+
cwd=workspace_path, project_key=project_key,
|
|
4828
|
+
)
|
|
4829
|
+
logger.info("Force-pushed (with lease) branch %s to origin", branch)
|
|
4830
|
+
return None
|
|
4831
|
+
except RuntimeError as exc:
|
|
4832
|
+
logger.error("Force-push (with lease) failed for %s: %s", branch, exc)
|
|
4833
|
+
return f"Push failed: {exc}"
|
|
4834
|
+
else:
|
|
4835
|
+
logger.error(
|
|
4836
|
+
"SAFETY: Refusing to push %s — remote has %d commit(s) "
|
|
4837
|
+
"not in local branch. This would destroy prior work. "
|
|
4838
|
+
"Remote-only commits:\n%s",
|
|
4839
|
+
branch, remote_count, remote_ahead,
|
|
4840
|
+
)
|
|
4841
|
+
return (
|
|
4842
|
+
f"Push refused: remote branch '{branch}' has {remote_count} "
|
|
4843
|
+
f"commit(s) not in local history. Force-pushing would "
|
|
4844
|
+
f"destroy prior implementation work."
|
|
4845
|
+
)
|
|
4846
|
+
|
|
4553
4847
|
logger.info("Found unpushed commits on %s, pushing...", branch)
|
|
4554
4848
|
try:
|
|
4555
4849
|
await git(
|
|
4556
|
-
"push", "
|
|
4850
|
+
"push", "-u", "origin", branch,
|
|
4557
4851
|
cwd=workspace_path, project_key=project_key,
|
|
4558
4852
|
)
|
|
4559
4853
|
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
|