Fournisseurs, routeur, file, pause et tick¶
Fournisseurs LLM¶
Le groupe grimoire providers croise le registre déclaratif
(llm-provider-registry.yaml, voir Compatibilité multi-provider)
et l'état de refroidissement/audit runtime pour répondre à : quel fournisseur
appeler maintenant, pour quel palier de coût (cheap/mid/strong) ?
| Commande | Description |
|---|---|
grimoire providers status [--json] |
Fournisseurs activés, modèles par palier, disponibilité (refroidissement + dernier audit), prochain choix |
grimoire providers audit [--json] |
Sonder chaque fournisseur activé sans dépenser (PATH, --version, modèles Ollama) et journaliser available/models_seen/probe_note dans l'état |
grimoire providers cooldown <id> --reason rate_limit\|timeout\|overloaded |
Enregistrer un échec à la main (429, timeout, 529) |
grimoire providers probe [<id>] [--json] |
Sonder (comme audit) puis rouvrir les fournisseurs fermés par une date de forfait illisible (unknown) ; une suspension à date n'est jamais levée |
grimoire providers quota [--json] |
Fenêtre de forfait lue dans les flux d'appel : lower_bound (minorant daté) ou unknown, jamais 0 |
grimoire providers history [--ledger-root <chemin>] [--json] |
Historique des dispatchs par couple (type de tâche, classe) et palier de départ recommandé |
providers audit ne fait jamais l'appel réel : présence du premier mot de
invocation sur le PATH, --version pour un exécutable reconnu (claude,
gemini, copilot, codex, ollama), et pour un fournisseur local (invocation
ollama ... ou provider_type: local) la liste des modèles via
GET /api/tags (hôte surchargeable par OLLAMA_HOST, timeout 3 s). Un
fournisseur jugé available: false est écarté par choose/candidates
jusqu'au prochain audit qui le retrouve — l'audit n'active ni ne désactive
jamais un fournisseur, enabled reste une décision de gouvernance.
providers history (issue #312) lit les événements task.dispatched du
Mission Ledger et compte, par couple (type de tâche, classe de
vérifiabilité) : le nombre de dispatchs et le taux d'escalade depuis
cheap et depuis mid (part des dispatchs où le palier de départ n'a pas
suffi). Pas de classifieur, pas d'entraînement — des compteurs. Avec au
moins 5 observations pour un couple, un taux d'escalade dépassant 40 %
depuis cheap fait recommander mid, dépassant 40 % depuis mid fait
recommander strong ; un couple monté à mid dont les 5 dernières
observations à ce palier n'ont plus escaladé, et qui n'a pas retenté
cheap depuis autant d'observations, voit cheap recommandé à nouveau
(re-sondage) — jamais sous le plancher qu'impose la classe (mid reste le
plancher d'une tâche V1). La colonne « départ recommandé » est exactement
ce que grimoire task dispatch retient par défaut pour ce couple.
Routeur de LLM : une politique déclarative, tracée avant d'être activée¶
grimoire task dispatch choisit un palier de départ par la classe de
vérifiabilité et l'historique d'escalade, puis prend le premier modèle du palier
que le forfait n'a pas fermé. Le routeur (épopée #705, lot K9, issue #774) y
ajoute une décision déclarative et rejouable, sans rien changer à ce chemin
tant qu'on ne l'active pas : route(task, history, provider_state) choisit un
modèle d'après la tâche seule (classe, type, difficulté), l'historique de
réussite par modèle et la disponibilité du fournisseur. Il ne lit jamais le
quota, le budget ni les tokens restants, et ne change jamais de modèle pour
économiser : ces notions relèvent de l'admission, pas du choix. Un fournisseur
suspendu par son forfait y figure comme indisponible, jusqu'à sa date de
réouverture, comme un fournisseur injoignable.
| Commande | Description |
|---|---|
grimoire route explain <tâche> [--json] |
Rejoue la dernière décision task.routed à partir de ses entrées et de inputs_digest ; sortie 1 si le rejeu diffère, 2 sans décision |
grimoire route report [--json] |
Réussite au premier essai (pass^1) par (type, classe, modèle), escalade par couple, accord entre le routeur et le dispatch en mode shadow seulement (une issue session, defer ou refuse suivie d'un appel compte comme un désaccord) ; « non mesuré » sous 5 observations |
grimoire route eval --set <jeu> [--json] |
Seuil d'activation D16 hors ligne ; sortie 0 (tenu), 1 (non tenu), 3 (non mesuré) |
Politique : _grimoire/standard/routing-policy.yaml, sinon le défaut livré
par le kit. Toutes les règles qui correspondent s'appliquent et le palier retenu
est le maximum de leurs min_tier : le résultat ne dépend pas de l'ordre des
règles. route: session est absorbant, V2 n'a jamais de route autonome, aucune
règle qui correspond donne session, et le palier ne descend jamais sous le
plancher de la classe (V0 cheap, V1 mid). Les conditions admises sont
class, type, risk_any, exclusive_files_gt, needs_local : une règle qui
nomme un quota ou un budget invalide la politique. type et risk_any se comparent
en minuscules, sans espaces autour (comme les étiquettes de la tâche) : risk_any: [Security]
vaut [security], une étiquette vide est refusée. Une politique malformée, sans
version ou de version inconnue n'est jamais remplacée par le défaut : aucune
route autonome (on_invalid: refuse) : la décision tracée est refuse et
task dispatch ne lance aucune cascade (refus routing_policy_invalid, sortie 2),
en shadow comme en active. Seul un routeur en panne (exception) laisse le
dispatch suivre l'ancien chemin, avec une décision refuse et son erreur tracées.
version: 1
mode: shadow # défaut ; `active` fait gouverner la décision
rules:
- {id: r-risk, when: {risk_any: [security, migration]}, min_tier: strong}
- {id: r-v1, when: {class: V1}, min_tier: mid}
Tentatives : max_attempts (défaut 3) borne le nombre d'appels de la cascade
en mode active (appels en échec compris, tous paliers confondus) ; en shadow
rien n'est appliqué, la valeur n'est que tracée dans les entrées de task.routed.
Mode : shadow (défaut) émet task.routed avant chaque cascade et laisse le
dispatch suivre l'ancien chemin ; active fait gouverner la décision (palier de
départ sauf --start-tier explicite, ordre des fournisseurs, modèle dans le
palier). Dans le palier, le départage est : réussite au premier essai (au moins 5
observations, fenêtre de 50), puis, à réussite égale seulement, prix catalogue
croissant (un prix inconnu passe après), puis ordre déclaré. Les modèles
suspendus ou refroidis sont listés dans excluded[] avec leur raison. Sans
candidat, la décision est defer : jamais de descente de palier. Revenir à
shadow se fait en un seul champ.
En active, la décision n'est pas contournée : une issue session (règle
route: session, V2, aucune règle qui correspond) ou defer ne lance aucune
cascade (refus routed_to_session ou routed_defer, sortie 2, aucun
task.dispatched). Les fournisseurs appelés sont ceux que la décision admet : une
règle providers: {currency: local} vaut pour tous les paliers de la cascade, un
fournisseur d'une autre monnaie n'est jamais appelé, et au palier retenu seuls les
fournisseurs du classement le sont. En shadow, ces issues ne restent qu'une trace.
Attribution : chaque tentative (task.dispatched) compte pour le couple
(type, classe, modèle) de cette tentative ; un 429, un timeout ou une panne
d'appel sont non attribués et ne comptent jamais comme un succès.
Évaluation : le jeu est un fichier version: 1 / tasks: de tâches V0/V1
closes, chacune avec ses résultats enregistrés par modèle
(results: {<modèle>: {passed, cost_usd}}). Le routeur est comparé à la base
« fixe » (V0 vers cheap, V1 vers mid) : pass^1 du routeur >= base - 1 tâche
et coût connu <= celui de la base. Un coût inconnu, un résultat manquant ou
moins de 20 tâches rendent « non mesuré » : on n'active pas.
La file et l'admission : des réservoirs déclarés, jamais devinés¶
task_list_ready rendait les tâches dans l'ordre du ledger, rien ne bornait la
concurrence au claim et aucune sonde de ressource n'existait. Le lot K10 de
l'épopée #705 ajoute une file triée et une admission pure (aucun appel de LLM,
aucun démon) ; le routeur ne lit pas ce module, le quota relève de l'admission seule.
| Commande | Description |
|---|---|
grimoire queue plan [--json] [--no-routes] |
Ce que l'admission déciderait maintenant, à blanc : l'ordre de la file, les tâches admises, et pour chaque Defer sa raison ; sortie 1 si la capacité déclarée est illisible, 2 si une tâche est en famine |
grimoire queue release <tâche> [--pool ci] [--reason ...] |
Libère un créneau CI dont la fin des checks n'est pas observable (capacity.released) ; geste d'humain, l'origine humaine est exigée comme pour task comment |
File : priorité, puis vieillissement (+1 cran toutes les 24 h passées à l'état ready,
plafonné à critical), puis ancienneté. TaskService.list_ready et l'outil MCP task_list_ready rendent cet ordre.
Une tâche dont une dépendance blocks n'est pas close, ou dont le backoff de réadmission n'est
pas échu, reste ready mais sort de la file du plan (hors file).
Admission : Admit, ou Defer(raison) avec la date de reprise ou unknown. Toutes les
gardes échouent fermées. Ordre : pause, route différée par le routeur, WIP, ci, host, llm, quota.
Le WIP global et par rôle (rôle = owner de la tâche) vient de capacity.yaml, sinon de
orchestration-policy.yaml (wip.limit_global, wip.limit_per_role) ; il est appliqué au claim,
sous le verrou du ledger. Une limite illisible refuse le claim. Sans limite déclarée, rien n'est refusé.
Seules les tâches claimed et running comptent : un bail perdu occupe son créneau jusqu'à task reap.
La reprise waiting → running (simple ou attestée) repasse par la même garde : sans place, elle est
refusée (« Reprise refusée … elle attend une place ») et la tâche reste waiting.
Capacité : _grimoire/standard/capacity.yaml (facultatif, version: 1). Un pool non
déclaré n'est pas sondé ; une section invalide refuse la garde qu'elle porte, les autres continuent. La table pools est une seule section : une clé inconnue ou mal casée (gpu, Ci) la rend illisible et fait refuser ci, host et llm (Defer(unknown)), jamais ignorer un pool déclaré.
version: 1
quota:
tokens_per_day: unlimited # défaut ; ou un entier > 0. 0 et les valeurs illisibles sont refusés, jamais lus comme « illimité »
pools:
ci:
runners: 3 # défaut 3 ; analysis et architecture ne prennent pas de créneau (exempt_types)
host: # facultatif : lu dans /proc ; hors Linux ou illisible, Defer(unknown)
max_load_per_cpu: 1.5
min_mem_available_mb: 2048
llm:
claude: {sessions: 2} # sessions concurrentes par fournisseur, d'après la route du dernier task.routed
wip: {limit_global: 5, limit_per_role: 2}
queue: {aging_hours: 24, famine_hours: 24}
Occupation : dérivée du ledger à chaque appel, aucun compteur ni fichier held/. Un créneau
ci est réservé dès le claim (type non exempté) ou par un push observé (task.ci_pushed) et reste
pris jusqu'à task.ci_finished ou capacity.released, tant que la tâche est claimed, running,
needs_verification ou waiting : se garer ne rend pas le créneau, les checks n'ont pas fini.
La clôture, l'annulation, l'échec ou le retour à ready le rendent. Si la fin des checks
n'est pas observable, le créneau reste pris et visible dans queue plan jusqu'à queue release.
Quota : unlimited est la valeur par défaut, acceptée par la configuration : le kit ne pose
alors jamais Defer(quota), seul un refus du fournisseur le peut. Fini, le jour (UTC) est atteint
à la consommation connue, un minimum lu dans quota.consumed ; la décision porte la route du
routeur, inchangée. Si des appels ont eu lieu aujourd'hui sans chiffre de tokens, la consommation
est unknown : refusée en autonome (GRIMOIRE_UNATTENDED=1), affichée en interactif.
Famine : une tâche différée depuis plus de 24 h (queue.famine_hours) monte au niveau 2
d'attention, sa raison est affichée.
La pause : drain et hold, jamais un arrêt brutal¶
PAUSED n'était émis nulle part et rien ne disait « stoppe tout » sans tuer les
sessions. Le lot K11 de l'épopée #705 ajoute un drapeau fichier, lu sans ouvrir
le ledger, et un tick qui le lit en premier.
| Commande | Description |
|---|---|
grimoire orchestrate pause [--drain\|--hold] [--project\|--global] [--task <id>...] [--reason ...] |
Pose le drapeau (drain par défaut) ; --global (toute la machine) est un geste d'humain |
grimoire orchestrate resume [--global] [--task <id>...] |
Lève le drapeau, ou seulement ces tâches ; geste d'humain (l'origine humaine est exigée, comme pour queue release) |
grimoire orchestrate status [--json] |
Les drapeaux en vigueur et, pour chaque tâche tenue, ce que hold fait sur son hôte (« hold dégradé en drain (hôte sans frein) ») |
grimoire orchestrate tick [--apply\|--plan] [--json] |
Un tick : lit la pause, puis récolte, rend les attentes levées, constate les effets externes et calcule l'admission ; sans --apply (ou avec --plan), le même tick joué sur une copie du ledger, rien n'est écrit ; sous pause, sort en 0 sans ouvrir le ledger ; un second tick simultané saute, en 0 |
grimoire orchestrate install-timer [--interval 5m] [--no-enable] |
Pose le minuteur optionnel du tick (unité systemd utilisateur oneshot et son .timer) ; sans systemd, rend la ligne crontab à poser à la main |
grimoire orchestrate uninstall-timer |
Désactive et retire le minuteur du projet |
Portée. _grimoire-output/control/pause.json porte sur le projet (avec tasks: [...], seulement
sur ces tâches) ; $GRIMOIRE_CONTROL_HOME/pause.json (par défaut ~/.grimoire/control/pause.json) porte
sur tous les projets de la machine. Le tick et le frein lisent les deux et appliquent l'union :
hold l'emporte sur drain. Un drapeau illisible (JSON invalide, permission, mode inconnu) vaut drain
à son niveau, avec une ligne d'attention ; resume le supprime.
drain: rien n'est admis (Defer(pause)au plan, claim refusé sous le verrou du ledger), les agents en cours finissent leur tâche. Une attentewaiting(ci|quota)reprend ; une attentewaiting(paused)attend la fin de la pause.hold, en plus dedrain, sur un hôte dont le refus d'outil est prouvé pour sa version (hosts.capabilities) : lePreToolUserefuse les outils mutants d'une session qui tient une tâche, avec « pause : dépose ton point de reprise ». Le premier refus ouvre une écriture,grimoire task wait <tâche> --reason paused(ou l'action MCPwaitavecto=paused), qui note la branche et le HEAD du worktree. Ensuite tout outil mutant reste refusé, les lectures passent et leStopest permis. Sur un hôte sans frein,holdse dégrade endrainet le dit : la consigne « pause demandée : gare-toi » est injectée au tour suivant.- Flows. À la frontière de node (le node vient d'être accepté, le suivant n'est pas encore ouvert), un
holdpose le run enPAUSED(workflow.paused) : le node suivant n'est ni présenté ni exécuté,flow resumerendpaused: trueet les pilotes (--executor dispatch,upgrade-flow) s'arrêtent. Pendant la pause,flow resumerefuse sans rien écrire ; une fois levée, il rouvre le node suivant (workflow.resumed, une seule fois).drainne pose aucun run : ils finissent. - Tâche garée et frein du bail. Une tâche
waitinggarde le bail de sa session pour la reprise, mais il ne se renouvelle pas pendant l'attente. Tant que l'outil tient dans le bail, rien ne change ; au-delà, lePreToolUserefuse les commandes de la session saufgrimoire task resume|show|attach|renew <sa tâche>(options--epoch,--actor,--session-idseulement, sans guillemets ni enchaînement) : la session reprend elle-même pargrimoire task resume <id> --epoch N, qui renouvelle aussitôt le battement de cœur. Une autre tâche, une écriture ou une forme non reconnue restent refusées. resume: le drapeau est supprimé. Le tick suivant rend leswaiting(paused)enready, dans l'ordre de la file et sans leur faire perdre l'ancienneté d'avant leur claim. Une session restée ouverte lit à son tour « Pause levée : ta tâche … est de nouveau ready » et peut la re-claimer ; une session fermée retrouveresume_command.
Limites écrites : un outil MCP tiers n'est pas classé mutant par le kit (seuls task_claim, task_update et task_add
du kit le sont pour le frein) ; la pause ne reprend pas un effet distant déjà parti ; le bail d'une tâche garée reste tenu
jusqu'au réveil par le tick (K6) ; le frein ne juge pas une session qui ne tient aucune tâche ; un run de flow n'est pas
refusé au démarrage sous hold.
Le tick : idempotent, rejouable, et son minuteur¶
Le lot K12 de l'épopée #705 rend le tick sûr à relancer, à interrompre et à prévisualiser.
- Un seul tick à la fois.
_grimoire-output/control/.tick.lock(verroufsio, sans attente) : un second tick qui le trouve pris saute, code 0. Avecflock(POSIX), un tick tué rend le verrou avec son processus. Sansflock(Windows), le repli est un fichier dont la date fait foi : le tick la rafraîchit tant qu'il tourne, pour qu'un tick de plus de 60 s ne soit pas cassé par un second ; un tick tué laisse ce fichier jusqu'à sa péremption (60 s). Le suivant reprend, et chaque étape relit le ledger sans réécrire ce qui y est déjà (récolte à barrière, différée unique, réveil rejoué) ; un effet relit l'état sur le disque sous son verrou, jamais un cache chargé avant lui. - Le plan est l'application.
orchestrate tick --plan(le défaut sans--apply) joue le tick appliqué sur une copie du ledger sous_grimoire-output/control/tick-plan-*, jamais sous/tmp, puis la retire : mêmes récoltes, mêmes réveils, et une admission qui voit les tâches que ces gestes rendent. Ni board, ni mémoire, ni dispatch ne bougent. - Le travail local n'est pas rendu. Un bail perdu dont le worktree
task/<id>porte des commits absents de toute branche distante, un arbre modifié ou un état git illisible passe enwaiting(human, resume_branch)avec la question, au lieu deready. Un worktree de tâche n'est jamais créé sous un dossier temporaire du système (tempfile.gettempdir(), qui suitTMPDIR, et aussi/tmp,/var/tmp,/dev/shm;%TEMP%et%TMP%sous Windows). - Effets externes réconciliés. Un push ou une PR passe par
effect.intendedpuiseffect.observed; une intention sans constat est vérifiée sur la remote (git ls-remote,gh pr list --head) avant tout nouvel essai : présent, constaté sans être refait ; absent, fait une fois ; invérifiable, rien n'est fait. Un push est « présent » quand le commit poussé est la tête de la branche distante ou son ancêtre (la branche a avancé depuis) ; une branche disparue de la remote alors qu'une branche distante suivie localement contient le commit est « invérifiable » (supprimée après le push : la refaire la ressusciterait). Le tick constate, il ne refait pas. Lesgitde la récolte (resume_branch) tournent avant le verrou du journal, jamais dessous. - Journal tronqué. Une dernière ligne coupée par une coupure de courant est ignorée au rejeu, l'ajout suivant ne s'y colle plus, et le tick l'affiche en attention.
- Minuteur optionnel, désactivé par défaut.
install-timerécritgrimoire-tick-<nom>-<empreinte>.serviceet.timersous$XDG_CONFIG_HOME/systemd/user, puissystemctl --user enable --now(sauf--no-enable).orchestrate statusdit s'il est posé. Sans minuteur, après une coupure ou un forfait épuisé, rien ne reprend avant la prochaine session ou commande.
Limites écrites : sans loginctl enable-linger, un minuteur utilisateur ne tourne pas tant que l'utilisateur ne s'est pas
reconnecté après un redémarrage (non vérifié sur un poste) ; le banc de coupure (pytest -m crash_bench, scénarios A à H)
simule les points de coupure, il ne tue aucun processus ; un backoff se compte depuis l'horodatage d'écriture de la récolte,
si bien que plan et application diffèrent de quelques millisecondes sur cette date ; aucun job du kit n'appelle encore
effects.perform (la primitive et la réconciliation du tick existent, le push et la PR restent à brancher).