fix(switcher+symlinks): rollback on ensure failure (Bug #1) + requiredShared contract test (Bug #10)
Bug #1 (CRITIQUE) — A3 flip+ensure inconsistency - Before: EnsureForAccount failure after flip was WARN-only, SetActiveAccount still fired → daemon declared target active while shared symlinks were absent/divergent → transcripts silently duplicated, resume broken. - After: ensure failure triggers rollback flip to previous account home; if rollback succeeds → explicit error, ActiveAccount stays on previous. If rollback ALSO fails → sticky partialSwap flag + ErrPartialSwap; all further swaps refused until operator intervention (daemon restart). - New public IsPartialSwap() for watchdog / health-check integration. Bug #10 (MOYENNE) — requiredShared contract never exercised - All existing tests override a.sharedSymlinks with tmpdir-scoped lists, so symlinks.RequiredShared itself was never tested. A rename or drop would pass every test but silently break prod failover. - TestRequiredSharedIsCoherent asserts (no filesystem): 3 entries with the exact required names, absolute targets, and a single shared parent directory (invariant EnsureForAccount depends on). Tests: - go test ./... PASS - go test -race ./... PASS (no data race) - 2 new switcher tests: TestFlipEnsureFailureTriggersRollback, TestFlipEnsureAndRollbackFailure - 1 new symlinks test: TestRequiredSharedIsCoherent - 1 obsolete test replaced: TestFlipEnsureSymlinksFailureDoesNotAbortSwap (encoded the old buggy best-effort behaviour)
This commit is contained in:
parent
8eaf0bbd35
commit
20063b1939
4 changed files with 356 additions and 24 deletions
|
|
@ -204,3 +204,73 @@ func TestRequiredShared_defaultsAreReasonable(t *testing.T) {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestRequiredSharedIsCoherent validates the contract of the package-level
|
||||
// RequiredShared constant that the switcher and lifecycle manager consume
|
||||
// in production. The rest of the suite exercises EnsureForAccount /
|
||||
// ValidateAll with tmpdir-scoped override lists, so the actual prod
|
||||
// constant (pointing under /home/ubuntu/.claude-*-shared) is never touched
|
||||
// by those tests — a regression that shrinks or renames RequiredShared
|
||||
// would pass every other test but silently break failover on real VMs
|
||||
// (missing a link → writes to private state → transcripts duplicated).
|
||||
//
|
||||
// The test is filesystem-free: it only asserts the shape of the constant.
|
||||
//
|
||||
// 1. Exactly three entries, one per Name required by the A-failover design.
|
||||
// 2. Every Target is absolute.
|
||||
// 3. All three Targets share the same parent directory — there is no
|
||||
// mode of operation where one shared dir lives elsewhere than the
|
||||
// others. `filepath.Dir(target)` must be identical across entries.
|
||||
//
|
||||
// This encodes the "3 links under one shared root" invariant that
|
||||
// EnsureForAccount relies on. Any future change to RequiredShared that
|
||||
// breaks this invariant should force the author to update the switcher
|
||||
// contract explicitly.
|
||||
func TestRequiredSharedIsCoherent(t *testing.T) {
|
||||
expectedNames := map[string]bool{
|
||||
"session-env": false,
|
||||
"file-history": false,
|
||||
"projects": false,
|
||||
}
|
||||
if len(RequiredShared) != len(expectedNames) {
|
||||
t.Fatalf("RequiredShared must contain exactly %d entries (session-env, file-history, projects); got %d: %+v",
|
||||
len(expectedNames), len(RequiredShared), RequiredShared)
|
||||
}
|
||||
|
||||
var sharedParent string
|
||||
for i, sl := range RequiredShared {
|
||||
if _, ok := expectedNames[sl.Name]; !ok {
|
||||
t.Errorf("RequiredShared[%d]: unexpected Name %q (allowed: session-env / file-history / projects)", i, sl.Name)
|
||||
continue
|
||||
}
|
||||
if expectedNames[sl.Name] {
|
||||
t.Errorf("RequiredShared[%d]: duplicate Name %q", i, sl.Name)
|
||||
}
|
||||
expectedNames[sl.Name] = true
|
||||
|
||||
if sl.Target == "" {
|
||||
t.Errorf("RequiredShared[%d] (%q): empty Target", i, sl.Name)
|
||||
continue
|
||||
}
|
||||
if !filepath.IsAbs(sl.Target) {
|
||||
t.Errorf("RequiredShared[%d] (%q): Target %q must be absolute", i, sl.Name, sl.Target)
|
||||
continue
|
||||
}
|
||||
|
||||
parent := filepath.Dir(sl.Target)
|
||||
if i == 0 {
|
||||
sharedParent = parent
|
||||
continue
|
||||
}
|
||||
if parent != sharedParent {
|
||||
t.Errorf("RequiredShared[%d] (%q): parent dir %q diverges from %q — all shared targets must live under the same root",
|
||||
i, sl.Name, parent, sharedParent)
|
||||
}
|
||||
}
|
||||
|
||||
for name, seen := range expectedNames {
|
||||
if !seen {
|
||||
t.Errorf("RequiredShared is missing the required %q entry", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue