feat(lifecycle): validate shared symlinks at daemon startup (A2)

Wire symlinks.ValidateAll into the lifecycle manager so the daemon
refuses to start if any configured account is missing one of the
shared-state symlinks or if a link diverges from the canonical target.

Previously, a missing link on a freshly deployed VM would silently
create a divergent state tree per account (duplicate JSONL transcripts,
broken undo history) — exactly the failure mode the symlinks package
(A1) was introduced to prevent.

The check runs once at startup before EnsureAllSessions, guarding a
single well-defined invariant: "every account home shares the same
projects/, file-history/ and session-env/ roots". No auto-heal on
divergence — we fail fast with an explicit error so the operator fixes
it manually rather than one account's state being overwritten.

Part of Phase 1 Chantier A — Failover robuste.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ubuntu 2026-04-16 19:03:43 +00:00
parent 91091d7abf
commit e16e3526a0
3 changed files with 68 additions and 1 deletions

View file

@ -4,11 +4,13 @@ package lifecycle
import (
"context"
"fmt"
"log"
"time"
"forge.secuaas.ovh/olivier/claude-failover/internal/config"
"forge.secuaas.ovh/olivier/claude-failover/internal/state"
"forge.secuaas.ovh/olivier/claude-failover/internal/symlinks"
"forge.secuaas.ovh/olivier/claude-failover/internal/tmux"
)
@ -47,6 +49,35 @@ func (m *Manager) Run(ctx context.Context) {
}
}
// ValidateSharedSymlinks verifies that every configured account home has
// the three shared-state symlinks (session-env, file-history, projects)
// in place and pointing at the canonical shared targets.
//
// Called once at daemon startup BEFORE sessions are recreated. A missing
// or divergent link would silently fork the state tree between the two
// accounts, breaking failover. We fail fast so the operator fixes it
// before any work is in flight.
//
// EnsureForAccount creates missing links but refuses to touch divergent
// ones — see internal/symlinks for the rationale.
func (m *Manager) ValidateSharedSymlinks() error {
if len(m.config.Accounts) == 0 {
return fmt.Errorf("[lifecycle] no accounts configured — cannot validate shared symlinks")
}
homes := make([]string, 0, len(m.config.Accounts))
for _, acc := range m.config.Accounts {
if acc.Home == "" {
return fmt.Errorf("[lifecycle] account %q has empty home — refusing to continue", acc.Name)
}
homes = append(homes, acc.Home)
}
if err := symlinks.ValidateAll(homes, symlinks.RequiredShared); err != nil {
return fmt.Errorf("shared symlinks invalid, refusing to start: %w", err)
}
m.logger.Printf("[lifecycle] shared symlinks OK for %d account(s)", len(homes))
return nil
}
// EnsureAllSessions creates all configured sessions that are not yet present in tmux.
// It is intended to be called once at daemon startup before Run is launched.
func (m *Manager) EnsureAllSessions() {