Release — suppress Windows git console windows (#6534) (#7115)

* fix: suppress console windows for git subprocess calls on Windows

On Windows, every git.exe spawned by the WebUI (update checks,
version detection, workspace git info, rollback inspection,
worktree management) creates a visible console window. When
Windows Terminal is the default terminal, each short-lived git
process opens a new tab. Combined with the 60-second version-skew
monitor polling /api/settings and the boot-time update check, this
produces a flood of flashing terminal tabs.

Root cause: subprocess.run() calls in api/updates.py, api/rollback.py,
api/worktrees.py, and api/workspace.py did not pass
creationflags=CREATE_NO_WINDOW on Windows. api/workspace_git.py
already had the fix (#5692); these four files were missed.

Backend fix:
- Add _windows_hide_flags() helper to each affected module
  (mirrors the existing pattern in api/workspace_git.py)
- Pass creationflags=_windows_hide_flags() to all git
  subprocess.run() calls (10 call sites across 4 files)

Frontend fix (prevent timeout->retry cascades):
- Increase update-check timeout from 30s/60s to 300s (5 min)
- Increase /api/settings fetches from 30s to 60s
- Increase /api/git-info fetch from 30s to 60s

The backend git fetch to GitHub can take 15s per repo; with both
WebUI and Agent repos checked sequentially plus other git calls,
the total easily exceeds the old 30s default frontend timeout,
triggering the api() wrapper auto-retry (3 attempts) and
amplifying the git process flood.

* [verified] fix: address Windows git subprocess review

---------

Co-authored-by: ya1yi2fa3 <ya1yi2fa3@users.noreply.github.com>
Co-authored-by: n <a@n>
This commit is contained in:
authored and GitHub committed 2026-08-18 04:25:26 +00:00
1 parent 509cdb8dfc
commit 5e8937981d
16 files changed
+218 -40

No files matched your search

+2
View File
@@ -5,6 +5,8 @@
### Fixed
- **Git operations no longer flash a console window on Windows.** Every git subprocess boundary (agent runtime, rollback, updates, workspace, worktrees) now routes through a shared `windows_hide_flags()` helper that passes `CREATE_NO_WINDOW` on Win32 and stays a no-op on POSIX, so short-lived git commands no longer pop a visible console window on Windows while keeping captured stdout/stderr intact. Thanks @ya1yi2fa3. (#6534)
- **A `/goal` launch no longer silently switches providers mid-session after a deliberate model pick.** When a session had a deliberate cross-provider model/provider choice, running `/goal` omitted the `explicit_model_pick` marker that `/api/chat/start` sends, so the model resolver treated the persisted pick as stale and "repaired" it back to the profile default — silently changing providers while the goal ran. Both the frontend `/goal` command and the backend `_handle_goal_command` (initial kickoff and kickoff-prompt fallback paths) now carry the explicit-pick marker and stamp the pick signature, matching `/api/chat/start`; ordinary default sessions are unaffected (they still omit the marker, so a default pick stays repairable). Thanks @webtecnica. (#6705)
- **A completed assistant turn no longer renders twice in the feed.** In `renderMessages()`, the live-turn preserve guard kept the in-flight assistant node across the DOM rebuild whenever it held any rendered content, so when a stream ended but its inflight bookkeeping was not yet cleared the just-completed turn was re-attached over the rebuilt settled transcript (a first copy without the model label, then a second with it). The stored data was always clean (state.db, sidecar, and `/api/session` each hold one row) — the duplicate existed only in the DOM. Preservation now requires a provable live owner (`S.activeStreamId` or explicit live-projection markers) instead of bare DOM content; the mid-stream flicker guard and blank-turn guard are preserved. Thanks @webtecnica. (#7109)
+4
View File
@@ -16,6 +16,7 @@ import threading
# Retain the discovered path as a diagnostic/test-visible compatibility value;
# runtime identity is deliberately captured from the loaded module below.
from api.config import _AGENT_DIR # noqa: F401
from api.subprocess_utils import windows_hide_flags
_RESTART_MESSAGE = (
"Hermes Agent was updated while Hermes WebUI was running. "
@@ -49,6 +50,7 @@ def _read_agent_revision(
capture_output=True,
text=True,
timeout=2,
creationflags=windows_hide_flags(),
)
if worktree_result.returncode != 0:
return None
@@ -69,6 +71,7 @@ def _read_agent_revision(
capture_output=True,
text=True,
timeout=2,
creationflags=windows_hide_flags(),
)
if tracked_result.returncode != 0:
return None
@@ -78,6 +81,7 @@ def _read_agent_revision(
capture_output=True,
text=True,
timeout=2,
creationflags=windows_hide_flags(),
)
except (OSError, subprocess.TimeoutExpired, RuntimeError, ValueError):
return None
+6
View File
@@ -17,6 +17,8 @@ from datetime import datetime
from pathlib import Path
from typing import Any
from api.subprocess_utils import windows_hide_flags
logger = logging.getLogger(__name__)
# Checkpoint identifiers are SHA-style hex hashes from the agent's
@@ -102,6 +104,7 @@ def _checkpoint_entry_modes(git: str, ckpt_dir: Path) -> dict[str, int]:
result = subprocess.run(
[git, "-C", str(ckpt_dir), "ls-files", "-s"],
capture_output=True, text=True, timeout=10,
creationflags=windows_hide_flags(),
)
if result.returncode != 0:
raise ValueError("Failed to list checkpoint files")
@@ -136,6 +139,7 @@ def _read_checkpoint_blob(git: str, ckpt_dir: Path, rel_path: str) -> bytes | No
result = subprocess.run(
[git, "-C", str(ckpt_dir), "show", f"HEAD:{rel_path}"],
capture_output=True, timeout=10,
creationflags=windows_hide_flags(),
)
if result.returncode != 0:
return None
@@ -250,6 +254,7 @@ def _inspect_checkpoint(ckpt_path: Path, git: str) -> dict[str, Any] | None:
result = subprocess.run(
[git, "-C", str(ckpt_path), "log", "--format=%H%n%s%n%aI", "-1"],
capture_output=True, text=True, timeout=5,
creationflags=windows_hide_flags(),
)
if result.returncode != 0 or not result.stdout.strip():
return None
@@ -272,6 +277,7 @@ def _inspect_checkpoint(ckpt_path: Path, git: str) -> dict[str, Any] | None:
files_result = subprocess.run(
[git, "-C", str(ckpt_path), "ls-files"],
capture_output=True, text=True, timeout=5,
creationflags=windows_hide_flags(),
)
file_count = len(files_result.stdout.strip().split("\n")) if files_result.stdout.strip() else 0
+18
View File
@@ -0,0 +1,18 @@
"""Dependency-light helpers for launching child processes consistently."""
from __future__ import annotations
import subprocess
import sys
def windows_hide_flags() -> int:
"""Hide a short-lived console child on Win32 and remain a POSIX no-op.
``CREATE_NO_WINDOW`` keeps captured stdout and stderr connected, unlike
detaching the process. Passing ``0`` elsewhere preserves the subprocess
default. See #5692.
"""
if sys.platform == "win32":
return getattr(subprocess, "CREATE_NO_WINDOW", 0)
return 0
+9 -3
View File
@@ -28,6 +28,7 @@ from api.agent_health import get_active_profile_gateway_running_pid
from api.gateway_restart import restart_active_profile_gateway
from api.profiles import get_active_profile_name
from api.config import REPO_ROOT, STREAMS, STREAMS_LOCK
from api.subprocess_utils import windows_hide_flags
logger = logging.getLogger(__name__)
@@ -215,9 +216,14 @@ def _run_git(args, cwd, timeout=10):
return 'git executable not found', False
try:
r = subprocess.run(
[git_executable] + args, cwd=str(cwd), capture_output=True,
text=True, timeout=timeout,
encoding='utf-8', errors='replace',
[git_executable] + args,
cwd=str(cwd),
capture_output=True,
text=True,
timeout=timeout,
encoding='utf-8',
errors='replace',
creationflags=windows_hide_flags(),
)
# On non-UTF-8 locales (e.g. Chinese Windows GBK), a binary git
# output that fails to decode used to leave r.stdout = None and crash
+2
View File
@@ -35,6 +35,7 @@ from api.config import (
DEFAULT_WORKSPACE as _BOOT_DEFAULT_WORKSPACE,
MAX_FILE_BYTES, IMAGE_EXTS, MD_EXTS
)
from api.subprocess_utils import windows_hide_flags
# ── Profile-aware path resolution ───────────────────────────────────────────
@@ -1779,6 +1780,7 @@ def _run_git(args, cwd, timeout=3):
r = subprocess.run(
['git'] + args, cwd=str(cwd), capture_output=True,
text=True, timeout=timeout,
creationflags=windows_hide_flags(),
)
return r.stdout.strip() if r.returncode == 0 else None
except (subprocess.TimeoutExpired, FileNotFoundError, OSError):
+5 -19
View File
@@ -12,7 +12,6 @@ import logging
import os
import shutil
import subprocess
import sys
import tempfile
import threading
import re
@@ -21,24 +20,11 @@ from dataclasses import dataclass
from pathlib import Path
from typing import Iterable
logger = logging.getLogger(__name__)
def _windows_hide_flags() -> int:
"""Win32 ``creationflags`` that hide a short-lived console child's window
(``CREATE_NO_WINDOW``) without detaching it, so ``capture_output`` still
works. Returns ``0`` on non-Windows — the ``subprocess`` default, a genuine
no-op. Mirrors the ``api/updates.py`` pattern; kept local so workspace-git
never takes a hard dependency on the optional ``hermes_cli`` package (a
standalone/agent-less WebUI must keep full git functionality). See #5692.
"""
if sys.platform == "win32":
return getattr(subprocess, "CREATE_NO_WINDOW", 0)
return 0
from api.subprocess_utils import windows_hide_flags
from api.workspace import rmtree_anchored, safe_resolve_ws, unlink_anchored
logger = logging.getLogger(__name__)
GIT_TIMEOUT = 5
GIT_REMOTE_TIMEOUT = 60
@@ -263,7 +249,7 @@ def _run_git(
text=True,
timeout=timeout,
env=run_env,
creationflags=_windows_hide_flags(),
creationflags=windows_hide_flags(),
)
except subprocess.TimeoutExpired as exc:
raise GitWorkspaceError("Git command timed out", "timeout") from exc
@@ -306,7 +292,7 @@ def _config_names_for_scope(
capture_output=True,
timeout=GIT_TIMEOUT,
env=env,
creationflags=_windows_hide_flags(),
creationflags=windows_hide_flags(),
)
if result.returncode not in {0, 1}:
if ignore_unsupported:
+4
View File
@@ -10,6 +10,8 @@ from pathlib import Path
import logging
from api.subprocess_utils import windows_hide_flags
logger = logging.getLogger(__name__)
@@ -21,6 +23,7 @@ def _run_git(args: list[str], cwd: str | Path, timeout: float = 2) -> subprocess
capture_output=True,
timeout=timeout,
check=False,
creationflags=windows_hide_flags(),
)
@@ -326,6 +329,7 @@ def find_git_repo_root(workspace: str | Path) -> Path:
capture_output=True,
timeout=5,
check=False,
creationflags=windows_hide_flags(),
)
except (OSError, subprocess.TimeoutExpired) as exc:
raise ValueError("Workspace is not inside a git repository") from exc
+1 -1
View File
@@ -3513,7 +3513,7 @@ window._mirrorSpeechSettingsFromServer=_mirrorSpeechSettingsFromServer;
const _testUpdates=new URLSearchParams(location.search).get('test_updates')==='1';
if(_testUpdates||(_bootSettings.check_for_updates!==false&&!sessionStorage.getItem('hermes-update-checked')&&!sessionStorage.getItem('hermes-update-dismissed'))){
const _checkUrl='api/updates/check'+(_testUpdates?'?simulate=1':'');
api(_checkUrl,{method:_testUpdates?'GET':'POST',body:_testUpdates?undefined:JSON.stringify({force:false})}).then(d=>{if(!_testUpdates)sessionStorage.setItem('hermes-update-checked','1');if((d.webui&&d.webui.behind>0)||(d.agent&&d.agent.behind>0))_showUpdateBanner(d);}).catch(()=>{});
api(_checkUrl,{method:_testUpdates?'GET':'POST',body:_testUpdates?undefined:JSON.stringify({force:false}),timeoutMs:300000}).then(d=>{if(!_testUpdates)sessionStorage.setItem('hermes-update-checked','1');if((d.webui&&d.webui.behind>0)||(d.agent&&d.agent.behind>0))_showUpdateBanner(d);}).catch(()=>{});
}
const _bootActiveProfileUnauthRedirectBudget=(()=>{
const markerKey='hermes-webui-active-profile-bootstrap-401';
+1 -1
View File
@@ -12128,7 +12128,7 @@ async function checkUpdatesNow(channelOverride){
// saved setting. (Fable UX gate.)
const _checkBody={force:true};
if(channelOverride==='stable'||channelOverride==='experimental') _checkBody.channel=channelOverride;
const data=await api('/api/updates/check',{method:'POST',body:JSON.stringify(_checkBody),timeoutMs:60000});
const data=await api('/api/updates/check',{method:'POST',body:JSON.stringify(_checkBody),timeoutMs:300000});
if(data.disabled){
if(status){status.textContent=t('settings_updates_disabled');status.style.color='var(--muted)';}
} else {
+6 -1
View File
@@ -344,7 +344,12 @@ console.log(JSON.stringify({
prefill_pos = BOOT_JS.find(
"const prefillIntent=(typeof _composerPrefillIntentFromLocation==='function')?_composerPrefillIntentFromLocation():null;"
)
first_await_pos = BOOT_JS.find("const s=await api('/api/settings');", prefill_pos)
first_await_match = re.search(
r"const s=await api\('/api/settings'(?:,\{[^)]*\})?\);",
BOOT_JS[prefill_pos:],
)
assert first_await_match, "settings fetch with optional options not found after prefill intent"
first_await_pos = prefill_pos + first_await_match.start()
active_profile_pos = BOOT_JS.find(
"const activeProfileState = await _resolveActiveProfileBootstrapState();",
first_await_pos,
+7 -2
View File
@@ -19,6 +19,7 @@ flipped the default to 'steer'; this file was updated for the rename while
preserving the persistence-mirror guarantees (the load-failure path must still
honor the saved preference, not clobber it with a hardcoded default).
"""
import re
from pathlib import Path
ROOT = Path(__file__).parent.parent
@@ -47,8 +48,12 @@ class TestEagerDefault:
eager_idx = BOOT_JS.find("window._defaultMessageMode=_readPersistedDefaultMessageMode()")
assert eager_idx >= 0, "eager default assignment not found"
# The async IIFE awaits /api/settings; the success-path assignment lives inside it.
await_idx = BOOT_JS.find("const s=await api('/api/settings')")
assert await_idx >= 0, "async settings fetch not found"
await_match = re.search(
r"const s=await api\('/api/settings'(?:,\{[^)]*\})?\)",
BOOT_JS,
)
assert await_match, "async settings fetch with optional options not found"
await_idx = await_match.start()
assert eager_idx < await_idx, (
"the eager window._defaultMessageMode default must be set BEFORE the async "
"/api/settings fetch — otherwise the boot-window race (#5167) persists"
+7 -3
View File
@@ -9,6 +9,7 @@ import textwrap
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
BOOT_JS = ROOT / "static" / "boot.js"
WORKSPACE_JS = ROOT / "static" / "workspace.js"
SESSIONS_JS = ROOT / "static" / "sessions.js"
UI_JS = ROOT / "static" / "ui.js"
@@ -208,12 +209,15 @@ def test_api_has_default_timeout_and_per_call_override_contract():
def test_update_flows_keep_explicit_longer_timeouts():
"""Legitimately long update flows should not inherit the generic 30s guard."""
"""Network-bound update checks need a single-attempt 300s latency budget."""
boot = _source(BOOT_JS)
src = _source(UI_JS)
panels = _source(PANELS_JS)
# /api/updates/check builds its body in a _checkBody var (to optionally add
# an explicit channel), but must still carry the 60s timeout override.
assert "api('/api/updates/check',{method:'POST',body:JSON.stringify(_checkBody),timeoutMs:60000})" in panels
# an explicit channel). Both boot and manual callers need enough time for
# the endpoint's bounded fetches; api() does not retry ordinary timeouts.
assert "api(_checkUrl,{method:_testUpdates?'GET':'POST',body:_testUpdates?undefined:JSON.stringify({force:false}),timeoutMs:300000})" in boot
assert "api('/api/updates/check',{method:'POST',body:JSON.stringify(_checkBody),timeoutMs:300000})" in panels
assert "api('/api/updates/summary',{method:'POST',body:JSON.stringify({updates:scopedUpdates,target:target||null}),timeoutMs:60000})" in src
# apply/force now build their body inline to optionally carry the offered
# channel (Codex debounce-race fix), but MUST still carry the 120s override.
+132
View File
@@ -0,0 +1,132 @@
"""Inventory coverage for windowless production Git subprocesses on Windows."""
from __future__ import annotations
import ast
from collections import Counter
from pathlib import Path
from types import SimpleNamespace
ROOT = Path(__file__).resolve().parents[1]
EXPECTED_GIT_RUNS = Counter(
{
"api/agent_runtime.py": 3,
"api/rollback.py": 4,
"api/updates.py": 1,
"api/workspace.py": 1,
"api/workspace_git.py": 2,
"api/worktrees.py": 2,
}
)
def _is_subprocess_run(node: ast.Call) -> bool:
"""Return whether ``node`` is a direct ``subprocess.run(...)`` boundary."""
return (
isinstance(node.func, ast.Attribute)
and isinstance(node.func.value, ast.Name)
and node.func.value.id == "subprocess"
and node.func.attr == "run"
)
def _git_argv_kind(node: ast.AST) -> str | None:
"""Recognize the production argv forms used by direct Git subprocess calls."""
if isinstance(node, ast.BinOp) and isinstance(node.op, ast.Add):
return _git_argv_kind(node.left)
if isinstance(node, (ast.List, ast.Tuple)) and node.elts:
executable = node.elts[0]
if isinstance(executable, ast.Constant) and executable.value == "git":
return "git"
if isinstance(executable, ast.Name) and executable.id in {"git", "git_executable"}:
return executable.id
if (
isinstance(node, ast.Call)
and isinstance(node.func, ast.Name)
and node.func.id == "_hardened_git_argv"
):
return node.func.id
return None
def _production_git_runs() -> list[tuple[str, ast.Call]]:
"""Inventory every recognizable direct Git ``subprocess.run`` in production code."""
calls: list[tuple[str, ast.Call]] = []
production_files = [*sorted((ROOT / "api").glob("*.py")), ROOT / "bootstrap.py", ROOT / "server.py"]
for path in production_files:
tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path))
relative_path = path.relative_to(ROOT).as_posix()
for node in ast.walk(tree):
if (
isinstance(node, ast.Call)
and _is_subprocess_run(node)
and node.args
and _git_argv_kind(node.args[0]) is not None
):
calls.append((relative_path, node))
return calls
def test_git_subprocess_inventory_uses_shared_windows_flags() -> None:
"""All production Git boundaries must use the one dependency-light helper."""
calls = _production_git_runs()
assert Counter(path for path, _node in calls) == EXPECTED_GIT_RUNS
missing_shared_helper: list[str] = []
for path, node in calls:
creationflags = next(
(keyword.value for keyword in node.keywords if keyword.arg == "creationflags"),
None,
)
if not (
isinstance(creationflags, ast.Call)
and isinstance(creationflags.func, ast.Name)
and creationflags.func.id == "windows_hide_flags"
):
missing_shared_helper.append(f"{path}:{node.lineno}")
assert not missing_shared_helper, (
"Git subprocess.run calls must pass creationflags=windows_hide_flags(): "
+ ", ".join(missing_shared_helper)
)
helper_definitions: list[str] = []
for path in sorted((ROOT / "api").glob("*.py")):
tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path))
if any(
isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef))
and node.name in {"windows_hide_flags", "_windows_hide_flags"}
for node in ast.walk(tree)
):
helper_definitions.append(path.relative_to(ROOT).as_posix())
assert helper_definitions == ["api/subprocess_utils.py"]
def test_windows_hide_flags_returns_win32_sentinel(monkeypatch) -> None:
"""The helper must expose the platform's CREATE_NO_WINDOW value on Win32."""
from api import subprocess_utils
sentinel = 0x08000000
monkeypatch.setattr(subprocess_utils, "sys", SimpleNamespace(platform="win32"))
monkeypatch.setattr(
subprocess_utils,
"subprocess",
SimpleNamespace(CREATE_NO_WINDOW=sentinel),
)
assert subprocess_utils.windows_hide_flags() == sentinel
def test_windows_hide_flags_is_zero_on_posix(monkeypatch) -> None:
"""Passing creationflags=0 must remain a genuine no-op off Windows."""
from api import subprocess_utils
monkeypatch.setattr(subprocess_utils, "sys", SimpleNamespace(platform="linux"))
monkeypatch.setattr(
subprocess_utils,
"subprocess",
SimpleNamespace(CREATE_NO_WINDOW=0x08000000),
)
assert subprocess_utils.windows_hide_flags() == 0
+6 -2
View File
@@ -61,8 +61,12 @@ class TestBootJsProfileDefaultWorkspace:
src = read('static/boot.js')
# Find the settings fetch and the _profileDefaultWorkspace ASSIGNMENT
# (the if(s.default_workspace) line, not usages elsewhere in the file)
settings_idx = src.find("await api('/api/settings')")
assert settings_idx != -1, "await api('/api/settings') not found in boot.js"
settings_match = re.search(
r"await api\('/api/settings'(?:,\{[^)]*\})?\)",
src,
)
assert settings_match, "await api('/api/settings', optionalOptions) not found in boot.js"
settings_idx = settings_match.start()
# Find the assignment specifically — it uses 's.default_workspace'
ws_assign_idx = src.find('S._profileDefaultWorkspace=s.default_workspace')
assert ws_assign_idx != -1, "S._profileDefaultWorkspace assignment not found in boot.js"
+8 -8
View File
@@ -2518,12 +2518,12 @@ def test_dirty_worktree_uses_filter_neutralization(tmp_path):
def test_run_git_passes_windows_hide_flags(monkeypatch, tmp_path):
"""_run_git must pass creationflags=_windows_hide_flags() so git child
"""_run_git must pass creationflags=windows_hide_flags() so git child
processes don't accumulate visible console windows on Windows (#5692).
windows_hide_flags() is 0 on non-Windows, so this is a safe no-op there;
the test asserts the kwarg is wired regardless of platform."""
import api.workspace_git as wg
from api.workspace_git import _windows_hide_flags
from api.subprocess_utils import windows_hide_flags
repo = _init_repo(tmp_path / "repo")
(repo / "f.txt").write_text("x\n", encoding="utf-8")
@@ -2539,17 +2539,17 @@ def test_run_git_passes_windows_hide_flags(monkeypatch, tmp_path):
monkeypatch.setattr(wg.subprocess, "run", fake_run)
wg._run_git(repo, ["rev-parse", "HEAD"])
assert captured.get("creationflags") == _windows_hide_flags(), (
"_run_git must pass creationflags=_windows_hide_flags() to subprocess.run"
assert captured.get("creationflags") == windows_hide_flags(), (
"_run_git must pass creationflags=windows_hide_flags() to subprocess.run"
)
def test_config_names_for_scope_passes_windows_hide_flags(monkeypatch, tmp_path):
"""_config_names_for_scope must also pass creationflags=_windows_hide_flags()
"""_config_names_for_scope must also pass creationflags=windows_hide_flags()
(#5692) — the git-config probe spawns a console window on Windows too."""
import re as _re
import api.workspace_git as wg
from api.workspace_git import _windows_hide_flags
from api.subprocess_utils import windows_hide_flags
repo = _init_repo(tmp_path / "repo")
@@ -2570,6 +2570,6 @@ def test_config_names_for_scope_passes_windows_hide_flags(monkeypatch, tmp_path)
ignore_unsupported=True,
)
assert captured.get("creationflags") == _windows_hide_flags(), (
"_config_names_for_scope must pass creationflags=_windows_hide_flags()"
assert captured.get("creationflags") == windows_hide_flags(), (
"_config_names_for_scope must pass creationflags=windows_hide_flags()"
)