forgexa-cli 1.8.5__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.5 → forgexa_cli-1.8.7}/PKG-INFO +1 -1
- {forgexa_cli-1.8.5 → forgexa_cli-1.8.7}/forgexa_cli/__init__.py +1 -1
- {forgexa_cli-1.8.5 → forgexa_cli-1.8.7}/forgexa_cli/daemon.py +158 -31
- {forgexa_cli-1.8.5 → forgexa_cli-1.8.7}/forgexa_cli.egg-info/PKG-INFO +1 -1
- {forgexa_cli-1.8.5 → forgexa_cli-1.8.7}/pyproject.toml +1 -1
- {forgexa_cli-1.8.5 → forgexa_cli-1.8.7}/README.md +0 -0
- {forgexa_cli-1.8.5 → forgexa_cli-1.8.7}/forgexa_cli/_build_config.py +0 -0
- {forgexa_cli-1.8.5 → forgexa_cli-1.8.7}/forgexa_cli/main.py +0 -0
- {forgexa_cli-1.8.5 → forgexa_cli-1.8.7}/forgexa_cli/py.typed +0 -0
- {forgexa_cli-1.8.5 → forgexa_cli-1.8.7}/forgexa_cli.egg-info/SOURCES.txt +0 -0
- {forgexa_cli-1.8.5 → forgexa_cli-1.8.7}/forgexa_cli.egg-info/dependency_links.txt +0 -0
- {forgexa_cli-1.8.5 → forgexa_cli-1.8.7}/forgexa_cli.egg-info/entry_points.txt +0 -0
- {forgexa_cli-1.8.5 → forgexa_cli-1.8.7}/forgexa_cli.egg-info/requires.txt +0 -0
- {forgexa_cli-1.8.5 → forgexa_cli-1.8.7}/forgexa_cli.egg-info/top_level.txt +0 -0
- {forgexa_cli-1.8.5 → 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(
|
|
@@ -5939,11 +6040,16 @@ class RuntimeDaemon:
|
|
|
5939
6040
|
"fetch", "origin", branch,
|
|
5940
6041
|
cwd=workspace_path,
|
|
5941
6042
|
)
|
|
5942
|
-
# 2. Rebase local commits onto the updated remote HEAD
|
|
6043
|
+
# 2. Rebase local commits onto the updated remote HEAD.
|
|
6044
|
+
# Use -X theirs so that when the same file was modified by
|
|
6045
|
+
# a previous agent run AND the current run (e.g. analysis
|
|
6046
|
+
# docs, PRD), git automatically accepts the current run's
|
|
6047
|
+
# version. This is correct: on an agent-managed feature
|
|
6048
|
+
# branch the latest agent output is always authoritative.
|
|
5943
6049
|
await git(
|
|
5944
6050
|
"-c", "user.name=Forgexa Agent",
|
|
5945
6051
|
"-c", "user.email=agent@forgexa.net",
|
|
5946
|
-
"rebase", f"origin/{branch}",
|
|
6052
|
+
"rebase", "-X", "theirs", f"origin/{branch}",
|
|
5947
6053
|
cwd=workspace_path,
|
|
5948
6054
|
)
|
|
5949
6055
|
# 3. Push normally (no force needed — we're ahead of origin now)
|
|
@@ -5953,7 +6059,7 @@ class RuntimeDaemon:
|
|
|
5953
6059
|
)
|
|
5954
6060
|
logger.info(
|
|
5955
6061
|
"Fetch+rebase recovery succeeded for branch %s "
|
|
5956
|
-
"(%d remote commit(s) incorporated)",
|
|
6062
|
+
"(%d remote commit(s) incorporated via rebase -X theirs)",
|
|
5957
6063
|
branch, remote_count,
|
|
5958
6064
|
)
|
|
5959
6065
|
return None
|
|
@@ -5964,17 +6070,38 @@ class RuntimeDaemon:
|
|
|
5964
6070
|
await git("rebase", "--abort", cwd=workspace_path)
|
|
5965
6071
|
except RuntimeError:
|
|
5966
6072
|
pass
|
|
5967
|
-
logger.
|
|
5968
|
-
"Fetch+rebase recovery failed for branch %s: %s — "
|
|
5969
|
-
"
|
|
6073
|
+
logger.warning(
|
|
6074
|
+
"Fetch+rebase (-X theirs) recovery failed for branch %s: %s — "
|
|
6075
|
+
"falling back to --force-with-lease (we just fetched, safe)",
|
|
5970
6076
|
branch, rebase_exc_str,
|
|
5971
6077
|
)
|
|
5972
|
-
|
|
5973
|
-
|
|
5974
|
-
|
|
5975
|
-
|
|
5976
|
-
|
|
5977
|
-
|
|
6078
|
+
# Fallback: --force-with-lease is safe here because we just ran
|
|
6079
|
+
# `git fetch origin {branch}`, so our lease ref is current.
|
|
6080
|
+
# Agent-managed feature branches have no concurrent human writers,
|
|
6081
|
+
# so the latest agent run always wins.
|
|
6082
|
+
try:
|
|
6083
|
+
await git(
|
|
6084
|
+
"push", "--force-with-lease", "-u", "origin", branch,
|
|
6085
|
+
cwd=workspace_path, project_key=project_key,
|
|
6086
|
+
)
|
|
6087
|
+
logger.info(
|
|
6088
|
+
"Force-with-lease fallback push succeeded for branch %s",
|
|
6089
|
+
branch,
|
|
6090
|
+
)
|
|
6091
|
+
return None
|
|
6092
|
+
except RuntimeError as force_exc:
|
|
6093
|
+
force_exc_str = str(force_exc)
|
|
6094
|
+
logger.error(
|
|
6095
|
+
"Force-with-lease fallback also failed for branch %s: %s",
|
|
6096
|
+
branch, force_exc_str,
|
|
6097
|
+
)
|
|
6098
|
+
return (
|
|
6099
|
+
f"Push refused: remote branch '{branch}' has {remote_count} "
|
|
6100
|
+
f"commit(s) not in local history, rebase (-X theirs) failed, "
|
|
6101
|
+
f"and force-with-lease fallback also failed. "
|
|
6102
|
+
f"Rebase error: {rebase_exc_str[:200]} | "
|
|
6103
|
+
f"Force error: {force_exc_str[:200]}"
|
|
6104
|
+
)
|
|
5978
6105
|
|
|
5979
6106
|
logger.info("Found unpushed commits on %s, pushing...", branch)
|
|
5980
6107
|
last_push_exc: Exception | None = None
|
|
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
|