fix(dispatcher+watcher): never auto-dispatch into dedicated sessions
Observed: tasks from filesecure/.agent-queue/inbox and SecuScan/ .agent-queue/inbox were being routed into ccl-1-conformvault and ccl-2-scanyze whenever those sessions happened to be idle. Those are the operator's manual interactive Claude sessions, not dispatch targets — the auto-dispatch was (a) hijacking a Claude instance the operator was using and (b) triggering /exit via the watcher's completion path when the side-task finished, kicking the operator out mid-conversation. findFreeSession was iterating Pool.Dedicated before the autonomous pool, so any idle dedicated session was the first candidate. - Dispatcher.findFreeSession: remove the Dedicated loop entirely. Auto-dispatch is now pool-only (ccl-auto-11..20). - Watcher.completeSession: defense-in-depth — even if a dedicated session ever ends up in "working" state, it is no longer /exit'd; just marked idle. Pool /exit behaviour unchanged (context recycle). - Tests: new TestFindFreeSessionSkipsDedicated proves the routing; 3 existing tests rewritten to use the autonomous pool instead of relying on Dedicated as a fake pool. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
6b109ed1bc
commit
4cbdcf143a
4 changed files with 136 additions and 29 deletions
62
VERSION.md
62
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
|
||||
|
|
|
|||
|
|
@ -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-"
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
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, "❯")
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue