[FIX] migration: repair, replay, and know when to stop
The failure that stops an upgrade is almost always the same one — a COW
copy left behind on the previous version — and the repair is known. So
Enter now repairs, and a repair that actually changed something replays
the command by itself.
A default that acts must know when to stop, and here it must twice over.
Repairing when there is nothing left to repair, then offering it again,
loops without end: measured, « no COW copy has drifted » over and over.
And a repair that does not help would replay the command forever. Three
attempts, then the prompt says plainly that this one needs a developer.
The turn counter is deliberately redundant with that logic: both guard
the same failure, but the counter holds even if the logic is broken one
day by accident. An endless loop in an unattended migration costs a
night.
Also: the smoke tool forced `ask=input` on its own prompt, which
short-circuited auto-run — the question waited for a keystroke nobody
was there to give.
--- FR ---
[FIX] migration : réparer, rejouer, et savoir s'arrêter
L'échec qui arrête une migration est presque toujours le même — une copie
COW restée sur la version d'avant — et la réparation est connue. Entrée
répare donc, et une réparation qui a vraiment changé quelque chose rejoue
la commande d'elle-même.
Un défaut qui agit doit savoir s'arrêter, et ici deux fois plutôt qu'une.
Réparer quand il n'y a plus rien à réparer, puis le reproposer, boucle
sans fin : mesuré, « Aucune copie COW n'a dérivé », encore et encore. Et
une réparation qui ne suffit pas rejouerait indéfiniment. Trois
tentatives, puis l'invite dit qu'il faut un développeur.
Le compteur de tours double volontairement cette logique : il tient même
si elle est cassée un jour par mégarde.
Assisted-by: Claude Opus 5
2026-08-18 00:08:31 -04:00
|
|
|
#!/usr/bin/env python3
|
|
|
|
|
# © 2021-2026 TechnoLibre (http://www.technolibre.ca)
|
|
|
|
|
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
|
|
|
|
|
|
|
|
|
|
"""Réparer puis rejouer — et savoir s'arrêter.
|
|
|
|
|
|
|
|
|
|
Le motif d'échec le plus fréquent d'une migration est une copie COW en
|
|
|
|
|
retard sur sa vue module : « Element <xpath …> cannot be located in parent
|
|
|
|
|
view ». La réparation est connue, et c'est presque toujours ce qu'on allait
|
|
|
|
|
faire. D'où « 3 » par défaut, et le rejeu automatique derrière.
|
|
|
|
|
|
|
|
|
|
Mais un défaut qui agit doit savoir s'arrêter, et à deux titres :
|
|
|
|
|
|
|
|
|
|
- réinitialiser quand il n'y a RIEN à réinitialiser puis reproposer la même
|
|
|
|
|
chose est une boucle sans fin. Vécu : « Aucune copie COW n'a dérivé »,
|
|
|
|
|
encore et encore ;
|
|
|
|
|
- une réparation qui ne suffit pas relancerait la commande indéfiniment.
|
|
|
|
|
Trois tentatives, puis on rend la main : une migration lancée en
|
|
|
|
|
auto-exécution tournerait sinon toute la nuit sur le même échec.
|
|
|
|
|
|
|
|
|
|
Ces tests comptent les tours. C'est la seule façon de prouver qu'une
|
|
|
|
|
boucle se termine.
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
import io
|
|
|
|
|
import os
|
|
|
|
|
import unittest
|
|
|
|
|
from contextlib import redirect_stdout
|
|
|
|
|
|
|
|
|
|
REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
|
|
|
|
|
|
|
|
|
from script.todo import todo_i18n # noqa: E402
|
|
|
|
|
from script.todo.todo_upgrade import TodoUpgrade # noqa: E402
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class Harness(unittest.TestCase):
|
|
|
|
|
"""Un pilote dont la commande échoue toujours, et qu'on observe."""
|
|
|
|
|
|
|
|
|
|
def setUp(self):
|
|
|
|
|
self.addCleanup(
|
|
|
|
|
setattr, todo_i18n, "_current_lang", todo_i18n._current_lang
|
|
|
|
|
)
|
|
|
|
|
todo_i18n._current_lang = "en"
|
|
|
|
|
|
|
|
|
|
def upgrade(self, lst_answer=None, resets=None, echec=True):
|
|
|
|
|
"""`resets` : ce que la réinitialisation rend, tour après tour."""
|
|
|
|
|
obj = TodoUpgrade.__new__(TodoUpgrade)
|
|
|
|
|
obj.dct_progression = {}
|
|
|
|
|
obj.lst_command_executed = []
|
|
|
|
|
obj.write_config = lambda: None
|
|
|
|
|
obj.database_from_command = lambda cmd: "db"
|
|
|
|
|
self.lst_run = []
|
|
|
|
|
self.lst_reset = []
|
|
|
|
|
|
|
|
|
|
class FauxExecute:
|
|
|
|
|
def exec_command_live(_self, cmd, **kw):
|
|
|
|
|
self.lst_run.append(cmd)
|
|
|
|
|
# Toujours en échec : c'est le cas qu'on veut borner.
|
|
|
|
|
return (1 if echec else 0), cmd
|
|
|
|
|
|
|
|
|
|
obj.execute = FauxExecute()
|
|
|
|
|
suite = iter(resets if resets is not None else [])
|
|
|
|
|
|
|
|
|
|
def faux_reset(database):
|
|
|
|
|
self.lst_reset.append(database)
|
|
|
|
|
try:
|
|
|
|
|
return next(suite)
|
|
|
|
|
except StopIteration:
|
|
|
|
|
return False
|
|
|
|
|
|
|
|
|
|
obj.prompt_reset_stale_cow_views = faux_reset
|
|
|
|
|
obj.check_stale_cow_views = lambda db: None
|
|
|
|
|
obj.run_on_terminal = lambda cmd: 0
|
|
|
|
|
reponses = iter(lst_answer or [])
|
|
|
|
|
|
|
|
|
|
def faux_ask(prompt, default=""):
|
|
|
|
|
try:
|
|
|
|
|
return next(reponses)
|
|
|
|
|
except StopIteration:
|
|
|
|
|
# Personne ne répond : c'est le mode auto, et c'est
|
|
|
|
|
# précisément là qu'une boucle sans fin se déclenche.
|
|
|
|
|
return default
|
|
|
|
|
|
|
|
|
|
obj.ask = faux_ask
|
|
|
|
|
return obj
|
|
|
|
|
|
|
|
|
|
def executer(self, obj):
|
|
|
|
|
out = io.StringIO()
|
|
|
|
|
with redirect_stdout(out):
|
|
|
|
|
obj.todo_upgrade_execute("./script/addons/update_addons_all.sh db")
|
|
|
|
|
return out.getvalue()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestRepairingThenReplaying(Harness):
|
|
|
|
|
def test_the_default_repairs(self):
|
|
|
|
|
# Sans réponse, on répare : c'est le geste qu'on allait faire.
|
|
|
|
|
obj = self.upgrade(resets=[True, False])
|
|
|
|
|
self.executer(obj)
|
|
|
|
|
self.assertTrue(self.lst_reset)
|
|
|
|
|
|
|
|
|
|
def test_a_successful_repair_replays_the_command(self):
|
|
|
|
|
obj = self.upgrade(resets=[True, False])
|
|
|
|
|
self.executer(obj)
|
|
|
|
|
self.assertGreaterEqual(len(self.lst_run), 2)
|
|
|
|
|
|
|
|
|
|
def test_a_repair_that_changed_nothing_does_NOT_replay(self):
|
|
|
|
|
# Rejouer sans avoir rien changé donnerait le même échec.
|
|
|
|
|
obj = self.upgrade(resets=[False])
|
|
|
|
|
self.executer(obj)
|
|
|
|
|
self.assertEqual(len(self.lst_run), 1)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestItAlwaysStops(Harness):
|
|
|
|
|
"""La propriété qui compte le plus : la boucle se termine."""
|
|
|
|
|
|
|
|
|
|
def test_nothing_to_reset_does_not_loop_forever(self):
|
|
|
|
|
# Vécu : « Aucune copie COW n'a dérivé », reproposé sans fin parce
|
|
|
|
|
# que le défaut restait « 3 ».
|
|
|
|
|
obj = self.upgrade(resets=[False, False, False, False, False])
|
|
|
|
|
self.executer(obj)
|
|
|
|
|
self.assertLessEqual(len(self.lst_reset), 2)
|
|
|
|
|
|
|
|
|
|
def test_a_repair_that_never_helps_stops_after_three(self):
|
|
|
|
|
obj = self.upgrade(resets=[True] * 20)
|
|
|
|
|
self.executer(obj)
|
|
|
|
|
self.assertEqual(len(self.lst_run), TodoUpgrade.MAX_ERROR_RETRY)
|
|
|
|
|
|
|
|
|
|
def test_giving_up_says_so_out_loud(self):
|
|
|
|
|
# S'arrêter en silence ferait croire que c'est réparé.
|
|
|
|
|
obj = self.upgrade(resets=[True] * 20)
|
|
|
|
|
text = self.executer(obj)
|
|
|
|
|
self.assertIn("needs a developer", text)
|
|
|
|
|
|
|
|
|
|
def test_the_loop_is_bounded_STRUCTURALLY(self):
|
|
|
|
|
"""Même si la bascule du défaut disparaissait un jour.
|
|
|
|
|
|
|
|
|
|
Deux protections pour la même panne, et c'est délibéré : celle-ci
|
|
|
|
|
ne dépend d'aucune logique métier. Une boucle infinie dans une
|
|
|
|
|
migration lancée sans surveillance coûte une nuit, et la seule
|
|
|
|
|
preuve qu'une boucle se termine est de compter ses tours.
|
|
|
|
|
"""
|
|
|
|
|
obj = self.upgrade(lst_answer=["2"] * 50, resets=[])
|
|
|
|
|
self.executer(obj)
|
|
|
|
|
self.assertLessEqual(len(self.lst_run), 2)
|
|
|
|
|
|
|
|
|
|
def test_the_turn_bound_says_so(self):
|
|
|
|
|
obj = self.upgrade(lst_answer=["2"] * 50, resets=[])
|
|
|
|
|
text = self.executer(obj)
|
|
|
|
|
self.assertIn("Too many turns", text)
|
|
|
|
|
|
|
|
|
|
def test_the_bound_is_three(self):
|
|
|
|
|
self.assertEqual(TodoUpgrade.MAX_ERROR_RETRY, 3)
|
|
|
|
|
|
|
|
|
|
def test_a_command_that_succeeds_never_asks(self):
|
|
|
|
|
obj = self.upgrade(echec=False)
|
|
|
|
|
self.executer(obj)
|
|
|
|
|
self.assertEqual(self.lst_reset, [])
|
|
|
|
|
self.assertEqual(len(self.lst_run), 1)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestTheHumanKeepsTheWheel(Harness):
|
|
|
|
|
def test_typing_one_replays_without_consuming_the_budget(self):
|
|
|
|
|
# Le plafond borne le rejeu AUTOMATIQUE. Quelqu'un qui tape « 1 »
|
|
|
|
|
# sait ce qu'il fait, et se voir refuser un quatrième essai serait
|
|
|
|
|
# une surprise désagréable.
|
|
|
|
|
obj = self.upgrade(lst_answer=["1", "1", "1", "1", ""])
|
|
|
|
|
self.executer(obj)
|
|
|
|
|
self.assertEqual(len(self.lst_run), 5)
|
|
|
|
|
|
|
|
|
|
def test_typing_n_continues_without_repairing(self):
|
|
|
|
|
obj = self.upgrade(lst_answer=[""] * 0 + ["x"])
|
|
|
|
|
self.executer(obj)
|
|
|
|
|
self.assertEqual(self.lst_reset, [])
|
|
|
|
|
self.assertEqual(len(self.lst_run), 1)
|
|
|
|
|
|
|
|
|
|
def test_the_prompt_says_what_enter_does(self):
|
|
|
|
|
import inspect
|
|
|
|
|
|
[ADD] migration: repair views whose stored type contradicts their parent
Odoo refused to load a database: it validated a <search> arch with tree
rules. The view is not a COW copy -- the COW tools were right to say
they had nothing to reset -- its stored `type` simply lies.
Nothing would ever have fixed it. In ir_ui_view.py, `type` is filled in
`create`, and only when absent; `write` never recomputes it. A wrong
value stays wrong, and `-u module` fails on the validation it causes
before it could rewrite anything.
Plain SQL, no ORM: the registry is what will not load, and a repair that
needed Odoo to fix what stops Odoo would be useless.
The rule is Odoo's own -- an inherited view takes its parent's type --
and it holds: zero disagreement on a pristine 18 install and on four
migrated databases, exactly one on the database that refused to load.
--- FR ---
Odoo refusait de charger une base : il validait un arch <search> avec
les règles d'un tree. La vue n'est pas une copie COW — les outils COW
avaient raison de dire qu'ils n'avaient rien à réinitialiser — c'est son
`type` stocké qui ment.
Rien ne l'aurait jamais réparé. Dans ir_ui_view.py, `type` est rempli
dans `create`, et seulement s'il est absent ; `write` ne le recalcule
jamais. Une valeur fausse le reste, et `-u module` échoue sur la
validation qu'elle provoque avant de pouvoir réécrire quoi que ce soit.
En SQL, sans ORM : c'est le registre qui ne charge plus, et une
réparation qui aurait besoin d'Odoo ne servirait à rien.
La règle est celle d'Odoo — une vue héritée prend le type de son parent
— et elle tient : zéro écart sur une 18 neuve et sur quatre bases
migrées, exactement un sur celle qui refusait de charger.
Assisted-by: Claude Opus 5
2026-08-22 00:46:42 -04:00
|
|
|
# Le menu d'erreur vit dans `_prompt_on_error`, extrait de
|
|
|
|
|
# `todo_upgrade_execute` quand celui-ci a passé le seuil de
|
|
|
|
|
# complexité. Lire les deux : c'est le CHEMIN d'erreur qu'on
|
|
|
|
|
# éprouve, pas une méthode en particulier.
|
|
|
|
|
source = inspect.getsource(
|
|
|
|
|
TodoUpgrade.todo_upgrade_execute
|
|
|
|
|
) + inspect.getsource(TodoUpgrade._prompt_on_error)
|
[FIX] migration: repair, replay, and know when to stop
The failure that stops an upgrade is almost always the same one — a COW
copy left behind on the previous version — and the repair is known. So
Enter now repairs, and a repair that actually changed something replays
the command by itself.
A default that acts must know when to stop, and here it must twice over.
Repairing when there is nothing left to repair, then offering it again,
loops without end: measured, « no COW copy has drifted » over and over.
And a repair that does not help would replay the command forever. Three
attempts, then the prompt says plainly that this one needs a developer.
The turn counter is deliberately redundant with that logic: both guard
the same failure, but the counter holds even if the logic is broken one
day by accident. An endless loop in an unattended migration costs a
night.
Also: the smoke tool forced `ask=input` on its own prompt, which
short-circuited auto-run — the question waited for a keystroke nobody
was there to give.
--- FR ---
[FIX] migration : réparer, rejouer, et savoir s'arrêter
L'échec qui arrête une migration est presque toujours le même — une copie
COW restée sur la version d'avant — et la réparation est connue. Entrée
répare donc, et une réparation qui a vraiment changé quelque chose rejoue
la commande d'elle-même.
Un défaut qui agit doit savoir s'arrêter, et ici deux fois plutôt qu'une.
Réparer quand il n'y a plus rien à réparer, puis le reproposer, boucle
sans fin : mesuré, « Aucune copie COW n'a dérivé », encore et encore. Et
une réparation qui ne suffit pas rejouerait indéfiniment. Trois
tentatives, puis l'invite dit qu'il faut un développeur.
Le compteur de tours double volontairement cette logique : il tient même
si elle est cassée un jour par mégarde.
Assisted-by: Claude Opus 5
2026-08-18 00:08:31 -04:00
|
|
|
self.assertIn('defaut = "3" if database_name else ""', source)
|
|
|
|
|
self.assertIn("default=defaut", source)
|
|
|
|
|
|
|
|
|
|
def test_without_a_database_the_default_is_to_continue(self):
|
|
|
|
|
# Les options 2 à 4 ne s'affichent pas : proposer « 3 » viserait
|
|
|
|
|
# une commande qui n'existe pas.
|
|
|
|
|
obj = self.upgrade()
|
|
|
|
|
obj.database_from_command = lambda cmd: None
|
|
|
|
|
self.executer(obj)
|
|
|
|
|
self.assertEqual(self.lst_reset, [])
|
|
|
|
|
self.assertEqual(len(self.lst_run), 1)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestTheResetReportsWhatItDid(unittest.TestCase):
|
|
|
|
|
"""Sans verdict, on ne peut pas décider de rejouer."""
|
|
|
|
|
|
|
|
|
|
def test_nothing_drifted_is_False(self):
|
|
|
|
|
obj = TodoUpgrade.__new__(TodoUpgrade)
|
|
|
|
|
obj.stale_cow_keys = lambda db: []
|
|
|
|
|
with redirect_stdout(io.StringIO()):
|
|
|
|
|
self.assertFalse(obj.prompt_reset_stale_cow_views("db"))
|
|
|
|
|
|
|
|
|
|
def test_saying_no_is_False(self):
|
|
|
|
|
obj = TodoUpgrade.__new__(TodoUpgrade)
|
|
|
|
|
obj.stale_cow_keys = lambda db: ["web.layout"]
|
|
|
|
|
obj.ask_gate = lambda prompt, default="": "n"
|
|
|
|
|
with redirect_stdout(io.StringIO()):
|
|
|
|
|
self.assertFalse(obj.prompt_reset_stale_cow_views("db"))
|
|
|
|
|
|
|
|
|
|
def test_a_reset_that_ran_is_True(self):
|
|
|
|
|
obj = TodoUpgrade.__new__(TodoUpgrade)
|
[ADD] migration: « t » shows where the migration stands
A migration crosses six bumps, runs hundreds of commands and lasts hours.
The journal said what had been LAUNCHED; it never said what came of it.
Three hours in, one reads two hundred command lines without knowing which
one failed, nor what the smoke test concluded.
« v » could not carry this: it already means « view the differences » in
three prompts, and a letter meaning two things is worse than a letter
meaning nothing. « t » was free, and it is the same everywhere — one
shared string carries both shortcuts, so no prompt can drift.
Looking is not answering: the same question comes back afterwards.
What was missing was the data. Failures and tool verdicts are now
recorded with the step they happened in, bounded so a progression file
cannot grow without end. A tool rerun after a repair keeps its LAST
verdict and the count of its runs — showing both without distinction
would read a repair as a lasting failure.
--- FR ---
[ADD] migration : « t » montre où en est la migration
Une migration traverse six paliers, lance des centaines de commandes et
dure des heures. Le journal disait ce qui avait été LANCÉ, jamais ce que
cela avait donné. Trois heures plus tard on relit deux cents lignes sans
savoir laquelle a échoué, ni ce que le test de fumée a conclu.
« v » ne pouvait pas porter cela : il veut déjà dire « voir les
différences » dans trois invites, et une lettre qui signifie deux choses
est pire qu'une lettre qui ne signifie rien. « t » était libre, et il est
le même partout — une seule chaîne porte les deux raccourcis.
Regarder n'est pas répondre : la même question revient ensuite.
Ce qui manquait, c'étaient les données. Les échecs et les verdicts
d'outils sont désormais retenus avec l'étape où ils se sont produits.
Assisted-by: Claude Opus 5
2026-08-19 03:53:56 -04:00
|
|
|
obj.dct_progression = {}
|
|
|
|
|
obj.write_config = lambda: None
|
[FIX] migration: repair, replay, and know when to stop
The failure that stops an upgrade is almost always the same one — a COW
copy left behind on the previous version — and the repair is known. So
Enter now repairs, and a repair that actually changed something replays
the command by itself.
A default that acts must know when to stop, and here it must twice over.
Repairing when there is nothing left to repair, then offering it again,
loops without end: measured, « no COW copy has drifted » over and over.
And a repair that does not help would replay the command forever. Three
attempts, then the prompt says plainly that this one needs a developer.
The turn counter is deliberately redundant with that logic: both guard
the same failure, but the counter holds even if the logic is broken one
day by accident. An endless loop in an unattended migration costs a
night.
Also: the smoke tool forced `ask=input` on its own prompt, which
short-circuited auto-run — the question waited for a keystroke nobody
was there to give.
--- FR ---
[FIX] migration : réparer, rejouer, et savoir s'arrêter
L'échec qui arrête une migration est presque toujours le même — une copie
COW restée sur la version d'avant — et la réparation est connue. Entrée
répare donc, et une réparation qui a vraiment changé quelque chose rejoue
la commande d'elle-même.
Un défaut qui agit doit savoir s'arrêter, et ici deux fois plutôt qu'une.
Réparer quand il n'y a plus rien à réparer, puis le reproposer, boucle
sans fin : mesuré, « Aucune copie COW n'a dérivé », encore et encore. Et
une réparation qui ne suffit pas rejouerait indéfiniment. Trois
tentatives, puis l'invite dit qu'il faut un développeur.
Le compteur de tours double volontairement cette logique : il tient même
si elle est cassée un jour par mégarde.
Assisted-by: Claude Opus 5
2026-08-18 00:08:31 -04:00
|
|
|
obj.stale_cow_keys = lambda db: ["web.layout"]
|
|
|
|
|
obj.ask_gate = lambda prompt, default="": default
|
|
|
|
|
obj.run_on_terminal = lambda cmd: 0
|
|
|
|
|
with redirect_stdout(io.StringIO()):
|
|
|
|
|
self.assertTrue(obj.prompt_reset_stale_cow_views("db"))
|
|
|
|
|
|
|
|
|
|
def test_a_reset_that_FAILED_is_False(self):
|
|
|
|
|
# Rejouer derrière une réinitialisation qui a échoué, c'est brûler
|
|
|
|
|
# une tentative sur un état inchangé.
|
|
|
|
|
obj = TodoUpgrade.__new__(TodoUpgrade)
|
[ADD] migration: « t » shows where the migration stands
A migration crosses six bumps, runs hundreds of commands and lasts hours.
The journal said what had been LAUNCHED; it never said what came of it.
Three hours in, one reads two hundred command lines without knowing which
one failed, nor what the smoke test concluded.
« v » could not carry this: it already means « view the differences » in
three prompts, and a letter meaning two things is worse than a letter
meaning nothing. « t » was free, and it is the same everywhere — one
shared string carries both shortcuts, so no prompt can drift.
Looking is not answering: the same question comes back afterwards.
What was missing was the data. Failures and tool verdicts are now
recorded with the step they happened in, bounded so a progression file
cannot grow without end. A tool rerun after a repair keeps its LAST
verdict and the count of its runs — showing both without distinction
would read a repair as a lasting failure.
--- FR ---
[ADD] migration : « t » montre où en est la migration
Une migration traverse six paliers, lance des centaines de commandes et
dure des heures. Le journal disait ce qui avait été LANCÉ, jamais ce que
cela avait donné. Trois heures plus tard on relit deux cents lignes sans
savoir laquelle a échoué, ni ce que le test de fumée a conclu.
« v » ne pouvait pas porter cela : il veut déjà dire « voir les
différences » dans trois invites, et une lettre qui signifie deux choses
est pire qu'une lettre qui ne signifie rien. « t » était libre, et il est
le même partout — une seule chaîne porte les deux raccourcis.
Regarder n'est pas répondre : la même question revient ensuite.
Ce qui manquait, c'étaient les données. Les échecs et les verdicts
d'outils sont désormais retenus avec l'étape où ils se sont produits.
Assisted-by: Claude Opus 5
2026-08-19 03:53:56 -04:00
|
|
|
obj.dct_progression = {}
|
|
|
|
|
obj.write_config = lambda: None
|
[FIX] migration: repair, replay, and know when to stop
The failure that stops an upgrade is almost always the same one — a COW
copy left behind on the previous version — and the repair is known. So
Enter now repairs, and a repair that actually changed something replays
the command by itself.
A default that acts must know when to stop, and here it must twice over.
Repairing when there is nothing left to repair, then offering it again,
loops without end: measured, « no COW copy has drifted » over and over.
And a repair that does not help would replay the command forever. Three
attempts, then the prompt says plainly that this one needs a developer.
The turn counter is deliberately redundant with that logic: both guard
the same failure, but the counter holds even if the logic is broken one
day by accident. An endless loop in an unattended migration costs a
night.
Also: the smoke tool forced `ask=input` on its own prompt, which
short-circuited auto-run — the question waited for a keystroke nobody
was there to give.
--- FR ---
[FIX] migration : réparer, rejouer, et savoir s'arrêter
L'échec qui arrête une migration est presque toujours le même — une copie
COW restée sur la version d'avant — et la réparation est connue. Entrée
répare donc, et une réparation qui a vraiment changé quelque chose rejoue
la commande d'elle-même.
Un défaut qui agit doit savoir s'arrêter, et ici deux fois plutôt qu'une.
Réparer quand il n'y a plus rien à réparer, puis le reproposer, boucle
sans fin : mesuré, « Aucune copie COW n'a dérivé », encore et encore. Et
une réparation qui ne suffit pas rejouerait indéfiniment. Trois
tentatives, puis l'invite dit qu'il faut un développeur.
Le compteur de tours double volontairement cette logique : il tient même
si elle est cassée un jour par mégarde.
Assisted-by: Claude Opus 5
2026-08-18 00:08:31 -04:00
|
|
|
obj.stale_cow_keys = lambda db: ["web.layout"]
|
|
|
|
|
obj.ask_gate = lambda prompt, default="": default
|
|
|
|
|
obj.run_on_terminal = lambda cmd: 2
|
|
|
|
|
with redirect_stdout(io.StringIO()):
|
|
|
|
|
self.assertFalse(obj.prompt_reset_stale_cow_views("db"))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
if __name__ == "__main__":
|
|
|
|
|
unittest.main()
|