forgexa-cli 1.21.7__tar.gz → 1.22.0__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.21.7 → forgexa_cli-1.22.0}/PKG-INFO +21 -1
- {forgexa_cli-1.21.7 → forgexa_cli-1.22.0}/README.md +20 -0
- {forgexa_cli-1.21.7 → forgexa_cli-1.22.0}/forgexa_cli/__init__.py +1 -1
- {forgexa_cli-1.21.7 → forgexa_cli-1.22.0}/forgexa_cli/daemon.py +212 -7
- {forgexa_cli-1.21.7 → forgexa_cli-1.22.0}/forgexa_cli/main.py +33 -0
- {forgexa_cli-1.21.7 → forgexa_cli-1.22.0}/forgexa_cli.egg-info/PKG-INFO +21 -1
- {forgexa_cli-1.21.7 → forgexa_cli-1.22.0}/pyproject.toml +1 -1
- {forgexa_cli-1.21.7 → forgexa_cli-1.22.0}/tests/test_auth_and_runtime_commands.py +126 -0
- {forgexa_cli-1.21.7 → forgexa_cli-1.22.0}/forgexa_cli/_build_config.py +0 -0
- {forgexa_cli-1.21.7 → forgexa_cli-1.22.0}/forgexa_cli/autoupgrade.py +0 -0
- {forgexa_cli-1.21.7 → forgexa_cli-1.22.0}/forgexa_cli/py.typed +0 -0
- {forgexa_cli-1.21.7 → forgexa_cli-1.22.0}/forgexa_cli.egg-info/SOURCES.txt +0 -0
- {forgexa_cli-1.21.7 → forgexa_cli-1.22.0}/forgexa_cli.egg-info/dependency_links.txt +0 -0
- {forgexa_cli-1.21.7 → forgexa_cli-1.22.0}/forgexa_cli.egg-info/entry_points.txt +0 -0
- {forgexa_cli-1.21.7 → forgexa_cli-1.22.0}/forgexa_cli.egg-info/requires.txt +0 -0
- {forgexa_cli-1.21.7 → forgexa_cli-1.22.0}/forgexa_cli.egg-info/top_level.txt +0 -0
- {forgexa_cli-1.21.7 → forgexa_cli-1.22.0}/setup.cfg +0 -0
- {forgexa_cli-1.21.7 → forgexa_cli-1.22.0}/tests/test_autoupgrade.py +0 -0
- {forgexa_cli-1.21.7 → forgexa_cli-1.22.0}/tests/test_check_command.py +0 -0
- {forgexa_cli-1.21.7 → forgexa_cli-1.22.0}/tests/test_silent_install.py +0 -0
- {forgexa_cli-1.21.7 → forgexa_cli-1.22.0}/tests/test_upgrade_observability.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: forgexa-cli
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.22.0
|
|
4
4
|
Summary: Forgexa CLI — command-line client and AI agent runtime for the Forgexa platform
|
|
5
5
|
Author-email: Jason Sun <dev.winds@gmail.com>
|
|
6
6
|
License-Expression: MIT
|
|
@@ -137,6 +137,26 @@ For safety, `forgexa upgrade` stops any running local daemon before upgrading. I
|
|
|
137
137
|
|
|
138
138
|
`forgexa upgrade` supports standard PyPI / pipx installs and non-editable local path installs. Editable installs, VCS installs, and other direct URL installs are rejected with a manual upgrade hint.
|
|
139
139
|
|
|
140
|
+
### One-Shot Upgrade Script (recommended for production runtimes)
|
|
141
|
+
|
|
142
|
+
`forgexa upgrade` alone only auto-restarts the daemon if it was already running in background mode *right before* the upgrade started — if it had already stopped for any other reason (crash, manual stop, first-time setup), you're left to restart it yourself. [`cli/scripts/upgrade.sh`](scripts/upgrade.sh) (macOS/Linux) and [`cli/scripts/upgrade.ps1`](scripts/upgrade.ps1) (Windows) wrap the full, correct sequence explicitly so the daemon is deterministically stopped, upgraded, and started again in the background every time, regardless of its state going in:
|
|
143
|
+
|
|
144
|
+
```bash
|
|
145
|
+
# macOS / Linux
|
|
146
|
+
./cli/scripts/upgrade.sh
|
|
147
|
+
./cli/scripts/upgrade.sh --target-version 1.21.7
|
|
148
|
+
./cli/scripts/upgrade.sh --server-url https://api.forgexa.net --login
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
```powershell
|
|
152
|
+
# Windows
|
|
153
|
+
.\cli\scripts\upgrade.ps1
|
|
154
|
+
.\cli\scripts\upgrade.ps1 -TargetVersion 1.21.7
|
|
155
|
+
.\cli\scripts\upgrade.ps1 -ServerUrl https://api.forgexa.net -Login
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
Steps: `daemon stop` (no-op if not running) → `upgrade` → `daemon start -d` → optional `login` → `daemon status`. Login is skipped by default (the previously saved session is reused) unless you pass `--email`/`--password` (`-Email`/`-Password`), set `FORGEXA_LOGIN_EMAIL`/`FORGEXA_LOGIN_PASSWORD`, or pass `--login`/`-Login` to be prompted interactively. Run with `--help`/`Get-Help .\upgrade.ps1` for all options.
|
|
159
|
+
|
|
140
160
|
### Automatic Background Upgrade
|
|
141
161
|
|
|
142
162
|
By default (`auto_upgrade=silent`), forgexa checks for updates in the background (once every 24h) and silently installs new releases — no need to run `forgexa upgrade` yourself, the same way GitHub Copilot CLI / Claude Code / Kimi Code work (see [docs/designs/cli-auto-upgrade-design.md](../docs/designs/cli-auto-upgrade-design.md) for the full design). `forgexa upgrade` remains fully available at any time for an immediate manual upgrade or to pin a specific `--target-version` — the two are independent and coexist.
|
|
@@ -105,6 +105,26 @@ For safety, `forgexa upgrade` stops any running local daemon before upgrading. I
|
|
|
105
105
|
|
|
106
106
|
`forgexa upgrade` supports standard PyPI / pipx installs and non-editable local path installs. Editable installs, VCS installs, and other direct URL installs are rejected with a manual upgrade hint.
|
|
107
107
|
|
|
108
|
+
### One-Shot Upgrade Script (recommended for production runtimes)
|
|
109
|
+
|
|
110
|
+
`forgexa upgrade` alone only auto-restarts the daemon if it was already running in background mode *right before* the upgrade started — if it had already stopped for any other reason (crash, manual stop, first-time setup), you're left to restart it yourself. [`cli/scripts/upgrade.sh`](scripts/upgrade.sh) (macOS/Linux) and [`cli/scripts/upgrade.ps1`](scripts/upgrade.ps1) (Windows) wrap the full, correct sequence explicitly so the daemon is deterministically stopped, upgraded, and started again in the background every time, regardless of its state going in:
|
|
111
|
+
|
|
112
|
+
```bash
|
|
113
|
+
# macOS / Linux
|
|
114
|
+
./cli/scripts/upgrade.sh
|
|
115
|
+
./cli/scripts/upgrade.sh --target-version 1.21.7
|
|
116
|
+
./cli/scripts/upgrade.sh --server-url https://api.forgexa.net --login
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
```powershell
|
|
120
|
+
# Windows
|
|
121
|
+
.\cli\scripts\upgrade.ps1
|
|
122
|
+
.\cli\scripts\upgrade.ps1 -TargetVersion 1.21.7
|
|
123
|
+
.\cli\scripts\upgrade.ps1 -ServerUrl https://api.forgexa.net -Login
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
Steps: `daemon stop` (no-op if not running) → `upgrade` → `daemon start -d` → optional `login` → `daemon status`. Login is skipped by default (the previously saved session is reused) unless you pass `--email`/`--password` (`-Email`/`-Password`), set `FORGEXA_LOGIN_EMAIL`/`FORGEXA_LOGIN_PASSWORD`, or pass `--login`/`-Login` to be prompted interactively. Run with `--help`/`Get-Help .\upgrade.ps1` for all options.
|
|
127
|
+
|
|
108
128
|
### Automatic Background Upgrade
|
|
109
129
|
|
|
110
130
|
By default (`auto_upgrade=silent`), forgexa checks for updates in the background (once every 24h) and silently installs new releases — no need to run `forgexa upgrade` yourself, the same way GitHub Copilot CLI / Claude Code / Kimi Code work (see [docs/designs/cli-auto-upgrade-design.md](../docs/designs/cli-auto-upgrade-design.md) for the full design). `forgexa upgrade` remains fully available at any time for an immediate manual upgrade or to pin a specific `--target-version` — the two are independent and coexist.
|
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
"""forgexa-cli — Forgexa command-line client."""
|
|
2
|
-
__version__ = "1.
|
|
2
|
+
__version__ = "1.22.0"
|
|
@@ -555,7 +555,7 @@ except (ImportError, ModuleNotFoundError):
|
|
|
555
555
|
# DAEMON_VERSION is the protocol/logic version of the daemon code.
|
|
556
556
|
# Kept in sync with pyproject.toml version via bump-version.sh.
|
|
557
557
|
# CLIENT_TYPE identifies which packaging/distribution this daemon runs in.
|
|
558
|
-
DAEMON_VERSION = "1.
|
|
558
|
+
DAEMON_VERSION = "1.22.0"
|
|
559
559
|
|
|
560
560
|
|
|
561
561
|
def _detect_client_type() -> str:
|
|
@@ -990,6 +990,12 @@ class TaskInfo:
|
|
|
990
990
|
requirement_key: str | None = None
|
|
991
991
|
graph_type: str = "execution"
|
|
992
992
|
analysis_branch: str | None = None
|
|
993
|
+
# Explicit workspace baseline resolved server-side (SI-887). When set,
|
|
994
|
+
# these override the legacy requirement_key-based branch derivation:
|
|
995
|
+
# "requirement_branch" -> checkout workspace_branch, fail closed
|
|
996
|
+
# "default_branch" -> detached HEAD at origin/workspace_branch, never push
|
|
997
|
+
workspace_source_mode: str | None = None
|
|
998
|
+
workspace_branch: str | None = None
|
|
993
999
|
|
|
994
1000
|
|
|
995
1001
|
@dataclass
|
|
@@ -2375,7 +2381,22 @@ class WorkspaceManager:
|
|
|
2375
2381
|
|
|
2376
2382
|
# Use human-readable branch name feature/{requirement_key}
|
|
2377
2383
|
# when requirement_key is available; fallback to feature/{workspace_key}
|
|
2378
|
-
|
|
2384
|
+
#
|
|
2385
|
+
# Explicit server-resolved baseline (SI-887) takes precedence over the
|
|
2386
|
+
# legacy requirement_key derivation:
|
|
2387
|
+
# - "requirement_branch": checkout the given branch, fail closed.
|
|
2388
|
+
# - "default_branch": detached HEAD at origin/<branch> (QA read-only
|
|
2389
|
+
# context) — never probe feature/{key}, never push.
|
|
2390
|
+
ws_source_mode = task.workspace_source_mode
|
|
2391
|
+
detach_head = ws_source_mode == "default_branch"
|
|
2392
|
+
if ws_source_mode == "requirement_branch":
|
|
2393
|
+
branch_name = task.workspace_branch or (
|
|
2394
|
+
f"feature/{task.requirement_key}" if task.requirement_key
|
|
2395
|
+
else f"feature/{workspace_key}"
|
|
2396
|
+
)
|
|
2397
|
+
elif detach_head:
|
|
2398
|
+
branch_name = task.workspace_branch or default_branch
|
|
2399
|
+
elif task.requirement_key:
|
|
2379
2400
|
branch_name = f"feature/{task.requirement_key}"
|
|
2380
2401
|
else:
|
|
2381
2402
|
branch_name = f"feature/{workspace_key}"
|
|
@@ -2408,16 +2429,25 @@ class WorkspaceManager:
|
|
|
2408
2429
|
# reflects the branch from a *previous* analysis run (e.g. a prior Copilot
|
|
2409
2430
|
# analysis) which may have been deleted as part of the fresh restart.
|
|
2410
2431
|
# Only refine/continuation tasks require the branch to already exist.
|
|
2411
|
-
|
|
2412
|
-
|
|
2413
|
-
|
|
2432
|
+
if ws_source_mode == "requirement_branch":
|
|
2433
|
+
# Server-resolved requirement baseline: the branch must exist.
|
|
2434
|
+
expect_branch = True
|
|
2435
|
+
elif detach_head:
|
|
2436
|
+
# Server-resolved default-branch baseline: read-only QA context,
|
|
2437
|
+
# never fail closed on the delivery branch protection.
|
|
2438
|
+
expect_branch = False
|
|
2439
|
+
else:
|
|
2440
|
+
expect_branch = (not is_fresh_start) and (
|
|
2441
|
+
bool(task.analysis_branch) or (
|
|
2442
|
+
bool(task.requirement_key) and task.node_type != "analysis"
|
|
2443
|
+
)
|
|
2414
2444
|
)
|
|
2415
|
-
)
|
|
2416
2445
|
ws_path = await self._create_worktree(
|
|
2417
2446
|
project_dir, repo_url, default_branch, workspace_key, branch_name,
|
|
2418
2447
|
fresh_start=is_fresh_start,
|
|
2419
2448
|
project_key=project_key,
|
|
2420
2449
|
expect_branch=expect_branch,
|
|
2450
|
+
detach_head=detach_head,
|
|
2421
2451
|
)
|
|
2422
2452
|
# After workspace creation, perform a final branch-specific fetch + reset
|
|
2423
2453
|
# to ensure the working tree is at the absolute latest remote state.
|
|
@@ -2427,7 +2457,9 @@ class WorkspaceManager:
|
|
|
2427
2457
|
# may have pushed commits while this runtime's agent was executing.
|
|
2428
2458
|
# A final sync here keeps the workspace current so the agent works
|
|
2429
2459
|
# on the latest codebase and avoids non-fast-forward push failures.
|
|
2430
|
-
|
|
2460
|
+
# Skipped for detach_head (default-branch QA baseline): the detached
|
|
2461
|
+
# worktree path already fetched and pinned HEAD to origin/<branch>.
|
|
2462
|
+
if not is_fresh_start and not detach_head:
|
|
2431
2463
|
sync_success = False
|
|
2432
2464
|
sync_error = ""
|
|
2433
2465
|
try:
|
|
@@ -2893,10 +2925,163 @@ class WorkspaceManager:
|
|
|
2893
2925
|
self._worktree_locks[key] = asyncio.Lock()
|
|
2894
2926
|
return self._worktree_locks[key]
|
|
2895
2927
|
|
|
2928
|
+
async def _refresh_or_invalidate_detached_worktree(
|
|
2929
|
+
self, main_repo: Path, ws_path: Path, branch_name: str, *, project_key: str,
|
|
2930
|
+
) -> bool:
|
|
2931
|
+
"""Refresh an existing healthy detached-mode worktree onto origin/<branch_name>.
|
|
2932
|
+
|
|
2933
|
+
Returns True when the worktree was successfully refreshed/re-pinned and
|
|
2934
|
+
the caller should reuse it as-is. Returns False when it was removed and
|
|
2935
|
+
the caller must fall through to full recreation.
|
|
2936
|
+
|
|
2937
|
+
SI-887 review fix: a worktree still checked out on a real local branch
|
|
2938
|
+
(not yet detached) is a leftover requirement_branch-mode checkout from
|
|
2939
|
+
before a baseline switch. That content must never be silently reused
|
|
2940
|
+
just because the refresh fetch below happens to fail — doing so would
|
|
2941
|
+
run the job against the wrong baseline entirely, not merely stale
|
|
2942
|
+
content. Only an already-detached worktree (same-mode refresh) may
|
|
2943
|
+
tolerate a fetch failure, matching the daemon's existing tolerance of
|
|
2944
|
+
transient fetch errors elsewhere when there is no baseline-identity
|
|
2945
|
+
risk.
|
|
2946
|
+
"""
|
|
2947
|
+
stale_branch_checkout = False
|
|
2948
|
+
try:
|
|
2949
|
+
await self._git("symbolic-ref", "-q", "HEAD", cwd=ws_path)
|
|
2950
|
+
stale_branch_checkout = True
|
|
2951
|
+
except RuntimeError:
|
|
2952
|
+
pass # already detached
|
|
2953
|
+
|
|
2954
|
+
try:
|
|
2955
|
+
await self._git(
|
|
2956
|
+
"fetch", "origin",
|
|
2957
|
+
f"{branch_name}:refs/remotes/origin/{branch_name}",
|
|
2958
|
+
cwd=ws_path, timeout=settings.GIT_FETCH_TIMEOUT,
|
|
2959
|
+
project_key=project_key,
|
|
2960
|
+
)
|
|
2961
|
+
except RuntimeError as exc:
|
|
2962
|
+
if not stale_branch_checkout:
|
|
2963
|
+
logger.warning(
|
|
2964
|
+
"fetch origin/%s failed for QA workspace %s: %s — reusing current checkout",
|
|
2965
|
+
branch_name, ws_path, exc,
|
|
2966
|
+
)
|
|
2967
|
+
return True
|
|
2968
|
+
logger.warning(
|
|
2969
|
+
"fetch origin/%s failed for QA workspace %s while switching off a "
|
|
2970
|
+
"requirement-branch baseline: %s — discarding the stale checkout "
|
|
2971
|
+
"instead of running this job against the wrong baseline",
|
|
2972
|
+
branch_name, ws_path, exc,
|
|
2973
|
+
)
|
|
2974
|
+
await self._remove_broken_worktree(main_repo, ws_path, ws_path.name)
|
|
2975
|
+
return False
|
|
2976
|
+
|
|
2977
|
+
try:
|
|
2978
|
+
await self._git(
|
|
2979
|
+
"checkout", "-f", "--detach", f"origin/{branch_name}",
|
|
2980
|
+
cwd=ws_path,
|
|
2981
|
+
)
|
|
2982
|
+
except RuntimeError as exc:
|
|
2983
|
+
logger.warning(
|
|
2984
|
+
"Failed to detach QA workspace %s onto origin/%s: %s — recreating",
|
|
2985
|
+
ws_path, branch_name, exc,
|
|
2986
|
+
)
|
|
2987
|
+
await self._remove_broken_worktree(main_repo, ws_path, ws_path.name)
|
|
2988
|
+
return False
|
|
2989
|
+
return True
|
|
2990
|
+
|
|
2991
|
+
async def _create_detached_worktree(
|
|
2992
|
+
self, repo_url: str, main_repo: Path, ws_path: Path, branch_name: str,
|
|
2993
|
+
*, default_branch: str, project_key: str = "default",
|
|
2994
|
+
) -> Path:
|
|
2995
|
+
"""Create or refresh a read-only worktree pinned to origin/<branch_name>.
|
|
2996
|
+
|
|
2997
|
+
Used for server-resolved default-branch QA baselines (SI-887): the
|
|
2998
|
+
worktree is checked out at a DETACHED HEAD so it never collides with
|
|
2999
|
+
the default branch held by _main, never creates/tracks a local branch,
|
|
3000
|
+
and is never pushed. AI job results flow back via the API only.
|
|
3001
|
+
"""
|
|
3002
|
+
if ws_path.exists():
|
|
3003
|
+
if not await self._is_healthy_worktree(ws_path, main_repo=main_repo):
|
|
3004
|
+
logger.warning("Broken worktree detected at %s — removing and recreating", ws_path)
|
|
3005
|
+
await self._remove_broken_worktree(main_repo, ws_path, ws_path.name)
|
|
3006
|
+
else:
|
|
3007
|
+
# Reuse: refresh origin/<branch_name> and re-pin HEAD onto it (or
|
|
3008
|
+
# discard and fall through to full recreation below) — see
|
|
3009
|
+
# _refresh_or_invalidate_detached_worktree for the baseline-switch
|
|
3010
|
+
# safety rationale.
|
|
3011
|
+
if await self._refresh_or_invalidate_detached_worktree(
|
|
3012
|
+
main_repo, ws_path, branch_name, project_key=project_key,
|
|
3013
|
+
):
|
|
3014
|
+
return ws_path
|
|
3015
|
+
|
|
3016
|
+
# Ensure _main exists (single-branch clone of the default branch).
|
|
3017
|
+
if not main_repo.exists():
|
|
3018
|
+
await self._clone_repo(
|
|
3019
|
+
repo_url, main_repo, branch=default_branch, project_key=project_key,
|
|
3020
|
+
)
|
|
3021
|
+
else:
|
|
3022
|
+
# Same interrupted-clone guard as the feature-branch path: without a
|
|
3023
|
+
# valid HEAD / readable tree, worktree add would always fail.
|
|
3024
|
+
try:
|
|
3025
|
+
await self._git("rev-parse", "HEAD", cwd=main_repo)
|
|
3026
|
+
await self._git("rev-parse", "HEAD^{tree}", cwd=main_repo)
|
|
3027
|
+
except RuntimeError:
|
|
3028
|
+
logger.warning(
|
|
3029
|
+
"_main repo at %s has no valid HEAD (likely interrupted initial clone) "
|
|
3030
|
+
"— deleting and re-cloning from scratch",
|
|
3031
|
+
main_repo,
|
|
3032
|
+
)
|
|
3033
|
+
await self._safe_rmtree_main(main_repo)
|
|
3034
|
+
if main_repo.exists():
|
|
3035
|
+
raise RuntimeError(
|
|
3036
|
+
f"Cannot re-clone: {main_repo} could not be fully removed "
|
|
3037
|
+
"(files may be locked by another process). "
|
|
3038
|
+
"Please stop the daemon, manually delete it, then restart."
|
|
3039
|
+
)
|
|
3040
|
+
await self._clone_repo(
|
|
3041
|
+
repo_url, main_repo, branch=default_branch, project_key=project_key,
|
|
3042
|
+
)
|
|
3043
|
+
|
|
3044
|
+
# Refresh origin/<branch_name>. _main is a single-branch clone of the
|
|
3045
|
+
# default branch and branch_name is the server-resolved baseline
|
|
3046
|
+
# (normally that same default branch); fetch the explicit refspec so
|
|
3047
|
+
# the pin below always has a fresh ref.
|
|
3048
|
+
try:
|
|
3049
|
+
await self._git(
|
|
3050
|
+
"fetch", "origin",
|
|
3051
|
+
f"{branch_name}:refs/remotes/origin/{branch_name}",
|
|
3052
|
+
cwd=main_repo, timeout=settings.GIT_FETCH_TIMEOUT,
|
|
3053
|
+
project_key=project_key,
|
|
3054
|
+
)
|
|
3055
|
+
except RuntimeError as exc:
|
|
3056
|
+
raise RuntimeError(
|
|
3057
|
+
f"Failed to fetch QA baseline branch '{branch_name}' from remote: {exc}"
|
|
3058
|
+
) from exc
|
|
3059
|
+
|
|
3060
|
+
try:
|
|
3061
|
+
await self._git(
|
|
3062
|
+
"worktree", "add", "--detach", str(ws_path), f"origin/{branch_name}",
|
|
3063
|
+
cwd=main_repo,
|
|
3064
|
+
)
|
|
3065
|
+
except RuntimeError:
|
|
3066
|
+
# A stale worktree registration may linger after a manual delete.
|
|
3067
|
+
await self._git("worktree", "prune", cwd=main_repo)
|
|
3068
|
+
try:
|
|
3069
|
+
await self._git(
|
|
3070
|
+
"worktree", "add", "--detach", str(ws_path), f"origin/{branch_name}",
|
|
3071
|
+
cwd=main_repo,
|
|
3072
|
+
)
|
|
3073
|
+
except RuntimeError as exc:
|
|
3074
|
+
self._safe_rmtree(ws_path)
|
|
3075
|
+
raise RuntimeError(
|
|
3076
|
+
f"Failed to create detached QA workspace at origin/{branch_name}: {exc}"
|
|
3077
|
+
) from exc
|
|
3078
|
+
return ws_path
|
|
3079
|
+
|
|
2896
3080
|
async def _create_worktree(
|
|
2897
3081
|
self, project_dir: Path, repo_url: str, default_branch: str,
|
|
2898
3082
|
workspace_key: str, branch_name: str, *, fresh_start: bool = False,
|
|
2899
3083
|
project_key: str = "default", expect_branch: bool = False,
|
|
3084
|
+
detach_head: bool = False,
|
|
2900
3085
|
) -> Path:
|
|
2901
3086
|
# Serialize all _create_worktree calls for the same project to prevent
|
|
2902
3087
|
# concurrent tasks from racing on _main (shutil.rmtree / git clone / worktree add).
|
|
@@ -2908,16 +3093,27 @@ class WorkspaceManager:
|
|
|
2908
3093
|
fresh_start=fresh_start,
|
|
2909
3094
|
project_key=project_key,
|
|
2910
3095
|
expect_branch=expect_branch,
|
|
3096
|
+
detach_head=detach_head,
|
|
2911
3097
|
)
|
|
2912
3098
|
|
|
2913
3099
|
async def _create_worktree_impl(
|
|
2914
3100
|
self, project_dir: Path, repo_url: str, default_branch: str,
|
|
2915
3101
|
workspace_key: str, branch_name: str, *, fresh_start: bool = False,
|
|
2916
3102
|
project_key: str = "default", expect_branch: bool = False,
|
|
3103
|
+
detach_head: bool = False,
|
|
2917
3104
|
) -> Path:
|
|
2918
3105
|
main_repo = project_dir / "_main"
|
|
2919
3106
|
ws_path = project_dir / workspace_key
|
|
2920
3107
|
|
|
3108
|
+
if detach_head:
|
|
3109
|
+
# Server-resolved default-branch baseline (SI-887 QA context):
|
|
3110
|
+
# dedicated path that never touches feature/* refs and pins the
|
|
3111
|
+
# worktree to a detached HEAD at origin/<branch_name>.
|
|
3112
|
+
return await self._create_detached_worktree(
|
|
3113
|
+
repo_url, main_repo, ws_path, branch_name,
|
|
3114
|
+
default_branch=default_branch, project_key=project_key,
|
|
3115
|
+
)
|
|
3116
|
+
|
|
2921
3117
|
if ws_path.exists():
|
|
2922
3118
|
# Validate that this worktree is healthy (its .git file points to a
|
|
2923
3119
|
# valid main repo). Worktrees break when directories are moved.
|
|
@@ -9540,6 +9736,13 @@ class RuntimeDaemon:
|
|
|
9540
9736
|
user_prompt = aj.get("user_prompt", "")
|
|
9541
9737
|
input_ctx = aj.get("input_context", {})
|
|
9542
9738
|
ai_workspace_scope = _resolve_ai_job_workspace_scope(aj)
|
|
9739
|
+
# Server-resolved QA workspace baseline (SI-887). Top-level field wins;
|
|
9740
|
+
# the input_context copy is the fallback for older payload shapes.
|
|
9741
|
+
# When absent, prepare_workspace keeps its legacy requirement_key
|
|
9742
|
+
# derivation so this daemon stays compatible with older servers.
|
|
9743
|
+
qa_ws_source = aj.get("qa_workspace_source") or (
|
|
9744
|
+
input_ctx.get("qa_workspace_source") if isinstance(input_ctx, dict) else None
|
|
9745
|
+
) or {}
|
|
9543
9746
|
|
|
9544
9747
|
reporter_url = f"{conn.server_url.rstrip('/')}/api/v1/runtimes/{conn.runtime_id}/ai-jobs/{job_id}"
|
|
9545
9748
|
# Use workspace-level key (project:requirement or project:job_id) so AI jobs
|
|
@@ -9615,6 +9818,8 @@ class RuntimeDaemon:
|
|
|
9615
9818
|
requirement_workflow_id=None,
|
|
9616
9819
|
requirement_key=requirement_key,
|
|
9617
9820
|
graph_type="ai_job",
|
|
9821
|
+
workspace_source_mode=qa_ws_source.get("mode"),
|
|
9822
|
+
workspace_branch=qa_ws_source.get("branch"),
|
|
9618
9823
|
)
|
|
9619
9824
|
workspace_path = await self.workspace_manager.prepare_workspace(
|
|
9620
9825
|
project_info, fake_task,
|
|
@@ -877,6 +877,8 @@ def _read_pid_file(path: Path) -> int | None:
|
|
|
877
877
|
|
|
878
878
|
|
|
879
879
|
def _process_is_running(pid: int) -> bool:
|
|
880
|
+
if sys.platform == "win32":
|
|
881
|
+
return _windows_process_is_running(pid)
|
|
880
882
|
try:
|
|
881
883
|
os.kill(pid, 0)
|
|
882
884
|
except ProcessLookupError:
|
|
@@ -886,6 +888,31 @@ def _process_is_running(pid: int) -> bool:
|
|
|
886
888
|
return True
|
|
887
889
|
|
|
888
890
|
|
|
891
|
+
def _windows_process_is_running(pid: int) -> bool:
|
|
892
|
+
"""Check whether `pid` is alive on Windows, without signaling it.
|
|
893
|
+
|
|
894
|
+
os.kill(pid, 0) is NOT a safe existence probe on Windows: signal 0 is
|
|
895
|
+
CTRL_C_EVENT, so it calls GenerateConsoleCtrlEvent (or TerminateProcess
|
|
896
|
+
for other signal values) instead of a POSIX-style no-op check. Against a
|
|
897
|
+
stale/recycled PID — e.g. left on disk from before an upgrade — this can
|
|
898
|
+
raise an arbitrary WinError (such as the WinError 11 "incorrect program
|
|
899
|
+
format" some users hit) instead of a clean "no such process" result,
|
|
900
|
+
crashing `forgexa daemon start` with an unreadable traceback. Shell out
|
|
901
|
+
to `tasklist` instead, mirroring the existing `taskkill`-based approach
|
|
902
|
+
in _stop_daemon_by_pid, so we only ever query — never signal.
|
|
903
|
+
"""
|
|
904
|
+
try:
|
|
905
|
+
result = subprocess.run(
|
|
906
|
+
["tasklist", "/FI", f"PID eq {pid}", "/NH"],
|
|
907
|
+
capture_output=True,
|
|
908
|
+
text=True,
|
|
909
|
+
timeout=5,
|
|
910
|
+
)
|
|
911
|
+
except (OSError, subprocess.TimeoutExpired):
|
|
912
|
+
return False
|
|
913
|
+
return str(pid) in result.stdout
|
|
914
|
+
|
|
915
|
+
|
|
889
916
|
def _cleanup_daemon_pid_files(pid: int | None = None) -> None:
|
|
890
917
|
for path in _daemon_pid_files():
|
|
891
918
|
recorded_pid = _read_pid_file(path)
|
|
@@ -1426,12 +1453,18 @@ def cmd_daemon_restart(args: argparse.Namespace) -> None:
|
|
|
1426
1453
|
def cmd_daemon_start(args: argparse.Namespace) -> None:
|
|
1427
1454
|
"""Start the local daemon (agent runtime)."""
|
|
1428
1455
|
# Refuse to start a second instance
|
|
1456
|
+
stale_pids = [p for p in (_read_pid_file(f) for f in _daemon_pid_files()) if p is not None]
|
|
1429
1457
|
existing_pid = _find_running_daemon_pid()
|
|
1430
1458
|
if existing_pid is not None:
|
|
1431
1459
|
print(f"Daemon is already running (PID {existing_pid}).")
|
|
1432
1460
|
print("Use 'forgexa daemon restart' to restart it.")
|
|
1433
1461
|
sys.exit(1)
|
|
1434
1462
|
|
|
1463
|
+
if stale_pids:
|
|
1464
|
+
print(f"Cleared a stale daemon record (PID {stale_pids[0]}) left over from a previous run.")
|
|
1465
|
+
print("(Tip: stop the daemon first if you upgrade forgexa outside of 'forgexa upgrade'.)")
|
|
1466
|
+
print()
|
|
1467
|
+
|
|
1435
1468
|
server_url = getattr(args, "server_url", None) or _api_url()
|
|
1436
1469
|
os.environ["DAEMON_SERVER_URL"] = server_url
|
|
1437
1470
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: forgexa-cli
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.22.0
|
|
4
4
|
Summary: Forgexa CLI — command-line client and AI agent runtime for the Forgexa platform
|
|
5
5
|
Author-email: Jason Sun <dev.winds@gmail.com>
|
|
6
6
|
License-Expression: MIT
|
|
@@ -137,6 +137,26 @@ For safety, `forgexa upgrade` stops any running local daemon before upgrading. I
|
|
|
137
137
|
|
|
138
138
|
`forgexa upgrade` supports standard PyPI / pipx installs and non-editable local path installs. Editable installs, VCS installs, and other direct URL installs are rejected with a manual upgrade hint.
|
|
139
139
|
|
|
140
|
+
### One-Shot Upgrade Script (recommended for production runtimes)
|
|
141
|
+
|
|
142
|
+
`forgexa upgrade` alone only auto-restarts the daemon if it was already running in background mode *right before* the upgrade started — if it had already stopped for any other reason (crash, manual stop, first-time setup), you're left to restart it yourself. [`cli/scripts/upgrade.sh`](scripts/upgrade.sh) (macOS/Linux) and [`cli/scripts/upgrade.ps1`](scripts/upgrade.ps1) (Windows) wrap the full, correct sequence explicitly so the daemon is deterministically stopped, upgraded, and started again in the background every time, regardless of its state going in:
|
|
143
|
+
|
|
144
|
+
```bash
|
|
145
|
+
# macOS / Linux
|
|
146
|
+
./cli/scripts/upgrade.sh
|
|
147
|
+
./cli/scripts/upgrade.sh --target-version 1.21.7
|
|
148
|
+
./cli/scripts/upgrade.sh --server-url https://api.forgexa.net --login
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
```powershell
|
|
152
|
+
# Windows
|
|
153
|
+
.\cli\scripts\upgrade.ps1
|
|
154
|
+
.\cli\scripts\upgrade.ps1 -TargetVersion 1.21.7
|
|
155
|
+
.\cli\scripts\upgrade.ps1 -ServerUrl https://api.forgexa.net -Login
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
Steps: `daemon stop` (no-op if not running) → `upgrade` → `daemon start -d` → optional `login` → `daemon status`. Login is skipped by default (the previously saved session is reused) unless you pass `--email`/`--password` (`-Email`/`-Password`), set `FORGEXA_LOGIN_EMAIL`/`FORGEXA_LOGIN_PASSWORD`, or pass `--login`/`-Login` to be prompted interactively. Run with `--help`/`Get-Help .\upgrade.ps1` for all options.
|
|
159
|
+
|
|
140
160
|
### Automatic Background Upgrade
|
|
141
161
|
|
|
142
162
|
By default (`auto_upgrade=silent`), forgexa checks for updates in the background (once every 24h) and silently installs new releases — no need to run `forgexa upgrade` yourself, the same way GitHub Copilot CLI / Claude Code / Kimi Code work (see [docs/designs/cli-auto-upgrade-design.md](../docs/designs/cli-auto-upgrade-design.md) for the full design). `forgexa upgrade` remains fully available at any time for an immediate manual upgrade or to pin a specific `--target-version` — the two are independent and coexist.
|
|
@@ -98,6 +98,132 @@ def test_windows_batch_agent_commands_expand_indexed_launcher_path(
|
|
|
98
98
|
assert "& ($forgexa_command[0]) @forgexa_arguments" in script
|
|
99
99
|
|
|
100
100
|
|
|
101
|
+
def test_windows_process_is_running_true_when_tasklist_finds_pid(
|
|
102
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
103
|
+
) -> None:
|
|
104
|
+
monkeypatch.setattr(
|
|
105
|
+
main.subprocess,
|
|
106
|
+
"run",
|
|
107
|
+
lambda *a, **k: SimpleNamespace(stdout="python.exe 4242 Console 1 12,345 K", returncode=0),
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
assert main._windows_process_is_running(4242) is True
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def test_windows_process_is_running_false_when_tasklist_reports_no_match(
|
|
114
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
115
|
+
) -> None:
|
|
116
|
+
monkeypatch.setattr(
|
|
117
|
+
main.subprocess,
|
|
118
|
+
"run",
|
|
119
|
+
lambda *a, **k: SimpleNamespace(
|
|
120
|
+
stdout="INFO: No tasks are running which match the specified criteria.",
|
|
121
|
+
returncode=0,
|
|
122
|
+
),
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
assert main._windows_process_is_running(4242) is False
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def test_windows_process_is_running_queries_tasklist_with_pid_filter(
|
|
129
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
130
|
+
) -> None:
|
|
131
|
+
calls: list[list[str]] = []
|
|
132
|
+
|
|
133
|
+
def fake_run(cmd, **kwargs):
|
|
134
|
+
calls.append(cmd)
|
|
135
|
+
return SimpleNamespace(stdout="", returncode=0)
|
|
136
|
+
|
|
137
|
+
monkeypatch.setattr(main.subprocess, "run", fake_run)
|
|
138
|
+
|
|
139
|
+
main._windows_process_is_running(4242)
|
|
140
|
+
|
|
141
|
+
assert calls == [["tasklist", "/FI", "PID eq 4242", "/NH"]]
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def test_windows_process_is_running_false_on_oserror(
|
|
145
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
146
|
+
) -> None:
|
|
147
|
+
"""A stale/recycled PID can make Windows raise arbitrary OSErrors (e.g. WinError 11).
|
|
148
|
+
|
|
149
|
+
This must resolve to "not running" instead of propagating and crashing
|
|
150
|
+
`forgexa daemon start` with an unreadable traceback.
|
|
151
|
+
"""
|
|
152
|
+
|
|
153
|
+
def boom(*a, **k):
|
|
154
|
+
raise OSError(11, "incorrect program format")
|
|
155
|
+
|
|
156
|
+
monkeypatch.setattr(main.subprocess, "run", boom)
|
|
157
|
+
|
|
158
|
+
assert main._windows_process_is_running(4242) is False
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def test_windows_process_is_running_false_on_timeout(
|
|
162
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
163
|
+
) -> None:
|
|
164
|
+
"""subprocess.TimeoutExpired is NOT an OSError subclass — must be caught separately."""
|
|
165
|
+
|
|
166
|
+
def boom(*a, **k):
|
|
167
|
+
raise main.subprocess.TimeoutExpired(cmd="tasklist", timeout=5)
|
|
168
|
+
|
|
169
|
+
monkeypatch.setattr(main.subprocess, "run", boom)
|
|
170
|
+
|
|
171
|
+
assert main._windows_process_is_running(4242) is False
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def test_process_is_running_dispatches_to_windows_helper_on_win32(
|
|
175
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
176
|
+
) -> None:
|
|
177
|
+
monkeypatch.setattr(main.sys, "platform", "win32")
|
|
178
|
+
monkeypatch.setattr(main, "_windows_process_is_running", lambda pid: pid == 4242)
|
|
179
|
+
|
|
180
|
+
assert main._process_is_running(4242) is True
|
|
181
|
+
assert main._process_is_running(1) is False
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def test_cmd_daemon_start_clears_stale_pid_with_friendly_message(
|
|
185
|
+
tmp_path: Path, monkeypatch: pytest.MonkeyPatch,
|
|
186
|
+
) -> None:
|
|
187
|
+
monkeypatch.setenv("HOME", str(tmp_path))
|
|
188
|
+
monkeypatch.delenv("FORGEXA_TOKEN", raising=False)
|
|
189
|
+
monkeypatch.delenv("FORGEXA_SERVER_URL", raising=False)
|
|
190
|
+
monkeypatch.delenv("DAEMON_SERVER_URL", raising=False)
|
|
191
|
+
monkeypatch.delenv("DAEMON_API_TOKEN", raising=False)
|
|
192
|
+
monkeypatch.setattr(main, "_SERVER_URL_OVERRIDE", None)
|
|
193
|
+
# Simulate a PID left on disk from before an upgrade that no longer resolves
|
|
194
|
+
# to a running process, without depending on real OS PID reuse semantics.
|
|
195
|
+
monkeypatch.setattr(main, "_process_is_running", lambda pid: False)
|
|
196
|
+
monkeypatch.setattr(main.subprocess, "Popen", lambda *a, **k: SimpleNamespace(pid=12345))
|
|
197
|
+
|
|
198
|
+
(tmp_path / ".forgexa-daemon.pid").write_text("424242")
|
|
199
|
+
|
|
200
|
+
buffer = io.StringIO()
|
|
201
|
+
with contextlib.redirect_stdout(buffer):
|
|
202
|
+
main.cmd_daemon_start(SimpleNamespace(detach=True, server_url=None))
|
|
203
|
+
|
|
204
|
+
output = buffer.getvalue()
|
|
205
|
+
assert "Cleared a stale daemon record (PID 424242) left over from a previous run." in output
|
|
206
|
+
assert "forgexa upgrade" in output
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def test_cmd_daemon_start_no_stale_pid_message_on_clean_start(
|
|
210
|
+
tmp_path: Path, monkeypatch: pytest.MonkeyPatch,
|
|
211
|
+
) -> None:
|
|
212
|
+
monkeypatch.setenv("HOME", str(tmp_path))
|
|
213
|
+
monkeypatch.delenv("FORGEXA_TOKEN", raising=False)
|
|
214
|
+
monkeypatch.delenv("FORGEXA_SERVER_URL", raising=False)
|
|
215
|
+
monkeypatch.delenv("DAEMON_SERVER_URL", raising=False)
|
|
216
|
+
monkeypatch.delenv("DAEMON_API_TOKEN", raising=False)
|
|
217
|
+
monkeypatch.setattr(main, "_SERVER_URL_OVERRIDE", None)
|
|
218
|
+
monkeypatch.setattr(main.subprocess, "Popen", lambda *a, **k: SimpleNamespace(pid=12345))
|
|
219
|
+
|
|
220
|
+
buffer = io.StringIO()
|
|
221
|
+
with contextlib.redirect_stdout(buffer):
|
|
222
|
+
main.cmd_daemon_start(SimpleNamespace(detach=True, server_url=None))
|
|
223
|
+
|
|
224
|
+
assert "stale daemon record" not in buffer.getvalue()
|
|
225
|
+
|
|
226
|
+
|
|
101
227
|
def test_get_retries_once_after_refresh_on_401(
|
|
102
228
|
tmp_path: Path,
|
|
103
229
|
monkeypatch: pytest.MonkeyPatch,
|
|
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
|