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:
Ubuntu 2026-04-16 13:30:26 +00:00
parent 6b109ed1bc
commit 4cbdcf143a
4 changed files with 136 additions and 29 deletions

View file

@ -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)
}