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

@ -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, "")