Files
shturman/docs/specs/plans/07-v0.3-power-safe.md
kk0t9 598070de96 docs(v0.3): план реализации power-safe (План 7)
P7.1 чистый PowerFsm (TDD, все переходы) → P7.2 Power-сервис на FSM
(dev-mock кормит события, grace-таймер + durable-barrier sync, integration abort) →
P7.3 watchdog-конфиг + save-time timer → P7.4 lima/E2E блок (N=3 цикла + abort +
power-cut-сим) → P7.5 verify Lima + acceptance + швы. Полный код, без плейсхолдеров.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: Alexander <akotenev2003@gmail.com>
2026-06-24 20:58:38 +03:00

27 KiB
Raw Permalink Blame History

План 7 — v0.3 Power-safe ядро (FSM + graceful shutdown)

REQUIRED SUB-SKILL: executing-plans (или subagent-driven-development) + TDD. Спека: docs/specs/v0.3-power-safe.md. Шаги — чекбоксы - [ ]. P7.4/P7.5 — тяжёлые (Lima); VM уже поднята.

Goal: стаб Power → реальный lifecycle-FSM: ACC → graceful shutdown с durable-write до PONR → переживание срыва питания.

Architecture: чистый PowerFsm (состояния/события/действия, юнит-тестируемый) в shturman-power; сервис оборачивает его (D-Bus state/signals из FSM, dev-mock кормит события, grace-таймер на монотонике, durable-barrier sync на commit). Teardown/unmount — через systemd (реальный poweroff) / харнесс (in-VM-цикл). Подход A спеки §6.

Tech Stack: Rust, zbus 4 (signals/properties), tokio (grace-таймер), systemd (watchdog/savetime), Lima E2E (bash).


File Structure

  • Create crates/core/shturman-power/src/fsm.rsPowerFsm (State/Event/Action/step) + проекции в PowerState/IgnitionState/PowerSource.
  • Modify crates/core/shturman-power/src/lib.rspub mod fsm;.
  • Modify crates/core/shturman-power/src/service.rs — обернуть FSM: D-Bus из FSM; dev-mock кормит события; apply_event (grace-таймер + durable-barrier).
  • Create systemd/watchdog-shturman.confRuntimeWatchdogSec/RebootWatchdogSec (system.conf.d).
  • Create systemd/shturman-savetime.service + systemd/shturman-savetime.timer (B07 periodic save).
  • Modify lima/shturman.yaml (разложить watchdog/savetime), tests/e2e/run.sh (блок power-safe).
  • Modify (P7.5) docs/domains/b-power-lifecycle.md, docs/contracts/ipc.md, docs/specs/v0.1-v0.6-foundation.md §5.2, CLAUDE.md.

P7.1: PowerFsm — чистый FSM питания (B03)

Files: Create crates/core/shturman-power/src/fsm.rs; Modify lib.rs.

  • Шаг 1 — реализация crates/core/shturman-power/src/fsm.rs (тесты — в этом же файле, шаг 2):
//! Чистый FSM питания (B03, спека v0.3 §5). Без D-Bus/async/I/O — сервис исполняет `Action`.

use shturman_ipc::types::{IgnitionState, PowerSource, PowerState, ShutdownReason};

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Phase {
    Abortable,
    Committed,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum State {
    Off,
    Accessory,
    Running,
    ShuttingDown { phase: Phase, reason: ShutdownReason },
    Sleep,         // зарезервировано (полные sleep/wake — v1/v2)
    BatteryCutoff, // зарезервировано (long-park — v1/v2)
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Event {
    AccOn,
    AccOff,
    EngineOn,
    EngineOff,
    UnderVoltage,
    ThermalTrip,
    GraceExpired,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Action {
    ShutdownImminent(ShutdownReason),
    ShutdownAborted,
    AccChanged(bool),
    StartGrace, // сервис запускает grace-таймер (длительность — конфиг сервиса)
    Commit,     // durable-barrier (sync) → PONR
}

/// FSM питания. v0: старт в `Running` (как стаб v0.1). Чистый: `step` без I/O.
pub struct PowerFsm {
    state: State,
}

impl Default for PowerFsm {
    fn default() -> Self {
        Self { state: State::Running }
    }
}

impl PowerFsm {
    pub fn new() -> Self {
        Self::default()
    }
    pub fn state(&self) -> State {
        self.state
    }

    pub fn power_state(&self) -> PowerState {
        match self.state {
            State::Off => PowerState::Off,
            State::Accessory => PowerState::Accessory,
            State::Running => PowerState::Running,
            State::ShuttingDown { .. } => PowerState::ShuttingDown,
            State::Sleep => PowerState::Sleep,
            State::BatteryCutoff => PowerState::BatteryCutoff,
        }
    }
    pub fn ignition(&self) -> IgnitionState {
        match self.state {
            State::Running => IgnitionState::Running,
            State::Accessory => IgnitionState::Accessory,
            _ => IgnitionState::Off,
        }
    }
    pub fn source(&self) -> PowerSource {
        match self.state {
            State::ShuttingDown { reason: ShutdownReason::UnderVoltage, .. } => PowerSource::LowBattery,
            State::ShuttingDown { .. } => PowerSource::HoldupCap,
            _ => PowerSource::Vehicle12v,
        }
    }

    /// Шаг FSM. Возвращает действия для исполнения сервисом (спека §5).
    pub fn step(&mut self, ev: Event) -> Vec<Action> {
        use Event as E;
        use Phase::*;
        use State::*;
        match (self.state, ev) {
            (Off, E::AccOn) => {
                self.state = Accessory;
                vec![Action::AccChanged(true)]
            }
            (Accessory, E::EngineOn) => {
                self.state = Running;
                vec![]
            }
            (Running, E::EngineOff) => {
                self.state = Accessory;
                vec![]
            }
            (Accessory | Running, E::AccOff) => self.begin_shutdown(ShutdownReason::AccOff),
            (Accessory | Running, E::UnderVoltage) => self.begin_shutdown(ShutdownReason::UnderVoltage),
            (Accessory | Running, E::ThermalTrip) => self.begin_shutdown(ShutdownReason::Thermal),
            (ShuttingDown { phase: Abortable, .. }, E::AccOn) => {
                self.state = Running;
                vec![Action::ShutdownAborted, Action::AccChanged(true)]
            }
            (ShuttingDown { phase: Abortable, reason }, E::GraceExpired) => {
                self.state = ShuttingDown { phase: Committed, reason };
                vec![Action::Commit]
            }
            // committed/off/sleep/battery_cutoff + всё прочее — no-op (инвариант: committed не abort-ится)
            _ => vec![],
        }
    }

    fn begin_shutdown(&mut self, reason: ShutdownReason) -> Vec<Action> {
        self.state = State::ShuttingDown { phase: Phase::Abortable, reason };
        vec![Action::ShutdownImminent(reason), Action::StartGrace]
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn off_acc_on_to_accessory() {
        let mut f = PowerFsm { state: State::Off };
        assert_eq!(f.step(Event::AccOn), vec![Action::AccChanged(true)]);
        assert_eq!(f.state(), State::Accessory);
    }

    #[test]
    fn accessory_engine_on_to_running_and_back() {
        let mut f = PowerFsm { state: State::Accessory };
        assert_eq!(f.step(Event::EngineOn), vec![]);
        assert_eq!(f.state(), State::Running);
        assert_eq!(f.step(Event::EngineOff), vec![]);
        assert_eq!(f.state(), State::Accessory);
    }

    #[test]
    fn acc_off_begins_abortable_shutdown() {
        let mut f = PowerFsm::new(); // Running
        assert_eq!(
            f.step(Event::AccOff),
            vec![Action::ShutdownImminent(ShutdownReason::AccOff), Action::StartGrace]
        );
        assert_eq!(f.power_state(), PowerState::ShuttingDown);
        assert_eq!(f.source(), PowerSource::HoldupCap);
    }

    #[test]
    fn under_voltage_reason_and_source() {
        let mut f = PowerFsm::new();
        let a = f.step(Event::UnderVoltage);
        assert_eq!(a[0], Action::ShutdownImminent(ShutdownReason::UnderVoltage));
        assert_eq!(f.source(), PowerSource::LowBattery);
    }

    #[test]
    fn abort_before_ponr() {
        let mut f = PowerFsm::new();
        f.step(Event::AccOff);
        assert_eq!(
            f.step(Event::AccOn),
            vec![Action::ShutdownAborted, Action::AccChanged(true)]
        );
        assert_eq!(f.state(), State::Running);
    }

    #[test]
    fn grace_expired_commits_and_is_irreversible() {
        let mut f = PowerFsm::new();
        f.step(Event::AccOff);
        assert_eq!(f.step(Event::GraceExpired), vec![Action::Commit]);
        // committed: abort игнорируется
        assert_eq!(f.step(Event::AccOn), vec![]);
        assert!(matches!(f.state(), State::ShuttingDown { phase: Phase::Committed, .. }));
    }

    #[test]
    fn reserved_states_noop() {
        let mut f = PowerFsm { state: State::Sleep };
        assert_eq!(f.step(Event::AccOn), vec![]);
        assert_eq!(f.state(), State::Sleep);
    }
}
  • Шаг 2 — lib.rs: добавить pub mod fsm;. (Прочитать crates/core/shturman-power/src/lib.rs, добавить строку рядом с другими mod.)

  • Шаг 3 — прогон. Run: cargo test -p shturman-power fsm. Expected: PASS (7 тестов).

  • Шаг 4 — commit.

git add crates/core/shturman-power/src/fsm.rs crates/core/shturman-power/src/lib.rs
git commit -s -m "feat(v0.3): чистый PowerFsm (состояния/переходы B03)"

P7.2: обернуть FSM в сервис (D-Bus из FSM + dev-mock кормит события + grace + barrier)

Files: Modify crates/core/shturman-power/src/service.rs. Test: crates/core/shturman-power/tests/integration.rs (расширить).

  • Шаг 1 — переписать service.rs (полностью — заменяет плоский State на FSM):
//! Server `ru.shturman.Power1` + (feature `dev-mocks`) `ru.shturman.dev.PowerMock1` (fake-ACC).
//! v0.3: оборачивает чистый `PowerFsm` (спека §5–§7). dev-mock кормит входы FSM (не флипает состояние).

use crate::fsm::{Action, Event, PowerFsm};
use shturman_common::monotonic_secs;
use std::sync::{Arc, Mutex};
use std::time::Duration;
use zbus::interface;
use zbus::object_server::SignalContext;

/// Grace-окно (сек): и поле сигнала `ShutdownImminent`, и длительность таймера. v0.3 — фикс. (конфиг — позже).
const GRACE_SECS: u32 = 2;

pub struct PowerService {
    fsm: Arc<Mutex<PowerFsm>>,
}

impl Default for PowerService {
    fn default() -> Self {
        Self { fsm: Arc::new(Mutex::new(PowerFsm::new())) }
    }
}

impl PowerService {
    pub fn new() -> Self {
        Self::default()
    }
    pub fn power_state(&self) -> shturman_ipc::types::PowerState {
        self.fsm.lock().unwrap().power_state()
    }
    pub fn ignition(&self) -> shturman_ipc::types::IgnitionState {
        self.fsm.lock().unwrap().ignition()
    }
    pub fn source(&self) -> shturman_ipc::types::PowerSource {
        self.fsm.lock().unwrap().source()
    }

    #[cfg(feature = "dev-mocks")]
    pub fn mock(&self) -> PowerMock {
        PowerMock { fsm: Arc::clone(&self.fsm) }
    }
}

/// Шагнуть FSM и исполнить действия (эмит сигналов, grace-таймер, durable-barrier). Свободная функция —
/// чтобы её мог звать и dev-mock, и фоновый grace-таймер (с owned-контекстом).
async fn apply_event(
    fsm: &Arc<Mutex<PowerFsm>>,
    ev: Event,
    ctx: &SignalContext<'_>,
) -> zbus::Result<()> {
    let actions = fsm.lock().unwrap().step(ev);
    for a in actions {
        match a {
            Action::ShutdownImminent(r) => {
                PowerService::shutdown_imminent(ctx, GRACE_SECS, r.as_str()).await?
            }
            Action::ShutdownAborted => PowerService::shutdown_aborted(ctx).await?,
            Action::AccChanged(on) => PowerService::acc_changed(ctx, on).await?,
            Action::StartGrace => {
                let fsm = Arc::clone(fsm);
                let octx = ctx.to_owned();
                tokio::spawn(async move {
                    tokio::time::sleep(Duration::from_secs(GRACE_SECS as u64)).await;
                    let _ = apply_event(&fsm, Event::GraceExpired, &octx).await;
                });
            }
            Action::Commit => {
                // Durable-write barrier (#5): сбросить грязные страницы /data ДО PONR. Settings уже синхронен.
                let _ = std::process::Command::new("sync").status();
                tracing::info!("power: commit (PONR) — durable barrier sync; load-shed: amp/backlight/modem (нет реальных нагрузок в v0)");
            }
        }
    }
    Ok(())
}

#[interface(name = "ru.shturman.Power1")]
impl PowerService {
    async fn get_power_state(&self) -> String {
        self.power_state().as_str().to_string()
    }

    /// Внутренний; sleep/wake — v1/v2 (B §7). В v0.3 — no-op.
    async fn request_sleep(&self) {}

    #[zbus(property)]
    async fn ignition_state(&self) -> String {
        self.ignition().as_str().to_string()
    }
    #[zbus(property)]
    async fn uptime(&self) -> u64 {
        monotonic_secs()
    }
    #[zbus(property)]
    async fn power_source(&self) -> String {
        self.source().as_str().to_string()
    }

    #[zbus(signal)]
    async fn acc_changed(ctx: &SignalContext<'_>, on: bool) -> zbus::Result<()>;
    #[zbus(signal)]
    async fn shutdown_imminent(ctx: &SignalContext<'_>, seconds: u32, reason: &str) -> zbus::Result<()>;
    #[zbus(signal)]
    async fn shutdown_aborted(ctx: &SignalContext<'_>) -> zbus::Result<()>;
    #[zbus(signal)]
    async fn sleep(ctx: &SignalContext<'_>) -> zbus::Result<()>;
    #[zbus(signal)]
    async fn wake(ctx: &SignalContext<'_>) -> zbus::Result<()>;
}

/// dev-mock «fake-ACC/voltage/thermal» — кормит входы FSM. Прод (`--no-default-features`) не регистрирует.
#[cfg(feature = "dev-mocks")]
pub struct PowerMock {
    fsm: Arc<Mutex<PowerFsm>>,
}

#[cfg(feature = "dev-mocks")]
#[interface(name = "ru.shturman.dev.PowerMock1")]
impl PowerMock {
    async fn set_acc(&self, on: bool, #[zbus(signal_context)] ctx: SignalContext<'_>) {
        let ev = if on { Event::AccOn } else { Event::AccOff };
        let _ = apply_event(&self.fsm, ev, &ctx).await;
    }

    async fn set_ignition(&self, state: String, #[zbus(signal_context)] ctx: SignalContext<'_>) {
        // accessory↔running — через EngineOn/Off; off — AccOff.
        let ev = match state.as_str() {
            "running" => Event::EngineOn,
            "accessory" => Event::EngineOff,
            _ => Event::AccOff,
        };
        let _ = apply_event(&self.fsm, ev, &ctx).await;
    }

    async fn trigger_shutdown(
        &self,
        _seconds: u32,
        reason: String,
        #[zbus(signal_context)] ctx: SignalContext<'_>,
    ) {
        let ev = match reason.as_str() {
            "thermal" => Event::ThermalTrip,
            "under_voltage" => Event::UnderVoltage,
            _ => Event::AccOff,
        };
        let _ = apply_event(&self.fsm, ev, &ctx).await;
    }

    async fn abort_shutdown(&self, #[zbus(signal_context)] ctx: SignalContext<'_>) {
        let _ = apply_event(&self.fsm, Event::AccOn, &ctx).await;
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use shturman_ipc::types::{IgnitionState, PowerState, PowerSource};

    #[test]
    fn defaults_running() {
        let svc = PowerService::new();
        assert_eq!(svc.power_state(), PowerState::Running);
        assert_eq!(svc.ignition(), IgnitionState::Running);
        assert_eq!(svc.source(), PowerSource::Vehicle12v);
    }
}
  • Шаг 2 — прогон unit. Run: cargo test -p shturman-power. Expected: PASS (fsm 7 + service 1).

  • Шаг 3 — расширить integration-тест crates/core/shturman-power/tests/integration.rs — добавить тест abort (после существующего power_state_and_fake_acc):

#[tokio::test]
#[ignore = "нужна session-шина: just test-integration"]
async fn shutdown_imminent_then_abort() {
    use futures_util::StreamExt;
    let svc = PowerService::new();
    let mock = svc.mock();
    let server = zbus::Connection::session().await.unwrap();
    server.object_server().at(names::power::PATH, svc).await.unwrap();
    server.object_server().at(names::power::PATH, mock).await.unwrap();
    server.request_name(names::power::NAME).await.unwrap();

    let client = zbus::Connection::session().await.unwrap();
    let power = PowerClient::new(&client).await.unwrap();
    let mut imminent = power.proxy().receive_shutdown_imminent().await.unwrap();
    let mut aborted = power.proxy().receive_shutdown_aborted().await.unwrap();

    // ACC-off → ShutdownImminent(acc_off)
    client.call_method(Some(names::power::NAME), names::power::PATH, Some(names::power::MOCK_IFACE), "SetAcc", &(false,)).await.unwrap();
    let sig = imminent.next().await.unwrap();
    assert_eq!(sig.args().unwrap().reason(), "acc_off");
    assert_eq!(power.power_state().await.unwrap(), PowerState::ShuttingDown);

    // re-power до grace → ShutdownAborted + running
    client.call_method(Some(names::power::NAME), names::power::PATH, Some(names::power::MOCK_IFACE), "SetAcc", &(true,)).await.unwrap();
    aborted.next().await.unwrap();
    assert_eq!(power.power_state().await.unwrap(), PowerState::Running);
}

(Power1Proxy уже объявляет сигналы shutdown_imminent/shutdown_abortedcrates/shturman-ipc/src/proxy.rs:29,31; zbus генерит receive_shutdown_imminent()/receive_shutdown_aborted(). sig.args().unwrap().reason()&str.)

  • Шаг 4 — прогон integration. Run: just test-integration (или dbus-run-session -- cargo test -p shturman-power -- --ignored). Expected: PASS (оба теста).

  • Шаг 5 — lint + commit.

cargo clippy -p shturman-power --all-targets -- -D warnings
git add crates/core/shturman-power/
git commit -s -m "feat(v0.3): Power-сервис на FSM — dev-mock кормит события, grace+durable-barrier"

P7.3: systemd watchdog drop-in + save-time timer (B05/A14/B07)

Files: Create systemd/watchdog-shturman.conf, systemd/shturman-savetime.service, systemd/shturman-savetime.timer.

  • Шаг 1 — systemd/watchdog-shturman.conf (system.conf.d; реальный /dev/watchdog — HW, в VM no-op):
# Watchdog (B05/A14): systemd пингует HW-watchdog в runtime + дедлайн на shutdown-фазу.
# Установка: /etc/systemd/system.conf.d/shturman-watchdog.conf. В VM /dev/watchdog нет → дисциплина (HW-арминг — v0.4).
[Manager]
RuntimeWatchdogSec=30s
RebootWatchdogSec=60s
  • Шаг 2 — systemd/shturman-savetime.service (B07 periodic save last-known-time):
[Unit]
Description=Штурман save last-known-time (fake-hwclock → /data, B07)
After=data.mount
Requires=data.mount

[Service]
Type=oneshot
# FILE из /etc/default/fake-hwclock (→ /data; v0.6). Сервис в Lima masked → зовём напрямую с env.
ExecStart=/bin/sh -c '. /etc/default/fake-hwclock 2>/dev/null; FILE="${FILE:-/data/state/fake-hwclock.data}" fake-hwclock save'
  • Шаг 3 — systemd/shturman-savetime.timer (периодика ~5 мин):
[Unit]
Description=Штурман periodic save-time (B07)

[Timer]
OnBootSec=2min
OnUnitActiveSec=5min

[Install]
WantedBy=shturman-stage2.target
  • Шаг 4 — commit (проверка — P7.4/Lima).
git add systemd/watchdog-shturman.conf systemd/shturman-savetime.service systemd/shturman-savetime.timer
git commit -s -m "feat(v0.3): watchdog-конфиг (B05/A14) + save-time timer (B07)"

P7.4: lima + E2E-блок power-safe (гибрид §9.3)

Files: Modify lima/shturman.yaml, tests/e2e/run.sh.

  • Шаг 1 — lima/shturman.yaml: в блоке раскладки юнитов добавить watchdog + savetime:
      install -d /etc/systemd/system.conf.d
      install -m644 /shturman/systemd/watchdog-shturman.conf /etc/systemd/system.conf.d/shturman-watchdog.conf
      # savetime.service/.timer ловит glob shturman-*.service/.timer? Нет — .timer не под *.service. Ставим явно:
      install -m644 /shturman/systemd/shturman-savetime.service /shturman/systemd/shturman-savetime.timer /etc/systemd/system/

(Существующий install /shturman/systemd/shturman-*.service НЕ ловит .timer — ставим явно выше.)

  • Шаг 2 — tests/e2e/run.sh: установка savetime + watchdog в раскладке. К блоку install (рядом с tmpfiles) добавить:
sudo install -d /etc/systemd/system.conf.d
sudo install -m644 systemd/watchdog-shturman.conf /etc/systemd/system.conf.d/shturman-watchdog.conf
sudo install -m644 systemd/shturman-savetime.service systemd/shturman-savetime.timer /etc/systemd/system/
  • Шаг 3 — tests/e2e/run.sh: блок power-safe. Вставить после блока «Stage 0/1/2», до §8:
# ---- power-safe (v0.3): FSM + N циклов зажигания + abort + power-cut ----
info "power-safe: ShutdownImminent + N=3 цикла зажигания + abort + power-cut"
P_CALL() { busctl --system call "$P_NAME" "$P_PATH" "$P_MOCK" "$@"; }
busctl --system call "$S_NAME" "$S_PATH" "$S_IFACE" Set sv ui.theme s night >/dev/null
echo 0 | sudo tee /data/state/power-cycles >/dev/null

observe_imminent() {  # SetAcc(false) → ждём ShutdownImminent
  local mon; mon=$(mktemp)
  # shellcheck disable=SC2024
  sudo busctl --system monitor "$P_NAME" >"$mon" 2>&1 & local M=$!
  sleep 0.7; P_CALL SetAcc b false; sleep 0.7
  sudo kill "$M" 2>/dev/null; wait "$M" 2>/dev/null
  grep -q ShutdownImminent "$mon" || { echo "--- mon ---"; cat "$mon"; rm -f "$mon"; return 1; }
  rm -f "$mon"
}

for i in 1 2 3; do
  observe_imminent || fail "цикл $i: ShutdownImminent не наблюдаем"
  n=$(($(sudo cat /data/state/power-cycles) + 1))
  sudo systemctl stop shturman-stage1.target   # освобождает /data
  sync; sudo umount /data || fail "цикл $i: umount /data"
  sudo mount /data || fail "цикл $i: mount /data"
  echo "$n" | sudo tee /data/state/power-cycles >/dev/null
  sudo systemctl start shturman.target
  for _ in $(seq 1 15); do systemctl is-active --quiet shturman-settings && break; sleep 1; done
  pass "цикл зажигания $i: stop→umount→remount→restart"
done
got=$(busctl --system call "$S_NAME" "$S_PATH" "$S_IFACE" Get s ui.theme 2>/dev/null)
echo "$got" | grep -q '"night"' || fail "ui.theme потерян после циклов"
[ "$(sudo cat /data/state/power-cycles)" = 3 ] || fail "счётчик циклов != 3"
pass "N=3 цикла: /data + счётчик целы (нет потери)"

# abort до PONR
mon=$(mktemp)
# shellcheck disable=SC2024
sudo busctl --system monitor "$P_NAME" >"$mon" 2>&1 & M=$!
sleep 0.7; P_CALL SetAcc b false; sleep 0.3; P_CALL SetAcc b true; sleep 0.7
sudo kill "$M" 2>/dev/null; wait "$M" 2>/dev/null
grep -q ShutdownAborted "$mon" || { cat "$mon"; rm -f "$mon"; fail "ShutdownAborted не наблюдаем"; }
rm -f "$mon"
findmnt /data >/dev/null || fail "/data не смонтирован после abort"
busctl --system call "$P_NAME" "$P_PATH" "$P_IFACE" GetPowerState | grep -q running || fail "не running после abort"
pass "abort до PONR: ShutdownAborted + /data RW + running"

# power-cut-сим: SIGKILL во время shutdown → /data консистентен
P_CALL SetAcc b false; sleep 0.3
sudo systemctl kill -s KILL shturman-power.service shturman-settings.service 2>/dev/null || true
sudo systemctl stop shturman-stage1.target 2>/dev/null || true
sudo umount /data 2>/dev/null || true
sudo fsck.ext4 -n /var/lib/shturman/data.img >/dev/null 2>&1 || fail "fsck /data не clean после power-cut"
sudo mount /data
sudo grep -q night /data/settings/settings.json || fail "last durable value потерян после power-cut"
pass "power-cut-сим: /data консистентен (fsck clean, night present)"
sudo systemctl start shturman.target
for _ in $(seq 1 15); do systemctl is-active --quiet shturman-settings && break; sleep 1; done

# watchdog/save-time конфиг присутствует
test -f /etc/systemd/system.conf.d/shturman-watchdog.conf || fail "нет watchdog-конфига"
systemctl is-active --quiet shturman-savetime.timer && pass "savetime.timer активен" || echo "  WARN: savetime.timer не активен"
pass "watchdog-конфиг (RuntimeWatchdogSec) на месте"
  • Шаг 4 — shellcheck + commit.
shellcheck -S warning tests/e2e/run.sh
git add lima/shturman.yaml tests/e2e/run.sh
git commit -s -m "feat(v0.3): lima/E2E блок power-safe (N циклов + abort + power-cut)"

P7.5: verify в Lima + acceptance + синхронизация доков

  • Шаг 1 — host-гейт. Run: just ci. Expected: exit 0 (fsm-юниты + service + integration #[ignore]; clippy; deny). Плюс just test-integration (session-шина) — оба Power-теста зелёные.

  • Шаг 2 — чистый E2E. Run: just vm-reset && just e2e. Expected: exit 0; power-safe-блок зелёный (N=3 цикла /data цел, abort→ShutdownAborted, power-cut fsck clean); регресс v0.1/v0.2 зелёный; E2E OK ✅.

  • Шаг 3 — итерации по реальным ошибкам (grace-таймауты, umount-holders, fsck, proxy-сигналы) — систематически, один симптом → одна правка, повтор P7.5 шаг 2.

  • Шаг 4 — prod-build-gate. Run: cargo build -p shturman-power --no-default-features && ! strings target/debug/shturman-power | grep -q PowerMock1. Expected: сборка ок, PowerMock1 отсутствует.

  • Шаг 5 — синхронизация доков (швы §10): docs/domains/b-power-lifecycle.md (реализованные срезы B01B07 в v0.3; abort/PONR-модель VM; HW/MCU/B08-B09 → v0.4); docs/contracts/ipc.md §3 (Power оживлён из FSM); docs/specs/v0.1-v0.6-foundation.md §5.2 («стаб» → реальный FSM); CLAUDE.md (статус v0.3 готово → v0.4/v0.5).

  • Шаг 6 — commit доков.

git add docs/ CLAUDE.md
git commit -s -m "docs(v0.3): синхронизация швов power-safe + статус"
  • Шаг 7 — finishing-a-development-branch (merge/PR — спросить пользователя; в main без явного «ок» не мержить).

Acceptance (спека v0.3 §9.4)

  • FSM: все переходы §5 — unit-тесты; sleep/battery_cutoff — no-op.
  • ShutdownImminent на ACC-off; abort до PONR → ShutdownAborted; commit после grace + durable-barrier.
  • N=3 цикла зажигания — /data + счётчик целы.
  • power-cut-сим — /data консистентен (fsck -n clean, last value present).
  • Uptime монотонен; watchdog/save-time конфиг на месте.
  • Регресс v0.1/v0.2 зелёный; just ci зелёный; prod-build-gate (нет PowerMock1); красные линии целы (нет CAN/actuator).