Skip to content

Commit 4f47b8e

Browse files
wesmclaude
andauthored
Fix wrong agent and duplicate reviews from post-commit hook (#197)
## Summary Fixes two problems when a fix agent creates a commit and the post-commit hook fires: 1. **Wrong agent**: The hook sends no `--agent` flag, so the server defaulted to `codex` even when codex wasn't installed. The enqueue handler now resolves the configured agent to the first **installed** agent via the fallback chain (codex -> claude-code -> gemini -> ...), so the job reflects what will actually run. 2. **Duplicate reviews**: Both the post-commit hook and `enqueueIfNeeded` (called after a fix completes) could create separate reviews for the same commit. Replace the fixed 2s sleep with a **10 x 1s polling loop** that returns as soon as the hook's job is found, plus a final check after the loop. Also returns **503** when no review agent is installed at all, failing fast instead of queuing a doomed job. ## Files changed - `internal/daemon/server.go` — resolve agent to first available at enqueue time, 503 on none - `cmd/roborev/fix.go` — polling loop in `enqueueIfNeeded` - `internal/daemon/server_test.go` — 5 agent availability subtests with cross-platform PATH isolation ## Test plan - [x] `go test ./internal/daemon/ -run TestHandleEnqueueAgentAvailability -v` — all 5 subtests pass - [x] `go test ./cmd/roborev/ -run TestEnqueueIfNeeded -v` — all 3 subtests pass - [x] `go test ./...` — full suite passes Fixes #196 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 62fb84f commit 4f47b8e

File tree

3 files changed

+145
-5
lines changed

3 files changed

+145
-5
lines changed

cmd/roborev/fix.go

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1004,12 +1004,15 @@ func addJobResponse(serverAddr string, jobID int64, commenter, response string)
10041004
func enqueueIfNeeded(serverAddr, repoPath, sha string) error {
10051005
// Check if a review job already exists for this commit (e.g., from the
10061006
// post-commit hook). If so, skip enqueuing to avoid duplicates.
1007-
// We check twice with a 1-second delay to give the post-commit hook
1008-
// time to fire before we enqueue ourselves.
1009-
if hasJobForSHA(serverAddr, sha) {
1010-
return nil
1007+
// The post-commit hook normally completes before control returns here,
1008+
// but under heavy load it may take longer. Poll with short intervals
1009+
// up to a max wait to avoid both unnecessary delays and duplicates.
1010+
for range 10 {
1011+
if hasJobForSHA(serverAddr, sha) {
1012+
return nil
1013+
}
1014+
time.Sleep(1 * time.Second)
10111015
}
1012-
time.Sleep(2 * time.Second)
10131016
if hasJobForSHA(serverAddr, sha) {
10141017
return nil
10151018
}

internal/daemon/server.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -451,6 +451,16 @@ func (s *Server) handleEnqueue(w http.ResponseWriter, r *http.Request) {
451451
// Resolve agent for review workflow at this reasoning level
452452
agentName := config.ResolveAgentForWorkflow(req.Agent, repoRoot, s.configWatcher.Config(), "review", reasoning)
453453

454+
// Resolve to an installed agent: if the configured agent isn't available,
455+
// fall back through the chain (codex -> claude-code -> gemini -> ...).
456+
// Fail fast with 503 if nothing is installed at all.
457+
if resolved, err := agent.GetAvailable(agentName); err != nil {
458+
writeError(w, http.StatusServiceUnavailable, fmt.Sprintf("no review agent available: %v", err))
459+
return
460+
} else {
461+
agentName = resolved.Name()
462+
}
463+
454464
// Resolve model for review workflow at this reasoning level
455465
model := config.ResolveModelForWorkflow(req.Model, repoRoot, s.configWatcher.Config(), "review", reasoning)
456466

internal/daemon/server_test.go

Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import (
1212
"os"
1313
"os/exec"
1414
"path/filepath"
15+
"runtime"
1516
"strings"
1617
"sync"
1718
"testing"
@@ -2582,6 +2583,132 @@ func TestHandleJobOutput_StreamingCompletedJob(t *testing.T) {
25822583
}
25832584

25842585
// TestHandleJobOutput_MissingJobID tests that missing job_id returns 400.
2586+
func TestHandleEnqueueAgentAvailability(t *testing.T) {
2587+
// Shared read-only git repo created once (all subtests use different servers for DB isolation)
2588+
repoDir := filepath.Join(t.TempDir(), "repo")
2589+
testutil.InitTestGitRepo(t, repoDir)
2590+
headSHA := testutil.GetHeadSHA(t, repoDir)
2591+
2592+
// Create an isolated dir containing only a wrapper for git.
2593+
// We can't just use git's parent dir because it may contain real agent
2594+
// binaries (e.g. codex, claude) that would defeat the PATH isolation.
2595+
// Symlinks don't work reliably on Windows, so we use wrapper scripts.
2596+
gitPath, err := exec.LookPath("git")
2597+
if err != nil {
2598+
t.Fatal("git not found in PATH")
2599+
}
2600+
gitOnlyDir := t.TempDir()
2601+
if runtime.GOOS == "windows" {
2602+
wrapper := fmt.Sprintf("@\"%s\" %%*\r\n", gitPath)
2603+
if err := os.WriteFile(filepath.Join(gitOnlyDir, "git.cmd"), []byte(wrapper), 0755); err != nil {
2604+
t.Fatal(err)
2605+
}
2606+
} else {
2607+
wrapper := fmt.Sprintf("#!/bin/sh\nexec '%s' \"$@\"\n", gitPath)
2608+
if err := os.WriteFile(filepath.Join(gitOnlyDir, "git"), []byte(wrapper), 0755); err != nil {
2609+
t.Fatal(err)
2610+
}
2611+
}
2612+
2613+
mockScript := "#!/bin/sh\nexit 0\n"
2614+
2615+
tests := []struct {
2616+
name string
2617+
requestAgent string
2618+
mockBinaries []string // binary names to place in PATH
2619+
expectedAgent string // expected agent stored in job
2620+
expectedCode int // expected HTTP status code
2621+
}{
2622+
{
2623+
name: "explicit test agent preserved",
2624+
requestAgent: "test",
2625+
mockBinaries: nil,
2626+
expectedAgent: "test",
2627+
expectedCode: http.StatusCreated,
2628+
},
2629+
{
2630+
name: "unavailable codex falls back to claude-code",
2631+
requestAgent: "codex",
2632+
mockBinaries: []string{"claude"},
2633+
expectedAgent: "claude-code",
2634+
expectedCode: http.StatusCreated,
2635+
},
2636+
{
2637+
name: "default agent falls back when codex not installed",
2638+
requestAgent: "",
2639+
mockBinaries: []string{"claude"},
2640+
expectedAgent: "claude-code",
2641+
expectedCode: http.StatusCreated,
2642+
},
2643+
{
2644+
name: "explicit codex kept when available",
2645+
requestAgent: "codex",
2646+
mockBinaries: []string{"codex"},
2647+
expectedAgent: "codex",
2648+
expectedCode: http.StatusCreated,
2649+
},
2650+
{
2651+
name: "no agents available returns 503",
2652+
requestAgent: "codex",
2653+
mockBinaries: nil,
2654+
expectedCode: http.StatusServiceUnavailable,
2655+
},
2656+
}
2657+
2658+
for _, tt := range tests {
2659+
t.Run(tt.name, func(t *testing.T) {
2660+
// Each subtest gets its own server/DB to avoid SHA dedup conflicts
2661+
server, _, _ := newTestServer(t)
2662+
2663+
// Isolate PATH: only mock binaries + git (no real agent CLIs)
2664+
origPath := os.Getenv("PATH")
2665+
mockDir := t.TempDir()
2666+
for _, bin := range tt.mockBinaries {
2667+
name := bin
2668+
content := mockScript
2669+
if runtime.GOOS == "windows" {
2670+
name = bin + ".cmd"
2671+
content = "@exit /b 0\r\n"
2672+
}
2673+
if err := os.WriteFile(filepath.Join(mockDir, name), []byte(content), 0755); err != nil {
2674+
t.Fatal(err)
2675+
}
2676+
}
2677+
os.Setenv("PATH", mockDir+string(os.PathListSeparator)+gitOnlyDir)
2678+
t.Cleanup(func() { os.Setenv("PATH", origPath) })
2679+
2680+
reqData := map[string]string{
2681+
"repo_path": repoDir,
2682+
"commit_sha": headSHA,
2683+
}
2684+
if tt.requestAgent != "" {
2685+
reqData["agent"] = tt.requestAgent
2686+
}
2687+
req := testutil.MakeJSONRequest(t, http.MethodPost, "/api/enqueue", reqData)
2688+
w := httptest.NewRecorder()
2689+
2690+
server.handleEnqueue(w, req)
2691+
2692+
if w.Code != tt.expectedCode {
2693+
t.Fatalf("Expected status %d, got %d: %s", tt.expectedCode, w.Code, w.Body.String())
2694+
}
2695+
2696+
if tt.expectedCode != http.StatusCreated {
2697+
return
2698+
}
2699+
2700+
var job storage.ReviewJob
2701+
if err := json.NewDecoder(w.Body).Decode(&job); err != nil {
2702+
t.Fatalf("Failed to decode response: %v", err)
2703+
}
2704+
2705+
if job.Agent != tt.expectedAgent {
2706+
t.Errorf("Expected agent %q, got %q", tt.expectedAgent, job.Agent)
2707+
}
2708+
})
2709+
}
2710+
}
2711+
25852712
func TestHandleJobOutput_MissingJobID(t *testing.T) {
25862713
server, _, _ := newTestServer(t)
25872714

0 commit comments

Comments
 (0)