Portanto, usar strace no processo substituído revelou que tail e head estavam obtendo um SIGHUP do kernel antes que tivessem uma chance de escrever. Uma solução simples é adicionar nohup à substituição:
rm -f log; exec ./script.sh >(nohup tail >log)
Acho que entendo por que isso falha com exec. O IIUC, > (tail > log) cria um processo filho do processo atual bash . No entanto, quando se usa exec, agora se torna um processo filho de script.sh . Quando script.sh sai, o kernel envia SIGHUP para todos os processos-filhos.
Ainda não sei por que isso costumava funcionar, talvez em versões mais recentes do kernel seja mais rápido / mais agressivo em enviar seus SIGHUPs.