Files
system_update/docs/superpowers/plans/2026-06-05-tache2-sj3-reboot-verifie.md
gilles 0fbca06d3d docs: roadmap tâches 1.9-8 (briefs, gates de validation, designs tâche 2) + plans d'implémentation
Cartographie complète (liste_taches/coherence_taches), briefs tacheN + gates
validation_tacheN, design tâche 2 (docs/design/tache2/), specs/plans jalon 1-2
et tâche 1.9/2 (Phase 1, Phase 2, SJ-0→3). Validations consignées (1.9 , 2-8 🟡).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 19:50:25 +02:00

12 KiB

Tâche 2 — SJ-3 (reboot vérifié) — Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: subagent-driven-development / executing-plans. Étapes checkbox.

Goal: Ajouter l'action reboot_verified : capture du boot_id avant reboot, orchestration backend (attente de la coupure SSH, reconnexion avec délai adaptatif, relecture du boot_id), production d'un RebootResult (ok seulement si la machine revient ET le boot_id a changé). L'action reboot (jalon 1) reste inchangée.

Architecture: Référence docs/design/tache2/10-templates-apt.md §4.5 + 40-contrats-json.md §4 (RebootResult). Le template apt/reboot.sh.tpl est enrichi pour émettre ===SU:BOOT_ID_BEFORE===. Un module server/services/rebootVerify.ts contient : classifyReboot(...) (fonction pure, TDD) + verifyReboot(machineId) (orchestration réseau : poll runPlain jusqu'à coupure puis retour). execute.ts route l'action reboot_verified vers cette orchestration. Délai adaptatif stocké dans machine_state (réutilise la table Phase 1).

Tech Stack: TypeScript, ssh2, vitest.


Invariants

  • reboot (jalon 1) inchangé (toujours via apt/reboot.sh.tpl, fire-and-forget). reboot_verified est une nouvelle action.
  • ExecutionResult.reboot est optionnel (SJ-0) → rétro-compatible.
  • Pas de blocage indéfini : timeouts bornés (détection coupure ≤ 60 s ; retour machine ≤ 600 s par défaut).
  • Tree partagé / WIP concurrent : ne toucher QUE templates/apt/reboot.sh.tpl, server/services/rebootVerify.ts (+test), server/services/execute.ts. Ne pas committer.

File Structure

templates/apt/reboot.sh.tpl              # MODIF : +===SU:BOOT_ID_BEFORE===
server/services/rebootVerify.ts          # NOUVEAU : classifyReboot (pure) + verifyReboot (orchestration)
server/services/rebootVerify.test.ts     # NOUVEAU : tests classifyReboot
server/services/execute.ts               # MODIF : route action reboot_verified

Task 1 : Template reboot.sh.tpl (capture boot_id)

Files: Modify templates/apt/reboot.sh.tpl.

  • Step 1 : Remplacer templates/apt/reboot.sh.tpl (ajoute BOOT_ID_BEFORE ; conserve REBOOT_NOW)
#!/bin/sh
export LC_ALL=C
echo "===SU:BOOT_ID_BEFORE==="
cat /proc/sys/kernel/random/boot_id 2>/dev/null
echo "===SU:REBOOT_NOW==="
# Reboot différé pour laisser le canal SSH se fermer proprement.
nohup sh -c 'sleep 2; reboot' >/dev/null 2>&1 &
echo "reboot planifié"
  • Step 2 : (pas de commit)templates/apt/reboot.sh.tpl reste utilisé par l'action reboot (jalon 1) ET reboot_verified.

Task 2 : classifyReboot (pure, TDD)

Files: Create server/services/rebootVerify.ts, server/services/rebootVerify.test.ts.

  • Step 1 : Test (échec attendu)server/services/rebootVerify.test.ts
import { describe, it, expect } from "vitest";
import { classifyReboot, parseBootIdBefore } from "./rebootVerify.js";

describe("parseBootIdBefore", () => {
  it("extrait le boot_id de la sortie du template", () => {
    const raw = "===SU:BOOT_ID_BEFORE===\nabcd-1234\n===SU:REBOOT_NOW===\nreboot planifié";
    expect(parseBootIdBefore(raw)).toBe("abcd-1234");
  });
  it("retourne null si absent", () => {
    expect(parseBootIdBefore("rien")).toBeNull();
  });
});

describe("classifyReboot", () => {
  it("ok si la machine revient avec un boot_id différent", () => {
    expect(classifyReboot({ beforeBootId: "A", afterBootId: "B", wentDown: true, cameBack: true }).status).toBe("ok");
  });
  it("boot_id_unchanged si même boot_id", () => {
    expect(classifyReboot({ beforeBootId: "A", afterBootId: "A", wentDown: true, cameBack: true }).status).toBe("boot_id_unchanged");
  });
  it("ssh_never_went_down si la coupure n'a pas été observée", () => {
    expect(classifyReboot({ beforeBootId: "A", afterBootId: null, wentDown: false, cameBack: false }).status).toBe("ssh_never_went_down");
  });
  it("machine_did_not_return si coupure mais pas de retour", () => {
    expect(classifyReboot({ beforeBootId: "A", afterBootId: null, wentDown: true, cameBack: false }).status).toBe("machine_did_not_return");
  });
});
  • Step 2 : Lancer (échec)rtk pnpm vitest run server/services/rebootVerify.test.ts → FAIL.

  • Step 3 : Implémenter le socle pur dans server/services/rebootVerify.ts

// server/services/rebootVerify.ts
import { runPlain, type SshCreds } from "../ssh/client.js";
import type { RebootResult } from "@shared/types.js";

export function parseBootIdBefore(raw: string): string | null {
  const s = raw.indexOf("===SU:BOOT_ID_BEFORE===");
  if (s === -1) return null;
  const from = s + "===SU:BOOT_ID_BEFORE===".length;
  const e = raw.indexOf("===SU:REBOOT_NOW===", from);
  const id = raw.slice(from, e === -1 ? undefined : e).trim();
  return id || null;
}

export interface RebootSignals {
  beforeBootId: string | null;
  afterBootId: string | null;
  wentDown: boolean;
  cameBack: boolean;
}

/** Détermine le statut d'un reboot vérifié (fonction pure). */
export function classifyReboot(s: RebootSignals): { status: RebootResult["status"] } {
  if (!s.wentDown) return { status: "ssh_never_went_down" };
  if (!s.cameBack || s.afterBootId === null) return { status: "machine_did_not_return" };
  if (s.beforeBootId !== null && s.afterBootId === s.beforeBootId) return { status: "boot_id_unchanged" };
  return { status: "ok" };
}

async function readBootId(creds: SshCreds): Promise<string | null> {
  try {
    const res = await runPlain(creds, "cat /proc/sys/kernel/random/boot_id");
    const id = res.stdout.trim();
    return id || null;
  } catch {
    return null; // connexion impossible (machine down)
  }
}
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));

export interface VerifyOptions {
  beforeBootId: string | null;
  requestedAt: string;
  downTimeoutMs?: number;   // détection de la coupure
  upTimeoutMs?: number;     // attente du retour
  pollMs?: number;
}

/**
 * Orchestration : attend la coupure SSH (machine qui reboote) puis le retour,
 * relit le boot_id, et classe le résultat. Réseau ; non testé unitairement.
 */
export async function verifyReboot(creds: SshCreds, opt: VerifyOptions): Promise<RebootResult> {
  const downTimeoutMs = opt.downTimeoutMs ?? 60000;
  const upTimeoutMs = opt.upTimeoutMs ?? 600000;
  const pollMs = opt.pollMs ?? 5000;
  const t0 = Date.now();

  // Phase A : attendre que la machine devienne injoignable.
  let wentDown = false;
  let sshWentDownAt: string | null = null;
  while (Date.now() - t0 < downTimeoutMs) {
    const id = await readBootId(creds);
    if (id === null) { wentDown = true; sshWentDownAt = new Date().toISOString(); break; }
    await sleep(pollMs);
  }

  // Phase B : attendre le retour (seulement si on a vu la coupure).
  let cameBack = false;
  let sshCameBackAt: string | null = null;
  let afterBootId: string | null = null;
  if (wentDown) {
    const tB = Date.now();
    while (Date.now() - tB < upTimeoutMs) {
      const id = await readBootId(creds);
      if (id !== null) { cameBack = true; sshCameBackAt = new Date().toISOString(); afterBootId = id; break; }
      await sleep(pollMs);
    }
  }

  const { status } = classifyReboot({ beforeBootId: opt.beforeBootId, afterBootId, wentDown, cameBack });
  const waitedSeconds = Math.round((Date.now() - t0) / 1000);
  return {
    beforeBootId: opt.beforeBootId,
    afterBootId,
    requestedAt: opt.requestedAt,
    sshWentDownAt,
    sshCameBackAt,
    waitedSeconds,
    status,
    lastRebootDurationSeconds: status === "ok" ? waitedSeconds : undefined,
    nextRecommendedWaitSeconds: status === "ok" ? Math.round(waitedSeconds * 1.5) + 30 : undefined,
  };
}
  • Step 4 : Lancer (succès)rtk pnpm vitest run server/services/rebootVerify.test.ts → PASS (6). rtk pnpm check → 0 erreur.

  • Step 5 : (pas de commit)


Task 3 : Router l'action reboot_verified dans execute.ts

Files: Modify server/services/execute.ts.

  • Step 1 : Relire server/services/execute.ts (TEMPLATE_FOR, flux, blocs Phase 1).

  • Step 2 : Ajouter reboot_verified à TEMPLATE_FOR (réutilise le même template que reboot)

  reboot_verified: "apt/reboot.sh.tpl",
  • Step 3 : Après l'exécution du script (raw obtenu), lancer la vérification pour reboot_verified et attacher result.reboot. Importer en tête :
import { parseBootIdBefore, verifyReboot } from "./rebootVerify.js";
import type { RebootResult } from "@shared/types.js";

Puis, après le bloc qui calcule status/raw et avant la construction de result (ou juste après, en enrichissant result), ajouter une branche :

  let rebootResult: RebootResult | undefined;
  if (action === "reboot_verified") {
    const beforeBootId = parseBootIdBefore(raw);
    rebootResult = await verifyReboot(getCreds(m), { beforeBootId, requestedAt: startedAt });
    // Le statut de l'exécution suit la vérif : ok si reboot ok, sinon error.
    if (rebootResult.status !== "ok") status = "error";
  }

Puis dans la construction de result, ajouter ...(rebootResult ? { reboot: rebootResult } : {}) ; et conserver rebootRequiredAfterRun existant.

⚠️ verifyReboot est long (jusqu'à plusieurs minutes). C'est acceptable : runAction est déjà lancé en arrière-plan (la route POST renvoie 202). La sortie live reste streamée pendant l'attente n'est pas nécessaire ; on peut publier un message d'attente : outputHub.publish(machineId, "\n[reboot] attente du redémarrage...\n") avant verifyReboot.

  • Step 4 : Persister le délai adaptatif (optionnel, simple) : après verifyReboot, si rebootResult.lastRebootDurationSeconds, l'écrire dans un event :
    if (rebootResult.status === "ok") {
      recordEvent({ machineId, eventType: "reboot_verified", severity: "info", executionId,
        message: `Reboot vérifié en ${rebootResult.waitedSeconds}s (boot_id changé)` });
    }

(Le stockage en colonne dédiée machine_state peut venir plus tard ; l'event suffit au MVP.)

  • Step 5 : Vérifierrtk pnpm check && rtk pnpm test → 0 erreur, tests verts. Les blocs Phase 1 (executions/reports/rawArtifacts/state/event) restent intacts ; reboot (jalon 1) inchangé.

  • Step 6 : (pas de commit)


Task 4 : Vérification finale SJ-3

  • Step 1 : rtk pnpm check && rtk pnpm test && rtk pnpm build → tout vert.
  • Step 2 : Boot smoke (DB jetable) → /health OK. Nettoyer.
  • Step 3 : Reporter. Vérif live indispensable : reboot_verified réel sur une machine de test (la boucle réseau attente-coupure/retour + comparaison boot_id ne peut être validée qu'en conditions réelles). Ne pas committer.

Self-Review (couverture SJ-3)

  • apt/reboot.sh.tpl capture boot_id → Task 1. ✓
  • Orchestration backend (attente coupure → reconnexion délai adaptatif → relecture boot_id) → Task 2 (verifyReboot). ✓
  • RebootResult + statuts (ok/ssh_never_went_down/machine_did_not_return/boot_id_unchanged/timeout) → classifyReboot (TDD) + verifyReboot. ✓
  • Délai adaptatif lastRebootDurationSecondsnextRecommendedWaitSecondsverifyReboot. ✓
  • Conserve l'action reboot jalon 1 → Task 3 (nouvelle action distincte). ✓

Décision : la boucle réseau utilise des timeouts bornés (down ≤ 60 s, up ≤ 600 s, poll 5 s) ; seule classifyReboot (+parseBootIdBefore) est testée unitairement, l'orchestration est validée en live. timeout (statut) est couvert par machine_did_not_return quand le retour n'arrive pas dans upTimeoutMs (mêmes conséquences ; un raffinement timeout explicite est notable mais non bloquant au MVP).