Skip to content

Commit a730a15

Browse files
whitmoclaude
andauthored
feat: Implement selective wakeup for agents (#15)
Instead of blindly waking all agents every 2 minutes, only wake agents that actually have work to do. This reduces unnecessary context churn and token usage for idle agents. Wake conditions by agent type: - Supervisor: woken when active workers exist to monitor - Merge-queue: woken when there are open PRs (active or in history) - Workers: only woken for pending messages (they drive their own work) - Review: only woken for pending messages Adds HasPending() to messages.Manager for efficient pending message detection, and agentHasWork() to daemon for per-agent work checking. Addresses P2 roadmap item: "Selective wakeup: Only wake agents when there's work to do" Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent ebbbef1 commit a730a15

4 files changed

Lines changed: 396 additions & 3 deletions

File tree

internal/daemon/daemon.go

Lines changed: 58 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -442,11 +442,12 @@ func (d *Daemon) wakeLoop() {
442442
}
443443
}
444444

445-
// wakeAgents sends periodic nudges to agents
445+
// wakeAgents sends periodic nudges to agents, but only when they have work to do
446446
func (d *Daemon) wakeAgents() {
447-
d.logger.Debug("Waking agents")
447+
d.logger.Debug("Waking agents (selective)")
448448

449449
now := time.Now()
450+
msgMgr := d.getMessageManager()
450451

451452
// Get a snapshot of repos to avoid concurrent map access
452453
repos := d.state.GetAllRepos()
@@ -462,6 +463,13 @@ func (d *Daemon) wakeAgents() {
462463
continue
463464
}
464465

466+
// Selective wakeup: only wake agents that have work to do
467+
reason := d.agentHasWork(repoName, agentName, agent, repo, msgMgr)
468+
if reason == "" {
469+
d.logger.Debug("Skipping wake for %s/%s: no work detected", repoName, agentName)
470+
continue
471+
}
472+
465473
// Send wake message based on agent type
466474
var message string
467475
switch agent.Type {
@@ -487,9 +495,56 @@ func (d *Daemon) wakeAgents() {
487495
d.logger.Error("Failed to update agent %s last nudge: %v", agentName, err)
488496
}
489497

490-
d.logger.Debug("Woke agent %s in repo %s", agentName, repoName)
498+
d.logger.Debug("Woke agent %s in repo %s (reason: %s)", agentName, repoName, reason)
499+
}
500+
}
501+
}
502+
503+
// agentHasWork checks whether an agent has work that warrants a wake nudge.
504+
// Returns a reason string if there's work, or empty string if no work detected.
505+
func (d *Daemon) agentHasWork(repoName, agentName string, agent state.Agent, repo *state.Repository, msgMgr *messages.Manager) string {
506+
// Any agent with pending messages has work
507+
if msgMgr.HasPending(repoName, agentName) {
508+
return "pending messages"
509+
}
510+
511+
switch agent.Type {
512+
case state.AgentTypeSupervisor:
513+
// Supervisor has work when there are active workers to monitor
514+
for _, a := range repo.Agents {
515+
if a.Type == state.AgentTypeWorker && !a.ReadyForCleanup {
516+
return "active workers"
517+
}
518+
}
519+
520+
case state.AgentTypeMergeQueue:
521+
// Merge queue has work when there are workers with open PRs
522+
for _, a := range repo.Agents {
523+
if a.Type == state.AgentTypeWorker && a.PRURL != "" && !a.ReadyForCleanup {
524+
return "open worker PRs"
525+
}
526+
}
527+
// Also check task history for recent open PRs
528+
history, err := d.state.GetTaskHistory(repoName, 10)
529+
if err == nil {
530+
for _, entry := range history {
531+
if entry.Status == state.TaskStatusOpen && entry.PRURL != "" {
532+
return "open PRs in history"
533+
}
534+
}
491535
}
536+
537+
case state.AgentTypeWorker:
538+
// Workers drive their own work - only wake for pending messages (checked above).
539+
// No periodic nudge needed since workers are actively executing tasks.
540+
return ""
541+
542+
case state.AgentTypeReview:
543+
// Review agents drive their own work - only wake for pending messages (checked above).
544+
return ""
492545
}
546+
547+
return ""
493548
}
494549

495550
// worktreeRefreshLoop periodically syncs worker worktrees with main branch

internal/daemon/daemon_test.go

Lines changed: 277 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1282,6 +1282,17 @@ func TestWakeLoopUpdatesNudgeTime(t *testing.T) {
12821282
t.Fatalf("Failed to add agent: %v", err)
12831283
}
12841284

1285+
// Add a worker so supervisor has work to do (selective wakeup requires it)
1286+
worker := state.Agent{
1287+
Type: state.AgentTypeWorker,
1288+
TmuxWindow: "worker",
1289+
Task: "test task",
1290+
CreatedAt: time.Now(),
1291+
}
1292+
if err := d.state.AddAgent("test-repo", "worker-1", worker); err != nil {
1293+
t.Fatalf("Failed to add worker: %v", err)
1294+
}
1295+
12851296
// Trigger wake
12861297
beforeWake := time.Now()
12871298
d.TriggerWake()
@@ -2828,3 +2839,269 @@ func TestHandleClearCurrentRepoWhenNone(t *testing.T) {
28282839
t.Errorf("clear_current_repo should succeed even when no repo set: %s", resp.Error)
28292840
}
28302841
}
2842+
2843+
func TestAgentHasWorkSupervisorWithActiveWorkers(t *testing.T) {
2844+
d, cleanup := setupTestDaemon(t)
2845+
defer cleanup()
2846+
2847+
repo := &state.Repository{
2848+
GithubURL: "https://github.com/test/repo",
2849+
TmuxSession: "mc-test",
2850+
Agents: make(map[string]state.Agent),
2851+
}
2852+
if err := d.state.AddRepo("test-repo", repo); err != nil {
2853+
t.Fatalf("Failed to add repo: %v", err)
2854+
}
2855+
2856+
// Add supervisor and an active worker
2857+
supervisor := state.Agent{Type: state.AgentTypeSupervisor, TmuxWindow: "supervisor", CreatedAt: time.Now()}
2858+
worker := state.Agent{Type: state.AgentTypeWorker, TmuxWindow: "worker", Task: "do stuff", CreatedAt: time.Now()}
2859+
d.state.AddAgent("test-repo", "supervisor", supervisor)
2860+
d.state.AddAgent("test-repo", "worker-1", worker)
2861+
2862+
msgMgr := d.getMessageManager()
2863+
2864+
// Re-fetch repo snapshot to include the worker
2865+
repos := d.state.GetAllRepos()
2866+
repoSnap := repos["test-repo"]
2867+
2868+
reason := d.agentHasWork("test-repo", "supervisor", supervisor, repoSnap, msgMgr)
2869+
if reason == "" {
2870+
t.Error("Supervisor should have work when active workers exist")
2871+
}
2872+
if reason != "active workers" {
2873+
t.Errorf("Expected reason 'active workers', got %q", reason)
2874+
}
2875+
}
2876+
2877+
func TestAgentHasWorkSupervisorNoWorkers(t *testing.T) {
2878+
d, cleanup := setupTestDaemon(t)
2879+
defer cleanup()
2880+
2881+
repo := &state.Repository{
2882+
GithubURL: "https://github.com/test/repo",
2883+
TmuxSession: "mc-test",
2884+
Agents: make(map[string]state.Agent),
2885+
}
2886+
if err := d.state.AddRepo("test-repo", repo); err != nil {
2887+
t.Fatalf("Failed to add repo: %v", err)
2888+
}
2889+
2890+
supervisor := state.Agent{Type: state.AgentTypeSupervisor, TmuxWindow: "supervisor", CreatedAt: time.Now()}
2891+
d.state.AddAgent("test-repo", "supervisor", supervisor)
2892+
2893+
msgMgr := d.getMessageManager()
2894+
repos := d.state.GetAllRepos()
2895+
repoSnap := repos["test-repo"]
2896+
2897+
reason := d.agentHasWork("test-repo", "supervisor", supervisor, repoSnap, msgMgr)
2898+
if reason != "" {
2899+
t.Errorf("Supervisor should have no work when no workers exist, got reason %q", reason)
2900+
}
2901+
}
2902+
2903+
func TestAgentHasWorkMergeQueueWithOpenPRs(t *testing.T) {
2904+
d, cleanup := setupTestDaemon(t)
2905+
defer cleanup()
2906+
2907+
repo := &state.Repository{
2908+
GithubURL: "https://github.com/test/repo",
2909+
TmuxSession: "mc-test",
2910+
Agents: make(map[string]state.Agent),
2911+
}
2912+
if err := d.state.AddRepo("test-repo", repo); err != nil {
2913+
t.Fatalf("Failed to add repo: %v", err)
2914+
}
2915+
2916+
mq := state.Agent{Type: state.AgentTypeMergeQueue, TmuxWindow: "merge-queue", CreatedAt: time.Now()}
2917+
worker := state.Agent{Type: state.AgentTypeWorker, TmuxWindow: "worker", PRURL: "https://github.com/test/repo/pull/1", CreatedAt: time.Now()}
2918+
d.state.AddAgent("test-repo", "merge-queue", mq)
2919+
d.state.AddAgent("test-repo", "worker-1", worker)
2920+
2921+
msgMgr := d.getMessageManager()
2922+
repos := d.state.GetAllRepos()
2923+
repoSnap := repos["test-repo"]
2924+
2925+
reason := d.agentHasWork("test-repo", "merge-queue", mq, repoSnap, msgMgr)
2926+
if reason == "" {
2927+
t.Error("Merge queue should have work when workers have open PRs")
2928+
}
2929+
}
2930+
2931+
func TestAgentHasWorkMergeQueueNoPRs(t *testing.T) {
2932+
d, cleanup := setupTestDaemon(t)
2933+
defer cleanup()
2934+
2935+
repo := &state.Repository{
2936+
GithubURL: "https://github.com/test/repo",
2937+
TmuxSession: "mc-test",
2938+
Agents: make(map[string]state.Agent),
2939+
}
2940+
if err := d.state.AddRepo("test-repo", repo); err != nil {
2941+
t.Fatalf("Failed to add repo: %v", err)
2942+
}
2943+
2944+
mq := state.Agent{Type: state.AgentTypeMergeQueue, TmuxWindow: "merge-queue", CreatedAt: time.Now()}
2945+
d.state.AddAgent("test-repo", "merge-queue", mq)
2946+
2947+
msgMgr := d.getMessageManager()
2948+
repos := d.state.GetAllRepos()
2949+
repoSnap := repos["test-repo"]
2950+
2951+
reason := d.agentHasWork("test-repo", "merge-queue", mq, repoSnap, msgMgr)
2952+
if reason != "" {
2953+
t.Errorf("Merge queue should have no work without PRs, got reason %q", reason)
2954+
}
2955+
}
2956+
2957+
func TestAgentHasWorkWorkerNoMessages(t *testing.T) {
2958+
d, cleanup := setupTestDaemon(t)
2959+
defer cleanup()
2960+
2961+
repo := &state.Repository{
2962+
GithubURL: "https://github.com/test/repo",
2963+
TmuxSession: "mc-test",
2964+
Agents: make(map[string]state.Agent),
2965+
}
2966+
if err := d.state.AddRepo("test-repo", repo); err != nil {
2967+
t.Fatalf("Failed to add repo: %v", err)
2968+
}
2969+
2970+
worker := state.Agent{Type: state.AgentTypeWorker, TmuxWindow: "worker", Task: "do stuff", CreatedAt: time.Now()}
2971+
d.state.AddAgent("test-repo", "worker-1", worker)
2972+
2973+
msgMgr := d.getMessageManager()
2974+
repos := d.state.GetAllRepos()
2975+
repoSnap := repos["test-repo"]
2976+
2977+
reason := d.agentHasWork("test-repo", "worker-1", worker, repoSnap, msgMgr)
2978+
if reason != "" {
2979+
t.Errorf("Worker should have no work without messages, got reason %q", reason)
2980+
}
2981+
}
2982+
2983+
func TestAgentHasWorkWithPendingMessages(t *testing.T) {
2984+
d, cleanup := setupTestDaemon(t)
2985+
defer cleanup()
2986+
2987+
repo := &state.Repository{
2988+
GithubURL: "https://github.com/test/repo",
2989+
TmuxSession: "mc-test",
2990+
Agents: make(map[string]state.Agent),
2991+
}
2992+
if err := d.state.AddRepo("test-repo", repo); err != nil {
2993+
t.Fatalf("Failed to add repo: %v", err)
2994+
}
2995+
2996+
worker := state.Agent{Type: state.AgentTypeWorker, TmuxWindow: "worker", Task: "do stuff", CreatedAt: time.Now()}
2997+
d.state.AddAgent("test-repo", "worker-1", worker)
2998+
2999+
// Send a message to the worker
3000+
msgMgr := d.getMessageManager()
3001+
_, err := msgMgr.Send("test-repo", "supervisor", "worker-1", "You have a task")
3002+
if err != nil {
3003+
t.Fatalf("Failed to send message: %v", err)
3004+
}
3005+
3006+
repos := d.state.GetAllRepos()
3007+
repoSnap := repos["test-repo"]
3008+
3009+
reason := d.agentHasWork("test-repo", "worker-1", worker, repoSnap, msgMgr)
3010+
if reason != "pending messages" {
3011+
t.Errorf("Worker with pending messages should have work, got reason %q", reason)
3012+
}
3013+
}
3014+
3015+
func TestAgentHasWorkMergeQueueWithHistoryPRs(t *testing.T) {
3016+
d, cleanup := setupTestDaemon(t)
3017+
defer cleanup()
3018+
3019+
repo := &state.Repository{
3020+
GithubURL: "https://github.com/test/repo",
3021+
TmuxSession: "mc-test",
3022+
Agents: make(map[string]state.Agent),
3023+
}
3024+
if err := d.state.AddRepo("test-repo", repo); err != nil {
3025+
t.Fatalf("Failed to add repo: %v", err)
3026+
}
3027+
3028+
mq := state.Agent{Type: state.AgentTypeMergeQueue, TmuxWindow: "merge-queue", CreatedAt: time.Now()}
3029+
d.state.AddAgent("test-repo", "merge-queue", mq)
3030+
3031+
// Add a task history entry with an open PR
3032+
entry := state.TaskHistoryEntry{
3033+
Name: "old-worker",
3034+
Task: "some task",
3035+
Branch: "work/old-worker",
3036+
PRURL: "https://github.com/test/repo/pull/5",
3037+
PRNumber: 5,
3038+
Status: state.TaskStatusOpen,
3039+
CreatedAt: time.Now(),
3040+
CompletedAt: time.Now(),
3041+
}
3042+
d.state.AddTaskHistory("test-repo", entry)
3043+
3044+
msgMgr := d.getMessageManager()
3045+
repos := d.state.GetAllRepos()
3046+
repoSnap := repos["test-repo"]
3047+
3048+
reason := d.agentHasWork("test-repo", "merge-queue", mq, repoSnap, msgMgr)
3049+
if reason == "" {
3050+
t.Error("Merge queue should have work when task history has open PRs")
3051+
}
3052+
if reason != "open PRs in history" {
3053+
t.Errorf("Expected reason 'open PRs in history', got %q", reason)
3054+
}
3055+
}
3056+
3057+
func TestSelectiveWakeSkipsWorkersWithoutWork(t *testing.T) {
3058+
tmuxClient := tmux.NewClient()
3059+
if !tmuxClient.IsTmuxAvailable() {
3060+
t.Skip("tmux not available")
3061+
}
3062+
3063+
d, cleanup := setupTestDaemon(t)
3064+
defer cleanup()
3065+
3066+
// Create a real tmux session
3067+
sessionName := "mc-test-selective-wake"
3068+
if err := tmuxClient.CreateSession(context.Background(), sessionName, true); err != nil {
3069+
t.Fatalf("Failed to create tmux session: %v", err)
3070+
}
3071+
defer tmuxClient.KillSession(context.Background(), sessionName)
3072+
3073+
// Create windows for agents
3074+
if err := tmuxClient.CreateWindow(context.Background(), sessionName, "worker"); err != nil {
3075+
t.Fatalf("Failed to create worker window: %v", err)
3076+
}
3077+
3078+
// Add repo and a worker with no messages (should NOT be woken)
3079+
repo := &state.Repository{
3080+
GithubURL: "https://github.com/test/repo",
3081+
TmuxSession: sessionName,
3082+
Agents: make(map[string]state.Agent),
3083+
}
3084+
if err := d.state.AddRepo("test-repo", repo); err != nil {
3085+
t.Fatalf("Failed to add repo: %v", err)
3086+
}
3087+
3088+
worker := state.Agent{
3089+
Type: state.AgentTypeWorker,
3090+
TmuxWindow: "worker",
3091+
Task: "Test task",
3092+
CreatedAt: time.Now(),
3093+
LastNudge: time.Time{}, // Never nudged
3094+
}
3095+
if err := d.state.AddAgent("test-repo", "worker-1", worker); err != nil {
3096+
t.Fatalf("Failed to add agent: %v", err)
3097+
}
3098+
3099+
// Trigger wake - worker has no messages, should NOT be woken
3100+
d.TriggerWake()
3101+
3102+
// Verify LastNudge was NOT updated (worker skipped due to no work)
3103+
updatedAgent, _ := d.state.GetAgent("test-repo", "worker-1")
3104+
if !updatedAgent.LastNudge.IsZero() {
3105+
t.Error("Worker without work should NOT be woken - LastNudge should remain zero")
3106+
}
3107+
}

0 commit comments

Comments
 (0)