forgexa-cli 1.8.6__tar.gz → 1.8.7__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.8.6 → forgexa_cli-1.8.7}/PKG-INFO +1 -1
- {forgexa_cli-1.8.6 → forgexa_cli-1.8.7}/forgexa_cli/__init__.py +1 -1
- {forgexa_cli-1.8.6 → forgexa_cli-1.8.7}/forgexa_cli/daemon.py +120 -19
- {forgexa_cli-1.8.6 → forgexa_cli-1.8.7}/forgexa_cli.egg-info/PKG-INFO +1 -1
- {forgexa_cli-1.8.6 → forgexa_cli-1.8.7}/pyproject.toml +1 -1
- {forgexa_cli-1.8.6 → forgexa_cli-1.8.7}/README.md +0 -0
- {forgexa_cli-1.8.6 → forgexa_cli-1.8.7}/forgexa_cli/_build_config.py +0 -0
- {forgexa_cli-1.8.6 → forgexa_cli-1.8.7}/forgexa_cli/main.py +0 -0
- {forgexa_cli-1.8.6 → forgexa_cli-1.8.7}/forgexa_cli/py.typed +0 -0
- {forgexa_cli-1.8.6 → forgexa_cli-1.8.7}/forgexa_cli.egg-info/SOURCES.txt +0 -0
- {forgexa_cli-1.8.6 → forgexa_cli-1.8.7}/forgexa_cli.egg-info/dependency_links.txt +0 -0
- {forgexa_cli-1.8.6 → forgexa_cli-1.8.7}/forgexa_cli.egg-info/entry_points.txt +0 -0
- {forgexa_cli-1.8.6 → forgexa_cli-1.8.7}/forgexa_cli.egg-info/requires.txt +0 -0
- {forgexa_cli-1.8.6 → forgexa_cli-1.8.7}/forgexa_cli.egg-info/top_level.txt +0 -0
- {forgexa_cli-1.8.6 → forgexa_cli-1.8.7}/setup.cfg +0 -0
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
"""forgexa-cli — Forgexa command-line client."""
|
|
2
|
-
__version__ = "1.8.
|
|
2
|
+
__version__ = "1.8.7"
|
|
@@ -392,7 +392,7 @@ except (ImportError, ModuleNotFoundError):
|
|
|
392
392
|
# DAEMON_VERSION is the protocol/logic version of the daemon code.
|
|
393
393
|
# Kept in sync with pyproject.toml version via bump-version.sh.
|
|
394
394
|
# CLIENT_TYPE identifies which packaging/distribution this daemon runs in.
|
|
395
|
-
DAEMON_VERSION = "1.8.
|
|
395
|
+
DAEMON_VERSION = "1.8.7"
|
|
396
396
|
|
|
397
397
|
|
|
398
398
|
def _detect_client_type() -> str:
|
|
@@ -648,6 +648,10 @@ _ANALYSIS_OUTPUTS_BY_TYPE: dict[str, list[str]] = {
|
|
|
648
648
|
"documentation": ["outline.md", "analysis.json"],
|
|
649
649
|
"improvement": ["improvement-spec.md", "TASKS.md", "analysis.json", "test-intent.json"],
|
|
650
650
|
"task": ["task-plan.md", "analysis.json"],
|
|
651
|
+
# Research / feasibility study — no PRD/SDD/TASKS, only a research plan and metadata
|
|
652
|
+
"spike": ["research.md", "analysis.json"],
|
|
653
|
+
# Customer support Q&A — lightweight answer doc + metadata only
|
|
654
|
+
"faq": ["faq-answer.md", "analysis.json"],
|
|
651
655
|
}
|
|
652
656
|
|
|
653
657
|
|
|
@@ -1123,17 +1127,34 @@ class WorkspaceManager:
|
|
|
1123
1127
|
)
|
|
1124
1128
|
|
|
1125
1129
|
if repo_url:
|
|
1130
|
+
# For non-fresh (refine/continuation) nodes, expand expect_branch to
|
|
1131
|
+
# cover any node that is part of a real requirement workflow AND is not
|
|
1132
|
+
# the initial analysis. This ensures a hard error (and workspace
|
|
1133
|
+
# re-clone) when the branch sync fails, rather than silently proceeding
|
|
1134
|
+
# with a stale workspace that will cause a non-fast-forward push later.
|
|
1135
|
+
expect_branch = bool(task.analysis_branch) or (
|
|
1136
|
+
bool(task.requirement_key) and not is_fresh_start and task.node_type != "analysis"
|
|
1137
|
+
)
|
|
1126
1138
|
ws_path = await self._create_worktree(
|
|
1127
1139
|
project_dir, repo_url, default_branch, workspace_key, branch_name,
|
|
1128
1140
|
fresh_start=is_fresh_start,
|
|
1129
1141
|
project_key=project_key,
|
|
1130
|
-
expect_branch=
|
|
1142
|
+
expect_branch=expect_branch,
|
|
1131
1143
|
)
|
|
1132
|
-
#
|
|
1133
|
-
#
|
|
1134
|
-
|
|
1144
|
+
# After workspace creation, perform a final branch-specific fetch + reset
|
|
1145
|
+
# to ensure the working tree is at the absolute latest remote state.
|
|
1146
|
+
# This is critical in two scenarios:
|
|
1147
|
+
# 1. Analysis refine mode: must be on the analysis branch history.
|
|
1148
|
+
# 2. All continuation nodes (design/coding/testing): another runtime
|
|
1149
|
+
# may have pushed commits while this runtime's agent was executing.
|
|
1150
|
+
# A final sync here keeps the workspace current so the agent works
|
|
1151
|
+
# on the latest codebase and avoids non-fast-forward push failures.
|
|
1152
|
+
if not is_fresh_start:
|
|
1135
1153
|
try:
|
|
1136
|
-
await self._git(
|
|
1154
|
+
await self._git(
|
|
1155
|
+
"fetch", "origin", branch_name,
|
|
1156
|
+
cwd=ws_path, project_key=project_key,
|
|
1157
|
+
)
|
|
1137
1158
|
except RuntimeError:
|
|
1138
1159
|
pass
|
|
1139
1160
|
try:
|
|
@@ -1141,15 +1162,28 @@ class WorkspaceManager:
|
|
|
1141
1162
|
await self._git("checkout", branch_name, cwd=ws_path)
|
|
1142
1163
|
except RuntimeError:
|
|
1143
1164
|
pass
|
|
1144
|
-
#
|
|
1165
|
+
# Use --ff-only to keep only fast-forward changes; if the branch has
|
|
1166
|
+
# diverged (force-pushed by prior phase), reset --hard is used below.
|
|
1167
|
+
pulled = False
|
|
1145
1168
|
try:
|
|
1146
1169
|
await self._git(
|
|
1147
1170
|
"pull", "--ff-only", "origin", branch_name,
|
|
1148
1171
|
cwd=ws_path, project_key=project_key,
|
|
1149
1172
|
)
|
|
1173
|
+
pulled = True
|
|
1150
1174
|
except RuntimeError:
|
|
1151
|
-
# Remote branch might not exist yet or has diverged; that's OK
|
|
1152
1175
|
pass
|
|
1176
|
+
if not pulled:
|
|
1177
|
+
# ff-only failed (diverged or remote not yet created) — try
|
|
1178
|
+
# reset --hard to force-sync to whatever the remote has.
|
|
1179
|
+
try:
|
|
1180
|
+
await self._git(
|
|
1181
|
+
"reset", "--hard", f"origin/{branch_name}",
|
|
1182
|
+
cwd=ws_path,
|
|
1183
|
+
)
|
|
1184
|
+
except RuntimeError:
|
|
1185
|
+
# Remote branch may not exist yet (first analysis on fresh repo)
|
|
1186
|
+
pass
|
|
1153
1187
|
return ws_path
|
|
1154
1188
|
else:
|
|
1155
1189
|
# No repo — create a directory with git init for change tracking
|
|
@@ -1292,18 +1326,29 @@ class WorkspaceManager:
|
|
|
1292
1326
|
# only fetches the default branch. Explicitly fetch the
|
|
1293
1327
|
# feature branch with a full refspec so that
|
|
1294
1328
|
# origin/{branch_name} is available for checkout/reset.
|
|
1329
|
+
_last_sync_err: str = ""
|
|
1295
1330
|
try:
|
|
1296
1331
|
await self._git("fetch", "origin", cwd=ws_path, project_key=project_key)
|
|
1297
|
-
except RuntimeError:
|
|
1298
|
-
|
|
1332
|
+
except RuntimeError as _pre_fe:
|
|
1333
|
+
logger.warning(
|
|
1334
|
+
"fetch origin failed for worktree %s: %s "
|
|
1335
|
+
"(likely auth/SSH issue — will retry in sync loop)",
|
|
1336
|
+
ws_path, _pre_fe,
|
|
1337
|
+
)
|
|
1338
|
+
_last_sync_err = str(_pre_fe)[:300]
|
|
1299
1339
|
try:
|
|
1300
1340
|
await self._git(
|
|
1301
1341
|
"fetch", "origin",
|
|
1302
1342
|
f"{branch_name}:refs/remotes/origin/{branch_name}",
|
|
1303
1343
|
cwd=ws_path, project_key=project_key,
|
|
1304
1344
|
)
|
|
1305
|
-
except RuntimeError:
|
|
1306
|
-
|
|
1345
|
+
except RuntimeError as _pre_fe2:
|
|
1346
|
+
logger.warning(
|
|
1347
|
+
"fetch branch %s failed for worktree %s: %s "
|
|
1348
|
+
"(likely auth/SSH issue — will retry in sync loop)",
|
|
1349
|
+
branch_name, ws_path, _pre_fe2,
|
|
1350
|
+
)
|
|
1351
|
+
_last_sync_err = str(_pre_fe2)[:300]
|
|
1307
1352
|
|
|
1308
1353
|
if fresh_start:
|
|
1309
1354
|
# Safety check: if the branch already exists on remote with
|
|
@@ -1392,8 +1437,12 @@ class WorkspaceManager:
|
|
|
1392
1437
|
cwd=ws_path,
|
|
1393
1438
|
project_key=project_key,
|
|
1394
1439
|
)
|
|
1395
|
-
except RuntimeError:
|
|
1396
|
-
|
|
1440
|
+
except RuntimeError as _sf:
|
|
1441
|
+
logger.warning(
|
|
1442
|
+
"Re-fetch %s failed (attempt %d): %s",
|
|
1443
|
+
branch_name, _sync_attempt + 1, _sf,
|
|
1444
|
+
)
|
|
1445
|
+
_last_sync_err = str(_sf)[:300]
|
|
1397
1446
|
continue
|
|
1398
1447
|
else:
|
|
1399
1448
|
logger.warning("Failed to checkout %s after retries: %s", branch_name, exc)
|
|
@@ -1419,8 +1468,12 @@ class WorkspaceManager:
|
|
|
1419
1468
|
cwd=ws_path,
|
|
1420
1469
|
project_key=project_key,
|
|
1421
1470
|
)
|
|
1422
|
-
except RuntimeError:
|
|
1423
|
-
|
|
1471
|
+
except RuntimeError as _sf2:
|
|
1472
|
+
logger.warning(
|
|
1473
|
+
"Re-fetch %s failed (attempt %d): %s",
|
|
1474
|
+
branch_name, _sync_attempt + 1, _sf2,
|
|
1475
|
+
)
|
|
1476
|
+
_last_sync_err = str(_sf2)[:300]
|
|
1424
1477
|
else:
|
|
1425
1478
|
logger.warning(
|
|
1426
1479
|
"Could not reset to origin/%s after retries: %s — "
|
|
@@ -1448,10 +1501,28 @@ class WorkspaceManager:
|
|
|
1448
1501
|
f"Stale local clone discarded. "
|
|
1449
1502
|
f"The task will be retried with a fresh clone."
|
|
1450
1503
|
)
|
|
1504
|
+
# Destroy the stale worktree before raising so the
|
|
1505
|
+
# next retry can re-create it fresh from origin.
|
|
1506
|
+
# Without this, every retry hits the same broken state.
|
|
1507
|
+
try:
|
|
1508
|
+
await self._remove_broken_worktree(
|
|
1509
|
+
main_repo, ws_path, workspace_key
|
|
1510
|
+
)
|
|
1511
|
+
logger.info(
|
|
1512
|
+
"Removed stale worktree %s — retry will re-clone from origin",
|
|
1513
|
+
ws_path,
|
|
1514
|
+
)
|
|
1515
|
+
except Exception as _rm_exc:
|
|
1516
|
+
logger.warning("Could not remove stale worktree %s: %s", ws_path, _rm_exc)
|
|
1517
|
+
_err_detail = (
|
|
1518
|
+
f"Git error: {_last_sync_err}" if _last_sync_err
|
|
1519
|
+
else "fetch timed out or credentials missing/invalid"
|
|
1520
|
+
)
|
|
1451
1521
|
raise RuntimeError(
|
|
1452
|
-
f"Failed to sync branch '{branch_name}' from remote after 3 attempts
|
|
1522
|
+
f"Failed to sync branch '{branch_name}' from remote after 3 attempts "
|
|
1523
|
+
f"({_err_detail}). "
|
|
1453
1524
|
f"The branch should exist (pushed by prior analysis/design phase). "
|
|
1454
|
-
f"
|
|
1525
|
+
f"Stale local workspace discarded — this task will be retried by the orchestrator."
|
|
1455
1526
|
)
|
|
1456
1527
|
else:
|
|
1457
1528
|
logger.warning(
|
|
@@ -1680,12 +1751,23 @@ class WorkspaceManager:
|
|
|
1680
1751
|
# interprets backslashes as escape sequences, corrupting the
|
|
1681
1752
|
# path (e.g. C:\Users → C:Users).
|
|
1682
1753
|
key_path_safe = key_path.replace("\\", "/") if sys.platform == "win32" else key_path
|
|
1754
|
+
# RC1 (Windows): os.chmod(S_IRUSR) does not set proper NTFS ACLs.
|
|
1755
|
+
# Windows OpenSSH rejects keys that aren't exclusively owner-readable
|
|
1756
|
+
# ("UNPROTECTED PRIVATE KEY FILE"). StrictModes=no bypasses this.
|
|
1757
|
+
# RC2 (Windows): /dev/null doesn't exist on Windows native OpenSSH
|
|
1758
|
+
# (C:\Windows\System32\OpenSSH\ssh.exe). Use NUL instead.
|
|
1759
|
+
if sys.platform == "win32":
|
|
1760
|
+
_known_hosts_null = "NUL"
|
|
1761
|
+
_strict_modes_opt = " -o StrictModes=no"
|
|
1762
|
+
else:
|
|
1763
|
+
_known_hosts_null = "/dev/null"
|
|
1764
|
+
_strict_modes_opt = ""
|
|
1683
1765
|
env = {
|
|
1684
1766
|
**os.environ,
|
|
1685
1767
|
"GIT_SSH_COMMAND": (
|
|
1686
1768
|
f'ssh -i "{key_path_safe}"'
|
|
1687
1769
|
f" -o StrictHostKeyChecking=accept-new"
|
|
1688
|
-
f" -o UserKnownHostsFile
|
|
1770
|
+
f" -o UserKnownHostsFile={_known_hosts_null}"
|
|
1689
1771
|
f" -o IdentitiesOnly=yes"
|
|
1690
1772
|
# Detect a stalled TCP connection (server accepts but
|
|
1691
1773
|
# never sends the git protocol banner). After 30 s of
|
|
@@ -1695,6 +1777,7 @@ class WorkspaceManager:
|
|
|
1695
1777
|
f" -o ConnectTimeout=30"
|
|
1696
1778
|
f" -o ServerAliveInterval=30"
|
|
1697
1779
|
f" -o ServerAliveCountMax=3"
|
|
1780
|
+
f"{_strict_modes_opt}"
|
|
1698
1781
|
),
|
|
1699
1782
|
}
|
|
1700
1783
|
except Exception:
|
|
@@ -5784,6 +5867,24 @@ class RuntimeDaemon:
|
|
|
5784
5867
|
branch = (await git("rev-parse", "--abbrev-ref", "HEAD", cwd=workspace_path)).strip()
|
|
5785
5868
|
|
|
5786
5869
|
if branch and branch != "HEAD":
|
|
5870
|
+
# Always refresh the remote tracking ref before any divergence
|
|
5871
|
+
# checks. Without this, origin/{branch} may be stale if another
|
|
5872
|
+
# runtime pushed commits while our agent was executing, causing
|
|
5873
|
+
# the remote_ahead check to return empty and the naive push to
|
|
5874
|
+
# fail with "non-fast-forward". This is the single most reliable
|
|
5875
|
+
# guard for cross-runtime / cross-machine collaboration scenarios.
|
|
5876
|
+
try:
|
|
5877
|
+
await git(
|
|
5878
|
+
"fetch", "origin", branch,
|
|
5879
|
+
cwd=workspace_path, project_key=project_key,
|
|
5880
|
+
)
|
|
5881
|
+
except RuntimeError as _pre_push_fetch_exc:
|
|
5882
|
+
logger.warning(
|
|
5883
|
+
"Pre-push fetch of branch '%s' failed: %s — "
|
|
5884
|
+
"divergence check will use possibly stale tracking ref",
|
|
5885
|
+
branch, _pre_push_fetch_exc,
|
|
5886
|
+
)
|
|
5887
|
+
|
|
5787
5888
|
# Check if there are unpushed commits
|
|
5788
5889
|
try:
|
|
5789
5890
|
unpushed = (await git(
|
|
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
|