22import subprocess
33import sys
44import tempfile
5+ import threading
56import time
67import uuid
78from dataclasses import dataclass
89from pathlib import Path
910from typing import Callable
1011
12+ from robot_orchestrator .bootlog import logged_run
1113from robot_orchestrator .config import SelfUpdateConfig
1214from robot_orchestrator .paths import Paths
1315from robot_orchestrator .repos import git
@@ -29,16 +31,17 @@ class SelfUpdateStatus:
2931 staged_at : float | None
3032 remote_sha : str | None = None
3133 error : str | None = None
34+ in_progress : bool = False
3235
3336
3437def _default_build_venv (venv_path : Path , release_dir : Path ) -> None :
3538 venv_path .parent .mkdir (parents = True , exist_ok = True )
36- subprocess . run ([sys .executable , "-m" , "venv" , str (venv_path )], check = True , capture_output = True , text = True )
39+ logged_run ([sys .executable , "-m" , "venv" , str (venv_path )], source = "self-update" , check = True )
3740 pip = str (venv_pip (venv_path ))
3841 requirements = release_dir / "requirements.txt"
3942 if requirements .exists ():
40- subprocess . run ([pip , "install" , "-r" , str (requirements )], check = True , capture_output = True , text = True )
41- subprocess . run ([pip , "install" , "-e" , str (release_dir )], check = True , capture_output = True , text = True )
43+ logged_run ([pip , "install" , "-r" , str (requirements )], source = "self-update" , check = True )
44+ logged_run ([pip , "install" , "-e" , str (release_dir )], source = "self-update" , check = True )
4245
4346
4447def _default_validate (venv_path : Path , release_dir : Path ) -> tuple [bool , str ]:
@@ -91,16 +94,28 @@ def __init__(
9194 self .venv_builder = venv_builder or _default_build_venv
9295 self .validator = validator or _default_validate
9396 self .source_root = source_root
97+ self ._stage_lock = threading .Lock ()
9498
9599 def status (self ) -> SelfUpdateStatus :
96100 return SelfUpdateStatus (
97101 current_sha = _resolve_current_sha (self .db , self .source_root ),
98102 staged_sha = self .db .get_meta ("self_update.staged_sha" ) or None ,
99103 staged_at = float (v ) if (v := self .db .get_meta ("self_update.staged_at" )) else None ,
100104 error = self .db .get_meta ("self_update.last_error" ) or None ,
105+ in_progress = self ._stage_lock .locked (),
101106 )
102107
103108 def check_and_stage (self ) -> SelfUpdateStatus :
109+ # a second concurrent run would rebuild (and, on failure, delete) the very same release/venv
110+ # directories the first one is still using, so a busy manager just reports its current status
111+ if not self ._stage_lock .acquire (blocking = False ):
112+ return self .status ()
113+ try :
114+ return self ._check_and_stage_locked ()
115+ finally :
116+ self ._stage_lock .release ()
117+
118+ def _check_and_stage_locked (self ) -> SelfUpdateStatus :
104119 current_sha = _resolve_current_sha (self .db , self .source_root )
105120 try :
106121 git .ensure_mirror_fresh (self .paths .self_mirror , self .config .url , timeout = self .config .fetch_timeout_s )
@@ -125,6 +140,9 @@ def _stage(self, target_sha: str, current_sha: str | None, superseded_sha: str |
125140 staging = self .paths .self_staging (op_id )
126141 release_dir = self .paths .self_release (target_sha [:12 ])
127142 venv_path = self .paths .self_venv (target_sha [:12 ])
143+ # leftovers of an attempt that crashed half-way would make the final rename fail
144+ git .rmtree_safe (release_dir )
145+ git .rmtree_safe (venv_path )
128146 try :
129147 git .clone_no_checkout (self .paths .self_mirror , staging )
130148 git .checkout_detach (staging , target_sha )
0 commit comments