diff --git a/VERSION.md b/VERSION.md index 1008d74..fe1d813 100644 --- a/VERSION.md +++ b/VERSION.md @@ -1,4 +1,64 @@ -# Version actuelle : 0.3.2 +# Version actuelle : 0.3.4 + +## [0.3.4] - 2026-04-16 +**Type:** Patch — Dispatcher ne route JAMAIS vers les sessions dédiées + +### Corrigé +- **Cause racine** (suite au symptôme v0.3.3) : le dispatcher parcourait + `config.Pool.Dedicated` EN PREMIER dans `findFreeSession`, donc les tâches + des inboxes `filesecure/` (conformvault) et `SecuScan/` (scanyze) étaient + routées vers `ccl-1-conformvault` / `ccl-2-scanyze` quand elles étaient + idle — alors que ces sessions sont réservées au travail interactif + manuel d'Olivier. +- Le watcher envoyait ensuite `/exit` quand la tâche se terminait, + éjectant Olivier de sa session Claude en cours. + +### Modifié +- `Dispatcher.findFreeSession` : n'itère plus `Pool.Dedicated`. L'auto-dispatch + utilise **uniquement** le pool autonome `StartIndex..StartIndex+Max`. +- `/exit` sur le pool reste le comportement voulu (recycle Claude avec + contexte propre pour le prochain dispatch). +- Le garde v0.3.3 dans `watcher.completeSession` (pas de `/exit` sur + dédié) reste en place comme défense en profondeur pour tout edge case + où un dédié se retrouverait marqué "working". + +### Tests +- ✅ `TestFindFreeSessionSkipsDedicated` (nouveau) : vérifie qu'un dédié + idle est **ignoré** au profit du pool. +- ✅ 3 tests existants réécrits pour utiliser le pool Autonomous (ils + utilisaient Dedicated comme un mock de pool par paresse). + +### Fichiers modifiés +- `internal/dispatcher/dispatcher.go` +- `internal/dispatcher/dispatcher_test.go` + +## [0.3.3] - 2026-04-16 +**Type:** Patch — Ne pas `/exit` les sessions dédiées quand leur dispatch finit + +### Corrigé +- **Bug user-visible** : après qu'une tâche dispatchée à `ccl-1-conformvault` + ou `ccl-2-scanyze` se terminait (prompt `❯` sans spinner, ou signal file), + le watcher envoyait `/exit` → Claude s'arrêtait → Olivier se retrouvait au + bash prompt en plein milieu de son travail interactif (prompt visible contient + littéralement `.../exit` à la fin). +- Les sessions dédiées sont une surface partagée : dispatcher peut y poser + des tâches, mais l'opérateur y travaille aussi en interactif et ne doit + jamais être éjecté. + +### Ajouté +- `SessionWatcher.isDedicated(name)` : test contre `config.Pool.Dedicated`. +- `completeSession` distingue pool vs dédié : + - **Pool** : `/exit` envoyé (recycle Claude avec contexte propre pour le + prochain dispatch, comportement inchangé). + - **Dédié** : log `DONE ... (dedicated — leaving Claude alive)`, + session marquée idle, Claude laissé tourner pour l'opérateur. + +### Tests +- ✅ `go test ./...` full suite +- ✅ Déploiement confirmé (ccl-1 relancée, tient depuis) + +### Fichiers modifiés +- `internal/watcher/session_watcher.go` ## [0.3.2] - 2026-04-15 **Type:** Patch — Double-Enter pour soumettre les prompts multi-lignes diff --git a/internal/dispatcher/dispatcher.go b/internal/dispatcher/dispatcher.go index e7ac58d..485fa5b 100644 --- a/internal/dispatcher/dispatcher.go +++ b/internal/dispatcher/dispatcher.go @@ -140,14 +140,13 @@ func (d *Dispatcher) dispatchProject(inboxDir string) { } } -// findFreeSession returns the name of an idle, live, cooldown-free session. -// Returns "" if no session is available. +// findFreeSession returns the name of an idle, live, cooldown-free session +// from the autonomous pool. Dedicated sessions are intentionally NOT +// considered: those host the operator's manual interactive work. Routing a +// background dispatch into them would (a) hijack a Claude instance the +// operator is using and (b) trigger the watcher's /exit recycle at task +// end, kicking the operator out mid-conversation. func (d *Dispatcher) findFreeSession() string { - for _, ds := range d.config.Pool.Dedicated { - if d.isSessionFree(ds.Name) { - return ds.Name - } - } prefix := d.config.Pool.Autonomous.Prefix if prefix == "" { prefix = "ccl-auto-" diff --git a/internal/dispatcher/dispatcher_test.go b/internal/dispatcher/dispatcher_test.go index f27d47e..c4ef545 100644 --- a/internal/dispatcher/dispatcher_test.go +++ b/internal/dispatcher/dispatcher_test.go @@ -85,20 +85,19 @@ func TestModelForPriority(t *testing.T) { // TestFindFreeSessionSkipsFailed verifies that recently-failed sessions are skipped. func TestFindFreeSessionSkipsFailed(t *testing.T) { tc := newMockTmux() + tc.sessions["sess-0"] = true tc.sessions["sess-1"] = true - tc.sessions["sess-2"] = true s := state.New("") + s.SetFailed("sess-0") s.SetIdle("sess-1") - s.SetFailed("sess-2") d := &Dispatcher{ tmux: tc, state: s, config: &config.Config{ Pool: config.PoolConfig{ - Dedicated: []config.DedicatedSession{{Name: "sess-1"}, {Name: "sess-2"}}, - Autonomous: config.AutonomousConfig{Max: 0}, + Autonomous: config.AutonomousConfig{Prefix: "sess-", Max: 2}, }, }, logger: log.Default(), @@ -113,28 +112,57 @@ func TestFindFreeSessionSkipsFailed(t *testing.T) { // TestFindFreeSessionMissingTmux skips sessions not in tmux. func TestFindFreeSessionMissingTmux(t *testing.T) { tc := newMockTmux() - // sess-1 missing from tmux, sess-2 present and idle. - tc.sessions["sess-2"] = true + // sess-0 missing from tmux, sess-1 present and idle. + tc.sessions["sess-1"] = true s := state.New("") + s.SetIdle("sess-0") s.SetIdle("sess-1") - s.SetIdle("sess-2") d := &Dispatcher{ tmux: tc, state: s, config: &config.Config{ Pool: config.PoolConfig{ - Dedicated: []config.DedicatedSession{{Name: "sess-1"}, {Name: "sess-2"}}, - Autonomous: config.AutonomousConfig{Max: 0}, + Autonomous: config.AutonomousConfig{Prefix: "sess-", Max: 2}, }, }, logger: log.Default(), } got := d.findFreeSession() - if got != "sess-2" { - t.Errorf("expected sess-2, got %q", got) + if got != "sess-1" { + t.Errorf("expected sess-1, got %q", got) + } +} + +// TestFindFreeSessionSkipsDedicated verifies that dedicated sessions are +// NEVER returned by the auto-dispatch path, even when idle. Those host the +// operator's manual interactive work and must stay untouched. +func TestFindFreeSessionSkipsDedicated(t *testing.T) { + tc := newMockTmux() + tc.sessions["ccl-1-conformvault"] = true + tc.sessions["sess-0"] = true + + s := state.New("") + s.SetIdle("ccl-1-conformvault") + s.SetIdle("sess-0") + + d := &Dispatcher{ + tmux: tc, + state: s, + config: &config.Config{ + Pool: config.PoolConfig{ + Dedicated: []config.DedicatedSession{{Name: "ccl-1-conformvault"}}, + Autonomous: config.AutonomousConfig{Prefix: "sess-", Max: 1}, + }, + }, + logger: log.Default(), + } + + got := d.findFreeSession() + if got != "sess-0" { + t.Errorf("expected pool sess-0 (dedicated must be skipped), got %q", got) } } @@ -149,20 +177,19 @@ func TestDispatchProject(t *testing.T) { os.WriteFile(taskPath, []byte(taskContent), 0644) tc := newMockTmux() - tc.sessions["free-sess"] = true + tc.sessions["pool-0"] = true // Return ❯ prompt on first CapturePaneTail call (Claude is ready). - tc.paneOutput["free-sess"] = "❯ " + tc.paneOutput["pool-0"] = "❯ " s := state.New("") - s.SetIdle("free-sess") + s.SetIdle("pool-0") d := &Dispatcher{ tmux: tc, state: s, config: &config.Config{ Pool: config.PoolConfig{ - Dedicated: []config.DedicatedSession{{Name: "free-sess", Project: dir}}, - Autonomous: config.AutonomousConfig{Max: 0}, + Autonomous: config.AutonomousConfig{Prefix: "pool-", Max: 1}, }, }, logger: log.Default(), @@ -170,7 +197,7 @@ func TestDispatchProject(t *testing.T) { d.dispatchProject(inbox) - if st := s.GetSession("free-sess"); st == nil || st.State != "working" { + if st := s.GetSession("pool-0"); st == nil || st.State != "working" { t.Errorf("expected session working after dispatch, got %v", st) } diff --git a/internal/watcher/session_watcher.go b/internal/watcher/session_watcher.go index b98919f..c0750c3 100644 --- a/internal/watcher/session_watcher.go +++ b/internal/watcher/session_watcher.go @@ -114,11 +114,19 @@ func (w *SessionWatcher) checkSession(name string, sess *state.SessionState) { } } -// completeSession sends /exit, marks the session idle, and notifies the dispatcher. +// completeSession marks the session idle and notifies the dispatcher. For +// pool sessions, /exit is sent to recycle the Claude process so the next +// dispatch starts with a clean context. For dedicated sessions, /exit is +// skipped — those host the operator's interactive work and must not be +// terminated when a side-dispatched task happens to finish. func (w *SessionWatcher) completeSession(name, sigFile string) { - w.logger.Printf("[watcher] DONE session=%q → /exit", name) - _ = w.tmux.SendKeys(name, "/exit") - time.Sleep(500 * time.Millisecond) + if w.isDedicated(name) { + w.logger.Printf("[watcher] DONE session=%q (dedicated — leaving Claude alive)", name) + } else { + w.logger.Printf("[watcher] DONE session=%q → /exit", name) + _ = w.tmux.SendKeys(name, "/exit") + time.Sleep(500 * time.Millisecond) + } w.state.SetIdle(name) os.Remove(sigFile) select { @@ -128,6 +136,19 @@ func (w *SessionWatcher) completeSession(name, sigFile string) { } } +// isDedicated reports whether name matches a configured dedicated session. +func (w *SessionWatcher) isDedicated(name string) bool { + if w.config == nil { + return false + } + for _, ds := range w.config.Pool.Dedicated { + if ds.Name == name { + return true + } + } + return false +} + // hasClaudePrompt returns true if the Claude Code interactive prompt is visible. func hasClaudePrompt(output string) bool { return strings.Contains(output, "❯")