feat(switcher): auto-resume dedicated sessions after a swap
When a legitimate quota hit triggered a swap, killAllPoolSessions tore down the dedicated interactive sessions (ccl-1-conformvault, ccl-2-scanyze) along with the pool, then recreatePoolSessions re-opened them at a bare bash prompt. The operator had to manually re-run CLAUDE_CONFIG_DIR=<target> claude --dangerously-skip-permissions --resume <uuid> after every swap, losing whatever conversation was mid-flight. saveAllSessions only iterates sessions tracked as "working" in state; user-driven dedicated sessions are rarely in that state so their resume UUIDs were never saved. - saveDedicatedUUIDs: capture resume UUID for every configured dedicated session regardless of tracked state, before kill. - relaunchDedicatedSessions(targetHome): after recreate, send a resume command on each dedicated session pointing CLAUDE_CONFIG_DIR at the target account's home. Missing UUID → leave at shell, no blind launch. - isValidResumeUUID hardens against a corrupted resume-id.txt. New TestDedicatedRelaunchAfterSwap verifies end-to-end: pane capture → UUID persisted → resume command sent with the correct CLAUDE_CONFIG_DIR. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
5cad53ac7a
commit
8fdb1937fc
4 changed files with 169 additions and 14 deletions
35
VERSION.md
35
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 <uuid>` 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=<targetHome> claude --dangerously-skip-permissions
|
||||
--resume <uuid>` 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
|
||||
|
|
|
|||
|
|
@ -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 <uuid>` 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.
|
||||
|
|
|
|||
|
|
@ -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 <uuid>` 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) {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
package switcher
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
|
|
@ -15,6 +16,7 @@ type mockTmux struct {
|
|||
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 <uuid>` 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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue