Skip to content

Commit 8659d23

Browse files
committed
feat(update): stage auto-updates instead of installing mid-session
On Windows the silent startup auto-updater launched the Inno Setup installer inline with /CLOSEAPPLICATIONS, letting its Restart Manager force-close the running pythinker.exe and kill the active session. Redesign the update lifecycle around a typed UpdateIntent (CHECK / STAGE_FOR_RESTART / INSTALL / INSTALL_AND_EXIT) so background callers are type-unable to request an in-session install: - Background startup updates download, sha256-verify, and stage the Windows installer with an atomically written manifest; they can no longer spawn installers, run package-manager upgrades, or raise SystemExit (contained in _run_silent_update_job; the done-callback stays as defense in depth). - A pre-session bootstrap in the CLI entry applies a verified staged update before any config/session/runtime construction and fails closed (discard + continue) on any invalid or stale stage; apply re-verifies the digest and version and guards against a concurrently superseded manifest. - In-shell /update stages on Windows (restart-to-apply notice); the standalone `pythinker update` CLI keeps its install-and-exit behavior as an explicit foreground operation. - config auto_update becomes a policy enum off|notify|download| apply_on_exit (default download) with legacy bool compatibility (true->download, false->notify) including PYTHINKER_AUTO_UPDATE; PYTHINKER_CLI_NO_AUTO_UPDATE stays the highest-precedence kill switch. /update auto, the settings panel, and pythinker info are mode-aware (info JSON auto_update_config is now a string). - The post-install smoke check is skipped for the Windows staged path: it would run the old executable and falsely certify the stage, which is instead digest-verified at staging and again at apply.
1 parent 4827a3c commit 8659d23

21 files changed

Lines changed: 1155 additions & 253 deletions

CHANGELOG.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,16 @@ GitHub Releases page; `0.8.0` is the new starting line.
1515

1616
## Unreleased
1717

18+
- **Updates never interrupt a running session.** On Windows, the background
19+
auto-updater previously launched the installer mid-session, force-closing the
20+
active Pythinker session. Updates are now downloaded and staged with a verified
21+
manifest, surfaced as a "restart to apply" notice, and applied before the next
22+
session starts (or at clean exit with the new `apply_on_exit` policy). The
23+
`auto_update` config becomes a policy enum — `off`, `notify`, `download`
24+
(default), `apply_on_exit` — with legacy booleans still accepted
25+
(`true``download`, `false``notify`); `pythinker info` now reports the
26+
mode string, and `/update auto` accepts the new mode names.
27+
1828
## 0.59.0 (2026-07-17)
1929

2030
- **Reviewer subagents now receive deterministic Git scopes.** Structured automatic,

src/pythinker_code/cli/__init__.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -859,6 +859,17 @@ def _emit_fatal_error(message: str) -> None:
859859
param_hint="--session",
860860
)
861861

862+
# Apply a previously staged Windows update before any session, runtime, or
863+
# agent is constructed. Interactive shell launches only: print/ACP/wire
864+
# callers are scripted flows where exiting to run an installer would break
865+
# the invoker. Fail closed inside: an invalid stage is discarded and normal
866+
# startup continues.
867+
if ui == "shell" and prompt is None:
868+
from pythinker_code.ui.shell.update import apply_staged_update_before_start
869+
870+
if apply_staged_update_before_start():
871+
raise typer.Exit(0)
872+
862873
config: Config | Path | None = None
863874
if config_string is not None:
864875
config_string = config_string.strip()

src/pythinker_code/cli/info.py

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -14,12 +14,12 @@ class InfoData(TypedDict):
1414
wire_protocol_version: str
1515
python_version: str
1616
auto_update: bool | None
17-
auto_update_config: bool | None
17+
auto_update_config: str | None
1818
auto_update_override: str | None
1919

2020

21-
def _auto_update_info() -> tuple[bool | None, bool | None, str | None]:
22-
"""Return ``(effective_enabled, config_value, override_reason)``.
21+
def _auto_update_info() -> tuple[bool | None, str | None, str | None]:
22+
"""Return ``(effective_enabled, config_mode, override_reason)``.
2323
2424
Every element is ``None`` when the status cannot be resolved. The whole
2525
block is guarded so an unreadable config or any other failure never turns
@@ -38,7 +38,7 @@ def _auto_update_info() -> tuple[bool | None, bool | None, str | None]:
3838
# has no config file yet rather than creating one as a side effect.
3939
config_exists = get_config_file(create=False).expanduser().exists()
4040
config = load_config() if config_exists else Config()
41-
return auto_update_enabled(config), config.auto_update, override
41+
return auto_update_enabled(config), config.auto_update.value, override
4242
except (OSError, ValueError, ImportError) as exc:
4343
# Read-only diagnostic: never abort `info`, but log the degraded path
4444
# instead of silently masking a real config/policy failure. ConfigError
@@ -73,7 +73,7 @@ def _auto_update_line(info: InfoData) -> str:
7373
if effective is None:
7474
return "auto-update: unknown"
7575
state = "enabled" if effective else "disabled"
76-
detail = f"config auto_update={'true' if info['auto_update_config'] else 'false'}"
76+
detail = f"config auto_update={info['auto_update_config'] or 'unknown'}"
7777
override = info["auto_update_override"]
7878
if override:
7979
detail += f"; {override}"

src/pythinker_code/cli/update.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,10 +23,13 @@ def update(
2323
if ctx.invoked_subcommand is not None:
2424
return
2525

26-
from pythinker_code.ui.shell.update import UpdateResult
26+
from pythinker_code.ui.shell.update import UpdateIntent, UpdateResult
2727
from pythinker_code.ui.shell.update_orchestrator import run_update_job
2828

29-
result = asyncio.run(run_update_job(print_output=True, check_only=check_only, source="cli"))
29+
# The standalone CLI is its own foreground process: exiting to hand off to
30+
# the platform installer is expected, unlike in-shell updates which stage.
31+
intent = UpdateIntent.CHECK if check_only else UpdateIntent.INSTALL_AND_EXIT
32+
result = asyncio.run(run_update_job(print_output=True, intent=intent, source="cli"))
3033
if result in (UpdateResult.FAILED, UpdateResult.UNSUPPORTED):
3134
raise typer.Exit(1)
3235

src/pythinker_code/config.py

Lines changed: 58 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import contextlib
44
import json
55
import os
6+
from enum import StrEnum
67
from pathlib import Path
78
from types import UnionType
89
from typing import Any, Literal, Self, Union, cast, get_args, get_origin
@@ -1115,6 +1116,47 @@ class PluginsConfig(BaseModel):
11151116
)
11161117

11171118

1119+
class AutoUpdateMode(StrEnum):
1120+
"""Startup auto-update policy.
1121+
1122+
``OFF`` schedules nothing at startup; ``NOTIFY`` only refreshes the passive
1123+
update notice; ``DOWNLOAD`` downloads and stages the new release in the
1124+
background so a restart applies it; ``APPLY_ON_EXIT`` additionally launches
1125+
the staged installer when the process exits cleanly. No mode ever installs
1126+
or restarts while an interactive session is running.
1127+
"""
1128+
1129+
OFF = "off"
1130+
NOTIFY = "notify"
1131+
DOWNLOAD = "download"
1132+
APPLY_ON_EXIT = "apply_on_exit"
1133+
1134+
1135+
# Legacy boolean spellings accepted for backward compatibility with the old
1136+
# `auto_update: bool` config field and PYTHINKER_AUTO_UPDATE env values.
1137+
_AUTO_UPDATE_LEGACY_TRUE = frozenset({"true", "1", "yes"})
1138+
_AUTO_UPDATE_LEGACY_FALSE = frozenset({"false", "0", "no"})
1139+
1140+
1141+
def coerce_auto_update_mode(value: object) -> object:
1142+
"""Map legacy boolean auto_update values onto the policy enum.
1143+
1144+
``true`` keeps its old meaning of "update automatically in the background"
1145+
(now download-and-stage); ``false`` maps to ``notify`` because the old
1146+
disabled state still surfaced the passive update notice.
1147+
"""
1148+
if isinstance(value, bool):
1149+
return AutoUpdateMode.DOWNLOAD if value else AutoUpdateMode.NOTIFY
1150+
if isinstance(value, str):
1151+
normalized = value.strip().lower()
1152+
if normalized in _AUTO_UPDATE_LEGACY_TRUE:
1153+
return AutoUpdateMode.DOWNLOAD
1154+
if normalized in _AUTO_UPDATE_LEGACY_FALSE:
1155+
return AutoUpdateMode.NOTIFY
1156+
return normalized
1157+
return value
1158+
1159+
11181160
class Config(BaseModel):
11191161
"""Main configuration structure."""
11201162

@@ -1223,10 +1265,23 @@ class Config(BaseModel):
12231265
"Supported on macOS, Linux, and Windows. Default: false."
12241266
),
12251267
)
1226-
auto_update: bool = Field(
1227-
default=True,
1228-
description="Automatically install new releases in the background at startup.",
1268+
auto_update: AutoUpdateMode = Field(
1269+
default=AutoUpdateMode.DOWNLOAD,
1270+
description=(
1271+
"Startup auto-update policy: 'off' (no startup update task), 'notify' "
1272+
"(show update notices only), 'download' (download and stage new releases "
1273+
"in the background; a restart applies them), or 'apply_on_exit' (also "
1274+
"launch the staged installer after the session exits). Updates are never "
1275+
"applied while a session is running. Legacy booleans are accepted: "
1276+
"true → download, false → notify."
1277+
),
12291278
)
1279+
1280+
@field_validator("auto_update", mode="before")
1281+
@classmethod
1282+
def _coerce_auto_update(cls, value: object) -> object:
1283+
return coerce_auto_update_mode(value)
1284+
12301285
models: dict[str, LLMModel] = Field(default_factory=dict, description="List of LLM models")
12311286
providers: dict[str, LLMProvider] = Field(
12321287
default_factory=dict, description="List of LLM providers"

src/pythinker_code/ui/shell/__init__.py

Lines changed: 53 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -71,14 +71,17 @@
7171
from pythinker_code.ui.shell.slash import registry as shell_slash_registry
7272
from pythinker_code.ui.shell.update import (
7373
MANAGED_CHANNEL_MARKER,
74+
UpdateIntent,
7475
UpdateResult,
7576
_detect_upgrade_command, # pyright: ignore[reportPrivateUsage]
7677
_mark_auto_update_check_attempt, # pyright: ignore[reportPrivateUsage]
7778
_should_auto_check_for_updates, # pyright: ignore[reportPrivateUsage]
7879
consume_whats_new,
7980
format_managed_channel_notice,
8081
pending_update_notice,
82+
read_windows_staged_update,
8183
refresh_update_cache_if_due,
84+
register_windows_staged_apply_on_exit,
8285
welcome_update_target,
8386
)
8487
from pythinker_code.ui.shell.update_orchestrator import (
@@ -94,7 +97,7 @@
9497
from pythinker_code.ui.terminal_capabilities import ascii_glyphs_enabled, motion_disabled
9598
from pythinker_code.ui.theme import BRAND, BrandToken, tui_rich_style
9699
from pythinker_code.ui.theme import get_tui_tokens as _get_tui_tokens
97-
from pythinker_code.update_policy import auto_update_enabled
100+
from pythinker_code.update_policy import resolve_auto_update_mode
98101
from pythinker_code.utils.aioqueue import QueueShutDown
99102
from pythinker_code.utils.envvar import get_env_bool
100103
from pythinker_code.utils.logging import logger
@@ -2145,7 +2148,12 @@ async def _auto_update(self) -> None:
21452148
self._refresh_update_notice_line()
21462149

21472150
async def _silent_auto_update(self) -> None:
2148-
"""Install a newer release silently in the background at startup."""
2151+
"""Download and stage a newer release in the background at startup.
2152+
2153+
This never installs mid-session: the Windows installer / native binary
2154+
is staged and applied at the next launch (or at clean exit under the
2155+
``apply_on_exit`` policy), which the persistent restart notice reflects.
2156+
"""
21492157
if not _should_auto_check_for_updates():
21502158
return
21512159

@@ -2156,16 +2164,36 @@ async def _silent_auto_update(self) -> None:
21562164
if result is not None and result is not UpdateResult.FAILED:
21572165
_mark_auto_update_check_attempt()
21582166
if result is UpdateResult.UPDATED:
2167+
self._maybe_arm_windows_apply_on_exit()
21592168
self._surface_installed_update_notice()
21602169
elif result is UpdateResult.UPDATE_AVAILABLE:
21612170
self._surface_managed_channel_notice()
21622171
# FAILED / UP_TO_DATE / UNSUPPORTED / None → silent (recorded in the job log).
21632172

2173+
def _maybe_arm_windows_apply_on_exit(self) -> None:
2174+
from pythinker_code.config import AutoUpdateMode
2175+
2176+
if not isinstance(self.soul, PythinkerSoul):
2177+
return
2178+
mode = resolve_auto_update_mode(self.soul.runtime.config)
2179+
if mode is not AutoUpdateMode.APPLY_ON_EXIT:
2180+
return
2181+
if read_windows_staged_update() is None:
2182+
return
2183+
register_windows_staged_apply_on_exit()
2184+
21642185
async def _run_silent_update_job(self) -> UpdateResult | None:
21652186
try:
2166-
return await run_update_job(print_output=False, check_only=False, source="startup-auto")
2187+
return await run_update_job(
2188+
print_output=False, intent=UpdateIntent.STAGE_FOR_RESTART, source="startup-auto"
2189+
)
21672190
except SystemExit:
2168-
raise
2191+
# STAGE_FOR_RESTART must never exit the process; reaching this means
2192+
# an install path leaked into the background task. Contain it — a
2193+
# propagated SystemExit would tear down the user's session (the
2194+
# exact mid-session kill this path is designed to prevent).
2195+
logger.error("Background update task attempted to exit the process; suppressed.")
2196+
return None
21692197
except Exception:
21702198
# Boundary-only recovery: update failure must not abort the shell,
21712199
# and run_update_job has already persisted status/log details.
@@ -2263,20 +2291,29 @@ def _schedule_startup_update_task(self) -> None:
22632291
22642292
- env kill-switch set → nothing (cache filters already suppress the
22652293
notice, matching today's hard-disable behavior).
2266-
- enabled → silent background install.
2267-
- config-disabled OR source checkout → refresh the persistent notice
2268-
only (`_auto_update`); self-suppresses for source checkouts because
2269-
`pending_update_notice()` returns None in that path.
2270-
- non-PythinkerSoul → same notice-refresh path (no runtime config to
2294+
- `off` (or source checkout) → nothing.
2295+
- `notify` → refresh the persistent notice only (`_auto_update`).
2296+
- `download` / `apply_on_exit` → background download-and-stage
2297+
(`_silent_auto_update`); never installs mid-session.
2298+
- non-PythinkerSoul → the notice-refresh path (no runtime config to
22712299
consult), matching the prior unconditional `_auto_update` behavior.
22722300
"""
2301+
from pythinker_code.config import AutoUpdateMode
2302+
22732303
if get_env_bool("PYTHINKER_CLI_NO_AUTO_UPDATE"):
22742304
logger.info("Auto-update disabled by PYTHINKER_CLI_NO_AUTO_UPDATE environment variable")
22752305
return
2276-
if isinstance(self.soul, PythinkerSoul) and auto_update_enabled(self.soul.runtime.config):
2277-
self._start_background_task(self._silent_auto_update())
2278-
else:
2306+
if not isinstance(self.soul, PythinkerSoul):
22792307
self._start_background_task(self._auto_update())
2308+
return
2309+
mode = resolve_auto_update_mode(self.soul.runtime.config)
2310+
if mode is AutoUpdateMode.OFF:
2311+
logger.info("Startup update task disabled by auto_update policy 'off'")
2312+
return
2313+
if mode is AutoUpdateMode.NOTIFY:
2314+
self._start_background_task(self._auto_update())
2315+
return
2316+
self._start_background_task(self._silent_auto_update())
22802317

22812318
def _start_background_task(self, coro: Coroutine[Any, Any, Any]) -> asyncio.Task[Any]:
22822319
task = asyncio.create_task(coro)
@@ -2289,9 +2326,10 @@ def _cleanup(t: asyncio.Task[Any]) -> None:
22892326
except asyncio.CancelledError:
22902327
pass
22912328
except SystemExit:
2292-
# The silent updater's Windows native/pip path raises SystemExit
2293-
# so the installer can replace the binary; don't crash the shell.
2294-
logger.info("Background task requested process exit (update installer launched).")
2329+
# Defense in depth: no background task is allowed to request
2330+
# process exit (updates stage for restart instead). If one
2331+
# slips through, contain it here rather than killing the shell.
2332+
logger.error("Background task raised SystemExit; suppressed to keep the session.")
22952333
except Exception:
22962334
logger.exception("Background task failed:")
22972335

src/pythinker_code/ui/shell/selectors/settings.py

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99

1010
from typing import Any, cast
1111

12-
from pythinker_code.config import Config
12+
from pythinker_code.config import AutoUpdateMode, Config
1313
from pythinker_code.llm import derive_model_capabilities
1414
from pythinker_code.thinking import (
1515
EXTENDED_THINKING_LEVELS,
@@ -23,6 +23,7 @@
2323
)
2424

2525
_BOOL_VALUES = ("true", "false")
26+
_AUTO_UPDATE_VALUES = tuple(mode.value for mode in AutoUpdateMode)
2627
_NONE_MODEL_VALUE = "(none)"
2728

2829

@@ -167,16 +168,16 @@ def _build_settings_config(config: Config) -> SettingsListConfig:
167168
id="auto_update",
168169
label="Auto-update",
169170
description=(
170-
"Silently install new releases in the background at startup "
171-
"(applied on next restart)."
171+
"Startup update policy: off, notify, download (stage in background, "
172+
"apply on restart), or apply_on_exit."
172173
if _auto_update_override is None
173174
else f"Auto-update is {_auto_update_override}; that override outranks this setting."
174175
),
175176
# Show the *effective* state, and make the row read-only when an
176177
# override (env kill-switch / source checkout) forces it off, so the
177178
# panel never offers a no-op toggle.
178-
current_value=(_bool(config.auto_update) if _auto_update_override is None else "false"),
179-
values=_BOOL_VALUES if _auto_update_override is None else None,
179+
current_value=(config.auto_update.value if _auto_update_override is None else "off"),
180+
values=_AUTO_UPDATE_VALUES if _auto_update_override is None else None,
180181
),
181182
SettingItem(
182183
id="merge_all_available_skills",
@@ -364,9 +365,9 @@ def mark(setting_id: str) -> None:
364365
case "auto_update":
365366
# Only reached for the live (non-override) row; a read-only row
366367
# never submits a change.
367-
new = value == "true"
368-
if config.auto_update != new:
369-
config.auto_update = new
368+
new_mode = AutoUpdateMode(value)
369+
if config.auto_update != new_mode:
370+
config.auto_update = new_mode
370371
mark(setting_id)
371372
case "merge_all_available_skills":
372373
new = value == "true"

0 commit comments

Comments
 (0)