forgexa-cli 1.27.2__tar.gz → 1.27.4__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.27.2 → forgexa_cli-1.27.4}/PKG-INFO +1 -1
- {forgexa_cli-1.27.2 → forgexa_cli-1.27.4}/forgexa_cli/__init__.py +1 -1
- {forgexa_cli-1.27.2 → forgexa_cli-1.27.4}/forgexa_cli/agent_core.py +6 -0
- {forgexa_cli-1.27.2 → forgexa_cli-1.27.4}/forgexa_cli/daemon.py +70 -17
- {forgexa_cli-1.27.2 → forgexa_cli-1.27.4}/forgexa_cli/main.py +13 -0
- {forgexa_cli-1.27.2 → forgexa_cli-1.27.4}/forgexa_cli.egg-info/PKG-INFO +1 -1
- {forgexa_cli-1.27.2 → forgexa_cli-1.27.4}/pyproject.toml +1 -1
- {forgexa_cli-1.27.2 → forgexa_cli-1.27.4}/tests/test_local_bind_commands.py +38 -0
- {forgexa_cli-1.27.2 → forgexa_cli-1.27.4}/README.md +0 -0
- {forgexa_cli-1.27.2 → forgexa_cli-1.27.4}/forgexa_cli/_build_config.py +0 -0
- {forgexa_cli-1.27.2 → forgexa_cli-1.27.4}/forgexa_cli/_local_bind.py +0 -0
- {forgexa_cli-1.27.2 → forgexa_cli-1.27.4}/forgexa_cli/autoupgrade.py +0 -0
- {forgexa_cli-1.27.2 → forgexa_cli-1.27.4}/forgexa_cli/py.typed +0 -0
- {forgexa_cli-1.27.2 → forgexa_cli-1.27.4}/forgexa_cli.egg-info/SOURCES.txt +0 -0
- {forgexa_cli-1.27.2 → forgexa_cli-1.27.4}/forgexa_cli.egg-info/dependency_links.txt +0 -0
- {forgexa_cli-1.27.2 → forgexa_cli-1.27.4}/forgexa_cli.egg-info/entry_points.txt +0 -0
- {forgexa_cli-1.27.2 → forgexa_cli-1.27.4}/forgexa_cli.egg-info/requires.txt +0 -0
- {forgexa_cli-1.27.2 → forgexa_cli-1.27.4}/forgexa_cli.egg-info/top_level.txt +0 -0
- {forgexa_cli-1.27.2 → forgexa_cli-1.27.4}/setup.cfg +0 -0
- {forgexa_cli-1.27.2 → forgexa_cli-1.27.4}/tests/test_auth_and_runtime_commands.py +0 -0
- {forgexa_cli-1.27.2 → forgexa_cli-1.27.4}/tests/test_autoupgrade.py +0 -0
- {forgexa_cli-1.27.2 → forgexa_cli-1.27.4}/tests/test_check_command.py +0 -0
- {forgexa_cli-1.27.2 → forgexa_cli-1.27.4}/tests/test_expiry_warnings_and_revoke.py +0 -0
- {forgexa_cli-1.27.2 → forgexa_cli-1.27.4}/tests/test_runtime_credentials.py +0 -0
- {forgexa_cli-1.27.2 → forgexa_cli-1.27.4}/tests/test_session_credentials.py +0 -0
- {forgexa_cli-1.27.2 → forgexa_cli-1.27.4}/tests/test_silent_install.py +0 -0
- {forgexa_cli-1.27.2 → forgexa_cli-1.27.4}/tests/test_upgrade_observability.py +0 -0
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
"""forgexa-cli — Forgexa command-line client."""
|
|
2
|
-
__version__ = "1.27.
|
|
2
|
+
__version__ = "1.27.4"
|
|
@@ -802,6 +802,12 @@ def _extract_output_signals(text: str) -> dict[str, Any]:
|
|
|
802
802
|
error_messages.append(err)
|
|
803
803
|
elif isinstance(data.get("message"), str) and data.get("message", "").strip():
|
|
804
804
|
error_messages.append(data["message"])
|
|
805
|
+
elif ev_type == "session.mcp_server_status_changed":
|
|
806
|
+
status_data = data.get("data") or {}
|
|
807
|
+
if isinstance(status_data, dict) and status_data.get("status") == "failed":
|
|
808
|
+
message = status_data.get("error") or status_data.get("message") or ""
|
|
809
|
+
if message:
|
|
810
|
+
error_messages.append(str(message))
|
|
805
811
|
elif ev_type == "error":
|
|
806
812
|
msg = data.get("message", "")
|
|
807
813
|
if msg:
|
|
@@ -909,7 +909,7 @@ except (ImportError, ModuleNotFoundError):
|
|
|
909
909
|
# DAEMON_VERSION is the protocol/logic version of the daemon code.
|
|
910
910
|
# Kept in sync with pyproject.toml version via bump-version.sh.
|
|
911
911
|
# CLIENT_TYPE identifies which packaging/distribution this daemon runs in.
|
|
912
|
-
DAEMON_VERSION = "1.27.
|
|
912
|
+
DAEMON_VERSION = "1.27.4"
|
|
913
913
|
|
|
914
914
|
|
|
915
915
|
def _detect_client_type() -> str:
|
|
@@ -1925,13 +1925,14 @@ async def _validate_local_workspace_binding(binding: dict[str, Any], runtime_id:
|
|
|
1925
1925
|
entry = _load_local_workspaces_mapping().get(binding_id)
|
|
1926
1926
|
if not binding_id or not isinstance(entry, dict):
|
|
1927
1927
|
return "mapping_missing"
|
|
1928
|
-
if (
|
|
1929
|
-
|
|
1930
|
-
|
|
1931
|
-
|
|
1932
|
-
|
|
1933
|
-
|
|
1934
|
-
|
|
1928
|
+
if str(entry.get("project_id") or "") != str(binding.get("project_id") or ""):
|
|
1929
|
+
return "mapping_project_mismatch"
|
|
1930
|
+
if str(entry.get("user_id") or "") != str(binding.get("user_id") or ""):
|
|
1931
|
+
return "mapping_user_mismatch"
|
|
1932
|
+
if str(entry.get("runtime_id") or "") != str(runtime_id):
|
|
1933
|
+
return "mapping_runtime_mismatch"
|
|
1934
|
+
if str(entry.get("repo_identity") or "") != str(binding.get("repo_identity") or ""):
|
|
1935
|
+
return "mapping_repository_mismatch"
|
|
1935
1936
|
|
|
1936
1937
|
local_path = Path(str(entry.get("local_path") or "")).expanduser()
|
|
1937
1938
|
try:
|
|
@@ -2267,6 +2268,21 @@ class WorkspaceManager:
|
|
|
2267
2268
|
)
|
|
2268
2269
|
return any(marker in err for marker in retry_markers)
|
|
2269
2270
|
|
|
2271
|
+
@staticmethod
|
|
2272
|
+
def _is_unreadable_git_object_error(exc: Exception | str) -> bool:
|
|
2273
|
+
detail = str(exc).lower()
|
|
2274
|
+
return any(marker in detail for marker in (
|
|
2275
|
+
"unable to read tree",
|
|
2276
|
+
"could not read tree",
|
|
2277
|
+
"cannot read tree",
|
|
2278
|
+
"bad tree object",
|
|
2279
|
+
"missing tree object",
|
|
2280
|
+
"bad object",
|
|
2281
|
+
"unknown object",
|
|
2282
|
+
"not a valid object",
|
|
2283
|
+
"无法读取树",
|
|
2284
|
+
))
|
|
2285
|
+
|
|
2270
2286
|
@staticmethod
|
|
2271
2287
|
def _safe_rmtree(path: Path) -> None:
|
|
2272
2288
|
"""Remove a directory tree, handling two distinct Windows failure modes.
|
|
@@ -3433,6 +3449,7 @@ class WorkspaceManager:
|
|
|
3433
3449
|
workspace_key: str, branch_name: str, *, fresh_start: bool = False,
|
|
3434
3450
|
project_key: str = "default", expect_branch: bool = False,
|
|
3435
3451
|
detach_head: bool = False,
|
|
3452
|
+
_recovered_unreadable_object: bool = False,
|
|
3436
3453
|
) -> Path:
|
|
3437
3454
|
main_repo = project_dir / "_main"
|
|
3438
3455
|
ws_path = project_dir / workspace_key
|
|
@@ -4211,6 +4228,36 @@ class WorkspaceManager:
|
|
|
4211
4228
|
return ws_path
|
|
4212
4229
|
except Exception as recovery_exc:
|
|
4213
4230
|
exc = recovery_exc
|
|
4231
|
+
if (
|
|
4232
|
+
not _recovered_unreadable_object
|
|
4233
|
+
and self._is_unreadable_git_object_error(exc)
|
|
4234
|
+
):
|
|
4235
|
+
logger.warning(
|
|
4236
|
+
"Fresh start could not read a Git object from %s; rebuilding _main cache",
|
|
4237
|
+
main_repo,
|
|
4238
|
+
)
|
|
4239
|
+
self._safe_rmtree(ws_path)
|
|
4240
|
+
await self._safe_rmtree_main(main_repo)
|
|
4241
|
+
if main_repo.exists():
|
|
4242
|
+
raise RuntimeError(
|
|
4243
|
+
f"Cannot rebuild local Git cache at {main_repo}; "
|
|
4244
|
+
"please stop the daemon and remove it manually."
|
|
4245
|
+
) from exc
|
|
4246
|
+
try:
|
|
4247
|
+
return await self._create_worktree_impl(
|
|
4248
|
+
project_dir, repo_url, default_branch,
|
|
4249
|
+
workspace_key, branch_name,
|
|
4250
|
+
fresh_start=fresh_start,
|
|
4251
|
+
project_key=project_key,
|
|
4252
|
+
expect_branch=expect_branch,
|
|
4253
|
+
detach_head=detach_head,
|
|
4254
|
+
_recovered_unreadable_object=True,
|
|
4255
|
+
)
|
|
4256
|
+
except Exception as recovery_exc:
|
|
4257
|
+
raise RuntimeError(
|
|
4258
|
+
"Failed to create fresh worktree after rebuilding a local Git cache "
|
|
4259
|
+
"with unreadable objects."
|
|
4260
|
+
) from recovery_exc
|
|
4214
4261
|
self._safe_rmtree(ws_path)
|
|
4215
4262
|
raise RuntimeError(
|
|
4216
4263
|
f"Failed to create clean fresh worktree for branch '{branch_name}'. "
|
|
@@ -5852,7 +5899,7 @@ class ProcessManager:
|
|
|
5852
5899
|
break
|
|
5853
5900
|
effective_rc = copilot_exit if copilot_exit is not None else returncode
|
|
5854
5901
|
|
|
5855
|
-
if
|
|
5902
|
+
if copilot_exit == 0 and returncode == 0:
|
|
5856
5903
|
return TaskResult(
|
|
5857
5904
|
status="success",
|
|
5858
5905
|
exit_code=0,
|
|
@@ -5860,14 +5907,15 @@ class ProcessManager:
|
|
|
5860
5907
|
stderr=stderr[-10000:],
|
|
5861
5908
|
metrics=metrics,
|
|
5862
5909
|
)
|
|
5863
|
-
elif
|
|
5864
|
-
|
|
5865
|
-
|
|
5866
|
-
|
|
5867
|
-
|
|
5868
|
-
|
|
5869
|
-
|
|
5870
|
-
|
|
5910
|
+
elif not copilot_specific_error and copilot_exit is None and completed_without_result:
|
|
5911
|
+
if returncode != 0:
|
|
5912
|
+
logger.warning(
|
|
5913
|
+
"Copilot exited with return code %s for task %s despite a completed assistant turn and no result event; recovering to validation path",
|
|
5914
|
+
returncode,
|
|
5915
|
+
task_id,
|
|
5916
|
+
)
|
|
5917
|
+
metrics["recovered_missing_result_event"] = True
|
|
5918
|
+
metrics["recovered_process_returncode"] = returncode
|
|
5871
5919
|
return TaskResult(
|
|
5872
5920
|
status="success",
|
|
5873
5921
|
exit_code=0,
|
|
@@ -5932,6 +5980,11 @@ class ProcessManager:
|
|
|
5932
5980
|
)
|
|
5933
5981
|
if copilot_specific_error:
|
|
5934
5982
|
failure_error = copilot_specific_error[:1500]
|
|
5983
|
+
elif copilot_exit is None and returncode == 0:
|
|
5984
|
+
stderr_detail = stderr[-500:].strip()
|
|
5985
|
+
failure_error = "Copilot exited without a successful result event"
|
|
5986
|
+
if stderr_detail:
|
|
5987
|
+
failure_error = f"{failure_error}: {stderr_detail}"
|
|
5935
5988
|
elif stderr.strip():
|
|
5936
5989
|
failure_error = f"Copilot exited with code {effective_rc}: {stderr[-500:].strip()}"
|
|
5937
5990
|
else:
|
|
@@ -2857,6 +2857,19 @@ def cmd_project_bind_local(args: argparse.Namespace) -> None:
|
|
|
2857
2857
|
if not binding_id:
|
|
2858
2858
|
print("Error: failed to create local workspace binding", file=sys.stderr)
|
|
2859
2859
|
sys.exit(1)
|
|
2860
|
+
expected_identity = binding.get("repo_identity")
|
|
2861
|
+
if isinstance(expected_identity, str) and expected_identity != identity:
|
|
2862
|
+
try:
|
|
2863
|
+
_delete(f"/projects/{args.project}/local-workspace-bindings/{binding_id}")
|
|
2864
|
+
except Exception:
|
|
2865
|
+
pass
|
|
2866
|
+
print(
|
|
2867
|
+
"Error: this folder's Git remote does not match the selected Forgexa project's repository "
|
|
2868
|
+
f"(project: {expected_identity}, this folder: {identity}). "
|
|
2869
|
+
"Open the matching folder or update the project's repository URL.",
|
|
2870
|
+
file=sys.stderr,
|
|
2871
|
+
)
|
|
2872
|
+
sys.exit(1)
|
|
2860
2873
|
|
|
2861
2874
|
mapping = load_local_workspaces_mapping()
|
|
2862
2875
|
local_path_str = str(local_path)
|
|
@@ -152,6 +152,44 @@ def test_bind_local_rejects_unrecognized_remote_url(tmp_path: Path, monkeypatch:
|
|
|
152
152
|
main.cmd_project_bind_local(args)
|
|
153
153
|
|
|
154
154
|
|
|
155
|
+
def test_bind_local_rejects_project_repository_mismatch_before_writing_mapping(
|
|
156
|
+
tmp_path: Path, monkeypatch: pytest.MonkeyPatch,
|
|
157
|
+
) -> None:
|
|
158
|
+
monkeypatch.setenv("HOME", str(tmp_path))
|
|
159
|
+
repo = tmp_path / "other-project"
|
|
160
|
+
_init_git_repo_with_remote(repo, "https://github.com/acme/other.git")
|
|
161
|
+
|
|
162
|
+
monkeypatch.setattr(main, "_get_runtimes", lambda: [
|
|
163
|
+
{"id": "runtime-1", "pool_type": "personal", "daemon_id": "d1", "device_name": "laptop"},
|
|
164
|
+
])
|
|
165
|
+
monkeypatch.setattr(main, "_get", lambda path: {"id": "user-1"} if path == "/auth/me" else None)
|
|
166
|
+
|
|
167
|
+
posts: list[tuple[str, dict]] = []
|
|
168
|
+
deletes: list[str] = []
|
|
169
|
+
|
|
170
|
+
def fake_post(path: str, data=None):
|
|
171
|
+
posts.append((path, data or {}))
|
|
172
|
+
assert path == "/projects/project-1/local-workspace-bindings"
|
|
173
|
+
return {
|
|
174
|
+
"id": "binding-1",
|
|
175
|
+
"status": "pending",
|
|
176
|
+
"repo_identity": "github.com/acme/widgets",
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
monkeypatch.setattr(main, "_post", fake_post)
|
|
180
|
+
monkeypatch.setattr(main, "_delete", lambda path: deletes.append(path))
|
|
181
|
+
|
|
182
|
+
args = SimpleNamespace(project="project-1", path=str(repo), runtime=None)
|
|
183
|
+
with pytest.raises(SystemExit):
|
|
184
|
+
main.cmd_project_bind_local(args)
|
|
185
|
+
|
|
186
|
+
assert posts == [
|
|
187
|
+
("/projects/project-1/local-workspace-bindings", {"runtime_id": "runtime-1"}),
|
|
188
|
+
]
|
|
189
|
+
assert deletes == ["/projects/project-1/local-workspace-bindings/binding-1"]
|
|
190
|
+
assert not (tmp_path / ".forgexa" / "local-workspaces.json").exists()
|
|
191
|
+
|
|
192
|
+
|
|
155
193
|
def test_bind_local_requires_disambiguation_with_multiple_personal_runtimes(
|
|
156
194
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch,
|
|
157
195
|
) -> 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
|
|
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
|