Skip to content

Commit 21deefd

Browse files
committed
fix: clear stale update-success notice after restarting into an old install
Homebrew installs can restart into the same pre-update binary when the formula bump doesn't actually land. The persisted "Restart to apply" status survived that restart forever because it only checked job state and target version, not which process wrote it. Scope the notice to the process that recorded the successful update (pid match) so a restart into a still-old version falls back to "Update available" instead of repeating an already-ineffective restart prompt.
1 parent d73d849 commit 21deefd

5 files changed

Lines changed: 72 additions & 21 deletions

File tree

CHANGELOG.md

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

1616
## Unreleased
1717

18+
- Fix stale update-success notices so restarting into an older Homebrew install
19+
shows `/update` again instead of a permanent "Restart to apply" banner.
20+
1821
## 0.55.0 (2026-06-30)
1922

2023
- **Local-model reliability fixes for compaction, Qwen3 reasoning, and stuck loops.**

src/pythinker_code/ui/shell/__init__.py

Lines changed: 3 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -81,9 +81,9 @@
8181
)
8282
from pythinker_code.ui.shell.update_orchestrator import (
8383
SMOKE_CHECK_FAILED_PREFIX,
84-
UpdateJobState,
8584
read_update_status,
8685
run_update_job,
86+
update_restart_pending,
8787
)
8888
from pythinker_code.ui.shell.visualize import (
8989
ApprovalPromptDelegate,
@@ -2213,12 +2213,7 @@ def _compute_update_notice(self) -> str | None:
22132213
# surface that here instead of telling the user to re-run an update that
22142214
# has already landed.
22152215
status = read_update_status()
2216-
installed = (
2217-
status is not None
2218-
and status.state is UpdateJobState.UPDATED
2219-
and status.target_version == target
2220-
)
2221-
if installed and not self._installed_update_smoke_check_failed():
2216+
if update_restart_pending(status, target):
22222217
text = self._installed_update_restart_notice()
22232218
else:
22242219
text = f"↑ Update available — v{target} · /update"
@@ -2434,13 +2429,7 @@ def _chip(markup: str, style: str) -> Text:
24342429
# ponytail: mirror _compute_update_notice — if /update already landed
24352430
# this session, show the restart line instead of "Update available".
24362431
status = read_update_status()
2437-
installed = (
2438-
status is not None
2439-
and status.state is UpdateJobState.UPDATED
2440-
and status.target_version == update_target
2441-
and not (status.message and status.message.startswith(SMOKE_CHECK_FAILED_PREFIX))
2442-
)
2443-
if installed:
2432+
if update_restart_pending(status, update_target):
24442433
from pythinker_code.constant import VERSION as current_version
24452434

24462435
return _chip(

src/pythinker_code/ui/shell/update_orchestrator.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -223,6 +223,16 @@ def read_update_status() -> UpdateJobStatus | None:
223223
)
224224

225225

226+
def update_restart_pending(status: UpdateJobStatus | None, target_version: str | None) -> bool:
227+
return (
228+
status is not None
229+
and status.state is UpdateJobState.UPDATED
230+
and status.target_version == target_version
231+
and status.pid == os.getpid()
232+
and not (status.message and status.message.startswith(SMOKE_CHECK_FAILED_PREFIX))
233+
)
234+
235+
226236
def _optional_str(value: object) -> str | None:
227237
return value if isinstance(value, str) else None
228238

tests/ui_and_conv/test_shell_welcome_info.py

Lines changed: 35 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import io
2+
import os
23

34
from rich.console import Console
45
from rich.text import Text
@@ -95,7 +96,7 @@ def test_welcome_banner_chip_shows_restart_after_successful_update(monkeypatch):
9596
result="ok",
9697
message=None,
9798
log_path="/dev/null",
98-
pid=123,
99+
pid=os.getpid(),
99100
),
100101
)
101102

@@ -109,6 +110,38 @@ def test_welcome_banner_chip_shows_restart_after_successful_update(monkeypatch):
109110
assert "Update available" not in text
110111

111112

113+
def test_welcome_banner_chip_shows_update_after_restart_if_version_is_still_old(monkeypatch):
114+
"""A persisted success from a previous process must not leave old installs
115+
stuck on 'Restart to apply' forever."""
116+
from pythinker_code.ui.shell.update_orchestrator import UpdateJobState, UpdateJobStatus
117+
118+
monkeypatch.setattr(shell_module, "consume_whats_new", lambda: None)
119+
monkeypatch.setattr(shell_module, "welcome_update_target", lambda: "0.51.0")
120+
monkeypatch.setattr(
121+
shell_module,
122+
"read_update_status",
123+
lambda: UpdateJobStatus(
124+
job_id="test",
125+
state=UpdateJobState.UPDATED,
126+
started_at=1.0,
127+
finished_at=2.0,
128+
current_version="0.50.0",
129+
target_version="0.51.0",
130+
result="ok",
131+
message=None,
132+
log_path="/dev/null",
133+
pid=os.getpid() + 1,
134+
),
135+
)
136+
137+
chip = shell_module._welcome_banner_chip()
138+
139+
assert chip is not None
140+
text = chip.plain
141+
assert "Update available" in text
142+
assert "Restart to apply" not in text
143+
144+
112145
def test_welcome_banner_chip_shows_update_if_smoke_check_failed(monkeypatch):
113146
"""If the post-update smoke check failed, keep showing 'Update available'
114147
— the install didn't land cleanly."""
@@ -129,7 +162,7 @@ def test_welcome_banner_chip_shows_update_if_smoke_check_failed(monkeypatch):
129162
result="smoke_failed",
130163
message="Updated, but smoke check did not pass: binary not executable",
131164
log_path="/dev/null",
132-
pid=123,
165+
pid=os.getpid(),
133166
),
134167
)
135168

tests/ui_and_conv/test_silent_auto_update.py

Lines changed: 21 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
from __future__ import annotations
44

55
import asyncio
6+
import os
67
import time
78
from pathlib import Path
89
from types import SimpleNamespace
@@ -49,7 +50,7 @@ async def test_silent_update_success_refreshes_persistent_notice_not_toast(
4950
monkeypatch.setattr(shell_module, "_detect_upgrade_command", lambda: ["pip"])
5051

5152
async def fake_job(**kw):
52-
assert kw["print_output"] is False
53+
assert not kw["print_output"]
5354
assert kw["source"] == "startup-auto"
5455
return UpdateResult.UPDATED
5556

@@ -64,7 +65,8 @@ async def fake_job(**kw):
6465

6566
assert _toasts == []
6667
assert invalidated == [True]
67-
assert shell._update_notice_cache == (0.0, None)
68+
expected_cache = (0.0, None)
69+
assert shell._update_notice_cache == expected_cache
6870

6971

7072
@pytest.mark.asyncio
@@ -195,7 +197,7 @@ async def fake_job(**kw):
195197

196198
monkeypatch.setattr(shell_module, "run_update_job", fake_job)
197199
await shell._silent_auto_update()
198-
assert called is False
200+
assert not called
199201
assert _toasts == []
200202

201203

@@ -317,10 +319,15 @@ def test_auto_update_override_reason_none_when_config_decides(monkeypatch):
317319
assert update_policy.auto_update_override_reason() is None
318320

319321

320-
def _updated_status(target: str, *, message: str = "updated"):
322+
def _updated_status(target: str, *, message: str = "updated", pid: int | None = None):
321323
from pythinker_code.ui.shell.update_orchestrator import UpdateJobState
322324

323-
return SimpleNamespace(state=UpdateJobState.UPDATED, target_version=target, message=message)
325+
return SimpleNamespace(
326+
state=UpdateJobState.UPDATED,
327+
target_version=target,
328+
message=message,
329+
pid=os.getpid() if pid is None else pid,
330+
)
324331

325332

326333
def test_update_notice_available_points_to_slash_update(runtime, tmp_path, monkeypatch):
@@ -354,6 +361,15 @@ def test_update_notice_installed_but_smoke_failed_falls_back(runtime, tmp_path,
354361
assert shell._compute_update_notice() == "↑ Update available — v9.9.9 · /update"
355362

356363

364+
def test_update_notice_previous_process_success_falls_back(runtime, tmp_path, monkeypatch):
365+
shell = _make_shell(runtime, tmp_path)
366+
monkeypatch.setattr(shell_module, "welcome_update_target", lambda: "9.9.9")
367+
monkeypatch.setattr(shell_module, "ascii_glyphs_enabled", lambda: False)
368+
status = _updated_status("9.9.9", pid=os.getpid() + 1)
369+
monkeypatch.setattr(shell_module, "read_update_status", lambda: status)
370+
assert shell._compute_update_notice() == "↑ Update available — v9.9.9 · /update"
371+
372+
357373
def test_update_notice_none_when_up_to_date(runtime, tmp_path, monkeypatch):
358374
shell = _make_shell(runtime, tmp_path)
359375
monkeypatch.setattr(shell_module, "welcome_update_target", lambda: None)

0 commit comments

Comments
 (0)