diff --git a/VERSION.md b/VERSION.md index 7f1ac26..802aa9c 100644 --- a/VERSION.md +++ b/VERSION.md @@ -1,4 +1,37 @@ -# Version actuelle : 0.2.3 +# Version actuelle : 0.3.0 + +## [0.3.0] - 2026-04-15 +**Type:** Minor — Auto-resume des sessions dédiées après un swap légitime + +### Corrigé +- **Les sessions dédiées (ccl-1-conformvault, ccl-2-scanyze) étaient tuées puis + recréées au bash prompt lors d'un swap légitime** (vrai 429 quota hit), + interrompant le travail interactif en cours. L'opérateur devait relancer + manuellement `claude --resume ` avec le bon `CLAUDE_CONFIG_DIR` après + chaque swap. +- La couverture de `saveAllSessions()` ne captait que les sessions tracked en + `state="working"`. Les sessions dédiées user-driven étaient ignorées, donc + leur UUID de resume était perdu au kill. + +### Ajouté +- `switcher.saveDedicatedUUIDs()` : capture le UUID de chaque session dédiée + configurée, peu importe son tracked state. Appelé juste avant `killAll`. +- `switcher.relaunchDedicatedSessions(targetHome)` : après recréation, + envoie `CLAUDE_CONFIG_DIR= claude --dangerously-skip-permissions + --resume ` dans chaque session dédiée. Si l'UUID manque, la session + reste au shell (pas de tentative aveugle). +- `isValidResumeUUID()` défense contre un fichier resume-id corrompu (check + longueur 36 + regex hex/dash). + +### Tests +- ✅ `TestDedicatedRelaunchAfterSwap` vérifie : capture UUID → write file → + relaunch avec la bonne commande → `CLAUDE_CONFIG_DIR` pointant sur le home + du compte cible. +- ✅ `go test ./...` full suite + +### Fichiers modifiés +- `internal/switcher/account_switcher.go` +- `internal/switcher/account_switcher_test.go` ## [0.2.3] - 2026-04-15 **Type:** Patch — Veto 5xx pour écarter les faux positifs persistants diff --git a/WORK_IN_PROGRESS.md b/WORK_IN_PROGRESS.md index 1529d27..049b34a 100644 --- a/WORK_IN_PROGRESS.md +++ b/WORK_IN_PROGRESS.md @@ -4,7 +4,7 @@ 2026-04-15 19:30:00 ## Version Actuelle -0.2.3 +0.3.0 ## Demande Actuelle Aucune — v0.2.3 shippée, service stable. @@ -20,12 +20,8 @@ Aucune — v0.2.3 shippée, service stable. - [x] Push sur Forgejo `origin/main` (commits `7c5f838` et `62e98cb`) ## Prochaines Étapes -- [ ] **Optionnel** : préserver les sessions dédiées (ccl-1-conformvault, - ccl-2-scanyze) lors d'un swap légitime — actuellement `killAllPoolSessions` - les tue aussi. Interruption désagréable pour le travail interactif. - Options : skip dedicated dans le kill, OU auto-relaunch avec - `--resume ` après kill. Non-bloquant tant que les vrais quota hits - sont rares. +- [x] ~~préserver les sessions dédiées lors d'un swap légitime~~ — fait en v0.3.0 + via `saveDedicatedUUIDs` + `relaunchDedicatedSessions`. - [ ] **Optionnel** : telegram alert quand `SwapRequested` est émis pour que l'opérateur soit au courant sans lire les logs. Le `notifier.Telegram` existe déjà — il suffit de câbler. diff --git a/internal/switcher/account_switcher.go b/internal/switcher/account_switcher.go index a3c0840..67e0c67 100644 --- a/internal/switcher/account_switcher.go +++ b/internal/switcher/account_switcher.go @@ -90,9 +90,13 @@ func (a *AccountSwitcher) Run(ctx context.Context) { func (a *AccountSwitcher) executeSwitch(req quota.SwitchRequest) { a.logger.Printf("[switcher] SWAP initiated from=%q reset=%q", req.From, req.ResetTime) - // 1. SAVING — capture resume UUIDs from all working sessions. + // 1. SAVING — capture resume UUIDs from all working sessions plus + // every dedicated session unconditionally (dedicated sessions are + // user-driven and may not be tracked as "working" in state, but their + // UUIDs are the most valuable to preserve across a swap). a.currentState = StateSaving a.saveAllSessions() + a.saveDedicatedUUIDs() // 2. SWITCHING — find target, flip symlink, restart sessions. a.currentState = StateSwitching @@ -108,6 +112,7 @@ func (a *AccountSwitcher) executeSwitch(req quota.SwitchRequest) { } a.killAllPoolSessions() a.recreatePoolSessions() + a.relaunchDedicatedSessions(target.Home) // Update active account and record the swap timestamp so the quota // monitor can enforce a cooldown before requesting another one. @@ -132,6 +137,75 @@ func (a *AccountSwitcher) executeSwitch(req quota.SwitchRequest) { a.currentState = StateNormal } +// saveDedicatedUUIDs captures the resume UUID for every configured dedicated +// session, regardless of its tracked state. Dedicated sessions are typically +// user-driven and not in state="working", but their UUIDs are the most +// valuable to preserve across a swap so the user's work is not lost. +func (a *AccountSwitcher) saveDedicatedUUIDs() { + for _, ds := range a.config.Pool.Dedicated { + if !a.tmux.HasSession(ds.Name) { + continue + } + tail, err := a.tmux.CapturePaneTail(ds.Name, 200) + if err != nil { + continue + } + uuid := extractResumeUUID(tail) + if uuid == "" { + continue + } + dir := a.resumeContextDir() + if err := os.MkdirAll(dir, 0700); err != nil { + a.logger.Printf("[switcher] mkdir %s: %v", dir, err) + continue + } + path := filepath.Join(dir, ds.Name+"-resume-id.txt") + if err := os.WriteFile(path, []byte(uuid), 0600); err != nil { + a.logger.Printf("[switcher] write %s: %v", path, err) + continue + } + a.logger.Printf("[switcher] saved dedicated resume UUID for %q: %s", ds.Name, uuid) + } +} + +// relaunchDedicatedSessions sends a `claude --resume ` command to each +// dedicated session after recreation, using the target account's home via +// CLAUDE_CONFIG_DIR so the session follows the active account. If no UUID was +// captured for a session, it is left at the bash prompt for manual restart. +func (a *AccountSwitcher) relaunchDedicatedSessions(targetHome string) { + for _, ds := range a.config.Pool.Dedicated { + path := filepath.Join(a.resumeContextDir(), ds.Name+"-resume-id.txt") + data, err := os.ReadFile(path) + if err != nil { + a.logger.Printf("[switcher] no saved resume UUID for %q (%v) — leaving at shell", ds.Name, err) + continue + } + uuid := strings.TrimSpace(string(data)) + if !isValidResumeUUID(uuid) { + a.logger.Printf("[switcher] invalid UUID for %q: %q", ds.Name, uuid) + continue + } + // targetHome is operator-controlled (config file); uuid is regex-validated. + // Neither is user-supplied runtime input, so shell interpolation is safe. + cmd := fmt.Sprintf("CLAUDE_CONFIG_DIR=%s claude --dangerously-skip-permissions --resume %s", + targetHome, uuid) + if err := a.tmux.SendKeys(ds.Name, cmd); err != nil { + a.logger.Printf("[switcher] relaunch %q: %v", ds.Name, err) + continue + } + a.logger.Printf("[switcher] relaunched %q on %s (resume=%s)", ds.Name, targetHome, uuid) + } +} + +// isValidResumeUUID defends against corrupted resume-id files by requiring +// the canonical 36-char lowercase hex+dash UUID format. +func isValidResumeUUID(s string) bool { + if len(s) != 36 { + return false + } + return resumeRe.MatchString("claude --resume " + s) +} + // saveAllSessions captures the resume UUID for every working session. func (a *AccountSwitcher) saveAllSessions() { a.state.ForEachWorking(func(name string, _ *state.SessionState) { diff --git a/internal/switcher/account_switcher_test.go b/internal/switcher/account_switcher_test.go index 5cc6dc8..24aeab5 100644 --- a/internal/switcher/account_switcher_test.go +++ b/internal/switcher/account_switcher_test.go @@ -1,6 +1,7 @@ package switcher import ( + "strings" "testing" "time" @@ -11,10 +12,11 @@ import ( // mockTmux for switcher tests. type mockTmux struct { - sessions map[string]bool - paneOutput map[string]string - killCalls []string - createCalls []string + sessions map[string]bool + paneOutput map[string]string + killCalls []string + createCalls []string + sendKeyCalls []string } func newMockTmux() *mockTmux { @@ -35,7 +37,10 @@ func (m *mockTmux) KillSession(name string) error { m.killCalls = append(m.killCalls, name) return nil } -func (m *mockTmux) SendKeys(_, _ string) error { return nil } +func (m *mockTmux) SendKeys(session, keys string) error { + m.sendKeyCalls = append(m.sendKeyCalls, session+":"+keys) + return nil +} func (m *mockTmux) CapturePaneTail(session string, _ int) (string, error) { return m.paneOutput[session], nil } @@ -164,3 +169,50 @@ func TestKillAndRecreatePoolSessions(t *testing.T) { t.Errorf("expected autonomous sessions recreated; createCalls=%v", tc.createCalls) } } + +// TestDedicatedRelaunchAfterSwap verifies that a dedicated session is +// automatically restarted with `claude --resume ` on the target +// account's home after a swap, so interactive user work is preserved. +func TestDedicatedRelaunchAfterSwap(t *testing.T) { + tc := newMockTmux() + tc.sessions["dedicated-1"] = true + // Pane shows the full resume command — saveDedicatedUUIDs will extract it. + tc.paneOutput["dedicated-1"] = "claude --resume aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee --dangerously-skip-permissions" + + s := state.New("") + s.SetActiveAccount("compte1") + + cfg := &config.Config{ + Accounts: []config.AccountConfig{ + {Name: "compte1", Home: "/tmp/claude-1-xxxx"}, + {Name: "compte2", Home: "/tmp/claude-2-xxxx"}, + }, + Pool: config.PoolConfig{ + Dedicated: []config.DedicatedSession{{Name: "dedicated-1", Project: "/tmp"}}, + Autonomous: config.AutonomousConfig{Prefix: "ccl-auto-", Min: 0, Max: 0}, + }, + } + + a := New(tc, s, cfg, make(chan quota.SwitchRequest), nil) + a.homeDir = t.TempDir() + a.executeSwitch(quota.SwitchRequest{From: "compte1"}) + + // The relaunch must send a resume command on the dedicated session, + // pointing CLAUDE_CONFIG_DIR at the target account's home. + var relaunch string + for _, k := range tc.sendKeyCalls { + if strings.HasPrefix(k, "dedicated-1:") && strings.Contains(k, "--resume") { + relaunch = k + break + } + } + if relaunch == "" { + t.Fatalf("expected dedicated-1 relaunch send-keys; got %v", tc.sendKeyCalls) + } + if !strings.Contains(relaunch, "CLAUDE_CONFIG_DIR=/tmp/claude-2-xxxx") { + t.Errorf("relaunch should set CLAUDE_CONFIG_DIR to target home; got %q", relaunch) + } + if !strings.Contains(relaunch, "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee") { + t.Errorf("relaunch should include captured UUID; got %q", relaunch) + } +}