Extract installed_target_matches_request helper for update skip check and use describe field for exact version matching

Add installed_target_matches_request helper to check if requested update target matches current installation. Replace short_tag comparison with describe field to ensure exact commit match - prevents skipping updates when current version is ahead of requested tag (e.g. v1.11-12-ge9d9c93d vs v1.11). Return false for "latest" selector tags to force update check. Add test coverage

frdel committed Mar 26, 2026 at 13:16 UTC 0bca80a49ff8e151b77f3a6b98b5531a688c754a
3 files changed +89 -12
docker/run/fs/exe/self_update_manager.py
+21 -5
@@ -1055,6 +1055,23 @@ def queue_update_request(
1055 return payload
1056
1057
1058 +def installed_target_matches_request(
1059 + current_info: dict[str, str],
1060 + *,
1061 + requested_branch: str,
1062 + requested_tag: str,
1063 +) -> bool:
1064 + normalized_tag = requested_tag.strip()
1065 + if not normalized_tag or is_latest_selector_tag(normalized_tag):
1066 + return False
1067 +
1068 + current_branch = current_info.get("branch", "").strip()
1069 + if requested_branch.strip() and current_branch != requested_branch.strip():
1070 + return False
1071 +
1072 + return current_info.get("describe", "").strip() == normalized_tag
1073 +
1074 +
1075 def trigger_update_command(args: list[str]) -> int:
1076 parser = argparse.ArgumentParser(
1077 prog="trigger_self_update.sh",
@@ -1137,11 +1154,10 @@ def docker_run_ui() -> int:
1154 current = get_repo_version_info(REPO_DIR)
1155 requested_branch = str(request_data.get("branch", "")).strip()
1156 requested_tag = str(request_data.get("tag", "")).strip()
1140 - current_branch = current.get("branch", "").strip()
1141 - if (
1142 - requested_tag
1143 - and current["short_tag"] == requested_tag
1144 - and (not requested_branch or current_branch == requested_branch)
1157 + if installed_target_matches_request(
1158 + current,
1159 + requested_branch=requested_branch,
1160 + requested_tag=requested_tag,
1161 ):
1162 logger.log(
1163 "Requested tag already matches the installed version, skipping file replacement."
tests/test_self_update_tag_filter.py
+44 -1
@@ -393,7 +393,9 @@ def test_self_update_frontend_uses_preloaded_select():
393 assert "getLastStatusBadgeClass(status)" in content
394 assert "this.info?.current?.display_version" in content
395 assert "resetRestartState()" in content
396 - assert "restartRequestError" in content
396 + assert "restartRequestStarted" in content
397 + assert "restartResponse.status >= 500" in content
398 + assert "while Agent Zero was shutting down" in content
399 assert "await notificationStore.frontendWarning(" not in content
400 assert "status-pill-error" in content
401 assert "status-pill-success" in content
@@ -658,6 +660,47 @@ def test_self_update_manager_explicit_tag_uses_peeled_commit(monkeypatch):
660 assert resolved["expected_commit"] == "192d6e2cae1a85c0a2e7a6ecf41c153b39f1b4c6"
661
662
663 +def test_self_update_manager_skip_check_requires_exact_describe_match():
664 + manager = load_self_update_manager()
665 +
666 + assert (
667 + manager.installed_target_matches_request(
668 + {
669 + "branch": "development",
670 + "describe": "v1.11",
671 + "short_tag": "v1.11",
672 + },
673 + requested_branch="development",
674 + requested_tag="v1.11",
675 + )
676 + is True
677 + )
678 + assert (
679 + manager.installed_target_matches_request(
680 + {
681 + "branch": "development",
682 + "describe": "v1.11-12-ge9d9c93d",
683 + "short_tag": "v1.11",
684 + },
685 + requested_branch="development",
686 + requested_tag="v1.11",
687 + )
688 + is False
689 + )
690 + assert (
691 + manager.installed_target_matches_request(
692 + {
693 + "branch": "development",
694 + "describe": "v1.11",
695 + "short_tag": "v1.11",
696 + },
697 + requested_branch="development",
698 + requested_tag="latest",
699 + )
700 + is False
701 + )
702 +
703 +
704 def test_self_update_manager_fetch_release_refs_checks_peeled_tag_commit(monkeypatch):
705 manager = load_self_update_manager()
706 commands = []
webui/components/settings/external/self-update-store.js
+24 -6
@@ -497,9 +497,10 @@ const model = {
497 );
498 this.ensureProgressOverlay();
499
500 - let restartRequestError = null;
500 + let restartRequestStarted = false;
501 try {
502 const token = await API.getCsrfToken();
503 + restartRequestStarted = true;
504 const restartResponse = await fetch("/api/restart", {
505 method: "POST",
506 credentials: "same-origin",
@@ -511,19 +512,36 @@ const model = {
512 body: JSON.stringify({}),
513 });
514 if (restartResponse && !restartResponse.ok) {
514 - restartRequestError = new Error(
515 - `Restart request failed with HTTP ${restartResponse.status}.`
515 + if (restartResponse.status >= 500) {
516 + console.warn(
517 + `Restart request returned HTTP ${restartResponse.status} while Agent Zero was shutting down. Continuing to wait for the new runtime.`
518 + );
519 + this.setRestartState(
520 + "Restarting backend",
521 + "Agent Zero is shutting down and applying the update. Waiting for the new runtime to come back healthy."
522 + );
523 + } else {
524 + throw new Error(
525 + `Restart request failed with HTTP ${restartResponse.status}.`
526 + );
527 + }
528 + } else {
529 + this.setRestartState(
530 + "Restarting backend",
531 + "Agent Zero accepted the restart request. Waiting for the updater to take over."
532 );
517 - throw restartRequestError;
533 }
534 } catch (error) {
520 - if (restartRequestError && error === restartRequestError) {
535 + if (!restartRequestStarted) {
536 this.restarting = false;
537 this.resetRestartState();
538 this.removeProgressOverlay();
539 throw error;
540 }
526 - // The restart request often terminates the backend mid-flight.
541 + console.warn(
542 + "Restart request connection closed while Agent Zero was restarting:",
543 + error
544 + );
545 }
546
547 const maxWaitMs =