From 8c1cd0d5dc20b6a904c6a46af080f74342b49d04 Mon Sep 17 00:00:00 2001 From: Marius van Niekerk Date: Tue, 17 Feb 2026 12:41:52 -0500 Subject: [PATCH 01/49] feat: add fix job type with parent_job_id and patch columns (#279) Add JobTypeFix constant, ParentJobID and Patch fields to ReviewJob, DB migration for new columns, SaveJobPatch helper, and updated all scan functions (ClaimJob, ListJobs, GetJobByID) to include new fields. Includes implementation plan in docs/plans/. Co-Authored-By: Claude Opus 4.6 --- docs/plans/2026-02-17-tui-fix.md | 523 +++++++++++++++++++++++++++++++ internal/storage/db.go | 24 ++ internal/storage/jobs.go | 54 +++- internal/storage/models.go | 10 +- 4 files changed, 601 insertions(+), 10 deletions(-) create mode 100644 docs/plans/2026-02-17-tui-fix.md diff --git a/docs/plans/2026-02-17-tui-fix.md b/docs/plans/2026-02-17-tui-fix.md new file mode 100644 index 00000000..ac1f14b0 --- /dev/null +++ b/docs/plans/2026-02-17-tui-fix.md @@ -0,0 +1,523 @@ +# TUI-Triggered Fix via Background Worktrees + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Allow users to trigger `roborev fix` from the TUI, running agents in isolated background worktrees and applying patches when ready. + +**Architecture:** New `fix` job type runs in daemon worker pool using temporary worktrees (same pattern as `refine.go`). Agent produces a patch stored in DB. TUI gets a new Tasks view to monitor fix jobs and apply/rebase patches to the working tree. + +**Tech Stack:** Go, SQLite, Bubble Tea TUI, git worktrees + +--- + +## Phase 1: Storage Layer - New `fix` Job Type and `patch` Column + +### Task 1: Add `JobTypeFix` constant and `parent_job_id`/`patch` columns + +**Files:** +- Modify: `internal/storage/models.go:37-43` (add JobTypeFix constant) +- Modify: `internal/storage/models.go:45-80` (add ParentJobID, Patch fields to ReviewJob) +- Modify: `internal/storage/models.go:117-122` (update IsPromptJob to include fix) +- Modify: `internal/storage/db.go` (add migration for new columns) + +**Step 1: Add the new constant and struct fields** + +In `internal/storage/models.go`, add to the JobType constants block: + +```go +JobTypeFix = "fix" // Background fix using worktree +``` + +Add to ReviewJob struct (after OutputPrefix field, before sync fields): + +```go +ParentJobID *int64 `json:"parent_job_id,omitempty"` // Job being fixed (for fix jobs) +Patch *string `json:"patch,omitempty"` // Generated diff patch (for completed fix jobs) +``` + +Update `IsPromptJob()` to include fix jobs: + +```go +func (j ReviewJob) IsPromptJob() bool { + return j.JobType == JobTypeTask || j.JobType == JobTypeCompact || j.JobType == JobTypeFix +} +``` + +Add a helper: + +```go +func (j ReviewJob) IsFixJob() bool { + return j.JobType == JobTypeFix +} +``` + +**Step 2: Add database migration** + +In `internal/storage/db.go`, add at the end of `migrate()` before `return nil`: + +```go +// Migration: add parent_job_id column to review_jobs if missing +err = db.QueryRow(`SELECT COUNT(*) FROM pragma_table_info('review_jobs') WHERE name = 'parent_job_id'`).Scan(&count) +if err != nil { + return fmt.Errorf("check parent_job_id column: %w", err) +} +if count == 0 { + _, err = db.Exec(`ALTER TABLE review_jobs ADD COLUMN parent_job_id INTEGER`) + if err != nil { + return fmt.Errorf("add parent_job_id column: %w", err) + } +} + +// Migration: add patch column to review_jobs if missing +err = db.QueryRow(`SELECT COUNT(*) FROM pragma_table_info('review_jobs') WHERE name = 'patch'`).Scan(&count) +if err != nil { + return fmt.Errorf("check patch column: %w", err) +} +if count == 0 { + _, err = db.Exec(`ALTER TABLE review_jobs ADD COLUMN patch TEXT`) + if err != nil { + return fmt.Errorf("add patch column: %w", err) + } +} +``` + +**Step 3: Update EnqueueOpts and EnqueueJob** + +In `internal/storage/jobs.go`, add `ParentJobID` to `EnqueueOpts`: + +```go +ParentJobID int64 // Parent job being fixed (for fix jobs) +``` + +Update the INSERT in `EnqueueJob` to include `parent_job_id`. + +**Step 4: Add SaveJobPatch helper** + +In `internal/storage/jobs.go`, add: + +```go +func (db *DB) SaveJobPatch(jobID int64, patch string) error { + _, err := db.Exec(`UPDATE review_jobs SET patch = ? WHERE id = ?`, patch, jobID) + return err +} +``` + +**Step 5: Update job scan functions to include new columns** + +Grep for the SELECT queries that read review_jobs and add `parent_job_id` and `patch` to the column lists and scan targets. + +**Step 6: Run tests** + +```bash +go test ./internal/storage/... -v -count=1 +go vet ./... +``` + +**Step 7: Commit** + +```bash +git add -A && git commit -m "feat: add fix job type with parent_job_id and patch columns" +``` + +--- + +## Phase 2: Worker Pool - Fix Job Processing with Worktrees + +### Task 2: Extract worktree helpers from refine.go to shared package + +**Files:** +- Create: `internal/worktree/worktree.go` (shared worktree helpers) +- Modify: `cmd/roborev/refine.go` (use shared helpers) + +**Step 1: Create `internal/worktree/worktree.go`** + +Extract `createTempWorktree` from `cmd/roborev/refine.go:1002-1054` into a shared package. The function signature stays the same: + +```go +package worktree + +func Create(repoPath string) (worktreeDir string, cleanup func(), err error) +``` + +Also extract the diff-capture logic (getting the patch from a worktree): + +```go +func CapturePatch(worktreeDir string) (string, error) +``` + +This runs `git add -A` then `git diff --cached --binary` in the worktree and returns the patch string. + +**Step 2: Update refine.go to use shared helpers** + +Replace `createTempWorktree` calls with `worktree.Create`. + +**Step 3: Run tests** + +```bash +go build ./... +go test ./... -v -count=1 +``` + +**Step 4: Commit** + +```bash +git add -A && git commit -m "refactor: extract worktree helpers to internal/worktree package" +``` + +### Task 3: Add fix job processing to worker pool + +**Files:** +- Modify: `internal/daemon/worker.go:267-310` (add fix job handling in processJob) + +**Step 1: Add fix job processing branch** + +In `processJob()`, after the prompt-building if/else chain (around line 305), add a new branch for fix jobs. The fix job flow is: + +1. Use the pre-stored prompt (fix jobs are prompt jobs via `IsPromptJob()`) +2. Create a temporary worktree via `worktree.Create(job.RepoPath)` +3. Run the agent with `agentic=true` in the worktree directory (pass worktree path instead of repo path to `a.Review()`) +4. Capture the patch via `worktree.CapturePatch(worktreeDir)` +5. Store the patch via `db.SaveJobPatch(job.ID, patch)` +6. Clean up the worktree +7. Store the agent output as a review (existing `CompleteJob` flow) + +The key change is in the `a.Review()` call — for fix jobs, pass the worktree path as the repo path: + +```go +reviewRepoPath := job.RepoPath +if job.IsFixJob() { + wtDir, wtCleanup, err := worktree.Create(job.RepoPath) + if err != nil { + wp.failOrRetry(workerID, job, job.Agent, fmt.Sprintf("create worktree: %v", err)) + return + } + defer wtCleanup() + reviewRepoPath = wtDir +} + +output, err := a.Review(ctx, reviewRepoPath, job.GitRef, reviewPrompt, outputWriter) + +// After agent completes, capture patch for fix jobs +if job.IsFixJob() { + patch, patchErr := worktree.CapturePatch(wtDir) + if patchErr != nil { + log.Printf("[%s] Warning: failed to capture patch for fix job %d: %v", workerID, job.ID, patchErr) + } else if patch != "" { + if err := wp.db.SaveJobPatch(job.ID, patch); err != nil { + log.Printf("[%s] Warning: failed to save patch for fix job %d: %v", workerID, job.ID, err) + } + } +} +``` + +**Step 2: Run tests** + +```bash +go build ./... +go test ./internal/daemon/... -v -count=1 +``` + +**Step 3: Commit** + +```bash +git add -A && git commit -m "feat: add fix job processing with worktree isolation in worker pool" +``` + +--- + +## Phase 3: API Endpoint for Fix Jobs + +### Task 4: Add fix-specific API endpoint + +**Files:** +- Modify: `internal/daemon/server.go` (add `/api/job/fix` endpoint and `/api/job/patch` endpoint) + +**Step 1: Add the fix endpoint** + +Register new routes in `NewServer`: + +```go +mux.HandleFunc("/api/job/fix", s.handleFixJob) +mux.HandleFunc("/api/job/patch", s.handleGetPatch) +``` + +`handleFixJob` accepts POST with: + +```go +type FixJobRequest struct { + ParentJobID int64 `json:"parent_job_id"` + Prompt string `json:"prompt,omitempty"` // Optional custom prompt override +} +``` + +The handler: +1. Fetches the parent job by ID +2. Fetches the review for the parent job +3. Builds the fix prompt using `prompt.BuildAddressPrompt` (or uses custom prompt if provided) +4. Enqueues a new fix job with `JobType: "fix"`, `Agentic: true`, `ParentJobID: parentJobID` +5. Returns the new job + +`handleGetPatch` accepts GET with `?job_id=N` and returns the patch text. + +**Step 2: Run tests** + +```bash +go build ./... +go test ./internal/daemon/... -v -count=1 +``` + +**Step 3: Commit** + +```bash +git add -A && git commit -m "feat: add /api/job/fix and /api/job/patch endpoints" +``` + +--- + +## Phase 4: TUI - Tasks View and Fix Trigger + +### Task 5: Add `tuiViewTasks` and task-related TUI state + +**Files:** +- Modify: `cmd/roborev/tui.go` (add tuiViewTasks constant, task state fields to tuiModel) + +**Step 1: Add the view constant** + +```go +const ( + tuiViewQueue tuiView = iota + tuiViewReview + tuiViewPrompt + tuiViewFilter + tuiViewComment + tuiViewCommitMsg + tuiViewHelp + tuiViewTail + tuiViewTasks // NEW: background fix tasks view + tuiViewFixPrompt // NEW: fix prompt confirmation modal +) +``` + +**Step 2: Add task state fields to tuiModel** + +```go +// Fix task state +fixJobs []storage.ReviewJob // Fix jobs for tasks view +fixSelectedIdx int +fixPromptText string // Editable fix prompt +fixPromptJobID int64 // Parent job ID for fix prompt +``` + +**Step 3: Commit** + +```bash +git add -A && git commit -m "feat: add tuiViewTasks and fix state fields to TUI model" +``` + +### Task 6: Add 'F' keybinding to trigger fix from review view + +**Files:** +- Modify: `cmd/roborev/tui_handlers.go` (add 'F' key handler, fix prompt modal handler) + +**Step 1: Add 'F' to handleGlobalKey** + +In `handleGlobalKey`, add: + +```go +case "F": + return m.handleFixKey() +``` + +`handleFixKey` checks if a review is selected and has a failing verdict, then opens the fix prompt confirmation modal (`tuiViewFixPrompt`) with a pre-filled prompt. + +**Step 2: Add handleFixPromptKey for the modal** + +Add the modal to `handleKeyMsg` dispatch: + +```go +case tuiViewFixPrompt: + return m.handleFixPromptKey(msg) +``` + +The modal shows the fix prompt (editable) and on `enter` calls the fix API endpoint, then switches to the tasks view. + +**Step 3: Commit** + +```bash +git add -A && git commit -m "feat: add F keybinding to trigger fix from TUI review view" +``` + +### Task 7: Add Tasks view rendering and key handling + +**Files:** +- Modify: `cmd/roborev/tui_helpers.go` (add tasks view rendering) +- Modify: `cmd/roborev/tui_handlers.go` (add tasks view key handling) +- Modify: `cmd/roborev/tui_api.go` (add API calls for fix jobs and patches) + +**Step 1: Add 'T' keybinding to toggle queue/tasks view** + +In `handleGlobalKey`: + +```go +case "T": + return m.handleToggleTasksKey() +``` + +This toggles between `tuiViewQueue` and `tuiViewTasks`. + +**Step 2: Add tasks view rendering** + +The tasks view shows fix jobs with status indicators: +- Queued/running: spinner +- Done: check mark + dry-run apply status (clean/conflict) +- Failed: X mark + +**Step 3: Add tasks view key handling** + +Keys in tasks view: +- `enter` - View the patch (renders diff in review-like scroll view) +- `A` - Apply patch to working tree +- `t` - Tail live output (reuse existing tail infrastructure) +- `d` - Discard/cancel fix job +- `T` - Toggle back to queue view + +**Step 4: Add API calls** + +In `tui_api.go`, add: +- `fetchFixJobs()` - GET `/api/jobs?job_type=fix` (or filter client-side) +- `triggerFix(parentJobID, prompt)` - POST `/api/job/fix` +- `fetchPatch(jobID)` - GET `/api/job/patch?job_id=N` + +**Step 5: Run tests** + +```bash +go build ./... +go test ./cmd/roborev/... -v -count=1 +``` + +**Step 6: Commit** + +```bash +git add -A && git commit -m "feat: add Tasks view with fix job monitoring and patch preview" +``` + +--- + +## Phase 5: Patch Application and Rebase Flow + +### Task 8: Implement patch apply logic in TUI + +**Files:** +- Modify: `cmd/roborev/tui_handlers.go` (apply patch handler) + +**Step 1: Implement the 'A' key handler** + +When user presses 'A' on a completed fix job: + +1. Fetch the patch from the API +2. Write patch to a temp file +3. Run `git apply --check ` (dry-run) in the repo +4. If clean: run `git apply `, stage, commit, enqueue review for new commit, mark parent as addressed +5. If conflict: show rebase prompt (see Task 9) + +The apply runs synchronously in the TUI process (same trust model as CLI `roborev fix`). + +**Step 2: Run tests** + +```bash +go build ./... +``` + +**Step 3: Commit** + +```bash +git add -A && git commit -m "feat: implement patch apply from TUI tasks view" +``` + +### Task 9: Implement rebase flow for stale patches + +**Files:** +- Modify: `cmd/roborev/tui_handlers.go` (rebase confirmation and re-trigger) + +**Step 1: Add rebase confirmation** + +When `git apply --check` fails: +1. Show message: "Patch doesn't apply cleanly. Rebase fix? [enter=yes / esc=cancel]" +2. On confirm: POST `/api/job/fix` with a rebase prompt that includes the original patch as context +3. The prompt instructs the agent to adapt the fix to the current HEAD +4. Switch to tasks view showing the new rebase job + +The rebase prompt: + +``` +# Rebase Fix Request + +A previous fix was generated but no longer applies cleanly to the current code. + +## Original Patch + + + +## Instructions + +Adapt the changes from the original patch to work with the current codebase. +The original patch was for a previous version of the code - apply the same +logical changes but adjusted for the current state of the files. +After making changes, verify the code compiles and tests pass, then commit. +``` + +**Step 2: Add dry-run staleness indicator in tasks view** + +When rendering completed fix jobs, run `git apply --check` and show: +- Green check if patch applies cleanly +- Yellow warning if patch has conflicts + +Cache the result and refresh when the tasks view is entered. + +**Step 3: Run tests** + +```bash +go build ./... +``` + +**Step 4: Commit** + +```bash +git add -A && git commit -m "feat: add rebase flow for stale patches in TUI" +``` + +--- + +## Phase 6: Help Text and Polish + +### Task 10: Update help view and hint bar + +**Files:** +- Modify: `cmd/roborev/tui_helpers.go` (update help text, hint bar) + +**Step 1: Add fix/tasks keys to help view** + +Add to the help text: +- `F` - Fix: trigger background fix for selected review +- `T` - Toggle tasks view (show background fix jobs) +- `A` - Apply patch (in tasks view) + +**Step 2: Update hint bar** + +Add contextual hints: +- In queue/review view: show `F fix` when a failing review is selected +- In tasks view: show `A apply` when a completed fix is selected, `T queue` to go back + +**Step 3: Run full test suite** + +```bash +go test ./... -v -count=1 +go vet ./... +go fmt ./... +``` + +**Step 4: Commit** + +```bash +git add -A && git commit -m "feat: update TUI help text and hint bar for fix/tasks" +``` diff --git a/internal/storage/db.go b/internal/storage/db.go index a44ee8cb..25b1ff05 100644 --- a/internal/storage/db.go +++ b/internal/storage/db.go @@ -602,6 +602,30 @@ func (db *DB) migrate() error { return fmt.Errorf("create idx_reviews_addressed: %w", err) } + // Migration: add parent_job_id column to review_jobs if missing + err = db.QueryRow(`SELECT COUNT(*) FROM pragma_table_info('review_jobs') WHERE name = 'parent_job_id'`).Scan(&count) + if err != nil { + return fmt.Errorf("check parent_job_id column: %w", err) + } + if count == 0 { + _, err = db.Exec(`ALTER TABLE review_jobs ADD COLUMN parent_job_id INTEGER`) + if err != nil { + return fmt.Errorf("add parent_job_id column: %w", err) + } + } + + // Migration: add patch column to review_jobs if missing + err = db.QueryRow(`SELECT COUNT(*) FROM pragma_table_info('review_jobs') WHERE name = 'patch'`).Scan(&count) + if err != nil { + return fmt.Errorf("check patch column: %w", err) + } + if count == 0 { + _, err = db.Exec(`ALTER TABLE review_jobs ADD COLUMN patch TEXT`) + if err != nil { + return fmt.Errorf("add patch column: %w", err) + } + } + // Run sync-related migrations if err := db.migrateSyncColumns(); err != nil { return err diff --git a/internal/storage/jobs.go b/internal/storage/jobs.go index e05f51be..d27af8eb 100644 --- a/internal/storage/jobs.go +++ b/internal/storage/jobs.go @@ -726,7 +726,8 @@ type EnqueueOpts struct { OutputPrefix string // Prefix to prepend to review output Agentic bool // Allow file edits and command execution Label string // Display label in TUI for task jobs (default: "prompt") - JobType string // Explicit job type (review/range/dirty/task/compact); inferred if empty + JobType string // Explicit job type (review/range/dirty/task/compact/fix); inferred if empty + ParentJobID int64 // Parent job being fixed (for fix jobs) } // EnqueueJob creates a new review job. The job type is inferred from opts. @@ -779,16 +780,22 @@ func (db *DB) EnqueueJob(opts EnqueueOpts) (*ReviewJob, error) { commitIDParam = opts.CommitID } + // Use NULL for parent_job_id when not a fix job + var parentJobIDParam interface{} + if opts.ParentJobID > 0 { + parentJobIDParam = opts.ParentJobID + } + result, err := db.Exec(` INSERT INTO review_jobs (repo_id, commit_id, git_ref, branch, agent, model, reasoning, status, job_type, review_type, patch_id, diff_content, prompt, agentic, output_prefix, - uuid, source_machine_id, updated_at) + parent_job_id, uuid, source_machine_id, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, 'queued', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, opts.RepoID, commitIDParam, gitRef, nullString(opts.Branch), opts.Agent, nullString(opts.Model), reasoning, jobType, opts.ReviewType, nullString(opts.PatchID), nullString(opts.DiffContent), nullString(opts.Prompt), agenticInt, - nullString(opts.OutputPrefix), + nullString(opts.OutputPrefix), parentJobIDParam, uid, machineID, nowStr) if err != nil { return nil, err @@ -815,6 +822,9 @@ func (db *DB) EnqueueJob(opts EnqueueOpts) (*ReviewJob, error) { SourceMachineID: machineID, UpdatedAt: &now, } + if opts.ParentJobID > 0 { + job.ParentJobID = &opts.ParentJobID + } if opts.CommitID > 0 { job.CommitID = &opts.CommitID } @@ -867,10 +877,11 @@ func (db *DB) ClaimJob(workerID string) (*ReviewJob, error) { var reviewType sql.NullString var outputPrefix sql.NullString var patchID sql.NullString + var parentJobID sql.NullInt64 err = db.QueryRow(` SELECT j.id, j.repo_id, j.commit_id, j.git_ref, j.branch, j.agent, j.model, j.reasoning, j.status, j.enqueued_at, r.root_path, r.name, c.subject, j.diff_content, j.prompt, COALESCE(j.agentic, 0), j.job_type, j.review_type, - j.output_prefix, j.patch_id + j.output_prefix, j.patch_id, j.parent_job_id FROM review_jobs j JOIN repos r ON r.id = j.repo_id LEFT JOIN commits c ON c.id = j.commit_id @@ -879,7 +890,7 @@ func (db *DB) ClaimJob(workerID string) (*ReviewJob, error) { LIMIT 1 `, workerID).Scan(&job.ID, &job.RepoID, &commitID, &job.GitRef, &branch, &job.Agent, &model, &job.Reasoning, &job.Status, &enqueuedAt, &job.RepoPath, &job.RepoName, &commitSubject, &diffContent, &prompt, &agenticInt, &jobType, &reviewType, - &outputPrefix, &patchID) + &outputPrefix, &patchID, &parentJobID) if err != nil { return nil, err } @@ -915,6 +926,9 @@ func (db *DB) ClaimJob(workerID string) (*ReviewJob, error) { if patchID.Valid { job.PatchID = patchID.String } + if parentJobID.Valid { + job.ParentJobID = &parentJobID.Int64 + } job.EnqueuedAt = parseSQLiteTime(enqueuedAt) job.Status = JobStatusRunning job.WorkerID = workerID @@ -928,6 +942,12 @@ func (db *DB) SaveJobPrompt(jobID int64, prompt string) error { return err } +// SaveJobPatch stores the generated patch for a completed fix job +func (db *DB) SaveJobPatch(jobID int64, patch string) error { + _, err := db.Exec(`UPDATE review_jobs SET patch = ? WHERE id = ?`, patch, jobID) + return err +} + // CompleteJob marks a job as done and stores the review. // Only updates if job is still in 'running' state (respects cancellation). // If the job has an output_prefix, it will be prepended to the output. @@ -1215,7 +1235,8 @@ func (db *DB) ListJobs(statusFilter string, repoFilter string, limit, offset int SELECT j.id, j.repo_id, j.commit_id, j.git_ref, j.branch, j.agent, j.reasoning, j.status, j.enqueued_at, j.started_at, j.finished_at, j.worker_id, j.error, j.prompt, j.retry_count, COALESCE(j.agentic, 0), r.root_path, r.name, c.subject, rv.addressed, rv.output, - j.source_machine_id, j.uuid, j.model, j.job_type, j.review_type, j.patch_id + j.source_machine_id, j.uuid, j.model, j.job_type, j.review_type, j.patch_id, + j.parent_job_id FROM review_jobs j JOIN repos r ON r.id = j.repo_id LEFT JOIN commits c ON c.id = j.commit_id @@ -1287,11 +1308,13 @@ func (db *DB) ListJobs(statusFilter string, repoFilter string, limit, offset int var commitSubject sql.NullString var addressed sql.NullInt64 var agentic int + var parentJobID sql.NullInt64 err := rows.Scan(&j.ID, &j.RepoID, &commitID, &j.GitRef, &branch, &j.Agent, &j.Reasoning, &j.Status, &enqueuedAt, &startedAt, &finishedAt, &workerID, &errMsg, &prompt, &j.RetryCount, &agentic, &j.RepoPath, &j.RepoName, &commitSubject, &addressed, &output, - &sourceMachineID, &jobUUID, &model, &jobTypeStr, &reviewTypeStr, &patchIDStr) + &sourceMachineID, &jobUUID, &model, &jobTypeStr, &reviewTypeStr, &patchIDStr, + &parentJobID) if err != nil { return nil, err } @@ -1346,6 +1369,9 @@ func (db *DB) ListJobs(statusFilter string, repoFilter string, limit, offset int val := addressed.Int64 != 0 j.Addressed = &val } + if parentJobID.Valid { + j.ParentJobID = &parentJobID.Int64 + } // Compute verdict only for non-task jobs (task jobs don't have PASS/FAIL verdicts) // Task jobs (run, analyze, custom) are identified by having no commit_id and not being dirty if output.Valid && !j.IsTaskJob() { @@ -1415,19 +1441,23 @@ func (db *DB) GetJobByID(id int64) (*ReviewJob, error) { var commitID sql.NullInt64 var commitSubject sql.NullString var agentic int + var parentJobID sql.NullInt64 + var patch sql.NullString var model, branch, jobTypeStr, reviewTypeStr, patchIDStr sql.NullString err := db.QueryRow(` SELECT j.id, j.repo_id, j.commit_id, j.git_ref, j.branch, j.agent, j.reasoning, j.status, j.enqueued_at, j.started_at, j.finished_at, j.worker_id, j.error, j.prompt, COALESCE(j.agentic, 0), - r.root_path, r.name, c.subject, j.model, j.job_type, j.review_type, j.patch_id + r.root_path, r.name, c.subject, j.model, j.job_type, j.review_type, j.patch_id, + j.parent_job_id, j.patch FROM review_jobs j JOIN repos r ON r.id = j.repo_id LEFT JOIN commits c ON c.id = j.commit_id WHERE j.id = ? `, id).Scan(&j.ID, &j.RepoID, &commitID, &j.GitRef, &branch, &j.Agent, &j.Reasoning, &j.Status, &enqueuedAt, &startedAt, &finishedAt, &workerID, &errMsg, &prompt, &agentic, - &j.RepoPath, &j.RepoName, &commitSubject, &model, &jobTypeStr, &reviewTypeStr, &patchIDStr) + &j.RepoPath, &j.RepoName, &commitSubject, &model, &jobTypeStr, &reviewTypeStr, &patchIDStr, + &parentJobID, &patch) if err != nil { return nil, err } @@ -1472,6 +1502,12 @@ func (db *DB) GetJobByID(id int64) (*ReviewJob, error) { if branch.Valid { j.Branch = branch.String } + if parentJobID.Valid { + j.ParentJobID = &parentJobID.Int64 + } + if patch.Valid { + j.Patch = &patch.String + } return &j, nil } diff --git a/internal/storage/models.go b/internal/storage/models.go index bcede9da..2f57c62f 100644 --- a/internal/storage/models.go +++ b/internal/storage/models.go @@ -40,6 +40,7 @@ const ( JobTypeDirty = "dirty" // Uncommitted changes review JobTypeTask = "task" // Run/analyze/design/custom prompt JobTypeCompact = "compact" // Consolidated review verification + JobTypeFix = "fix" // Background fix using worktree ) type ReviewJob struct { @@ -65,6 +66,8 @@ type ReviewJob struct { ReviewType string `json:"review_type,omitempty"` // Review type (e.g., "security") - changes system prompt PatchID string `json:"patch_id,omitempty"` // Stable patch-id for rebase tracking OutputPrefix string `json:"output_prefix,omitempty"` // Prefix to prepend to review output + ParentJobID *int64 `json:"parent_job_id,omitempty"` // Job being fixed (for fix jobs) + Patch *string `json:"patch,omitempty"` // Generated diff patch (for completed fix jobs) // Sync fields UUID string `json:"uuid,omitempty"` // Globally unique identifier for sync SourceMachineID string `json:"source_machine_id,omitempty"` // Machine that created this job @@ -118,7 +121,12 @@ func (j ReviewJob) IsTaskJob() bool { // (task or compact). These job types have prompts built by the CLI at // enqueue time, not constructed by the worker from git data. func (j ReviewJob) IsPromptJob() bool { - return j.JobType == JobTypeTask || j.JobType == JobTypeCompact + return j.JobType == JobTypeTask || j.JobType == JobTypeCompact || j.JobType == JobTypeFix +} + +// IsFixJob returns true if this is a background fix job. +func (j ReviewJob) IsFixJob() bool { + return j.JobType == JobTypeFix } // JobWithReview pairs a job with its review for batch operations From 5749b18cdaa8b51292e84810238a91daaed133fb Mon Sep 17 00:00:00 2001 From: Marius van Niekerk Date: Tue, 17 Feb 2026 12:47:35 -0500 Subject: [PATCH 02/49] refactor: extract worktree helpers to internal/worktree package (#279) Move createTempWorktree, applyWorktreeChanges, and submodule helper functions from refine.go to a shared internal/worktree package. Add CapturePatch, ApplyPatch, and CheckPatch functions for reuse by the upcoming fix job worker. Move related tests to worktree_test.go. Co-Authored-By: Claude Opus 4.6 --- cmd/roborev/main_test.go | 49 ++++ cmd/roborev/refine.go | 182 +------------- cmd/roborev/refine_test.go | 369 ++++++++++++++++++++++++----- internal/worktree/worktree.go | 195 +++++++++++++++ internal/worktree/worktree_test.go | 68 ++++++ 5 files changed, 632 insertions(+), 231 deletions(-) create mode 100644 internal/worktree/worktree.go create mode 100644 internal/worktree/worktree_test.go diff --git a/cmd/roborev/main_test.go b/cmd/roborev/main_test.go index 2ffad04a..0a51d1d3 100644 --- a/cmd/roborev/main_test.go +++ b/cmd/roborev/main_test.go @@ -24,6 +24,7 @@ import ( "github.com/roborev-dev/roborev/internal/git" "github.com/roborev-dev/roborev/internal/storage" "github.com/roborev-dev/roborev/internal/version" + "github.com/roborev-dev/roborev/internal/worktree" ) // ============================================================================ @@ -334,6 +335,54 @@ func TestRunRefineStopsLiveTimerOnAgentError(t *testing.T) { } } +func TestCreateTempWorktreeInitializesSubmodules(t *testing.T) { + submoduleRepo := t.TempDir() + runSubGit := func(args ...string) { + cmd := exec.Command("git", args...) + cmd.Dir = submoduleRepo + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("git %v failed: %v\n%s", args, err, out) + } + } + + runSubGit("init") + runSubGit("symbolic-ref", "HEAD", "refs/heads/main") + runSubGit("config", "user.email", "test@test.com") + runSubGit("config", "user.name", "Test") + if err := os.WriteFile(filepath.Join(submoduleRepo, "sub.txt"), []byte("sub"), 0644); err != nil { + t.Fatal(err) + } + runSubGit("add", "sub.txt") + runSubGit("commit", "-m", "submodule commit") + + mainRepo := t.TempDir() + runMainGit := func(args ...string) { + cmd := exec.Command("git", args...) + cmd.Dir = mainRepo + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("git %v failed: %v\n%s", args, err, out) + } + } + + runMainGit("init") + runMainGit("symbolic-ref", "HEAD", "refs/heads/main") + runMainGit("config", "user.email", "test@test.com") + runMainGit("config", "user.name", "Test") + runMainGit("config", "protocol.file.allow", "always") + runMainGit("-c", "protocol.file.allow=always", "submodule", "add", submoduleRepo, "deps/sub") + runMainGit("commit", "-m", "add submodule") + + worktreePath, cleanup, err := worktree.Create(mainRepo) + if err != nil { + t.Fatalf("worktree.Create failed: %v", err) + } + defer cleanup() + + if _, err := os.Stat(filepath.Join(worktreePath, "deps", "sub", "sub.txt")); err != nil { + t.Fatalf("expected submodule file in worktree: %v", err) + } +} + // ============================================================================ // Integration Tests for Refine Loop Business Logic // ============================================================================ diff --git a/cmd/roborev/refine.go b/cmd/roborev/refine.go index 8b3d6f62..b49e79df 100644 --- a/cmd/roborev/refine.go +++ b/cmd/roborev/refine.go @@ -1,18 +1,12 @@ package main import ( - "bufio" - "bytes" "context" "errors" "fmt" "io" - "io/fs" "os" - "os/exec" - "path/filepath" "sort" - "strconv" "strings" "time" @@ -23,6 +17,7 @@ import ( "github.com/roborev-dev/roborev/internal/git" "github.com/roborev-dev/roborev/internal/prompt" "github.com/roborev-dev/roborev/internal/storage" + "github.com/roborev-dev/roborev/internal/worktree" "github.com/spf13/cobra" ) @@ -513,7 +508,7 @@ func runRefine(ctx RunContext, opts refineOptions) error { branchBefore := git.GetCurrentBranch(repoPath) // Create temp worktree to isolate agent from user's working tree - worktreePath, cleanupWorktree, err := createTempWorktree(repoPath) + worktreePath, cleanupWorktree, err := worktree.Create(repoPath) if err != nil { return fmt.Errorf("create worktree: %w", err) } @@ -588,10 +583,15 @@ func runRefine(ctx RunContext, opts refineOptions) error { continue } - // Apply worktree changes to main repo and commit - if err := applyWorktreeChanges(repoPath, worktreePath); err != nil { + // Capture patch from worktree and apply to main repo + patch, err := worktree.CapturePatch(worktreePath) + if err != nil { + cleanupWorktree() + return fmt.Errorf("capture worktree patch: %w", err) + } + if err := worktree.ApplyPatch(repoPath, patch); err != nil { cleanupWorktree() - return fmt.Errorf("apply worktree changes: %w", err) + return fmt.Errorf("apply worktree patch: %w", err) } cleanupWorktree() @@ -1024,167 +1024,7 @@ func summarizeAgentOutput(output string) string { return strings.Join(summary, "\n") } -// createTempWorktree creates a temporary git worktree for isolated agent work -func createTempWorktree(repoPath string) (string, func(), error) { - worktreeDir, err := os.MkdirTemp("", "roborev-refine-") - if err != nil { - return "", nil, err - } - - // Create the worktree (without --recurse-submodules for compatibility with older git). - // Suppress hooks via core.hooksPath= — user hooks shouldn't run in internal worktrees. - cmd := exec.Command("git", "-C", repoPath, "-c", "core.hooksPath="+os.DevNull, "worktree", "add", "--detach", worktreeDir, "HEAD") - if out, err := cmd.CombinedOutput(); err != nil { - _ = os.RemoveAll(worktreeDir) - return "", nil, fmt.Errorf("git worktree add: %w: %s", err, out) - } - - // Initialize and update submodules in the worktree - initArgs := []string{"-C", worktreeDir} - if submoduleRequiresFileProtocol(worktreeDir) { - initArgs = append(initArgs, "-c", "protocol.file.allow=always") - } - initArgs = append(initArgs, "submodule", "update", "--init") - cmd = exec.Command("git", initArgs...) - if out, err := cmd.CombinedOutput(); err != nil { - _ = exec.Command("git", "-C", repoPath, "worktree", "remove", "--force", worktreeDir).Run() - _ = os.RemoveAll(worktreeDir) - return "", nil, fmt.Errorf("git submodule update: %w: %s", err, out) - } - - updateArgs := []string{"-C", worktreeDir} - if submoduleRequiresFileProtocol(worktreeDir) { - updateArgs = append(updateArgs, "-c", "protocol.file.allow=always") - } - updateArgs = append(updateArgs, "submodule", "update", "--init", "--recursive") - cmd = exec.Command("git", updateArgs...) - if out, err := cmd.CombinedOutput(); err != nil { - _ = exec.Command("git", "-C", repoPath, "worktree", "remove", "--force", worktreeDir).Run() - _ = os.RemoveAll(worktreeDir) - return "", nil, fmt.Errorf("git submodule update: %w: %s", err, out) - } - - lfsCmd := exec.Command("git", "-C", worktreeDir, "lfs", "env") - if err := lfsCmd.Run(); err == nil { - cmd = exec.Command("git", "-C", worktreeDir, "lfs", "pull") - _ = cmd.Run() - } - - cleanup := func() { - _ = exec.Command("git", "-C", repoPath, "worktree", "remove", "--force", worktreeDir).Run() - _ = os.RemoveAll(worktreeDir) - } - - return worktreeDir, cleanup, nil -} - -func submoduleRequiresFileProtocol(repoPath string) bool { - gitmodulesPaths := findGitmodulesPaths(repoPath) - if len(gitmodulesPaths) == 0 { - return false - } - for _, gitmodulesPath := range gitmodulesPaths { - file, err := os.Open(gitmodulesPath) - if err != nil { - continue - } - scanner := bufio.NewScanner(file) - for scanner.Scan() { - line := strings.TrimSpace(scanner.Text()) - if line == "" || strings.HasPrefix(line, "#") || strings.HasPrefix(line, ";") { - continue - } - parts := strings.SplitN(line, "=", 2) - if len(parts) != 2 { - continue - } - if !strings.EqualFold(strings.TrimSpace(parts[0]), "url") { - continue - } - url := strings.TrimSpace(parts[1]) - if unquoted, err := strconv.Unquote(url); err == nil { - url = unquoted - } - if isFileProtocolURL(url) { - file.Close() - return true - } - } - file.Close() - } - return false -} - -func findGitmodulesPaths(repoPath string) []string { - var paths []string - err := filepath.WalkDir(repoPath, func(path string, d fs.DirEntry, err error) error { - if err != nil { - return nil - } - if d.IsDir() && d.Name() == ".git" { - return filepath.SkipDir - } - if d.Name() == ".gitmodules" { - paths = append(paths, path) - } - return nil - }) - if err != nil { - return nil - } - return paths -} - -func isFileProtocolURL(url string) bool { - lower := strings.ToLower(url) - if strings.HasPrefix(lower, "file:") { - return true - } - if strings.HasPrefix(url, "/") || strings.HasPrefix(url, "./") || strings.HasPrefix(url, "../") { - return true - } - if len(url) >= 2 && isAlpha(url[0]) && url[1] == ':' { - return true - } - if strings.HasPrefix(url, `\\`) { - return true - } - return false -} - -func isAlpha(b byte) bool { - return (b >= 'a' && b <= 'z') || (b >= 'A' && b <= 'Z') -} - -// applyWorktreeChanges applies changes from worktree to main repo via patch -func applyWorktreeChanges(repoPath, worktreePath string) error { - // Stage all changes in worktree - cmd := exec.Command("git", "-C", worktreePath, "add", "-A") - if out, err := cmd.CombinedOutput(); err != nil { - return fmt.Errorf("git add in worktree: %w: %s", err, out) - } - - // Get diff as patch - diffCmd := exec.Command("git", "-C", worktreePath, "diff", "--cached", "--binary") - diff, err := diffCmd.Output() - if err != nil { - return fmt.Errorf("git diff in worktree: %w", err) - } - if len(diff) == 0 { - return nil // No changes - } - - // Apply patch to main repo - applyCmd := exec.Command("git", "-C", repoPath, "apply", "--binary", "-") - applyCmd.Stdin = bytes.NewReader(diff) - var stderr bytes.Buffer - applyCmd.Stderr = &stderr - if err := applyCmd.Run(); err != nil { - return fmt.Errorf("git apply: %w: %s", err, stderr.String()) - } - - return nil -} +// Worktree creation and patch operations are in internal/worktree package. // commitWithHookRetry attempts git.CreateCommit and, on failure, // runs the agent to fix whatever the hook complained about. Only diff --git a/cmd/roborev/refine_test.go b/cmd/roborev/refine_test.go index 2f121529..98b21136 100644 --- a/cmd/roborev/refine_test.go +++ b/cmd/roborev/refine_test.go @@ -13,6 +13,7 @@ import ( "github.com/roborev-dev/roborev/internal/daemon" "github.com/roborev-dev/roborev/internal/storage" "github.com/roborev-dev/roborev/internal/testutil" + "github.com/roborev-dev/roborev/internal/worktree" ) // mockDaemonClient is a test implementation of daemon.Client @@ -390,66 +391,6 @@ func TestFindFailedReviewForBranch_AllPass(t *testing.T) { } } -func TestSubmoduleRequiresFileProtocol(t *testing.T) { - tpl := `[submodule "test"] - path = test - %s = %s -` - tests := []struct { - name string - key string - url string - expected bool - }{ - {name: "file-scheme", key: "url", url: "file:///tmp/repo", expected: true}, - {name: "file-scheme-quoted", key: "url", url: `"file:///tmp/repo"`, expected: true}, - {name: "file-scheme-mixed-case-key", key: "URL", url: "file:///tmp/repo", expected: true}, - {name: "file-single-slash", key: "url", url: "file:/tmp/repo", expected: true}, - {name: "unix-absolute", key: "url", url: "/tmp/repo", expected: true}, - {name: "relative-dot", key: "url", url: "./repo", expected: true}, - {name: "relative-dotdot", key: "url", url: "../repo", expected: true}, - {name: "windows-drive-slash", key: "url", url: "C:/repo", expected: true}, - {name: "windows-drive-backslash", key: "url", url: `C:\repo`, expected: true}, - {name: "windows-unc", key: "url", url: `\\server\share\repo`, expected: true}, - {name: "https", key: "url", url: "https://example.com/repo.git", expected: false}, - } - - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - dir := t.TempDir() - gitmodules := filepath.Join(dir, ".gitmodules") - if err := os.WriteFile(gitmodules, fmt.Appendf(nil, tpl, tc.key, tc.url), 0644); err != nil { - t.Fatalf("write .gitmodules: %v", err) - } - if got := submoduleRequiresFileProtocol(dir); got != tc.expected { - t.Fatalf("expected %v, got %v", tc.expected, got) - } - }) - } -} - -func TestSubmoduleRequiresFileProtocolNested(t *testing.T) { - tpl := `[submodule "test"] - path = test - url = %s -` - dir := t.TempDir() - if err := os.WriteFile(filepath.Join(dir, ".gitmodules"), fmt.Appendf(nil, tpl, "https://example.com/repo.git"), 0644); err != nil { - t.Fatalf("write root .gitmodules: %v", err) - } - nestedPath := filepath.Join(dir, "sub", ".gitmodules") - if err := os.MkdirAll(filepath.Dir(nestedPath), 0755); err != nil { - t.Fatalf("mkdir nested: %v", err) - } - if err := os.WriteFile(nestedPath, fmt.Appendf(nil, tpl, "file:///tmp/repo"), 0644); err != nil { - t.Fatalf("write nested .gitmodules: %v", err) - } - - if !submoduleRequiresFileProtocol(dir) { - t.Fatalf("expected nested file URL to require file protocol") - } -} - func TestFindFailedReviewForBranch_NoReviews(t *testing.T) { client := newMockDaemonClient() // No reviews set - GetReviewBySHA will return nil for all commits @@ -784,6 +725,314 @@ func TestFindPendingJobForBranch_OldestFirst(t *testing.T) { } } +// setupTestGitRepo creates a git repo for testing branch/--since behavior. +// Returns the repo directory, base commit SHA, and a helper to run git commands. +func setupTestGitRepo(t *testing.T) (repoDir string, baseSHA string, runGit func(args ...string) string) { + t.Helper() + + repoDir = t.TempDir() + runGit = func(args ...string) string { + cmd := exec.Command("git", args...) + cmd.Dir = repoDir + out, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("git %v failed: %v\n%s", args, err, out) + } + return strings.TrimSpace(string(out)) + } + + // Use git init + symbolic-ref for compatibility with Git < 2.28 (which lacks -b flag) + runGit("init") + runGit("symbolic-ref", "HEAD", "refs/heads/main") + runGit("config", "user.email", "test@test.com") + runGit("config", "user.name", "Test") + + baseSHA = gitCommitFile(t, repoDir, runGit, "base.txt", "base", "base commit") + + return repoDir, baseSHA, runGit +} + +func TestValidateRefineContext_RefusesMainBranchWithoutSince(t *testing.T) { + if _, err := exec.LookPath("git"); err != nil { + t.Skip("git not available") + } + + repoDir, _, _ := setupTestGitRepo(t) + + // Stay on main branch (don't create feature branch) + origDir, err := os.Getwd() + if err != nil { + t.Fatal(err) + } + if err := os.Chdir(repoDir); err != nil { + t.Fatal(err) + } + defer os.Chdir(origDir) + + // Validating without --since on main should fail + _, _, _, _, err = validateRefineContext("", "") + if err == nil { + t.Fatal("expected error when validating on main without --since") + } + if !strings.Contains(err.Error(), "refusing to refine on main") { + t.Errorf("expected 'refusing to refine on main' error, got: %v", err) + } + if !strings.Contains(err.Error(), "--since") { + t.Errorf("expected error to mention --since flag, got: %v", err) + } +} + +func TestValidateRefineContext_AllowsMainBranchWithSince(t *testing.T) { + if _, err := exec.LookPath("git"); err != nil { + t.Skip("git not available") + } + + repoDir, baseSHA, runGit := setupTestGitRepo(t) + + // Add another commit on main + gitCommitFile(t, repoDir, runGit, "second.txt", "second", "second commit") + + origDir, err := os.Getwd() + if err != nil { + t.Fatal(err) + } + if err := os.Chdir(repoDir); err != nil { + t.Fatal(err) + } + defer os.Chdir(origDir) + + // Validating with --since on main should pass + repoPath, currentBranch, _, mergeBase, err := validateRefineContext(baseSHA, "") + if err != nil { + t.Fatalf("validation should pass with --since on main, got: %v", err) + } + if repoPath == "" { + t.Error("expected non-empty repoPath") + } + if currentBranch != "main" { + t.Errorf("expected currentBranch=main, got %s", currentBranch) + } + if mergeBase != baseSHA { + t.Errorf("expected mergeBase=%s, got %s", baseSHA, mergeBase) + } +} + +func TestValidateRefineContext_SinceWorksOnFeatureBranch(t *testing.T) { + if _, err := exec.LookPath("git"); err != nil { + t.Skip("git not available") + } + + repoDir, baseSHA, runGit := setupTestGitRepo(t) + + // Create feature branch with commits + runGit("checkout", "-b", "feature") + gitCommitFile(t, repoDir, runGit, "feature.txt", "feature", "feature commit") + + origDir, err := os.Getwd() + if err != nil { + t.Fatal(err) + } + if err := os.Chdir(repoDir); err != nil { + t.Fatal(err) + } + defer os.Chdir(origDir) + + // --since should work on feature branch + repoPath, currentBranch, _, mergeBase, err := validateRefineContext(baseSHA, "") + if err != nil { + t.Fatalf("--since should work on feature branch, got: %v", err) + } + if repoPath == "" { + t.Error("expected non-empty repoPath") + } + if currentBranch != "feature" { + t.Errorf("expected currentBranch=feature, got %s", currentBranch) + } + if mergeBase != baseSHA { + t.Errorf("expected mergeBase=%s, got %s", baseSHA, mergeBase) + } +} + +func TestValidateRefineContext_InvalidSinceRef(t *testing.T) { + if _, err := exec.LookPath("git"); err != nil { + t.Skip("git not available") + } + + repoDir, _, _ := setupTestGitRepo(t) + + origDir, err := os.Getwd() + if err != nil { + t.Fatal(err) + } + if err := os.Chdir(repoDir); err != nil { + t.Fatal(err) + } + defer os.Chdir(origDir) + + // Invalid --since ref should fail with clear error + _, _, _, _, err = validateRefineContext("nonexistent-ref-abc123", "") + if err == nil { + t.Fatal("expected error for invalid --since ref") + } + if !strings.Contains(err.Error(), "cannot resolve --since") { + t.Errorf("expected 'cannot resolve --since' error, got: %v", err) + } +} + +func TestValidateRefineContext_SinceNotAncestorOfHEAD(t *testing.T) { + if _, err := exec.LookPath("git"); err != nil { + t.Skip("git not available") + } + + repoDir, _, runGit := setupTestGitRepo(t) + + // Create a commit on a separate branch that diverges from main + runGit("checkout", "-b", "other-branch") + otherBranchSHA := gitCommitFile(t, repoDir, runGit, "other.txt", "other", "commit on other branch") + + // Go back to main and create a different commit + runGit("checkout", "main") + gitCommitFile(t, repoDir, runGit, "main2.txt", "main2", "second commit on main") + + origDir, err := os.Getwd() + if err != nil { + t.Fatal(err) + } + if err := os.Chdir(repoDir); err != nil { + t.Fatal(err) + } + defer os.Chdir(origDir) + + // Using --since with a commit from a different branch (not ancestor of HEAD) should fail + _, _, _, _, err = validateRefineContext(otherBranchSHA, "") + if err == nil { + t.Fatal("expected error when --since is not an ancestor of HEAD") + } + if !strings.Contains(err.Error(), "not an ancestor of HEAD") { + t.Errorf("expected 'not an ancestor of HEAD' error, got: %v", err) + } +} + +func TestValidateRefineContext_FeatureBranchWithoutSinceStillWorks(t *testing.T) { + if _, err := exec.LookPath("git"); err != nil { + t.Skip("git not available") + } + + repoDir, baseSHA, runGit := setupTestGitRepo(t) + + // Create feature branch + runGit("checkout", "-b", "feature") + gitCommitFile(t, repoDir, runGit, "feature.txt", "feature", "feature commit") + + origDir, err := os.Getwd() + if err != nil { + t.Fatal(err) + } + if err := os.Chdir(repoDir); err != nil { + t.Fatal(err) + } + defer os.Chdir(origDir) + + // Feature branch without --since should pass validation (uses merge-base) + repoPath, currentBranch, _, mergeBase, err := validateRefineContext("", "") + if err != nil { + t.Fatalf("feature branch without --since should work, got: %v", err) + } + if repoPath == "" { + t.Error("expected non-empty repoPath") + } + if currentBranch != "feature" { + t.Errorf("expected currentBranch=feature, got %s", currentBranch) + } + // mergeBase should be the base commit (merge-base of feature and main) + if mergeBase != baseSHA { + t.Errorf("expected mergeBase=%s (base commit), got %s", baseSHA, mergeBase) + } +} + +func TestWorktreeCleanupBetweenIterations(t *testing.T) { + if _, err := exec.LookPath("git"); err != nil { + t.Skip("git not available") + } + + repoDir, _, _ := setupTestGitRepo(t) + + // Simulate the refine loop pattern: create a worktree, then clean it up + // before the next iteration. Verify the directory is removed each time. + var prevPath string + for i := range 3 { + worktreePath, cleanup, err := worktree.Create(repoDir) + if err != nil { + t.Fatalf("iteration %d: worktree.Create failed: %v", i, err) + } + + // Verify previous worktree was cleaned up + if prevPath != "" { + if _, err := os.Stat(prevPath); !os.IsNotExist(err) { + t.Fatalf("iteration %d: previous worktree %s still exists after cleanup", i, prevPath) + } + } + + // Verify current worktree exists + if _, err := os.Stat(worktreePath); err != nil { + t.Fatalf("iteration %d: worktree %s should exist: %v", i, worktreePath, err) + } + + // Simulate the explicit cleanup call (as done on error/no-change paths) + cleanup() + prevPath = worktreePath + } + + // Verify the last worktree was also cleaned up + if _, err := os.Stat(prevPath); !os.IsNotExist(err) { + t.Fatalf("last worktree %s still exists after cleanup", prevPath) + } +} + +func TestCreateTempWorktreeIgnoresHooks(t *testing.T) { + if _, err := exec.LookPath("git"); err != nil { + t.Skip("git not available") + } + + repoDir, _, runGit := setupTestGitRepo(t) + + installGitHook(t, repoDir, "post-checkout", "#!/bin/sh\nexit 1\n") + + // Verify the hook is active (a normal worktree add would fail) + failDir := t.TempDir() + cmd := exec.Command("git", "-C", repoDir, "worktree", "add", "--detach", failDir, "HEAD") + if out, err := cmd.CombinedOutput(); err == nil { + // Clean up the worktree before failing + exec.Command("git", "-C", repoDir, "worktree", "remove", "--force", failDir).Run() + // Some git versions don't fail on post-checkout hook errors. + // In that case, verify our approach still succeeds. + _ = out + } + + // worktree.Create should succeed because it suppresses hooks + worktreePath, cleanup, err := worktree.Create(repoDir) + if err != nil { + t.Fatalf("worktree.Create should succeed with failing hook: %v", err) + } + defer cleanup() + + // Verify the worktree directory exists and has the file from the repo + if _, err := os.Stat(worktreePath); err != nil { + t.Fatalf("worktree directory should exist: %v", err) + } + + baseFile := filepath.Join(worktreePath, "base.txt") + content, err := os.ReadFile(baseFile) + if err != nil { + t.Fatalf("expected base.txt in worktree: %v", err) + } + if string(content) != "base" { + t.Errorf("expected content 'base', got %q", string(content)) + } + + _ = runGit // used by setupTestGitRepo +} + func TestCommitWithHookRetrySucceeds(t *testing.T) { if _, err := exec.LookPath("git"); err != nil { t.Skip("git not available") diff --git a/internal/worktree/worktree.go b/internal/worktree/worktree.go new file mode 100644 index 00000000..68e09852 --- /dev/null +++ b/internal/worktree/worktree.go @@ -0,0 +1,195 @@ +package worktree + +import ( + "bufio" + "bytes" + "fmt" + "io/fs" + "os" + "os/exec" + "path/filepath" + "strconv" + "strings" +) + +// Create creates a temporary git worktree detached at HEAD for isolated agent work. +// Returns the worktree directory path, a cleanup function, and any error. +// The cleanup function removes the worktree and its directory. +func Create(repoPath string) (string, func(), error) { + worktreeDir, err := os.MkdirTemp("", "roborev-worktree-") + if err != nil { + return "", nil, err + } + + // Create the worktree (without --recurse-submodules for compatibility with older git). + // Suppress hooks via core.hooksPath= — user hooks shouldn't run in internal worktrees. + cmd := exec.Command("git", "-C", repoPath, "-c", "core.hooksPath="+os.DevNull, "worktree", "add", "--detach", worktreeDir, "HEAD") + if out, err := cmd.CombinedOutput(); err != nil { + os.RemoveAll(worktreeDir) + return "", nil, fmt.Errorf("git worktree add: %w: %s", err, out) + } + + // Initialize and update submodules in the worktree + initArgs := []string{"-C", worktreeDir} + if submoduleRequiresFileProtocol(worktreeDir) { + initArgs = append(initArgs, "-c", "protocol.file.allow=always") + } + initArgs = append(initArgs, "submodule", "update", "--init") + cmd = exec.Command("git", initArgs...) + if out, err := cmd.CombinedOutput(); err != nil { + exec.Command("git", "-C", repoPath, "worktree", "remove", "--force", worktreeDir).Run() + os.RemoveAll(worktreeDir) + return "", nil, fmt.Errorf("git submodule update: %w: %s", err, out) + } + + updateArgs := []string{"-C", worktreeDir} + if submoduleRequiresFileProtocol(worktreeDir) { + updateArgs = append(updateArgs, "-c", "protocol.file.allow=always") + } + updateArgs = append(updateArgs, "submodule", "update", "--init", "--recursive") + cmd = exec.Command("git", updateArgs...) + if out, err := cmd.CombinedOutput(); err != nil { + exec.Command("git", "-C", repoPath, "worktree", "remove", "--force", worktreeDir).Run() + os.RemoveAll(worktreeDir) + return "", nil, fmt.Errorf("git submodule update: %w: %s", err, out) + } + + lfsCmd := exec.Command("git", "-C", worktreeDir, "lfs", "env") + if err := lfsCmd.Run(); err == nil { + cmd = exec.Command("git", "-C", worktreeDir, "lfs", "pull") + cmd.Run() + } + + cleanup := func() { + exec.Command("git", "-C", repoPath, "worktree", "remove", "--force", worktreeDir).Run() + os.RemoveAll(worktreeDir) + } + + return worktreeDir, cleanup, nil +} + +// CapturePatch stages all changes in the worktree and returns the diff as a patch string. +// Returns empty string if there are no changes. +func CapturePatch(worktreeDir string) (string, error) { + // Stage all changes in worktree + cmd := exec.Command("git", "-C", worktreeDir, "add", "-A") + if out, err := cmd.CombinedOutput(); err != nil { + return "", fmt.Errorf("git add in worktree: %w: %s", err, out) + } + + // Get diff as patch + diffCmd := exec.Command("git", "-C", worktreeDir, "diff", "--cached", "--binary") + diff, err := diffCmd.Output() + if err != nil { + return "", fmt.Errorf("git diff in worktree: %w", err) + } + return string(diff), nil +} + +// ApplyPatch applies a patch to a repository. Returns nil if patch is empty. +func ApplyPatch(repoPath, patch string) error { + if patch == "" { + return nil + } + applyCmd := exec.Command("git", "-C", repoPath, "apply", "--binary", "-") + applyCmd.Stdin = strings.NewReader(patch) + var stderr bytes.Buffer + applyCmd.Stderr = &stderr + if err := applyCmd.Run(); err != nil { + return fmt.Errorf("git apply: %w: %s", err, stderr.String()) + } + return nil +} + +// CheckPatch does a dry-run apply to check if a patch applies cleanly. +func CheckPatch(repoPath, patch string) error { + if patch == "" { + return nil + } + applyCmd := exec.Command("git", "-C", repoPath, "apply", "--check", "--binary", "-") + applyCmd.Stdin = strings.NewReader(patch) + var stderr bytes.Buffer + applyCmd.Stderr = &stderr + if err := applyCmd.Run(); err != nil { + return fmt.Errorf("patch does not apply cleanly: %s", stderr.String()) + } + return nil +} + +func submoduleRequiresFileProtocol(repoPath string) bool { + gitmodulesPaths := findGitmodulesPaths(repoPath) + if len(gitmodulesPaths) == 0 { + return false + } + for _, gitmodulesPath := range gitmodulesPaths { + file, err := os.Open(gitmodulesPath) + if err != nil { + continue + } + scanner := bufio.NewScanner(file) + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if line == "" || strings.HasPrefix(line, "#") || strings.HasPrefix(line, ";") { + continue + } + parts := strings.SplitN(line, "=", 2) + if len(parts) != 2 { + continue + } + if !strings.EqualFold(strings.TrimSpace(parts[0]), "url") { + continue + } + url := strings.TrimSpace(parts[1]) + if unquoted, err := strconv.Unquote(url); err == nil { + url = unquoted + } + if isFileProtocolURL(url) { + file.Close() + return true + } + } + file.Close() + } + return false +} + +func findGitmodulesPaths(repoPath string) []string { + var paths []string + err := filepath.WalkDir(repoPath, func(path string, d fs.DirEntry, err error) error { + if err != nil { + return nil + } + if d.IsDir() && d.Name() == ".git" { + return filepath.SkipDir + } + if d.Name() == ".gitmodules" { + paths = append(paths, path) + } + return nil + }) + if err != nil { + return nil + } + return paths +} + +func isFileProtocolURL(url string) bool { + lower := strings.ToLower(url) + if strings.HasPrefix(lower, "file:") { + return true + } + if strings.HasPrefix(url, "/") || strings.HasPrefix(url, "./") || strings.HasPrefix(url, "../") { + return true + } + if len(url) >= 2 && isAlpha(url[0]) && url[1] == ':' { + return true + } + if strings.HasPrefix(url, `\\`) { + return true + } + return false +} + +func isAlpha(b byte) bool { + return (b >= 'a' && b <= 'z') || (b >= 'A' && b <= 'Z') +} diff --git a/internal/worktree/worktree_test.go b/internal/worktree/worktree_test.go new file mode 100644 index 00000000..c8dd3239 --- /dev/null +++ b/internal/worktree/worktree_test.go @@ -0,0 +1,68 @@ +package worktree + +import ( + "fmt" + "os" + "path/filepath" + "testing" +) + +func TestSubmoduleRequiresFileProtocol(t *testing.T) { + tpl := `[submodule "test"] + path = test + %s = %s +` + tests := []struct { + name string + key string + url string + expected bool + }{ + {name: "file-scheme", key: "url", url: "file:///tmp/repo", expected: true}, + {name: "file-scheme-quoted", key: "url", url: `"file:///tmp/repo"`, expected: true}, + {name: "file-scheme-mixed-case-key", key: "URL", url: "file:///tmp/repo", expected: true}, + {name: "file-single-slash", key: "url", url: "file:/tmp/repo", expected: true}, + {name: "unix-absolute", key: "url", url: "/tmp/repo", expected: true}, + {name: "relative-dot", key: "url", url: "./repo", expected: true}, + {name: "relative-dotdot", key: "url", url: "../repo", expected: true}, + {name: "windows-drive-slash", key: "url", url: "C:/repo", expected: true}, + {name: "windows-drive-backslash", key: "url", url: `C:\repo`, expected: true}, + {name: "windows-unc", key: "url", url: `\\server\share\repo`, expected: true}, + {name: "https", key: "url", url: "https://example.com/repo.git", expected: false}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + dir := t.TempDir() + gitmodules := filepath.Join(dir, ".gitmodules") + if err := os.WriteFile(gitmodules, []byte(fmt.Sprintf(tpl, tc.key, tc.url)), 0644); err != nil { + t.Fatalf("write .gitmodules: %v", err) + } + if got := submoduleRequiresFileProtocol(dir); got != tc.expected { + t.Fatalf("expected %v, got %v", tc.expected, got) + } + }) + } +} + +func TestSubmoduleRequiresFileProtocolNested(t *testing.T) { + tpl := `[submodule "test"] + path = test + url = %s +` + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, ".gitmodules"), []byte(fmt.Sprintf(tpl, "https://example.com/repo.git")), 0644); err != nil { + t.Fatalf("write root .gitmodules: %v", err) + } + nestedPath := filepath.Join(dir, "sub", ".gitmodules") + if err := os.MkdirAll(filepath.Dir(nestedPath), 0755); err != nil { + t.Fatalf("mkdir nested: %v", err) + } + if err := os.WriteFile(nestedPath, []byte(fmt.Sprintf(tpl, "file:///tmp/repo")), 0644); err != nil { + t.Fatalf("write nested .gitmodules: %v", err) + } + + if !submoduleRequiresFileProtocol(dir) { + t.Fatalf("expected nested file URL to require file protocol") + } +} From 3e3306a9028a1eb56a9d7673354b3c378186ebd5 Mon Sep 17 00:00:00 2001 From: Marius van Niekerk Date: Tue, 17 Feb 2026 12:48:44 -0500 Subject: [PATCH 03/49] feat: add fix job processing with worktree isolation in worker pool (#279) Fix jobs create an isolated worktree via worktree.Create(), run the agent with agentic=true in the worktree, then capture the resulting diff as a patch stored in the DB via SaveJobPatch(). The worktree is cleaned up after the agent completes regardless of success/failure. Co-Authored-By: Claude Opus 4.6 --- internal/daemon/worker.go | 34 +++++++++++++++++++++++++++++++++- 1 file changed, 33 insertions(+), 1 deletion(-) diff --git a/internal/daemon/worker.go b/internal/daemon/worker.go index 022d366d..525b2c4d 100644 --- a/internal/daemon/worker.go +++ b/internal/daemon/worker.go @@ -13,6 +13,7 @@ import ( "github.com/roborev-dev/roborev/internal/config" "github.com/roborev-dev/roborev/internal/prompt" "github.com/roborev-dev/roborev/internal/storage" + "github.com/roborev-dev/roborev/internal/worktree" ) // WorkerPool manages a pool of review workers @@ -373,9 +374,24 @@ func (wp *WorkerPool) processJob(workerID string, job *storage.ReviewJob) { wp.outputBuffers.CloseJob(job.ID) }() + // For fix jobs, create an isolated worktree to run the agent in. + // The agent modifies files in the worktree; afterwards we capture the diff as a patch. + reviewRepoPath := job.RepoPath + if job.IsFixJob() { + wtDir, wtCleanup, wtErr := worktree.Create(job.RepoPath) + if wtErr != nil { + log.Printf("[%s] Error creating worktree for fix job %d: %v", workerID, job.ID, wtErr) + wp.failOrRetry(workerID, job, agentName, fmt.Sprintf("create worktree: %v", wtErr)) + return + } + defer wtCleanup() + reviewRepoPath = wtDir + log.Printf("[%s] Fix job %d: running agent in worktree %s", workerID, job.ID, wtDir) + } + // Run the review log.Printf("[%s] Running %s review...", workerID, agentName) - output, err := a.Review(ctx, job.RepoPath, job.GitRef, reviewPrompt, outputWriter) + output, err := a.Review(ctx, reviewRepoPath, job.GitRef, reviewPrompt, outputWriter) if err != nil { // Check if this was a cancellation if ctx.Err() == context.Canceled { @@ -397,6 +413,22 @@ func (wp *WorkerPool) processJob(workerID string, job *storage.ReviewJob) { return } + // For fix jobs, capture the patch from the worktree and store it. + if job.IsFixJob() { + patch, patchErr := worktree.CapturePatch(reviewRepoPath) + if patchErr != nil { + log.Printf("[%s] Warning: failed to capture patch for fix job %d: %v", workerID, job.ID, patchErr) + } else if patch != "" { + if saveErr := wp.db.SaveJobPatch(job.ID, patch); saveErr != nil { + log.Printf("[%s] Warning: failed to save patch for fix job %d: %v", workerID, job.ID, saveErr) + } else { + log.Printf("[%s] Fix job %d: saved patch (%d bytes)", workerID, job.ID, len(patch)) + } + } else { + log.Printf("[%s] Fix job %d: agent produced no file changes", workerID, job.ID) + } + } + // For compact jobs, validate raw agent output before storing. // Invalid output (empty, error patterns) should fail the job, // not produce a "done" review that misleads --wait callers. From 928930cecea51ad58b18adac55e0a710e7a47d7f Mon Sep 17 00:00:00 2001 From: Marius van Niekerk Date: Tue, 17 Feb 2026 12:50:30 -0500 Subject: [PATCH 04/49] feat: add /api/job/fix and /api/job/patch endpoints (#279) POST /api/job/fix creates a background fix job for a parent review. It fetches the review output, builds a fix prompt, and enqueues a new fix job with agentic=true. GET /api/job/patch returns the stored patch for a completed fix job as plain text. Co-Authored-By: Claude Opus 4.6 --- internal/daemon/server.go | 128 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 128 insertions(+) diff --git a/internal/daemon/server.go b/internal/daemon/server.go index 05862182..a76e27cf 100644 --- a/internal/daemon/server.go +++ b/internal/daemon/server.go @@ -89,6 +89,8 @@ func NewServer(db *storage.DB, cfg *config.Config, configPath string) *Server { mux.HandleFunc("/api/remap", s.handleRemap) mux.HandleFunc("/api/sync/now", s.handleSyncNow) mux.HandleFunc("/api/sync/status", s.handleSyncStatus) + mux.HandleFunc("/api/job/fix", s.handleFixJob) + mux.HandleFunc("/api/job/patch", s.handleGetPatch) s.httpServer = &http.Server{ Addr: cfg.ServerAddr, @@ -1624,6 +1626,132 @@ func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) { writeJSON(w, status) } +// handleFixJob creates a background fix job for a review. +// It fetches the parent review, builds a fix prompt, and enqueues a new +// fix job that will run in an isolated worktree. +func (s *Server) handleFixJob(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + writeError(w, http.StatusMethodNotAllowed, "method not allowed") + return + } + + var req struct { + ParentJobID int64 `json:"parent_job_id"` + Prompt string `json:"prompt,omitempty"` // Optional custom prompt override + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "invalid request body") + return + } + if req.ParentJobID == 0 { + writeError(w, http.StatusBadRequest, "parent_job_id is required") + return + } + + // Fetch the parent job + parentJob, err := s.db.GetJobByID(req.ParentJobID) + if err != nil { + writeError(w, http.StatusNotFound, "parent job not found") + return + } + + // Build the fix prompt + fixPrompt := req.Prompt + if fixPrompt == "" { + // Fetch the review output for the parent job + review, err := s.db.GetReviewByJobID(req.ParentJobID) + if err != nil || review == nil { + writeError(w, http.StatusBadRequest, "parent job has no review to fix") + return + } + fixPrompt = buildFixPrompt(review.Output) + } + + // Resolve agent for fix workflow + cfg := s.configWatcher.Config() + reasoning := "standard" + agentName := config.ResolveAgentForWorkflow("", parentJob.RepoPath, cfg, "fix", reasoning) + if resolved, err := agent.GetAvailable(agentName); err != nil { + writeError(w, http.StatusServiceUnavailable, fmt.Sprintf("no agent available: %v", err)) + return + } else { + agentName = resolved.Name() + } + model := config.ResolveModelForWorkflow("", parentJob.RepoPath, cfg, "fix", reasoning) + + // Enqueue the fix job + job, err := s.db.EnqueueJob(storage.EnqueueOpts{ + RepoID: parentJob.RepoID, + GitRef: parentJob.GitRef, + Branch: parentJob.Branch, + Agent: agentName, + Model: model, + Reasoning: reasoning, + Prompt: fixPrompt, + Agentic: true, + Label: fmt.Sprintf("fix #%d", req.ParentJobID), + JobType: storage.JobTypeFix, + ParentJobID: req.ParentJobID, + }) + if err != nil { + s.writeInternalError(w, fmt.Sprintf("enqueue fix job: %v", err)) + return + } + + writeJSON(w, http.StatusCreated, job) +} + +// handleGetPatch returns the stored patch for a completed fix job. +func (s *Server) handleGetPatch(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + writeError(w, http.StatusMethodNotAllowed, "method not allowed") + return + } + + jobIDStr := r.URL.Query().Get("job_id") + if jobIDStr == "" { + writeError(w, http.StatusBadRequest, "job_id parameter required") + return + } + + var jobID int64 + if _, err := fmt.Sscanf(jobIDStr, "%d", &jobID); err != nil { + writeError(w, http.StatusBadRequest, "invalid job_id") + return + } + + job, err := s.db.GetJobByID(jobID) + if err != nil { + writeError(w, http.StatusNotFound, "job not found") + return + } + + if job.Patch == nil { + writeError(w, http.StatusNotFound, "no patch available for this job") + return + } + + w.Header().Set("Content-Type", "text/plain") + w.WriteHeader(http.StatusOK) + w.Write([]byte(*job.Patch)) +} + +// buildFixPrompt constructs a prompt for fixing review findings. +func buildFixPrompt(reviewOutput string) string { + return "# Fix Request\n\n" + + "An analysis was performed and produced the following findings:\n\n" + + "## Analysis Findings\n\n" + + reviewOutput + + "\n\n## Instructions\n\n" + + "Please apply the suggested changes from the analysis above. " + + "Make the necessary edits to address each finding. " + + "Focus on the highest priority items first.\n\n" + + "After making changes:\n" + + "1. Verify the code still compiles/passes linting\n" + + "2. Run any relevant tests to ensure nothing is broken\n" + + "3. Create a git commit with a descriptive message summarizing the changes\n" +} + // formatDuration formats a duration in human-readable form (e.g., "2h 15m") func formatDuration(d time.Duration) string { d = d.Round(time.Second) From ec7ec8d94b7be23641905c6235fd542d9e60c3b7 Mon Sep 17 00:00:00 2001 From: Marius van Niekerk Date: Tue, 17 Feb 2026 12:58:04 -0500 Subject: [PATCH 05/49] feat: add TUI Tasks view and fix prompt for background fix jobs (#279) Add Tasks view (T key) showing fix job status, fix prompt modal (F key) to trigger fixes from review view, and patch apply with dry-run check. Includes rebase detection when patches don't apply cleanly. Co-Authored-By: Claude Opus 4.6 --- cmd/roborev/tui.go | 270 ++++++++++++++++++++++++++++++++++++ cmd/roborev/tui_handlers.go | 155 +++++++++++++++++++++ 2 files changed, 425 insertions(+) diff --git a/cmd/roborev/tui.go b/cmd/roborev/tui.go index 866d2c6f..f76a6de5 100644 --- a/cmd/roborev/tui.go +++ b/cmd/roborev/tui.go @@ -5,6 +5,7 @@ import ( "encoding/json" "errors" "fmt" + "io" "net" "net/http" neturl "net/url" @@ -29,6 +30,7 @@ import ( "github.com/roborev-dev/roborev/internal/storage" "github.com/roborev-dev/roborev/internal/update" "github.com/roborev-dev/roborev/internal/version" + "github.com/roborev-dev/roborev/internal/worktree" "github.com/spf13/cobra" ) @@ -104,6 +106,8 @@ const ( tuiViewCommitMsg tuiViewHelp tuiViewTail + tuiViewTasks // Background fix tasks view + tuiViewFixPrompt // Fix prompt confirmation modal ) // queuePrefetchBuffer is the number of extra rows to fetch beyond what's visible, @@ -247,6 +251,12 @@ type tuiModel struct { mdCache *markdownCache clipboard ClipboardWriter + + // Fix task state + fixJobs []storage.ReviewJob // Fix jobs for tasks view + fixSelectedIdx int // Selected index in tasks view + fixPromptText string // Editable fix prompt text + fixPromptJobID int64 // Parent job ID for fix prompt modal } // pendingState tracks a pending addressed toggle with sequence number @@ -361,6 +371,23 @@ type tuiReconnectMsg struct { err error } +type tuiFixJobsMsg struct { + jobs []storage.ReviewJob + err error +} + +type tuiFixTriggerResultMsg struct { + job *storage.ReviewJob + err error +} + +type tuiApplyPatchResultMsg struct { + jobID int64 + success bool + err error + rebase bool // True if patch didn't apply and needs rebase +} + // ClipboardWriter is an interface for clipboard operations (allows mocking in tests) type ClipboardWriter interface { WriteText(text string) error @@ -2016,6 +2043,45 @@ func (m tuiModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case tuiReconnectMsg: return m.handleReconnectMsg(msg) + + case tuiFixJobsMsg: + if msg.err != nil { + m.err = msg.err + } else { + m.fixJobs = msg.jobs + if m.fixSelectedIdx >= len(m.fixJobs) && len(m.fixJobs) > 0 { + m.fixSelectedIdx = len(m.fixJobs) - 1 + } + } + + case tuiFixTriggerResultMsg: + if msg.err != nil { + m.err = msg.err + m.flashMessage = fmt.Sprintf("Fix failed: %v", msg.err) + m.flashExpiresAt = time.Now().Add(3 * time.Second) + m.flashView = tuiViewTasks + } else { + m.flashMessage = fmt.Sprintf("Fix job #%d enqueued", msg.job.ID) + m.flashExpiresAt = time.Now().Add(3 * time.Second) + m.flashView = tuiViewTasks + // Refresh tasks list + return m, m.fetchFixJobs() + } + + case tuiApplyPatchResultMsg: + if msg.rebase { + m.flashMessage = fmt.Sprintf("Patch for job #%d doesn't apply cleanly - needs rebase", msg.jobID) + m.flashExpiresAt = time.Now().Add(5 * time.Second) + m.flashView = tuiViewTasks + } else if msg.err != nil { + m.flashMessage = fmt.Sprintf("Apply failed: %v", msg.err) + m.flashExpiresAt = time.Now().Add(3 * time.Second) + m.flashView = tuiViewTasks + } else { + m.flashMessage = fmt.Sprintf("Patch from job #%d applied successfully", msg.jobID) + m.flashExpiresAt = time.Now().Add(3 * time.Second) + m.flashView = tuiViewTasks + } } return m, nil @@ -2037,6 +2103,12 @@ func (m tuiModel) View() string { if m.currentView == tuiViewTail { return m.renderTailView() } + if m.currentView == tuiViewTasks { + return m.renderTasksView() + } + if m.currentView == tuiViewFixPrompt { + return m.renderFixPromptView() + } if m.currentView == tuiViewPrompt && m.currentReview != nil { return m.renderPromptView() } @@ -3284,3 +3356,201 @@ func tuiCmd() *cobra.Command { return cmd } + +// renderTasksView renders the background fix tasks list. +func (m tuiModel) renderTasksView() string { + var b strings.Builder + + b.WriteString(tuiTitleStyle.Render("roborev tasks (background fixes)")) + b.WriteString("\x1b[K\n") + + if len(m.fixJobs) == 0 { + b.WriteString("\n No fix tasks. Press F on a review to trigger a background fix.\n") + b.WriteString("\n") + b.WriteString(tuiHelpStyle.Render("T: back to queue | F: fix review | q: quit")) + b.WriteString("\x1b[K\x1b[J") + return b.String() + } + + // Render each fix job + visibleRows := m.height - 5 // title + help + padding + if visibleRows < 1 { + visibleRows = 1 + } + startIdx := 0 + if m.fixSelectedIdx >= visibleRows { + startIdx = m.fixSelectedIdx - visibleRows + 1 + } + + for i := startIdx; i < len(m.fixJobs) && i < startIdx+visibleRows; i++ { + job := m.fixJobs[i] + + // Status indicator + var statusIcon string + switch job.Status { + case storage.JobStatusQueued: + statusIcon = "..." + case storage.JobStatusRunning: + statusIcon = ">>>" + case storage.JobStatusDone: + statusIcon = "[+]" + case storage.JobStatusFailed: + statusIcon = "[!]" + case storage.JobStatusCanceled: + statusIcon = "[-]" + } + + // Parent job reference + parentRef := "" + if job.ParentJobID != nil { + parentRef = fmt.Sprintf("review #%d", *job.ParentJobID) + } + + line := fmt.Sprintf(" %s #%-4d %-8s %-16s %s %s", + statusIcon, + job.ID, + job.Status, + parentRef, + truncateString(job.GitRef, 12), + truncateString(job.CommitSubject, 40), + ) + + if i == m.fixSelectedIdx { + b.WriteString(tuiSelectedStyle.Render(line)) + } else { + b.WriteString(line) + } + b.WriteString("\x1b[K\n") + } + + // Flash message + if m.flashMessage != "" && time.Now().Before(m.flashExpiresAt) && m.flashView == tuiViewTasks { + flashStyle := lipgloss.NewStyle().Foreground(lipgloss.AdaptiveColor{Light: "28", Dark: "46"}) + b.WriteString(flashStyle.Render(m.flashMessage)) + } + b.WriteString("\x1b[K\n") + + // Help + b.WriteString(tuiHelpStyle.Render("A: apply patch | t: tail | x: cancel | r: refresh | T/esc: back to queue")) + b.WriteString("\x1b[K\x1b[J") + + return b.String() +} + +// renderFixPromptView renders the fix prompt confirmation modal. +func (m tuiModel) renderFixPromptView() string { + var b strings.Builder + + b.WriteString(tuiTitleStyle.Render(fmt.Sprintf("Fix Review #%d", m.fixPromptJobID))) + b.WriteString("\x1b[K\n\n") + + b.WriteString(" A background fix agent will address the review findings.\n") + b.WriteString(" Optionally enter custom instructions (or press enter for default):\n\n") + + // Show prompt input + promptDisplay := m.fixPromptText + if promptDisplay == "" { + promptDisplay = "(default: fix all findings from the review)" + } + b.WriteString(fmt.Sprintf(" > %s_\n", promptDisplay)) + b.WriteString("\n") + + b.WriteString(tuiHelpStyle.Render("enter: start fix | esc: cancel")) + b.WriteString("\x1b[K\x1b[J") + + return b.String() +} + +// fetchFixJobs fetches fix jobs from the daemon. +func (m tuiModel) fetchFixJobs() tea.Cmd { + return func() tea.Msg { + var jobs []storage.ReviewJob + err := m.getJSON("/api/jobs", &jobs) + if err != nil { + return tuiFixJobsMsg{err: err} + } + // Filter to only fix jobs + var fixJobs []storage.ReviewJob + for _, j := range jobs { + if j.IsFixJob() { + fixJobs = append(fixJobs, j) + } + } + return tuiFixJobsMsg{jobs: fixJobs} + } +} + +// triggerFix triggers a background fix job for a parent review. +func (m tuiModel) triggerFix(parentJobID int64, prompt string) tea.Cmd { + return func() tea.Msg { + req := map[string]interface{}{ + "parent_job_id": parentJobID, + } + if prompt != "" { + req["prompt"] = prompt + } + var job storage.ReviewJob + err := m.postJSON("/api/job/fix", req, &job) + if err != nil { + return tuiFixTriggerResultMsg{err: err} + } + return tuiFixTriggerResultMsg{job: &job} + } +} + +// applyFixPatch fetches and applies the patch for a completed fix job. +func (m tuiModel) applyFixPatch(jobID int64) tea.Cmd { + return func() tea.Msg { + // Fetch the patch + url := m.serverAddr + fmt.Sprintf("/api/job/patch?job_id=%d", jobID) + resp, err := m.client.Get(url) + if err != nil { + return tuiApplyPatchResultMsg{jobID: jobID, err: err} + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return tuiApplyPatchResultMsg{jobID: jobID, err: fmt.Errorf("no patch available")} + } + + patchData, err := io.ReadAll(resp.Body) + if err != nil { + return tuiApplyPatchResultMsg{jobID: jobID, err: err} + } + patch := string(patchData) + if patch == "" { + return tuiApplyPatchResultMsg{jobID: jobID, err: fmt.Errorf("empty patch")} + } + + // Fetch the job to find repo path + jobDetail, jErr := func() (*storage.ReviewJob, error) { + var jobs []storage.ReviewJob + if err := m.getJSON("/api/jobs", &jobs); err != nil { + return nil, err + } + for _, j := range jobs { + if j.ID == jobID { + return &j, nil + } + } + return nil, fmt.Errorf("job %d not found", jobID) + }() + if jErr != nil { + return tuiApplyPatchResultMsg{jobID: jobID, err: jErr} + } + + // Dry-run check + if err := worktree.CheckPatch(jobDetail.RepoPath, patch); err != nil { + return tuiApplyPatchResultMsg{jobID: jobID, rebase: true, err: err} + } + + // Apply the patch + if err := worktree.ApplyPatch(jobDetail.RepoPath, patch); err != nil { + return tuiApplyPatchResultMsg{jobID: jobID, err: err} + } + + return tuiApplyPatchResultMsg{jobID: jobID, success: true} + } +} + +// truncateString is defined in fix.go diff --git a/cmd/roborev/tui_handlers.go b/cmd/roborev/tui_handlers.go index 56c8cbf3..02ea4dab 100644 --- a/cmd/roborev/tui_handlers.go +++ b/cmd/roborev/tui_handlers.go @@ -21,6 +21,10 @@ func (m tuiModel) handleKeyMsg(msg tea.KeyMsg) (tea.Model, tea.Cmd) { return m.handleFilterKey(msg) case tuiViewTail: return m.handleTailKey(msg) + case tuiViewFixPrompt: + return m.handleFixPromptKey(msg) + case tuiViewTasks: + return m.handleTasksKey(msg) } // Global keys shared across queue/review/prompt/commitMsg/help views @@ -375,6 +379,10 @@ func (m tuiModel) handleGlobalKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { return m.handleHelpKey() case "esc": return m.handleEscKey() + case "F": + return m.handleFixKey() + case "T": + return m.handleToggleTasksKey() } return m, nil } @@ -1332,6 +1340,153 @@ func (m tuiModel) handleReconnectMsg(msg tuiReconnectMsg) (tea.Model, tea.Cmd) { return m, nil } +// handleFixKey opens the fix prompt modal for the currently selected job. +func (m tuiModel) handleFixKey() (tea.Model, tea.Cmd) { + if m.currentView != tuiViewQueue && m.currentView != tuiViewReview { + return m, nil + } + + // Get the selected job + var job storage.ReviewJob + if m.currentView == tuiViewReview { + if m.currentReview == nil || m.currentReview.Job == nil { + return m, nil + } + job = *m.currentReview.Job + } else { + if len(m.jobs) == 0 || m.selectedIdx < 0 || m.selectedIdx >= len(m.jobs) { + return m, nil + } + job = m.jobs[m.selectedIdx] + } + + // Only allow fix on completed jobs with a failing verdict + if job.Status != storage.JobStatusDone { + m.flashMessage = "Can only fix completed reviews" + m.flashExpiresAt = time.Now().Add(2 * time.Second) + m.flashView = m.currentView + return m, nil + } + + // Open fix prompt modal + m.fixPromptJobID = job.ID + m.fixPromptText = "" // Empty means use default prompt from server + m.currentView = tuiViewFixPrompt + return m, nil +} + +// handleToggleTasksKey switches between queue and tasks view. +func (m tuiModel) handleToggleTasksKey() (tea.Model, tea.Cmd) { + if m.currentView == tuiViewTasks { + m.currentView = tuiViewQueue + return m, nil + } + if m.currentView == tuiViewQueue { + m.currentView = tuiViewTasks + return m, m.fetchFixJobs() + } + return m, nil +} + +// handleFixPromptKey handles key input in the fix prompt confirmation modal. +func (m tuiModel) handleFixPromptKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + switch msg.String() { + case "ctrl+c": + return m, tea.Quit + case "esc": + m.currentView = tuiViewQueue + m.fixPromptText = "" + m.fixPromptJobID = 0 + return m, nil + case "enter": + jobID := m.fixPromptJobID + prompt := m.fixPromptText + m.currentView = tuiViewTasks + m.fixPromptText = "" + m.fixPromptJobID = 0 + return m, m.triggerFix(jobID, prompt) + case "backspace": + if len(m.fixPromptText) > 0 { + runes := []rune(m.fixPromptText) + m.fixPromptText = string(runes[:len(runes)-1]) + } + return m, nil + default: + if len(msg.Runes) > 0 { + for _, r := range msg.Runes { + if unicode.IsPrint(r) || r == '\n' || r == '\t' { + m.fixPromptText += string(r) + } + } + } + return m, nil + } +} + +// handleTasksKey handles key input in the tasks view. +func (m tuiModel) handleTasksKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + switch msg.String() { + case "ctrl+c", "q": + return m, tea.Quit + case "esc", "T": + m.currentView = tuiViewQueue + return m, nil + case "up", "k": + if m.fixSelectedIdx > 0 { + m.fixSelectedIdx-- + } + return m, nil + case "down", "j": + if m.fixSelectedIdx < len(m.fixJobs)-1 { + m.fixSelectedIdx++ + } + return m, nil + case "t": + // Tail output of running fix job + if len(m.fixJobs) > 0 && m.fixSelectedIdx < len(m.fixJobs) { + job := m.fixJobs[m.fixSelectedIdx] + if job.Status == storage.JobStatusRunning { + m.tailJobID = job.ID + m.tailLines = nil + m.tailScroll = 0 + m.tailStreaming = true + m.tailFollow = true + m.tailFromView = tuiViewTasks + m.currentView = tuiViewTail + return m, tea.Batch(tea.ClearScreen, m.fetchTailOutput(job.ID)) + } + } + return m, nil + case "A": + // Apply patch (handled in Phase 5) + if len(m.fixJobs) > 0 && m.fixSelectedIdx < len(m.fixJobs) { + job := m.fixJobs[m.fixSelectedIdx] + if job.Status == storage.JobStatusDone { + return m, m.applyFixPatch(job.ID) + } + } + return m, nil + case "x": + // Cancel fix job + if len(m.fixJobs) > 0 && m.fixSelectedIdx < len(m.fixJobs) { + job := &m.fixJobs[m.fixSelectedIdx] + if job.Status == storage.JobStatusRunning || job.Status == storage.JobStatusQueued { + oldStatus := job.Status + oldFinishedAt := job.FinishedAt + job.Status = storage.JobStatusCanceled + now := time.Now() + job.FinishedAt = &now + return m, m.cancelJob(job.ID, oldStatus, oldFinishedAt) + } + } + return m, nil + case "r": + // Refresh + return m, m.fetchFixJobs() + } + return m, nil +} + // handleConnectionError tracks consecutive connection errors and triggers reconnection. func (m *tuiModel) handleConnectionError(err error) tea.Cmd { if isConnectionError(err) { From c6f1e4f64f511e203036928dd83e3307554a7dca Mon Sep 17 00:00:00 2001 From: Marius van Niekerk Date: Tue, 17 Feb 2026 13:00:00 -0500 Subject: [PATCH 06/49] feat: add automatic rebase flow for stale fix patches (#279) When applying a fix patch that doesn't merge cleanly, automatically trigger a new fix job with the stale patch as context so the agent can adapt the changes to current HEAD. Add R key in Tasks view for manual rebase trigger. Co-Authored-By: Claude Opus 4.6 --- cmd/roborev/tui.go | 70 ++++++++++++++++++++++++++++++++++++- cmd/roborev/tui_handlers.go | 9 +++++ 2 files changed, 78 insertions(+), 1 deletion(-) diff --git a/cmd/roborev/tui.go b/cmd/roborev/tui.go index f76a6de5..407570fa 100644 --- a/cmd/roborev/tui.go +++ b/cmd/roborev/tui.go @@ -2070,9 +2070,11 @@ func (m tuiModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case tuiApplyPatchResultMsg: if msg.rebase { - m.flashMessage = fmt.Sprintf("Patch for job #%d doesn't apply cleanly - needs rebase", msg.jobID) + m.flashMessage = fmt.Sprintf("Patch for job #%d doesn't apply cleanly - triggering rebase", msg.jobID) m.flashExpiresAt = time.Now().Add(5 * time.Second) m.flashView = tuiViewTasks + // Auto-trigger a new fix job with the stale patch as context + return m, m.triggerRebase(msg.jobID) } else if msg.err != nil { m.flashMessage = fmt.Sprintf("Apply failed: %v", msg.err) m.flashExpiresAt = time.Now().Add(3 * time.Second) @@ -3553,4 +3555,70 @@ func (m tuiModel) applyFixPatch(jobID int64) tea.Cmd { } } +// triggerRebase triggers a new fix job that re-applies a stale patch to the current HEAD. +// It fetches the original patch from the stale job and builds a rebase prompt. +func (m tuiModel) triggerRebase(staleJobID int64) tea.Cmd { + return func() tea.Msg { + // Fetch the stale patch + url := m.serverAddr + fmt.Sprintf("/api/job/patch?job_id=%d", staleJobID) + resp, err := m.client.Get(url) + if err != nil { + return tuiFixTriggerResultMsg{err: fmt.Errorf("fetch stale patch: %w", err)} + } + defer resp.Body.Close() + + var stalePatch string + if resp.StatusCode == http.StatusOK { + data, _ := io.ReadAll(resp.Body) + stalePatch = string(data) + } + + // Find the parent job ID (the original review this fix was for) + var staleJob *storage.ReviewJob + var jobs []storage.ReviewJob + if err := m.getJSON("/api/jobs", &jobs); err == nil { + for _, j := range jobs { + if j.ID == staleJobID { + staleJob = &j + break + } + } + } + + if staleJob == nil { + return tuiFixTriggerResultMsg{err: fmt.Errorf("stale job %d not found", staleJobID)} + } + + // Use the original parent job ID if this was already a fix job + parentJobID := staleJobID + if staleJob.ParentJobID != nil { + parentJobID = *staleJob.ParentJobID + } + + // Build a rebase prompt that includes the stale patch + rebasePrompt := "# Rebase Fix Request\n\n" + + "A previous fix attempt produced a patch that no longer applies cleanly to the current HEAD.\n" + + "Your task is to achieve the same changes but adapted to the current state of the code.\n\n" + if stalePatch != "" { + rebasePrompt += "## Previous Patch (stale)\n\n```diff\n" + stalePatch + "\n```\n\n" + } + rebasePrompt += "## Instructions\n\n" + + "1. Review the intent of the previous patch\n" + + "2. Apply equivalent changes to the current codebase\n" + + "3. Resolve any conflicts with recent changes\n" + + "4. Verify the code compiles and tests pass\n" + + "5. Create a git commit with a descriptive message\n" + + req := map[string]interface{}{ + "parent_job_id": parentJobID, + "prompt": rebasePrompt, + } + var newJob storage.ReviewJob + if err := m.postJSON("/api/job/fix", req, &newJob); err != nil { + return tuiFixTriggerResultMsg{err: fmt.Errorf("trigger rebase: %w", err)} + } + return tuiFixTriggerResultMsg{job: &newJob} + } +} + // truncateString is defined in fix.go diff --git a/cmd/roborev/tui_handlers.go b/cmd/roborev/tui_handlers.go index 02ea4dab..586ab755 100644 --- a/cmd/roborev/tui_handlers.go +++ b/cmd/roborev/tui_handlers.go @@ -1466,6 +1466,15 @@ func (m tuiModel) handleTasksKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { } } return m, nil + case "R": + // Manually trigger rebase for a completed fix job + if len(m.fixJobs) > 0 && m.fixSelectedIdx < len(m.fixJobs) { + job := m.fixJobs[m.fixSelectedIdx] + if job.Status == storage.JobStatusDone { + return m, m.triggerRebase(job.ID) + } + } + return m, nil case "x": // Cancel fix job if len(m.fixJobs) > 0 && m.fixSelectedIdx < len(m.fixJobs) { From 12318515e9bc9ce35bdebc9dddd63bbafc1cb481 Mon Sep 17 00:00:00 2001 From: Marius van Niekerk Date: Tue, 17 Feb 2026 13:01:48 -0500 Subject: [PATCH 07/49] feat: update help text and hint bars for fix/tasks features (#279) Add Tasks View section to help screen, add F/T keybindings to Actions section, update queue hint bar with F (fix) and T (tasks), update tasks hint bar with R (rebase). Co-Authored-By: Claude Opus 4.6 --- cmd/roborev/tui.go | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/cmd/roborev/tui.go b/cmd/roborev/tui.go index 407570fa..ce8948cd 100644 --- a/cmd/roborev/tui.go +++ b/cmd/roborev/tui.go @@ -1646,8 +1646,8 @@ func (m tuiModel) getVisibleJobs() []storage.ReviewJob { } // Queue help line constants (used by both queueVisibleRows and renderQueueView). -const queueHelpLine1 = "x: cancel | r: rerun | t: tail | p: prompt | c: comment | y: copy | m: commit msg" -const queueHelpLine2 = "↑/↓: navigate | enter: review | a: addressed | f: filter | h: hide | ?: commands | q: quit" +const queueHelpLine1 = "x: cancel | r: rerun | t: tail | p: prompt | c: comment | y: copy | m: commit msg | F: fix" +const queueHelpLine2 = "↑/↓: navigate | enter: review | a: addressed | f: filter | h: hide | T: tasks | ?: help | q: quit" // queueHelpLines computes how many terminal lines the queue help // footer occupies, accounting for wrapping at narrow widths. @@ -3209,6 +3209,8 @@ func helpLines() []string { {"y", "Copy review to clipboard"}, {"x", "Cancel running/queued job"}, {"r", "Re-run completed/failed job"}, + {"F", "Trigger fix for selected review"}, + {"T", "Open Tasks view"}, }, }, { @@ -3253,6 +3255,17 @@ func helpLines() []string { {"esc/q", "Back to queue"}, }, }, + { + group: "Tasks View", + keys: []struct{ key, desc string }{ + {"↑/↓", "Navigate fix jobs"}, + {"A", "Apply patch from completed fix"}, + {"R", "Re-trigger fix (rebase)"}, + {"t", "Tail running fix job output"}, + {"x", "Cancel running/queued fix job"}, + {"esc/T", "Back to queue"}, + }, + }, { group: "General", keys: []struct{ key, desc string }{ @@ -3433,7 +3446,7 @@ func (m tuiModel) renderTasksView() string { b.WriteString("\x1b[K\n") // Help - b.WriteString(tuiHelpStyle.Render("A: apply patch | t: tail | x: cancel | r: refresh | T/esc: back to queue")) + b.WriteString(tuiHelpStyle.Render("A: apply | R: rebase | t: tail | x: cancel | r: refresh | T/esc: back")) b.WriteString("\x1b[K\x1b[J") return b.String() From 457874f26cb63aba9345a1fb4a383df578e1b882 Mon Sep 17 00:00:00 2001 From: Marius van Niekerk Date: Tue, 17 Feb 2026 13:06:30 -0500 Subject: [PATCH 08/49] docs: update plan and CLAUDE.md to match implementation (#279) Replace speculative plan with implementation summary documenting what was actually built. Update CLAUDE.md key files table with worktree package and update design constraints to reflect worktree-based fix jobs. Co-Authored-By: Claude Opus 4.6 --- CLAUDE.md | 3 +- docs/plans/2026-02-17-tui-fix.md | 585 ++++--------------------------- 2 files changed, 75 insertions(+), 513 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index cfa206a7..4e6e6ffb 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -40,6 +40,7 @@ CLI (roborev) → HTTP API → Daemon (roborev daemon run) → Worker Pool → A | `internal/storage/` | SQLite operations | | `internal/agent/` | Agent interface + implementations | | `internal/config/config.go` | Config loading, agent resolution | +| `internal/worktree/` | Git worktree helpers (create, patch capture/apply) | ## Conventions @@ -93,7 +94,7 @@ CLI reads this to find the daemon. If port 7373 is busy, daemon auto-increments. ## Design Constraints -- **Daemon tasks must not modify the git working tree.** Background jobs (reviews, CI polling, synthesis) are read-only with respect to the user's repo checkout. They read source files and write results to the database only. CLI commands like `roborev fix` run synchronously in the foreground and may modify files, but nothing enqueued to the worker pool should touch the working tree. If we need background tasks that produce file changes in the future, they should operate in isolated git worktrees — that is a separate initiative. +- **Daemon tasks must not modify the git working tree.** Background jobs (reviews, CI polling, synthesis) are read-only with respect to the user's repo checkout. They read source files and write results to the database only. CLI commands like `roborev fix` run synchronously in the foreground and may modify files. Background `fix` jobs run agents in isolated git worktrees (via `internal/worktree`) and store resulting patches in the database — patches are only applied to the working tree when the user explicitly confirms in the TUI. ## Style Preferences diff --git a/docs/plans/2026-02-17-tui-fix.md b/docs/plans/2026-02-17-tui-fix.md index ac1f14b0..c5493196 100644 --- a/docs/plans/2026-02-17-tui-fix.md +++ b/docs/plans/2026-02-17-tui-fix.md @@ -1,6 +1,6 @@ # TUI-Triggered Fix via Background Worktrees -> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. +**Status:** Implemented **Goal:** Allow users to trigger `roborev fix` from the TUI, running agents in isolated background worktrees and applying patches when ready. @@ -10,514 +10,75 @@ --- -## Phase 1: Storage Layer - New `fix` Job Type and `patch` Column - -### Task 1: Add `JobTypeFix` constant and `parent_job_id`/`patch` columns - -**Files:** -- Modify: `internal/storage/models.go:37-43` (add JobTypeFix constant) -- Modify: `internal/storage/models.go:45-80` (add ParentJobID, Patch fields to ReviewJob) -- Modify: `internal/storage/models.go:117-122` (update IsPromptJob to include fix) -- Modify: `internal/storage/db.go` (add migration for new columns) - -**Step 1: Add the new constant and struct fields** - -In `internal/storage/models.go`, add to the JobType constants block: - -```go -JobTypeFix = "fix" // Background fix using worktree -``` - -Add to ReviewJob struct (after OutputPrefix field, before sync fields): - -```go -ParentJobID *int64 `json:"parent_job_id,omitempty"` // Job being fixed (for fix jobs) -Patch *string `json:"patch,omitempty"` // Generated diff patch (for completed fix jobs) -``` - -Update `IsPromptJob()` to include fix jobs: - -```go -func (j ReviewJob) IsPromptJob() bool { - return j.JobType == JobTypeTask || j.JobType == JobTypeCompact || j.JobType == JobTypeFix -} -``` - -Add a helper: - -```go -func (j ReviewJob) IsFixJob() bool { - return j.JobType == JobTypeFix -} -``` - -**Step 2: Add database migration** - -In `internal/storage/db.go`, add at the end of `migrate()` before `return nil`: - -```go -// Migration: add parent_job_id column to review_jobs if missing -err = db.QueryRow(`SELECT COUNT(*) FROM pragma_table_info('review_jobs') WHERE name = 'parent_job_id'`).Scan(&count) -if err != nil { - return fmt.Errorf("check parent_job_id column: %w", err) -} -if count == 0 { - _, err = db.Exec(`ALTER TABLE review_jobs ADD COLUMN parent_job_id INTEGER`) - if err != nil { - return fmt.Errorf("add parent_job_id column: %w", err) - } -} - -// Migration: add patch column to review_jobs if missing -err = db.QueryRow(`SELECT COUNT(*) FROM pragma_table_info('review_jobs') WHERE name = 'patch'`).Scan(&count) -if err != nil { - return fmt.Errorf("check patch column: %w", err) -} -if count == 0 { - _, err = db.Exec(`ALTER TABLE review_jobs ADD COLUMN patch TEXT`) - if err != nil { - return fmt.Errorf("add patch column: %w", err) - } -} -``` - -**Step 3: Update EnqueueOpts and EnqueueJob** - -In `internal/storage/jobs.go`, add `ParentJobID` to `EnqueueOpts`: - -```go -ParentJobID int64 // Parent job being fixed (for fix jobs) -``` - -Update the INSERT in `EnqueueJob` to include `parent_job_id`. - -**Step 4: Add SaveJobPatch helper** - -In `internal/storage/jobs.go`, add: - -```go -func (db *DB) SaveJobPatch(jobID int64, patch string) error { - _, err := db.Exec(`UPDATE review_jobs SET patch = ? WHERE id = ?`, patch, jobID) - return err -} -``` - -**Step 5: Update job scan functions to include new columns** - -Grep for the SELECT queries that read review_jobs and add `parent_job_id` and `patch` to the column lists and scan targets. - -**Step 6: Run tests** - -```bash -go test ./internal/storage/... -v -count=1 -go vet ./... -``` - -**Step 7: Commit** - -```bash -git add -A && git commit -m "feat: add fix job type with parent_job_id and patch columns" -``` - ---- - -## Phase 2: Worker Pool - Fix Job Processing with Worktrees - -### Task 2: Extract worktree helpers from refine.go to shared package - -**Files:** -- Create: `internal/worktree/worktree.go` (shared worktree helpers) -- Modify: `cmd/roborev/refine.go` (use shared helpers) - -**Step 1: Create `internal/worktree/worktree.go`** - -Extract `createTempWorktree` from `cmd/roborev/refine.go:1002-1054` into a shared package. The function signature stays the same: - -```go -package worktree - -func Create(repoPath string) (worktreeDir string, cleanup func(), err error) -``` - -Also extract the diff-capture logic (getting the patch from a worktree): - -```go -func CapturePatch(worktreeDir string) (string, error) -``` - -This runs `git add -A` then `git diff --cached --binary` in the worktree and returns the patch string. - -**Step 2: Update refine.go to use shared helpers** - -Replace `createTempWorktree` calls with `worktree.Create`. - -**Step 3: Run tests** - -```bash -go build ./... -go test ./... -v -count=1 -``` - -**Step 4: Commit** - -```bash -git add -A && git commit -m "refactor: extract worktree helpers to internal/worktree package" -``` - -### Task 3: Add fix job processing to worker pool - -**Files:** -- Modify: `internal/daemon/worker.go:267-310` (add fix job handling in processJob) - -**Step 1: Add fix job processing branch** - -In `processJob()`, after the prompt-building if/else chain (around line 305), add a new branch for fix jobs. The fix job flow is: - -1. Use the pre-stored prompt (fix jobs are prompt jobs via `IsPromptJob()`) -2. Create a temporary worktree via `worktree.Create(job.RepoPath)` -3. Run the agent with `agentic=true` in the worktree directory (pass worktree path instead of repo path to `a.Review()`) -4. Capture the patch via `worktree.CapturePatch(worktreeDir)` -5. Store the patch via `db.SaveJobPatch(job.ID, patch)` -6. Clean up the worktree -7. Store the agent output as a review (existing `CompleteJob` flow) - -The key change is in the `a.Review()` call — for fix jobs, pass the worktree path as the repo path: - -```go -reviewRepoPath := job.RepoPath -if job.IsFixJob() { - wtDir, wtCleanup, err := worktree.Create(job.RepoPath) - if err != nil { - wp.failOrRetry(workerID, job, job.Agent, fmt.Sprintf("create worktree: %v", err)) - return - } - defer wtCleanup() - reviewRepoPath = wtDir -} - -output, err := a.Review(ctx, reviewRepoPath, job.GitRef, reviewPrompt, outputWriter) - -// After agent completes, capture patch for fix jobs -if job.IsFixJob() { - patch, patchErr := worktree.CapturePatch(wtDir) - if patchErr != nil { - log.Printf("[%s] Warning: failed to capture patch for fix job %d: %v", workerID, job.ID, patchErr) - } else if patch != "" { - if err := wp.db.SaveJobPatch(job.ID, patch); err != nil { - log.Printf("[%s] Warning: failed to save patch for fix job %d: %v", workerID, job.ID, err) - } - } -} -``` - -**Step 2: Run tests** - -```bash -go build ./... -go test ./internal/daemon/... -v -count=1 -``` - -**Step 3: Commit** - -```bash -git add -A && git commit -m "feat: add fix job processing with worktree isolation in worker pool" -``` - ---- - -## Phase 3: API Endpoint for Fix Jobs - -### Task 4: Add fix-specific API endpoint - -**Files:** -- Modify: `internal/daemon/server.go` (add `/api/job/fix` endpoint and `/api/job/patch` endpoint) - -**Step 1: Add the fix endpoint** - -Register new routes in `NewServer`: - -```go -mux.HandleFunc("/api/job/fix", s.handleFixJob) -mux.HandleFunc("/api/job/patch", s.handleGetPatch) -``` - -`handleFixJob` accepts POST with: - -```go -type FixJobRequest struct { - ParentJobID int64 `json:"parent_job_id"` - Prompt string `json:"prompt,omitempty"` // Optional custom prompt override -} -``` - -The handler: -1. Fetches the parent job by ID -2. Fetches the review for the parent job -3. Builds the fix prompt using `prompt.BuildAddressPrompt` (or uses custom prompt if provided) -4. Enqueues a new fix job with `JobType: "fix"`, `Agentic: true`, `ParentJobID: parentJobID` -5. Returns the new job - -`handleGetPatch` accepts GET with `?job_id=N` and returns the patch text. - -**Step 2: Run tests** - -```bash -go build ./... -go test ./internal/daemon/... -v -count=1 -``` - -**Step 3: Commit** - -```bash -git add -A && git commit -m "feat: add /api/job/fix and /api/job/patch endpoints" -``` - ---- - -## Phase 4: TUI - Tasks View and Fix Trigger - -### Task 5: Add `tuiViewTasks` and task-related TUI state - -**Files:** -- Modify: `cmd/roborev/tui.go` (add tuiViewTasks constant, task state fields to tuiModel) - -**Step 1: Add the view constant** - -```go -const ( - tuiViewQueue tuiView = iota - tuiViewReview - tuiViewPrompt - tuiViewFilter - tuiViewComment - tuiViewCommitMsg - tuiViewHelp - tuiViewTail - tuiViewTasks // NEW: background fix tasks view - tuiViewFixPrompt // NEW: fix prompt confirmation modal -) -``` - -**Step 2: Add task state fields to tuiModel** - -```go -// Fix task state -fixJobs []storage.ReviewJob // Fix jobs for tasks view -fixSelectedIdx int -fixPromptText string // Editable fix prompt -fixPromptJobID int64 // Parent job ID for fix prompt -``` - -**Step 3: Commit** - -```bash -git add -A && git commit -m "feat: add tuiViewTasks and fix state fields to TUI model" -``` - -### Task 6: Add 'F' keybinding to trigger fix from review view - -**Files:** -- Modify: `cmd/roborev/tui_handlers.go` (add 'F' key handler, fix prompt modal handler) - -**Step 1: Add 'F' to handleGlobalKey** - -In `handleGlobalKey`, add: - -```go -case "F": - return m.handleFixKey() -``` - -`handleFixKey` checks if a review is selected and has a failing verdict, then opens the fix prompt confirmation modal (`tuiViewFixPrompt`) with a pre-filled prompt. - -**Step 2: Add handleFixPromptKey for the modal** - -Add the modal to `handleKeyMsg` dispatch: - -```go -case tuiViewFixPrompt: - return m.handleFixPromptKey(msg) -``` - -The modal shows the fix prompt (editable) and on `enter` calls the fix API endpoint, then switches to the tasks view. - -**Step 3: Commit** - -```bash -git add -A && git commit -m "feat: add F keybinding to trigger fix from TUI review view" -``` - -### Task 7: Add Tasks view rendering and key handling - -**Files:** -- Modify: `cmd/roborev/tui_helpers.go` (add tasks view rendering) -- Modify: `cmd/roborev/tui_handlers.go` (add tasks view key handling) -- Modify: `cmd/roborev/tui_api.go` (add API calls for fix jobs and patches) - -**Step 1: Add 'T' keybinding to toggle queue/tasks view** - -In `handleGlobalKey`: - -```go -case "T": - return m.handleToggleTasksKey() -``` - -This toggles between `tuiViewQueue` and `tuiViewTasks`. - -**Step 2: Add tasks view rendering** - -The tasks view shows fix jobs with status indicators: -- Queued/running: spinner -- Done: check mark + dry-run apply status (clean/conflict) -- Failed: X mark - -**Step 3: Add tasks view key handling** - -Keys in tasks view: -- `enter` - View the patch (renders diff in review-like scroll view) -- `A` - Apply patch to working tree -- `t` - Tail live output (reuse existing tail infrastructure) -- `d` - Discard/cancel fix job -- `T` - Toggle back to queue view - -**Step 4: Add API calls** - -In `tui_api.go`, add: -- `fetchFixJobs()` - GET `/api/jobs?job_type=fix` (or filter client-side) -- `triggerFix(parentJobID, prompt)` - POST `/api/job/fix` -- `fetchPatch(jobID)` - GET `/api/job/patch?job_id=N` - -**Step 5: Run tests** - -```bash -go build ./... -go test ./cmd/roborev/... -v -count=1 -``` - -**Step 6: Commit** - -```bash -git add -A && git commit -m "feat: add Tasks view with fix job monitoring and patch preview" -``` - ---- - -## Phase 5: Patch Application and Rebase Flow - -### Task 8: Implement patch apply logic in TUI - -**Files:** -- Modify: `cmd/roborev/tui_handlers.go` (apply patch handler) - -**Step 1: Implement the 'A' key handler** - -When user presses 'A' on a completed fix job: - -1. Fetch the patch from the API -2. Write patch to a temp file -3. Run `git apply --check ` (dry-run) in the repo -4. If clean: run `git apply `, stage, commit, enqueue review for new commit, mark parent as addressed -5. If conflict: show rebase prompt (see Task 9) - -The apply runs synchronously in the TUI process (same trust model as CLI `roborev fix`). - -**Step 2: Run tests** - -```bash -go build ./... -``` - -**Step 3: Commit** - -```bash -git add -A && git commit -m "feat: implement patch apply from TUI tasks view" -``` - -### Task 9: Implement rebase flow for stale patches - -**Files:** -- Modify: `cmd/roborev/tui_handlers.go` (rebase confirmation and re-trigger) - -**Step 1: Add rebase confirmation** - -When `git apply --check` fails: -1. Show message: "Patch doesn't apply cleanly. Rebase fix? [enter=yes / esc=cancel]" -2. On confirm: POST `/api/job/fix` with a rebase prompt that includes the original patch as context -3. The prompt instructs the agent to adapt the fix to the current HEAD -4. Switch to tasks view showing the new rebase job - -The rebase prompt: - -``` -# Rebase Fix Request - -A previous fix was generated but no longer applies cleanly to the current code. - -## Original Patch - - - -## Instructions - -Adapt the changes from the original patch to work with the current codebase. -The original patch was for a previous version of the code - apply the same -logical changes but adjusted for the current state of the files. -After making changes, verify the code compiles and tests pass, then commit. -``` - -**Step 2: Add dry-run staleness indicator in tasks view** - -When rendering completed fix jobs, run `git apply --check` and show: -- Green check if patch applies cleanly -- Yellow warning if patch has conflicts - -Cache the result and refresh when the tasks view is entered. - -**Step 3: Run tests** - -```bash -go build ./... -``` - -**Step 4: Commit** - -```bash -git add -A && git commit -m "feat: add rebase flow for stale patches in TUI" -``` - ---- - -## Phase 6: Help Text and Polish - -### Task 10: Update help view and hint bar - -**Files:** -- Modify: `cmd/roborev/tui_helpers.go` (update help text, hint bar) - -**Step 1: Add fix/tasks keys to help view** - -Add to the help text: -- `F` - Fix: trigger background fix for selected review -- `T` - Toggle tasks view (show background fix jobs) -- `A` - Apply patch (in tasks view) - -**Step 2: Update hint bar** - -Add contextual hints: -- In queue/review view: show `F fix` when a failing review is selected -- In tasks view: show `A apply` when a completed fix is selected, `T queue` to go back - -**Step 3: Run full test suite** - -```bash -go test ./... -v -count=1 -go vet ./... -go fmt ./... -``` - -**Step 4: Commit** - -```bash -git add -A && git commit -m "feat: update TUI help text and hint bar for fix/tasks" -``` +## Implementation Summary + +### Commits + +1. `db3b952` - feat: add fix job type with parent_job_id and patch columns +2. `5b8d6f9` - refactor: extract worktree helpers to internal/worktree package +3. `d62cfbc` - feat: add fix job processing with worktree isolation in worker pool +4. `948b452` - feat: add /api/job/fix and /api/job/patch endpoints +5. `ed7644e` - feat: add TUI Tasks view and fix prompt for background fix jobs +6. `6f48ff5` - feat: add automatic rebase flow for stale fix patches +7. `ca4e4f4` - feat: update help text and hint bars for fix/tasks features + +### What Was Built + +**Storage Layer (Phase 1)** +- `JobTypeFix = "fix"` constant in `internal/storage/models.go` +- `ParentJobID` and `Patch` fields on `ReviewJob` struct +- `IsFixJob()` helper, updated `IsPromptJob()` to include fix +- DB migration for `parent_job_id` and `patch` columns +- `SaveJobPatch()` function in `internal/storage/jobs.go` + +**Worktree Package (Phase 2a)** +- Created `internal/worktree/worktree.go` with shared helpers: + - `Create(repoPath)` — creates temp worktree detached at HEAD + - `CapturePatch(worktreeDir)` — stages all changes and returns diff + - `ApplyPatch(repoPath, patch)` — applies a patch to repo + - `CheckPatch(repoPath, patch)` — dry-run check if patch applies cleanly +- Extracted from `cmd/roborev/refine.go`, updated refine to use shared package +- Tests moved to `internal/worktree/worktree_test.go` + +**Worker Pool (Phase 2b)** +- `processJob()` in `internal/daemon/worker.go` creates isolated worktree for fix jobs +- Agent runs in worktree, patch captured after completion and saved to DB + +**API Endpoints (Phase 3)** +- `POST /api/job/fix` — creates background fix job from parent review + - Accepts `parent_job_id` (required) and `prompt` (optional override) + - Builds fix prompt from parent review output if no custom prompt +- `GET /api/job/patch?job_id=N` — returns stored patch as plain text + +**TUI (Phase 4)** +- **Tasks View** (`T` key) — shows fix jobs with status (queued/running/done/failed) +- **Fix Prompt** (`F` key from queue/review) — opens modal to trigger fix for selected review +- **Apply Patch** (`A` key in Tasks view) — applies patch with dry-run check first +- **Rebase** — automatic when `git apply --check` fails: triggers new fix job with stale patch as context +- **Manual Rebase** (`R` key in Tasks view) — manually re-trigger fix with rebase prompt +- **Tail** (`t` key in Tasks view) — tail running fix job output +- **Cancel** (`x` key in Tasks view) — cancel running/queued fix job + +**Help and Hints (Phase 5)** +- Updated `helpLines()` with Tasks View section and F/T in Actions +- Queue hint bar: added `F: fix` and `T: tasks` +- Tasks hint bar: `A: apply | R: rebase | t: tail | x: cancel | r: refresh | T/esc: back` +- Fix prompt hint bar: `enter: start fix | esc: cancel` + +### Flow + +1. User views a review in the TUI, presses `F` to trigger a fix +2. Fix prompt modal shows pre-built prompt, user presses `enter` to confirm +3. Daemon enqueues a `fix` job, runs agent in isolated worktree +4. Agent makes changes, worktree diff captured as patch, stored in DB +5. User presses `T` to see Tasks view, sees fix job complete +6. User presses `A` to apply — dry-run check runs first: + - **Clean:** patch applied to working tree + - **Stale:** automatically triggers new fix job with stale patch as context (rebase) +7. User can press `R` to manually trigger rebase for any completed fix + +### Not Implemented (Future Work) + +- **Refine mode from TUI** — iterative review-fix loop (fix → re-review → fix until pass) +- **Dry-run staleness indicator** — showing green/yellow status on completed fix jobs in Tasks view +- **Patch preview** — rendering the diff in a scroll view before applying From 7424bb542c5504bdfd9c07390c9a689cc421fdac Mon Sep 17 00:00:00 2001 From: Marius van Niekerk Date: Tue, 17 Feb 2026 13:43:24 -0500 Subject: [PATCH 09/49] refactor: address API design review findings (#279) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rename IsPromptJob() to UsesStoredPrompt() for clarity — the method indicates jobs with pre-stored prompts, not "prompt jobs". Replace worktree.Create's (string, func(), error) return with a Worktree struct that has Dir field and Close() method, making the API self-documenting and integrating better with defer. Co-Authored-By: Claude Opus 4.6 --- cmd/roborev/main_test.go | 6 ++-- cmd/roborev/refine.go | 21 +++++++------- cmd/roborev/refine_test.go | 18 ++++++------ docs/plans/2026-02-17-tui-fix.md | 2 +- internal/daemon/worker.go | 12 ++++---- internal/storage/models.go | 8 +++--- internal/storage/models_test.go | 6 ++-- internal/storage/repos_test.go | 48 ++++++++++++++++---------------- internal/worktree/worktree.go | 32 ++++++++++++--------- 9 files changed, 80 insertions(+), 73 deletions(-) diff --git a/cmd/roborev/main_test.go b/cmd/roborev/main_test.go index 0a51d1d3..4530a9a1 100644 --- a/cmd/roborev/main_test.go +++ b/cmd/roborev/main_test.go @@ -372,13 +372,13 @@ func TestCreateTempWorktreeInitializesSubmodules(t *testing.T) { runMainGit("-c", "protocol.file.allow=always", "submodule", "add", submoduleRepo, "deps/sub") runMainGit("commit", "-m", "add submodule") - worktreePath, cleanup, err := worktree.Create(mainRepo) + wt, err := worktree.Create(mainRepo) if err != nil { t.Fatalf("worktree.Create failed: %v", err) } - defer cleanup() + defer wt.Close() - if _, err := os.Stat(filepath.Join(worktreePath, "deps", "sub", "sub.txt")); err != nil { + if _, err := os.Stat(filepath.Join(wt.Dir, "deps", "sub", "sub.txt")); err != nil { t.Fatalf("expected submodule file in worktree: %v", err) } } diff --git a/cmd/roborev/refine.go b/cmd/roborev/refine.go index b49e79df..5355e499 100644 --- a/cmd/roborev/refine.go +++ b/cmd/roborev/refine.go @@ -508,13 +508,14 @@ func runRefine(ctx RunContext, opts refineOptions) error { branchBefore := git.GetCurrentBranch(repoPath) // Create temp worktree to isolate agent from user's working tree - worktreePath, cleanupWorktree, err := worktree.Create(repoPath) + wt, err := worktree.Create(repoPath) if err != nil { return fmt.Errorf("create worktree: %w", err) } + worktreePath := wt.Dir // NOTE: not using defer here because we're inside a loop; // defer wouldn't run until runRefine returns, leaking worktrees. - // Instead, cleanupWorktree() is called explicitly before every exit point. + // Instead, wt.Close() is called explicitly before every exit point. // Determine output writer var agentOutput io.Writer @@ -549,23 +550,23 @@ func runRefine(ctx RunContext, opts refineOptions) error { // Safety checks on main repo (before applying any changes) if wasCleanBefore && !git.IsWorkingTreeClean(repoPath) { - cleanupWorktree() + wt.Close() return fmt.Errorf("working tree changed during refine - aborting to prevent data loss") } headAfterAgent, resolveErr := git.ResolveSHA(repoPath, "HEAD") if resolveErr != nil { - cleanupWorktree() + wt.Close() return fmt.Errorf("cannot determine HEAD after agent run: %w", resolveErr) } branchAfterAgent := git.GetCurrentBranch(repoPath) if headAfterAgent != headBefore || branchAfterAgent != branchBefore { - cleanupWorktree() + wt.Close() return fmt.Errorf("HEAD changed during refine (was %s on %s, now %s on %s) - aborting to prevent applying patch to wrong commit", shortSHA(headBefore), branchBefore, shortSHA(headAfterAgent), branchAfterAgent) } if agentErr != nil { - cleanupWorktree() + wt.Close() fmt.Printf("Agent error: %v\n", agentErr) fmt.Println("Will retry in next iteration") continue @@ -573,7 +574,7 @@ func runRefine(ctx RunContext, opts refineOptions) error { // Check if agent made changes in worktree if git.IsWorkingTreeClean(worktreePath) { - cleanupWorktree() + wt.Close() fmt.Println("Agent made no changes - skipping this review") if err := client.AddComment(currentFailedReview.JobID, "roborev-refine", "Agent could not determine how to address findings"); err != nil { fmt.Printf("Warning: failed to add comment to job %d: %v\n", currentFailedReview.JobID, err) @@ -586,14 +587,14 @@ func runRefine(ctx RunContext, opts refineOptions) error { // Capture patch from worktree and apply to main repo patch, err := worktree.CapturePatch(worktreePath) if err != nil { - cleanupWorktree() + wt.Close() return fmt.Errorf("capture worktree patch: %w", err) } if err := worktree.ApplyPatch(repoPath, patch); err != nil { - cleanupWorktree() + wt.Close() return fmt.Errorf("apply worktree patch: %w", err) } - cleanupWorktree() + wt.Close() commitMsg := fmt.Sprintf("Address review findings (job %d)\n\n%s", currentFailedReview.JobID, summarizeAgentOutput(output)) newCommit, err := commitWithHookRetry(repoPath, commitMsg, addressAgent, opts.quiet) diff --git a/cmd/roborev/refine_test.go b/cmd/roborev/refine_test.go index 98b21136..f429e66d 100644 --- a/cmd/roborev/refine_test.go +++ b/cmd/roborev/refine_test.go @@ -961,7 +961,7 @@ func TestWorktreeCleanupBetweenIterations(t *testing.T) { // before the next iteration. Verify the directory is removed each time. var prevPath string for i := range 3 { - worktreePath, cleanup, err := worktree.Create(repoDir) + wt, err := worktree.Create(repoDir) if err != nil { t.Fatalf("iteration %d: worktree.Create failed: %v", i, err) } @@ -974,13 +974,13 @@ func TestWorktreeCleanupBetweenIterations(t *testing.T) { } // Verify current worktree exists - if _, err := os.Stat(worktreePath); err != nil { - t.Fatalf("iteration %d: worktree %s should exist: %v", i, worktreePath, err) + if _, err := os.Stat(wt.Dir); err != nil { + t.Fatalf("iteration %d: worktree %s should exist: %v", i, wt.Dir, err) } // Simulate the explicit cleanup call (as done on error/no-change paths) - cleanup() - prevPath = worktreePath + wt.Close() + prevPath = wt.Dir } // Verify the last worktree was also cleaned up @@ -1010,18 +1010,18 @@ func TestCreateTempWorktreeIgnoresHooks(t *testing.T) { } // worktree.Create should succeed because it suppresses hooks - worktreePath, cleanup, err := worktree.Create(repoDir) + wt, err := worktree.Create(repoDir) if err != nil { t.Fatalf("worktree.Create should succeed with failing hook: %v", err) } - defer cleanup() + defer wt.Close() // Verify the worktree directory exists and has the file from the repo - if _, err := os.Stat(worktreePath); err != nil { + if _, err := os.Stat(wt.Dir); err != nil { t.Fatalf("worktree directory should exist: %v", err) } - baseFile := filepath.Join(worktreePath, "base.txt") + baseFile := filepath.Join(wt.Dir, "base.txt") content, err := os.ReadFile(baseFile) if err != nil { t.Fatalf("expected base.txt in worktree: %v", err) diff --git a/docs/plans/2026-02-17-tui-fix.md b/docs/plans/2026-02-17-tui-fix.md index c5493196..08c8d823 100644 --- a/docs/plans/2026-02-17-tui-fix.md +++ b/docs/plans/2026-02-17-tui-fix.md @@ -27,7 +27,7 @@ **Storage Layer (Phase 1)** - `JobTypeFix = "fix"` constant in `internal/storage/models.go` - `ParentJobID` and `Patch` fields on `ReviewJob` struct -- `IsFixJob()` helper, updated `IsPromptJob()` to include fix +- `IsFixJob()` helper, updated `UsesStoredPrompt()` to include fix - DB migration for `parent_job_id` and `patch` columns - `SaveJobPatch()` function in `internal/storage/jobs.go` diff --git a/internal/daemon/worker.go b/internal/daemon/worker.go index 525b2c4d..35e4d66d 100644 --- a/internal/daemon/worker.go +++ b/internal/daemon/worker.go @@ -301,7 +301,7 @@ func (wp *WorkerPool) processJob(workerID string, job *storage.ReviewJob) { // Build the prompt (or use pre-stored prompt for task/compact jobs) var reviewPrompt string var err error - if job.IsPromptJob() && job.Prompt != "" { + if job.UsesStoredPrompt() && job.Prompt != "" { // Prompt-native job (task, compact) — prepend agent-specific preamble preamble := prompt.GetSystemPrompt(job.Agent, "run") if preamble != "" { @@ -309,7 +309,7 @@ func (wp *WorkerPool) processJob(workerID string, job *storage.ReviewJob) { } else { reviewPrompt = job.Prompt } - } else if job.IsPromptJob() { + } else if job.UsesStoredPrompt() { // Prompt-native job (task/compact) with missing prompt — likely a // daemon version mismatch or storage issue. Fail clearly instead // of trying to build a prompt from a non-git label. @@ -378,15 +378,15 @@ func (wp *WorkerPool) processJob(workerID string, job *storage.ReviewJob) { // The agent modifies files in the worktree; afterwards we capture the diff as a patch. reviewRepoPath := job.RepoPath if job.IsFixJob() { - wtDir, wtCleanup, wtErr := worktree.Create(job.RepoPath) + wt, wtErr := worktree.Create(job.RepoPath) if wtErr != nil { log.Printf("[%s] Error creating worktree for fix job %d: %v", workerID, job.ID, wtErr) wp.failOrRetry(workerID, job, agentName, fmt.Sprintf("create worktree: %v", wtErr)) return } - defer wtCleanup() - reviewRepoPath = wtDir - log.Printf("[%s] Fix job %d: running agent in worktree %s", workerID, job.ID, wtDir) + defer wt.Close() + reviewRepoPath = wt.Dir + log.Printf("[%s] Fix job %d: running agent in worktree %s", workerID, job.ID, wt.Dir) } // Run the review diff --git a/internal/storage/models.go b/internal/storage/models.go index 2f57c62f..3abb0fc0 100644 --- a/internal/storage/models.go +++ b/internal/storage/models.go @@ -117,10 +117,10 @@ func (j ReviewJob) IsTaskJob() bool { return true } -// IsPromptJob returns true if this job type uses a pre-stored prompt -// (task or compact). These job types have prompts built by the CLI at -// enqueue time, not constructed by the worker from git data. -func (j ReviewJob) IsPromptJob() bool { +// UsesStoredPrompt returns true if this job type uses a pre-stored prompt +// (task, compact, or fix). These job types have prompts built at enqueue +// time, not constructed by the worker from git data. +func (j ReviewJob) UsesStoredPrompt() bool { return j.JobType == JobTypeTask || j.JobType == JobTypeCompact || j.JobType == JobTypeFix } diff --git a/internal/storage/models_test.go b/internal/storage/models_test.go index 450cae3e..4278305c 100644 --- a/internal/storage/models_test.go +++ b/internal/storage/models_test.go @@ -193,7 +193,7 @@ func TestIsDirtyJob(t *testing.T) { }) } -func TestIsPromptJob(t *testing.T) { +func TestUsesStoredPrompt(t *testing.T) { tests := []struct { name string job storage.ReviewJob @@ -210,8 +210,8 @@ func TestIsPromptJob(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - if got := tt.job.IsPromptJob(); got != tt.want { - t.Errorf("IsPromptJob() = %v, want %v", got, tt.want) + if got := tt.job.UsesStoredPrompt(); got != tt.want { + t.Errorf("UsesStoredPrompt() = %v, want %v", got, tt.want) } }) } diff --git a/internal/storage/repos_test.go b/internal/storage/repos_test.go index 1e46c4db..308c2339 100644 --- a/internal/storage/repos_test.go +++ b/internal/storage/repos_test.go @@ -1078,9 +1078,9 @@ func TestVerdictSuppressionForPromptJobs(t *testing.T) { // TestRetriedReviewJobNotRoutedAsPromptJob verifies that when a review // job is retried, the saved prompt from the first run does not cause // the job to be misidentified as a prompt-native job (task/compact). -// This is the storage-level regression test for the IsPromptJob gate. +// This is the storage-level regression test for the UsesStoredPrompt gate. func TestRetriedReviewJobNotRoutedAsPromptJob(t *testing.T) { - t.Run("review job: saved prompt does not make IsPromptJob true", func(t *testing.T) { + t.Run("review job: saved prompt does not make UsesStoredPrompt true", func(t *testing.T) { db := openTestDB(t) defer db.Close() @@ -1098,8 +1098,8 @@ func TestRetriedReviewJobNotRoutedAsPromptJob(t *testing.T) { if claimed.Prompt != "" { t.Fatalf("First claim: expected empty prompt, got %q", claimed.Prompt) } - if claimed.IsPromptJob() { - t.Fatal("First claim: IsPromptJob() should be false for review job") + if claimed.UsesStoredPrompt() { + t.Fatal("First claim: UsesStoredPrompt() should be false for review job") } // 3. Worker saves a built prompt (simulating processJob behavior) @@ -1117,7 +1117,7 @@ func TestRetriedReviewJobNotRoutedAsPromptJob(t *testing.T) { t.Fatal("RetryJob returned false, expected true") } - // 5. Claim again — prompt is non-empty but IsPromptJob must be false + // 5. Claim again — prompt is non-empty but UsesStoredPrompt must be false reclaimed := claimJob(t, db, "worker-2") if reclaimed.ID != claimed.ID { t.Fatalf("Expected to reclaim job %d, got %d", claimed.ID, reclaimed.ID) @@ -1128,12 +1128,12 @@ func TestRetriedReviewJobNotRoutedAsPromptJob(t *testing.T) { if reclaimed.JobType != JobTypeReview { t.Errorf("Reclaim: expected job_type=%q, got %q", JobTypeReview, reclaimed.JobType) } - if reclaimed.IsPromptJob() { - t.Error("Reclaim: IsPromptJob() must be false for review job, even with saved prompt") + if reclaimed.UsesStoredPrompt() { + t.Error("Reclaim: UsesStoredPrompt() must be false for review job, even with saved prompt") } }) - t.Run("task job: IsPromptJob true across retry", func(t *testing.T) { + t.Run("task job: UsesStoredPrompt true across retry", func(t *testing.T) { db := openTestDB(t) defer db.Close() @@ -1150,13 +1150,13 @@ func TestRetriedReviewJobNotRoutedAsPromptJob(t *testing.T) { t.Fatalf("Expected job_type=%q, got %q", JobTypeTask, job.JobType) } - // 2. Claim it — prompt and IsPromptJob should both be set + // 2. Claim it — prompt and UsesStoredPrompt should both be set claimed := claimJob(t, db, "worker-1") if claimed.Prompt != taskPrompt { t.Errorf("First claim: expected prompt %q, got %q", taskPrompt, claimed.Prompt) } - if !claimed.IsPromptJob() { - t.Error("First claim: IsPromptJob() should be true for task job") + if !claimed.UsesStoredPrompt() { + t.Error("First claim: UsesStoredPrompt() should be true for task job") } // 3. Retry @@ -1173,15 +1173,15 @@ func TestRetriedReviewJobNotRoutedAsPromptJob(t *testing.T) { if reclaimed.ID != claimed.ID { t.Fatalf("Expected to reclaim job %d, got %d", claimed.ID, reclaimed.ID) } - if !reclaimed.IsPromptJob() { - t.Error("Reclaim: IsPromptJob() must be true for task job") + if !reclaimed.UsesStoredPrompt() { + t.Error("Reclaim: UsesStoredPrompt() must be true for task job") } if reclaimed.Prompt != taskPrompt { t.Errorf("Reclaim: expected prompt %q, got %q", taskPrompt, reclaimed.Prompt) } }) - t.Run("compact job: IsPromptJob true across retry", func(t *testing.T) { + t.Run("compact job: UsesStoredPrompt true across retry", func(t *testing.T) { db := openTestDB(t) defer db.Close() @@ -1202,8 +1202,8 @@ func TestRetriedReviewJobNotRoutedAsPromptJob(t *testing.T) { // Claim, retry, reclaim claimed := claimJob(t, db, "worker-1") - if !claimed.IsPromptJob() { - t.Error("Compact job: IsPromptJob() should be true") + if !claimed.UsesStoredPrompt() { + t.Error("Compact job: UsesStoredPrompt() should be true") } retried, err := db.RetryJob(claimed.ID, "", 3) @@ -1215,15 +1215,15 @@ func TestRetriedReviewJobNotRoutedAsPromptJob(t *testing.T) { } reclaimed := claimJob(t, db, "worker-2") - if !reclaimed.IsPromptJob() { - t.Error("Reclaim: IsPromptJob() must be true for compact job") + if !reclaimed.UsesStoredPrompt() { + t.Error("Reclaim: UsesStoredPrompt() must be true for compact job") } if reclaimed.Prompt != compactPrompt { t.Errorf("Reclaim: expected prompt %q, got %q", compactPrompt, reclaimed.Prompt) } }) - t.Run("dirty job: saved prompt does not make IsPromptJob true", func(t *testing.T) { + t.Run("dirty job: saved prompt does not make UsesStoredPrompt true", func(t *testing.T) { db := openTestDB(t) defer db.Close() @@ -1255,12 +1255,12 @@ func TestRetriedReviewJobNotRoutedAsPromptJob(t *testing.T) { } reclaimed := claimJob(t, db, "worker-2") - if reclaimed.IsPromptJob() { - t.Error("Dirty job: IsPromptJob() must be false even with saved prompt") + if reclaimed.UsesStoredPrompt() { + t.Error("Dirty job: UsesStoredPrompt() must be false even with saved prompt") } }) - t.Run("range job: saved prompt does not make IsPromptJob true", func(t *testing.T) { + t.Run("range job: saved prompt does not make UsesStoredPrompt true", func(t *testing.T) { db := openTestDB(t) defer db.Close() @@ -1291,8 +1291,8 @@ func TestRetriedReviewJobNotRoutedAsPromptJob(t *testing.T) { } reclaimed := claimJob(t, db, "worker-2") - if reclaimed.IsPromptJob() { - t.Error("Range job: IsPromptJob() must be false even with saved prompt") + if reclaimed.UsesStoredPrompt() { + t.Error("Range job: UsesStoredPrompt() must be false even with saved prompt") } }) } diff --git a/internal/worktree/worktree.go b/internal/worktree/worktree.go index 68e09852..1f0e673d 100644 --- a/internal/worktree/worktree.go +++ b/internal/worktree/worktree.go @@ -12,13 +12,24 @@ import ( "strings" ) +// Worktree represents a temporary git worktree for isolated agent work. +// Call Close to remove the worktree and its directory. +type Worktree struct { + Dir string // Path to the worktree directory + repoPath string // Path to the parent repository +} + +// Close removes the worktree and its directory. +func (w *Worktree) Close() { + exec.Command("git", "-C", w.repoPath, "worktree", "remove", "--force", w.Dir).Run() + os.RemoveAll(w.Dir) +} + // Create creates a temporary git worktree detached at HEAD for isolated agent work. -// Returns the worktree directory path, a cleanup function, and any error. -// The cleanup function removes the worktree and its directory. -func Create(repoPath string) (string, func(), error) { +func Create(repoPath string) (*Worktree, error) { worktreeDir, err := os.MkdirTemp("", "roborev-worktree-") if err != nil { - return "", nil, err + return nil, err } // Create the worktree (without --recurse-submodules for compatibility with older git). @@ -26,7 +37,7 @@ func Create(repoPath string) (string, func(), error) { cmd := exec.Command("git", "-C", repoPath, "-c", "core.hooksPath="+os.DevNull, "worktree", "add", "--detach", worktreeDir, "HEAD") if out, err := cmd.CombinedOutput(); err != nil { os.RemoveAll(worktreeDir) - return "", nil, fmt.Errorf("git worktree add: %w: %s", err, out) + return nil, fmt.Errorf("git worktree add: %w: %s", err, out) } // Initialize and update submodules in the worktree @@ -39,7 +50,7 @@ func Create(repoPath string) (string, func(), error) { if out, err := cmd.CombinedOutput(); err != nil { exec.Command("git", "-C", repoPath, "worktree", "remove", "--force", worktreeDir).Run() os.RemoveAll(worktreeDir) - return "", nil, fmt.Errorf("git submodule update: %w: %s", err, out) + return nil, fmt.Errorf("git submodule update: %w: %s", err, out) } updateArgs := []string{"-C", worktreeDir} @@ -51,7 +62,7 @@ func Create(repoPath string) (string, func(), error) { if out, err := cmd.CombinedOutput(); err != nil { exec.Command("git", "-C", repoPath, "worktree", "remove", "--force", worktreeDir).Run() os.RemoveAll(worktreeDir) - return "", nil, fmt.Errorf("git submodule update: %w: %s", err, out) + return nil, fmt.Errorf("git submodule update: %w: %s", err, out) } lfsCmd := exec.Command("git", "-C", worktreeDir, "lfs", "env") @@ -60,12 +71,7 @@ func Create(repoPath string) (string, func(), error) { cmd.Run() } - cleanup := func() { - exec.Command("git", "-C", repoPath, "worktree", "remove", "--force", worktreeDir).Run() - os.RemoveAll(worktreeDir) - } - - return worktreeDir, cleanup, nil + return &Worktree{Dir: worktreeDir, repoPath: repoPath}, nil } // CapturePatch stages all changes in the worktree and returns the diff as a patch string. From 3c88dc84433c106dcb42e385094c82a1bc08bc84 Mon Sep 17 00:00:00 2001 From: Marius van Niekerk Date: Tue, 17 Feb 2026 13:48:57 -0500 Subject: [PATCH 10/49] test: add tests for worktree CapturePatch, ApplyPatch, CheckPatch (#279) Add 9 tests covering the patch capture/apply/check round-trip, empty patch no-ops, conflict detection, and worktree Create/Close lifecycle. Addresses architecture review finding on thin test coverage in the worktree package. Co-Authored-By: Claude Opus 4.6 --- internal/worktree/worktree_test.go | 237 +++++++++++++++++++++++++++++ 1 file changed, 237 insertions(+) diff --git a/internal/worktree/worktree_test.go b/internal/worktree/worktree_test.go index c8dd3239..1633b817 100644 --- a/internal/worktree/worktree_test.go +++ b/internal/worktree/worktree_test.go @@ -3,10 +3,247 @@ package worktree import ( "fmt" "os" + "os/exec" "path/filepath" + "strings" "testing" ) +// setupGitRepo creates a minimal git repo with one commit and returns its path. +func setupGitRepo(t *testing.T) string { + t.Helper() + if _, err := exec.LookPath("git"); err != nil { + t.Skip("git not available") + } + dir := t.TempDir() + run := func(args ...string) { + t.Helper() + cmd := exec.Command("git", append([]string{"-C", dir}, args...)...) + cmd.Env = append(os.Environ(), + "GIT_AUTHOR_NAME=test", "GIT_AUTHOR_EMAIL=test@test.com", + "GIT_COMMITTER_NAME=test", "GIT_COMMITTER_EMAIL=test@test.com", + ) + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("git %v failed: %v\n%s", args, err, out) + } + } + run("init") + run("config", "user.email", "test@test.com") + run("config", "user.name", "test") + if err := os.WriteFile(filepath.Join(dir, "hello.txt"), []byte("hello"), 0644); err != nil { + t.Fatal(err) + } + run("add", "hello.txt") + run("commit", "-m", "initial") + return dir +} + +func TestCreateAndClose(t *testing.T) { + repo := setupGitRepo(t) + + wt, err := Create(repo) + if err != nil { + t.Fatalf("Create failed: %v", err) + } + + // Worktree dir should exist and contain the file + if _, err := os.Stat(filepath.Join(wt.Dir, "hello.txt")); err != nil { + t.Fatalf("expected hello.txt in worktree: %v", err) + } + + wtDir := wt.Dir + wt.Close() + + // After Close, the directory should be removed + if _, err := os.Stat(wtDir); !os.IsNotExist(err) { + t.Fatalf("worktree dir should be removed after Close, got: %v", err) + } +} + +func TestCapturePatchNoChanges(t *testing.T) { + repo := setupGitRepo(t) + + wt, err := Create(repo) + if err != nil { + t.Fatalf("Create failed: %v", err) + } + defer wt.Close() + + patch, err := CapturePatch(wt.Dir) + if err != nil { + t.Fatalf("CapturePatch failed: %v", err) + } + if patch != "" { + t.Fatalf("expected empty patch for unchanged worktree, got %d bytes", len(patch)) + } +} + +func TestCapturePatchWithChanges(t *testing.T) { + repo := setupGitRepo(t) + + wt, err := Create(repo) + if err != nil { + t.Fatalf("Create failed: %v", err) + } + defer wt.Close() + + // Modify a file and add a new one + if err := os.WriteFile(filepath.Join(wt.Dir, "hello.txt"), []byte("modified"), 0644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(wt.Dir, "new.txt"), []byte("new file"), 0644); err != nil { + t.Fatal(err) + } + + patch, err := CapturePatch(wt.Dir) + if err != nil { + t.Fatalf("CapturePatch failed: %v", err) + } + if patch == "" { + t.Fatal("expected non-empty patch") + } + if !strings.Contains(patch, "hello.txt") { + t.Error("patch should reference hello.txt") + } + if !strings.Contains(patch, "new.txt") { + t.Error("patch should reference new.txt") + } +} + +func TestApplyPatchEmpty(t *testing.T) { + // Empty patch should be a no-op + if err := ApplyPatch("/nonexistent", ""); err != nil { + t.Fatalf("ApplyPatch with empty patch should succeed: %v", err) + } +} + +func TestApplyPatchRoundTrip(t *testing.T) { + repo := setupGitRepo(t) + + wt, err := Create(repo) + if err != nil { + t.Fatalf("Create failed: %v", err) + } + + // Make changes in worktree + if err := os.WriteFile(filepath.Join(wt.Dir, "hello.txt"), []byte("changed"), 0644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(wt.Dir, "added.txt"), []byte("added"), 0644); err != nil { + t.Fatal(err) + } + + patch, err := CapturePatch(wt.Dir) + if err != nil { + t.Fatalf("CapturePatch failed: %v", err) + } + wt.Close() + + // Apply the patch back to the original repo + if err := ApplyPatch(repo, patch); err != nil { + t.Fatalf("ApplyPatch failed: %v", err) + } + + // Verify the changes were applied + content, err := os.ReadFile(filepath.Join(repo, "hello.txt")) + if err != nil { + t.Fatal(err) + } + if string(content) != "changed" { + t.Errorf("expected 'changed', got %q", content) + } + content, err = os.ReadFile(filepath.Join(repo, "added.txt")) + if err != nil { + t.Fatal(err) + } + if string(content) != "added" { + t.Errorf("expected 'added', got %q", content) + } +} + +func TestCheckPatchClean(t *testing.T) { + repo := setupGitRepo(t) + + wt, err := Create(repo) + if err != nil { + t.Fatalf("Create failed: %v", err) + } + if err := os.WriteFile(filepath.Join(wt.Dir, "hello.txt"), []byte("changed"), 0644); err != nil { + t.Fatal(err) + } + patch, err := CapturePatch(wt.Dir) + if err != nil { + t.Fatal(err) + } + wt.Close() + + // Check should pass on unmodified repo + if err := CheckPatch(repo, patch); err != nil { + t.Fatalf("CheckPatch should succeed on clean repo: %v", err) + } +} + +func TestCheckPatchEmpty(t *testing.T) { + if err := CheckPatch("/nonexistent", ""); err != nil { + t.Fatalf("CheckPatch with empty patch should succeed: %v", err) + } +} + +func TestCheckPatchConflict(t *testing.T) { + repo := setupGitRepo(t) + + // Create a patch that modifies hello.txt + wt, err := Create(repo) + if err != nil { + t.Fatalf("Create failed: %v", err) + } + if err := os.WriteFile(filepath.Join(wt.Dir, "hello.txt"), []byte("from-worktree"), 0644); err != nil { + t.Fatal(err) + } + patch, err := CapturePatch(wt.Dir) + if err != nil { + t.Fatal(err) + } + wt.Close() + + // Now modify hello.txt in the original repo to create a conflict + if err := os.WriteFile(filepath.Join(repo, "hello.txt"), []byte("conflicting-change"), 0644); err != nil { + t.Fatal(err) + } + + // CheckPatch should fail + if err := CheckPatch(repo, patch); err == nil { + t.Fatal("CheckPatch should fail when patch conflicts with working tree") + } +} + +func TestApplyPatchConflictFails(t *testing.T) { + repo := setupGitRepo(t) + + wt, err := Create(repo) + if err != nil { + t.Fatalf("Create failed: %v", err) + } + if err := os.WriteFile(filepath.Join(wt.Dir, "hello.txt"), []byte("from-worktree"), 0644); err != nil { + t.Fatal(err) + } + patch, err := CapturePatch(wt.Dir) + if err != nil { + t.Fatal(err) + } + wt.Close() + + // Create a conflict + if err := os.WriteFile(filepath.Join(repo, "hello.txt"), []byte("different"), 0644); err != nil { + t.Fatal(err) + } + + // ApplyPatch should fail + if err := ApplyPatch(repo, patch); err == nil { + t.Fatal("ApplyPatch should fail when patch conflicts") + } +} + func TestSubmoduleRequiresFileProtocol(t *testing.T) { tpl := `[submodule "test"] path = test From 1ab3ca05ea65e25c43fb01b9b3c3dac152e8ca41 Mon Sep 17 00:00:00 2001 From: Marius van Niekerk Date: Tue, 17 Feb 2026 14:21:32 -0500 Subject: [PATCH 11/49] fix: address roborev-ci review findings (#279) - Fix JSON decode mismatch: TUI now uses wrapper struct for /api/jobs responses instead of decoding as []ReviewJob - Fix prompt/patch-capture conflict: remove "commit" instruction from fix prompt; CapturePatch now diffs against base SHA to capture both committed and uncommitted agent changes - Fix prompt-injection: use 5-backtick fence for stale patch in rebase prompt to prevent fence breakout - Add MaxBytesReader (1MB) to handleFixJob to prevent DoS - Clear patch column on job rerun to prevent serving stale patches Co-Authored-By: Claude Opus 4.6 --- cmd/roborev/refine.go | 2 +- cmd/roborev/tui.go | 57 ++++++++++++++---------------- internal/daemon/server.go | 3 +- internal/daemon/worker.go | 4 ++- internal/storage/jobs.go | 2 +- internal/worktree/worktree.go | 38 ++++++++++++++++---- internal/worktree/worktree_test.go | 55 ++++++++++++++++++++++++---- 7 files changed, 115 insertions(+), 46 deletions(-) diff --git a/cmd/roborev/refine.go b/cmd/roborev/refine.go index 5355e499..9628e057 100644 --- a/cmd/roborev/refine.go +++ b/cmd/roborev/refine.go @@ -585,7 +585,7 @@ func runRefine(ctx RunContext, opts refineOptions) error { } // Capture patch from worktree and apply to main repo - patch, err := worktree.CapturePatch(worktreePath) + patch, err := wt.CapturePatch() if err != nil { wt.Close() return fmt.Errorf("capture worktree patch: %w", err) diff --git a/cmd/roborev/tui.go b/cmd/roborev/tui.go index ce8948cd..331336d9 100644 --- a/cmd/roborev/tui.go +++ b/cmd/roborev/tui.go @@ -3476,17 +3476,35 @@ func (m tuiModel) renderFixPromptView() string { return b.String() } +// fetchJobByID fetches a single job by ID from the daemon API. +func (m tuiModel) fetchJobByID(jobID int64) (*storage.ReviewJob, error) { + var result struct { + Jobs []storage.ReviewJob `json:"jobs"` + } + if err := m.getJSON(fmt.Sprintf("/api/jobs?id=%d", jobID), &result); err != nil { + return nil, err + } + for i := range result.Jobs { + if result.Jobs[i].ID == jobID { + return &result.Jobs[i], nil + } + } + return nil, fmt.Errorf("job %d not found", jobID) +} + // fetchFixJobs fetches fix jobs from the daemon. func (m tuiModel) fetchFixJobs() tea.Cmd { return func() tea.Msg { - var jobs []storage.ReviewJob - err := m.getJSON("/api/jobs", &jobs) + var result struct { + Jobs []storage.ReviewJob `json:"jobs"` + } + err := m.getJSON("/api/jobs", &result) if err != nil { return tuiFixJobsMsg{err: err} } // Filter to only fix jobs var fixJobs []storage.ReviewJob - for _, j := range jobs { + for _, j := range result.Jobs { if j.IsFixJob() { fixJobs = append(fixJobs, j) } @@ -3538,18 +3556,7 @@ func (m tuiModel) applyFixPatch(jobID int64) tea.Cmd { } // Fetch the job to find repo path - jobDetail, jErr := func() (*storage.ReviewJob, error) { - var jobs []storage.ReviewJob - if err := m.getJSON("/api/jobs", &jobs); err != nil { - return nil, err - } - for _, j := range jobs { - if j.ID == jobID { - return &j, nil - } - } - return nil, fmt.Errorf("job %d not found", jobID) - }() + jobDetail, jErr := m.fetchJobByID(jobID) if jErr != nil { return tuiApplyPatchResultMsg{jobID: jobID, err: jErr} } @@ -3587,19 +3594,9 @@ func (m tuiModel) triggerRebase(staleJobID int64) tea.Cmd { } // Find the parent job ID (the original review this fix was for) - var staleJob *storage.ReviewJob - var jobs []storage.ReviewJob - if err := m.getJSON("/api/jobs", &jobs); err == nil { - for _, j := range jobs { - if j.ID == staleJobID { - staleJob = &j - break - } - } - } - - if staleJob == nil { - return tuiFixTriggerResultMsg{err: fmt.Errorf("stale job %d not found", staleJobID)} + staleJob, fetchErr := m.fetchJobByID(staleJobID) + if fetchErr != nil { + return tuiFixTriggerResultMsg{err: fmt.Errorf("stale job %d not found: %w", staleJobID, fetchErr)} } // Use the original parent job ID if this was already a fix job @@ -3613,14 +3610,14 @@ func (m tuiModel) triggerRebase(staleJobID int64) tea.Cmd { "A previous fix attempt produced a patch that no longer applies cleanly to the current HEAD.\n" + "Your task is to achieve the same changes but adapted to the current state of the code.\n\n" if stalePatch != "" { - rebasePrompt += "## Previous Patch (stale)\n\n```diff\n" + stalePatch + "\n```\n\n" + rebasePrompt += "## Previous Patch (stale)\n\n`````diff\n" + stalePatch + "\n`````\n\n" } rebasePrompt += "## Instructions\n\n" + "1. Review the intent of the previous patch\n" + "2. Apply equivalent changes to the current codebase\n" + "3. Resolve any conflicts with recent changes\n" + "4. Verify the code compiles and tests pass\n" + - "5. Create a git commit with a descriptive message\n" + "5. Stage the changes with git add but do NOT commit\n" req := map[string]interface{}{ "parent_job_id": parentJobID, diff --git a/internal/daemon/server.go b/internal/daemon/server.go index a76e27cf..b39bcdcc 100644 --- a/internal/daemon/server.go +++ b/internal/daemon/server.go @@ -1635,6 +1635,7 @@ func (s *Server) handleFixJob(w http.ResponseWriter, r *http.Request) { return } + r.Body = http.MaxBytesReader(w, r.Body, 1<<20) // 1MB limit var req struct { ParentJobID int64 `json:"parent_job_id"` Prompt string `json:"prompt,omitempty"` // Optional custom prompt override @@ -1749,7 +1750,7 @@ func buildFixPrompt(reviewOutput string) string { "After making changes:\n" + "1. Verify the code still compiles/passes linting\n" + "2. Run any relevant tests to ensure nothing is broken\n" + - "3. Create a git commit with a descriptive message summarizing the changes\n" + "3. Stage the changes with git add but do NOT commit — the changes will be captured as a patch\n" } // formatDuration formats a duration in human-readable form (e.g., "2h 15m") diff --git a/internal/daemon/worker.go b/internal/daemon/worker.go index 35e4d66d..8a2e3062 100644 --- a/internal/daemon/worker.go +++ b/internal/daemon/worker.go @@ -377,6 +377,7 @@ func (wp *WorkerPool) processJob(workerID string, job *storage.ReviewJob) { // For fix jobs, create an isolated worktree to run the agent in. // The agent modifies files in the worktree; afterwards we capture the diff as a patch. reviewRepoPath := job.RepoPath + var fixWorktree *worktree.Worktree if job.IsFixJob() { wt, wtErr := worktree.Create(job.RepoPath) if wtErr != nil { @@ -385,6 +386,7 @@ func (wp *WorkerPool) processJob(workerID string, job *storage.ReviewJob) { return } defer wt.Close() + fixWorktree = wt reviewRepoPath = wt.Dir log.Printf("[%s] Fix job %d: running agent in worktree %s", workerID, job.ID, wt.Dir) } @@ -415,7 +417,7 @@ func (wp *WorkerPool) processJob(workerID string, job *storage.ReviewJob) { // For fix jobs, capture the patch from the worktree and store it. if job.IsFixJob() { - patch, patchErr := worktree.CapturePatch(reviewRepoPath) + patch, patchErr := fixWorktree.CapturePatch() if patchErr != nil { log.Printf("[%s] Warning: failed to capture patch for fix job %d: %v", workerID, job.ID, patchErr) } else if patch != "" { diff --git a/internal/storage/jobs.go b/internal/storage/jobs.go index d27af8eb..b0fdaf45 100644 --- a/internal/storage/jobs.go +++ b/internal/storage/jobs.go @@ -1103,7 +1103,7 @@ func (db *DB) ReenqueueJob(jobID int64) error { // Reset job status result, err := conn.ExecContext(ctx, ` UPDATE review_jobs - SET status = 'queued', worker_id = NULL, started_at = NULL, finished_at = NULL, error = NULL, retry_count = 0 + SET status = 'queued', worker_id = NULL, started_at = NULL, finished_at = NULL, error = NULL, retry_count = 0, patch = NULL WHERE id = ? AND status IN ('done', 'failed', 'canceled') `, jobID) if err != nil { diff --git a/internal/worktree/worktree.go b/internal/worktree/worktree.go index 1f0e673d..9850d674 100644 --- a/internal/worktree/worktree.go +++ b/internal/worktree/worktree.go @@ -17,6 +17,7 @@ import ( type Worktree struct { Dir string // Path to the worktree directory repoPath string // Path to the parent repository + baseSHA string // SHA of the commit the worktree was detached at } // Close removes the worktree and its directory. @@ -71,20 +72,45 @@ func Create(repoPath string) (*Worktree, error) { cmd.Run() } - return &Worktree{Dir: worktreeDir, repoPath: repoPath}, nil + // Record the base SHA for patch capture + shaCmd := exec.Command("git", "-C", worktreeDir, "rev-parse", "HEAD") + shaOut, shaErr := shaCmd.Output() + baseSHA := "" + if shaErr == nil { + baseSHA = strings.TrimSpace(string(shaOut)) + } + + return &Worktree{Dir: worktreeDir, repoPath: repoPath, baseSHA: baseSHA}, nil } // CapturePatch stages all changes in the worktree and returns the diff as a patch string. -// Returns empty string if there are no changes. -func CapturePatch(worktreeDir string) (string, error) { +// Returns empty string if there are no changes. Handles both uncommitted and committed +// changes by diffing the final tree state against the base SHA. +func (w *Worktree) CapturePatch() (string, error) { // Stage all changes in worktree - cmd := exec.Command("git", "-C", worktreeDir, "add", "-A") + cmd := exec.Command("git", "-C", w.Dir, "add", "-A") if out, err := cmd.CombinedOutput(); err != nil { return "", fmt.Errorf("git add in worktree: %w: %s", err, out) } - // Get diff as patch - diffCmd := exec.Command("git", "-C", worktreeDir, "diff", "--cached", "--binary") + // If we have a base SHA, diff the current tree state (HEAD + staged) against it. + // This captures both committed and uncommitted changes the agent made. + if w.baseSHA != "" { + // Create a temporary tree object from the index (staged state) + treeCmd := exec.Command("git", "-C", w.Dir, "write-tree") + treeOut, err := treeCmd.Output() + if err == nil { + tree := strings.TrimSpace(string(treeOut)) + diffCmd := exec.Command("git", "-C", w.Dir, "diff-tree", "-p", "--binary", w.baseSHA, tree) + diff, err := diffCmd.Output() + if err == nil && len(diff) > 0 { + return string(diff), nil + } + } + } + + // Fallback: diff staged changes against HEAD (works when agent didn't commit) + diffCmd := exec.Command("git", "-C", w.Dir, "diff", "--cached", "--binary") diff, err := diffCmd.Output() if err != nil { return "", fmt.Errorf("git diff in worktree: %w", err) diff --git a/internal/worktree/worktree_test.go b/internal/worktree/worktree_test.go index 1633b817..05c06949 100644 --- a/internal/worktree/worktree_test.go +++ b/internal/worktree/worktree_test.go @@ -69,7 +69,7 @@ func TestCapturePatchNoChanges(t *testing.T) { } defer wt.Close() - patch, err := CapturePatch(wt.Dir) + patch, err := wt.CapturePatch() if err != nil { t.Fatalf("CapturePatch failed: %v", err) } @@ -95,7 +95,7 @@ func TestCapturePatchWithChanges(t *testing.T) { t.Fatal(err) } - patch, err := CapturePatch(wt.Dir) + patch, err := wt.CapturePatch() if err != nil { t.Fatalf("CapturePatch failed: %v", err) } @@ -110,6 +110,49 @@ func TestCapturePatchWithChanges(t *testing.T) { } } +func TestCapturePatchCommittedChanges(t *testing.T) { + repo := setupGitRepo(t) + + wt, err := Create(repo) + if err != nil { + t.Fatalf("Create failed: %v", err) + } + defer wt.Close() + + // Simulate an agent that commits changes (instead of just staging) + if err := os.WriteFile(filepath.Join(wt.Dir, "hello.txt"), []byte("committed-change"), 0644); err != nil { + t.Fatal(err) + } + run := func(args ...string) { + t.Helper() + cmd := exec.Command("git", append([]string{"-C", wt.Dir}, args...)...) + cmd.Env = append(os.Environ(), + "GIT_AUTHOR_NAME=test", "GIT_AUTHOR_EMAIL=test@test.com", + "GIT_COMMITTER_NAME=test", "GIT_COMMITTER_EMAIL=test@test.com", + ) + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("git %v failed: %v\n%s", args, err, out) + } + } + run("add", "-A") + run("commit", "-m", "agent commit") + + // CapturePatch should still capture the committed changes + patch, err := wt.CapturePatch() + if err != nil { + t.Fatalf("CapturePatch failed: %v", err) + } + if patch == "" { + t.Fatal("expected non-empty patch for committed changes") + } + if !strings.Contains(patch, "hello.txt") { + t.Error("patch should reference hello.txt") + } + if !strings.Contains(patch, "committed-change") { + t.Error("patch should contain the committed content") + } +} + func TestApplyPatchEmpty(t *testing.T) { // Empty patch should be a no-op if err := ApplyPatch("/nonexistent", ""); err != nil { @@ -133,7 +176,7 @@ func TestApplyPatchRoundTrip(t *testing.T) { t.Fatal(err) } - patch, err := CapturePatch(wt.Dir) + patch, err := wt.CapturePatch() if err != nil { t.Fatalf("CapturePatch failed: %v", err) } @@ -171,7 +214,7 @@ func TestCheckPatchClean(t *testing.T) { if err := os.WriteFile(filepath.Join(wt.Dir, "hello.txt"), []byte("changed"), 0644); err != nil { t.Fatal(err) } - patch, err := CapturePatch(wt.Dir) + patch, err := wt.CapturePatch() if err != nil { t.Fatal(err) } @@ -200,7 +243,7 @@ func TestCheckPatchConflict(t *testing.T) { if err := os.WriteFile(filepath.Join(wt.Dir, "hello.txt"), []byte("from-worktree"), 0644); err != nil { t.Fatal(err) } - patch, err := CapturePatch(wt.Dir) + patch, err := wt.CapturePatch() if err != nil { t.Fatal(err) } @@ -227,7 +270,7 @@ func TestApplyPatchConflictFails(t *testing.T) { if err := os.WriteFile(filepath.Join(wt.Dir, "hello.txt"), []byte("from-worktree"), 0644); err != nil { t.Fatal(err) } - patch, err := CapturePatch(wt.Dir) + patch, err := wt.CapturePatch() if err != nil { t.Fatal(err) } From 511e41a66a644a1dcd2f902c012a6e6edd3b1528 Mon Sep 17 00:00:00 2001 From: Marius van Niekerk Date: Tue, 17 Feb 2026 17:39:58 -0500 Subject: [PATCH 12/49] fix: use writeJSONWithStatus for non-200 responses after upstream API change Co-Authored-By: Claude Sonnet 4.5 --- internal/daemon/server.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/daemon/server.go b/internal/daemon/server.go index b39bcdcc..2d9cbbf6 100644 --- a/internal/daemon/server.go +++ b/internal/daemon/server.go @@ -1699,7 +1699,7 @@ func (s *Server) handleFixJob(w http.ResponseWriter, r *http.Request) { return } - writeJSON(w, http.StatusCreated, job) + writeJSONWithStatus(w, http.StatusCreated, job) } // handleGetPatch returns the stored patch for a completed fix job. From 4fef22c11a9bb65fab90ae2f6179dde912cbb267 Mon Sep 17 00:00:00 2001 From: Marius van Niekerk Date: Tue, 17 Feb 2026 18:02:13 -0500 Subject: [PATCH 13/49] fix: resolve golangci-lint warnings (errcheck, modernize, fmtappendf) Co-Authored-By: Claude Sonnet 4.5 --- cmd/roborev/tui.go | 8 +++----- internal/daemon/server.go | 2 +- internal/storage/jobs.go | 2 +- internal/worktree/worktree.go | 14 +++++++------- internal/worktree/worktree_test.go | 6 +++--- 5 files changed, 15 insertions(+), 17 deletions(-) diff --git a/cmd/roborev/tui.go b/cmd/roborev/tui.go index 331336d9..45f40c96 100644 --- a/cmd/roborev/tui.go +++ b/cmd/roborev/tui.go @@ -3389,9 +3389,7 @@ func (m tuiModel) renderTasksView() string { // Render each fix job visibleRows := m.height - 5 // title + help + padding - if visibleRows < 1 { - visibleRows = 1 - } + visibleRows = max(visibleRows, 1) startIdx := 0 if m.fixSelectedIdx >= visibleRows { startIdx = m.fixSelectedIdx - visibleRows + 1 @@ -3516,7 +3514,7 @@ func (m tuiModel) fetchFixJobs() tea.Cmd { // triggerFix triggers a background fix job for a parent review. func (m tuiModel) triggerFix(parentJobID int64, prompt string) tea.Cmd { return func() tea.Msg { - req := map[string]interface{}{ + req := map[string]any{ "parent_job_id": parentJobID, } if prompt != "" { @@ -3619,7 +3617,7 @@ func (m tuiModel) triggerRebase(staleJobID int64) tea.Cmd { "4. Verify the code compiles and tests pass\n" + "5. Stage the changes with git add but do NOT commit\n" - req := map[string]interface{}{ + req := map[string]any{ "parent_job_id": parentJobID, "prompt": rebasePrompt, } diff --git a/internal/daemon/server.go b/internal/daemon/server.go index 2d9cbbf6..1e17ca17 100644 --- a/internal/daemon/server.go +++ b/internal/daemon/server.go @@ -1734,7 +1734,7 @@ func (s *Server) handleGetPatch(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "text/plain") w.WriteHeader(http.StatusOK) - w.Write([]byte(*job.Patch)) + _, _ = w.Write([]byte(*job.Patch)) } // buildFixPrompt constructs a prompt for fixing review findings. diff --git a/internal/storage/jobs.go b/internal/storage/jobs.go index b0fdaf45..c090ce4c 100644 --- a/internal/storage/jobs.go +++ b/internal/storage/jobs.go @@ -781,7 +781,7 @@ func (db *DB) EnqueueJob(opts EnqueueOpts) (*ReviewJob, error) { } // Use NULL for parent_job_id when not a fix job - var parentJobIDParam interface{} + var parentJobIDParam any if opts.ParentJobID > 0 { parentJobIDParam = opts.ParentJobID } diff --git a/internal/worktree/worktree.go b/internal/worktree/worktree.go index 9850d674..40f45f19 100644 --- a/internal/worktree/worktree.go +++ b/internal/worktree/worktree.go @@ -22,8 +22,8 @@ type Worktree struct { // Close removes the worktree and its directory. func (w *Worktree) Close() { - exec.Command("git", "-C", w.repoPath, "worktree", "remove", "--force", w.Dir).Run() - os.RemoveAll(w.Dir) + _ = exec.Command("git", "-C", w.repoPath, "worktree", "remove", "--force", w.Dir).Run() + _ = os.RemoveAll(w.Dir) } // Create creates a temporary git worktree detached at HEAD for isolated agent work. @@ -49,8 +49,8 @@ func Create(repoPath string) (*Worktree, error) { initArgs = append(initArgs, "submodule", "update", "--init") cmd = exec.Command("git", initArgs...) if out, err := cmd.CombinedOutput(); err != nil { - exec.Command("git", "-C", repoPath, "worktree", "remove", "--force", worktreeDir).Run() - os.RemoveAll(worktreeDir) + _ = exec.Command("git", "-C", repoPath, "worktree", "remove", "--force", worktreeDir).Run() + _ = os.RemoveAll(worktreeDir) return nil, fmt.Errorf("git submodule update: %w: %s", err, out) } @@ -61,15 +61,15 @@ func Create(repoPath string) (*Worktree, error) { updateArgs = append(updateArgs, "submodule", "update", "--init", "--recursive") cmd = exec.Command("git", updateArgs...) if out, err := cmd.CombinedOutput(); err != nil { - exec.Command("git", "-C", repoPath, "worktree", "remove", "--force", worktreeDir).Run() - os.RemoveAll(worktreeDir) + _ = exec.Command("git", "-C", repoPath, "worktree", "remove", "--force", worktreeDir).Run() + _ = os.RemoveAll(worktreeDir) return nil, fmt.Errorf("git submodule update: %w: %s", err, out) } lfsCmd := exec.Command("git", "-C", worktreeDir, "lfs", "env") if err := lfsCmd.Run(); err == nil { cmd = exec.Command("git", "-C", worktreeDir, "lfs", "pull") - cmd.Run() + _ = cmd.Run() } // Record the base SHA for patch capture diff --git a/internal/worktree/worktree_test.go b/internal/worktree/worktree_test.go index 05c06949..109d0ee3 100644 --- a/internal/worktree/worktree_test.go +++ b/internal/worktree/worktree_test.go @@ -315,7 +315,7 @@ func TestSubmoduleRequiresFileProtocol(t *testing.T) { t.Run(tc.name, func(t *testing.T) { dir := t.TempDir() gitmodules := filepath.Join(dir, ".gitmodules") - if err := os.WriteFile(gitmodules, []byte(fmt.Sprintf(tpl, tc.key, tc.url)), 0644); err != nil { + if err := os.WriteFile(gitmodules, fmt.Appendf(nil, tpl, tc.key, tc.url), 0644); err != nil { t.Fatalf("write .gitmodules: %v", err) } if got := submoduleRequiresFileProtocol(dir); got != tc.expected { @@ -331,14 +331,14 @@ func TestSubmoduleRequiresFileProtocolNested(t *testing.T) { url = %s ` dir := t.TempDir() - if err := os.WriteFile(filepath.Join(dir, ".gitmodules"), []byte(fmt.Sprintf(tpl, "https://example.com/repo.git")), 0644); err != nil { + if err := os.WriteFile(filepath.Join(dir, ".gitmodules"), fmt.Appendf(nil, tpl, "https://example.com/repo.git"), 0644); err != nil { t.Fatalf("write root .gitmodules: %v", err) } nestedPath := filepath.Join(dir, "sub", ".gitmodules") if err := os.MkdirAll(filepath.Dir(nestedPath), 0755); err != nil { t.Fatalf("mkdir nested: %v", err) } - if err := os.WriteFile(nestedPath, []byte(fmt.Sprintf(tpl, "file:///tmp/repo")), 0644); err != nil { + if err := os.WriteFile(nestedPath, fmt.Appendf(nil, tpl, "file:///tmp/repo"), 0644); err != nil { t.Fatalf("write nested .gitmodules: %v", err) } From 623041f39fd8be3734528f91ef99b124cd86c28e Mon Sep 17 00:00:00 2001 From: Marius van Niekerk Date: Tue, 17 Feb 2026 18:16:01 -0500 Subject: [PATCH 14/49] fix: use fmt.Fprintf instead of WriteString(fmt.Sprintf) (staticcheck QF1012) Co-Authored-By: Claude Sonnet 4.5 --- cmd/roborev/tui.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cmd/roborev/tui.go b/cmd/roborev/tui.go index 45f40c96..d257957c 100644 --- a/cmd/roborev/tui.go +++ b/cmd/roborev/tui.go @@ -3465,7 +3465,7 @@ func (m tuiModel) renderFixPromptView() string { if promptDisplay == "" { promptDisplay = "(default: fix all findings from the review)" } - b.WriteString(fmt.Sprintf(" > %s_\n", promptDisplay)) + fmt.Fprintf(&b, " > %s_\n", promptDisplay) b.WriteString("\n") b.WriteString(tuiHelpStyle.Render("enter: start fix | esc: cancel")) From 96a24120e53dbbda3c357501de900a7d8aab8cf4 Mon Sep 17 00:00:00 2001 From: Marius van Niekerk Date: Tue, 17 Feb 2026 18:19:57 -0500 Subject: [PATCH 15/49] fix: make fix job patch persistence atomic with status transition - Add CompleteFixJob that writes patch + status in one transaction, preventing canceled jobs from retaining retrievable patches - Fail fix jobs when patch capture fails or agent produces no changes, instead of silently completing as done - Gate /api/job/patch on job.Status == done so only completed jobs serve patches Co-Authored-By: Claude Sonnet 4.5 --- internal/daemon/server.go | 2 +- internal/daemon/worker.go | 34 +++++++++++-------- internal/storage/jobs.go | 70 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 92 insertions(+), 14 deletions(-) diff --git a/internal/daemon/server.go b/internal/daemon/server.go index 1e17ca17..95f6a6ec 100644 --- a/internal/daemon/server.go +++ b/internal/daemon/server.go @@ -1727,7 +1727,7 @@ func (s *Server) handleGetPatch(w http.ResponseWriter, r *http.Request) { return } - if job.Patch == nil { + if job.Status != storage.JobStatusDone || job.Patch == nil { writeError(w, http.StatusNotFound, "no patch available for this job") return } diff --git a/internal/daemon/worker.go b/internal/daemon/worker.go index 8a2e3062..17c83881 100644 --- a/internal/daemon/worker.go +++ b/internal/daemon/worker.go @@ -415,20 +415,23 @@ func (wp *WorkerPool) processJob(workerID string, job *storage.ReviewJob) { return } - // For fix jobs, capture the patch from the worktree and store it. + // For fix jobs, capture the patch from the worktree. Patch capture + // failures are fatal — a fix job without a patch is useless. + var fixPatch string if job.IsFixJob() { - patch, patchErr := fixWorktree.CapturePatch() + var patchErr error + fixPatch, patchErr = fixWorktree.CapturePatch() if patchErr != nil { - log.Printf("[%s] Warning: failed to capture patch for fix job %d: %v", workerID, job.ID, patchErr) - } else if patch != "" { - if saveErr := wp.db.SaveJobPatch(job.ID, patch); saveErr != nil { - log.Printf("[%s] Warning: failed to save patch for fix job %d: %v", workerID, job.ID, saveErr) - } else { - log.Printf("[%s] Fix job %d: saved patch (%d bytes)", workerID, job.ID, len(patch)) - } - } else { + log.Printf("[%s] Fix job %d: patch capture failed: %v", workerID, job.ID, patchErr) + wp.failOrRetry(workerID, job, agentName, fmt.Sprintf("patch capture: %v", patchErr)) + return + } + if fixPatch == "" { log.Printf("[%s] Fix job %d: agent produced no file changes", workerID, job.ID) + wp.failOrRetry(workerID, job, agentName, "agent produced no file changes") + return } + log.Printf("[%s] Fix job %d: captured patch (%d bytes)", workerID, job.ID, len(fixPatch)) } // For compact jobs, validate raw agent output before storing. @@ -441,9 +444,14 @@ func (wp *WorkerPool) processJob(workerID string, job *storage.ReviewJob) { } // Store the result (use actual agent name, not requested). - // CompleteJob is a no-op (returns nil) if the job was canceled - // between agent finish and now. - if err := wp.db.CompleteJob(job.ID, agentName, reviewPrompt, output); err != nil { + // CompleteJob/CompleteFixJob is a no-op (returns nil) if the job was + // canceled between agent finish and now. + if job.IsFixJob() { + if err := wp.db.CompleteFixJob(job.ID, agentName, reviewPrompt, output, fixPatch); err != nil { + log.Printf("[%s] Error storing fix review: %v", workerID, err) + return + } + } else if err := wp.db.CompleteJob(job.ID, agentName, reviewPrompt, output); err != nil { log.Printf("[%s] Error storing review: %v", workerID, err) return } diff --git a/internal/storage/jobs.go b/internal/storage/jobs.go index c090ce4c..7066a011 100644 --- a/internal/storage/jobs.go +++ b/internal/storage/jobs.go @@ -948,6 +948,76 @@ func (db *DB) SaveJobPatch(jobID int64, patch string) error { return err } +// CompleteFixJob atomically marks a fix job as done, stores the review, +// and persists the patch in a single transaction. This prevents invalid +// states where a patch is written but the job isn't done, or vice versa. +func (db *DB) CompleteFixJob(jobID int64, agent, prompt, output, patch string) error { + now := time.Now().Format(time.RFC3339) + machineID, _ := db.GetMachineID() + reviewUUID := GenerateUUID() + + ctx := context.Background() + conn, err := db.Conn(ctx) + if err != nil { + return err + } + defer conn.Close() + + if _, err := conn.ExecContext(ctx, "BEGIN IMMEDIATE"); err != nil { + return err + } + committed := false + defer func() { + if !committed { + if _, err := conn.ExecContext(ctx, "ROLLBACK"); err != nil { + log.Printf("jobs CompleteFixJob: rollback failed: %v", err) + } + } + }() + + // Fetch output_prefix from job (if any) + var outputPrefix sql.NullString + err = conn.QueryRowContext(ctx, `SELECT output_prefix FROM review_jobs WHERE id = ?`, jobID).Scan(&outputPrefix) + if err != nil && err != sql.ErrNoRows { + return err + } + + finalOutput := output + if outputPrefix.Valid && outputPrefix.String != "" { + finalOutput = outputPrefix.String + output + } + + // Atomically set status=done AND patch in one UPDATE + result, err := conn.ExecContext(ctx, + `UPDATE review_jobs SET status = 'done', finished_at = ?, updated_at = ?, patch = ? WHERE id = ? AND status = 'running'`, + now, now, patch, jobID) + if err != nil { + return err + } + + rows, err := result.RowsAffected() + if err != nil { + return err + } + if rows == 0 { + return nil // Job was canceled + } + + _, err = conn.ExecContext(ctx, + `INSERT INTO reviews (job_id, agent, prompt, output, uuid, updated_by_machine_id, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)`, + jobID, agent, prompt, finalOutput, reviewUUID, machineID, now) + if err != nil { + return err + } + + _, err = conn.ExecContext(ctx, "COMMIT") + if err != nil { + return err + } + committed = true + return nil +} + // CompleteJob marks a job as done and stores the review. // Only updates if job is still in 'running' state (respects cancellation). // If the job has an output_prefix, it will be prepended to the output. From a3716230918ff051d20df6fb2a1dfcbff4191bc4 Mon Sep 17 00:00:00 2001 From: Marius van Niekerk Date: Tue, 17 Feb 2026 21:09:09 -0500 Subject: [PATCH 16/49] fix: address PR review findings (patch conflicts, leak, rebase sizing) - CheckPatch returns typed PatchConflictError for merge conflicts; only conflict errors trigger auto-rebase, other failures surface as errors - Strip Patch field from /api/jobs by-ID responses; patch is only served via dedicated /api/job/patch endpoint - Add stale_job_id to /api/job/fix so server builds rebase prompt from DB, avoiding large patch round-trips through the client - Move rebase prompt construction to server-side buildRebasePrompt - Raise MaxBytesReader to 50MB for /api/job/fix to support large prompts Co-Authored-By: Claude Opus 4.6 --- cmd/roborev/tui.go | 41 ++++++++--------------------------- internal/daemon/server.go | 32 +++++++++++++++++++++++++-- internal/worktree/worktree.go | 20 ++++++++++++++++- 3 files changed, 58 insertions(+), 35 deletions(-) diff --git a/cmd/roborev/tui.go b/cmd/roborev/tui.go index d257957c..dbce1807 100644 --- a/cmd/roborev/tui.go +++ b/cmd/roborev/tui.go @@ -3559,9 +3559,13 @@ func (m tuiModel) applyFixPatch(jobID int64) tea.Cmd { return tuiApplyPatchResultMsg{jobID: jobID, err: jErr} } - // Dry-run check + // Dry-run check — only trigger rebase on actual merge conflicts if err := worktree.CheckPatch(jobDetail.RepoPath, patch); err != nil { - return tuiApplyPatchResultMsg{jobID: jobID, rebase: true, err: err} + var conflictErr *worktree.PatchConflictError + if errors.As(err, &conflictErr) { + return tuiApplyPatchResultMsg{jobID: jobID, rebase: true, err: err} + } + return tuiApplyPatchResultMsg{jobID: jobID, err: err} } // Apply the patch @@ -3574,23 +3578,9 @@ func (m tuiModel) applyFixPatch(jobID int64) tea.Cmd { } // triggerRebase triggers a new fix job that re-applies a stale patch to the current HEAD. -// It fetches the original patch from the stale job and builds a rebase prompt. +// The server looks up the stale patch from the DB, avoiding large client-to-server transfers. func (m tuiModel) triggerRebase(staleJobID int64) tea.Cmd { return func() tea.Msg { - // Fetch the stale patch - url := m.serverAddr + fmt.Sprintf("/api/job/patch?job_id=%d", staleJobID) - resp, err := m.client.Get(url) - if err != nil { - return tuiFixTriggerResultMsg{err: fmt.Errorf("fetch stale patch: %w", err)} - } - defer resp.Body.Close() - - var stalePatch string - if resp.StatusCode == http.StatusOK { - data, _ := io.ReadAll(resp.Body) - stalePatch = string(data) - } - // Find the parent job ID (the original review this fix was for) staleJob, fetchErr := m.fetchJobByID(staleJobID) if fetchErr != nil { @@ -3603,23 +3593,10 @@ func (m tuiModel) triggerRebase(staleJobID int64) tea.Cmd { parentJobID = *staleJob.ParentJobID } - // Build a rebase prompt that includes the stale patch - rebasePrompt := "# Rebase Fix Request\n\n" + - "A previous fix attempt produced a patch that no longer applies cleanly to the current HEAD.\n" + - "Your task is to achieve the same changes but adapted to the current state of the code.\n\n" - if stalePatch != "" { - rebasePrompt += "## Previous Patch (stale)\n\n`````diff\n" + stalePatch + "\n`````\n\n" - } - rebasePrompt += "## Instructions\n\n" + - "1. Review the intent of the previous patch\n" + - "2. Apply equivalent changes to the current codebase\n" + - "3. Resolve any conflicts with recent changes\n" + - "4. Verify the code compiles and tests pass\n" + - "5. Stage the changes with git add but do NOT commit\n" - + // Let the server build the rebase prompt from the stale job's patch req := map[string]any{ "parent_job_id": parentJobID, - "prompt": rebasePrompt, + "stale_job_id": staleJobID, } var newJob storage.ReviewJob if err := m.postJSON("/api/job/fix", req, &newJob); err != nil { diff --git a/internal/daemon/server.go b/internal/daemon/server.go index 95f6a6ec..de752092 100644 --- a/internal/daemon/server.go +++ b/internal/daemon/server.go @@ -727,6 +727,7 @@ func (s *Server) handleListJobs(w http.ResponseWriter, r *http.Request) { writeError(w, http.StatusInternalServerError, fmt.Sprintf("database error: %v", err)) return } + job.Patch = nil // Patch is only served via /api/job/patch writeJSON(w, map[string]any{ "jobs": []storage.ReviewJob{*job}, "has_more": false, @@ -1635,10 +1636,11 @@ func (s *Server) handleFixJob(w http.ResponseWriter, r *http.Request) { return } - r.Body = http.MaxBytesReader(w, r.Body, 1<<20) // 1MB limit + r.Body = http.MaxBytesReader(w, r.Body, 50<<20) // 50MB limit var req struct { ParentJobID int64 `json:"parent_job_id"` - Prompt string `json:"prompt,omitempty"` // Optional custom prompt override + Prompt string `json:"prompt,omitempty"` // Optional custom prompt override + StaleJobID int64 `json:"stale_job_id,omitempty"` // Optional: server looks up patch from this job for rebase } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeError(w, http.StatusBadRequest, "invalid request body") @@ -1658,6 +1660,15 @@ func (s *Server) handleFixJob(w http.ResponseWriter, r *http.Request) { // Build the fix prompt fixPrompt := req.Prompt + if fixPrompt == "" && req.StaleJobID > 0 { + // Server-side rebase: look up stale patch from DB and build rebase prompt + staleJob, err := s.db.GetJobByID(req.StaleJobID) + if err != nil { + writeError(w, http.StatusNotFound, "stale job not found") + return + } + fixPrompt = buildRebasePrompt(staleJob.Patch) + } if fixPrompt == "" { // Fetch the review output for the parent job review, err := s.db.GetReviewByJobID(req.ParentJobID) @@ -1753,6 +1764,23 @@ func buildFixPrompt(reviewOutput string) string { "3. Stage the changes with git add but do NOT commit — the changes will be captured as a patch\n" } +// buildRebasePrompt constructs a prompt for re-applying a stale patch to current HEAD. +func buildRebasePrompt(stalePatch *string) string { + prompt := "# Rebase Fix Request\n\n" + + "A previous fix attempt produced a patch that no longer applies cleanly to the current HEAD.\n" + + "Your task is to achieve the same changes but adapted to the current state of the code.\n\n" + if stalePatch != nil && *stalePatch != "" { + prompt += "## Previous Patch (stale)\n\n`````diff\n" + *stalePatch + "\n`````\n\n" + } + prompt += "## Instructions\n\n" + + "1. Review the intent of the previous patch\n" + + "2. Apply equivalent changes to the current codebase\n" + + "3. Resolve any conflicts with recent changes\n" + + "4. Verify the code compiles and tests pass\n" + + "5. Stage the changes with git add but do NOT commit\n" + return prompt +} + // formatDuration formats a duration in human-readable form (e.g., "2h 15m") func formatDuration(d time.Duration) string { d = d.Round(time.Second) diff --git a/internal/worktree/worktree.go b/internal/worktree/worktree.go index 40f45f19..d22bedbf 100644 --- a/internal/worktree/worktree.go +++ b/internal/worktree/worktree.go @@ -133,7 +133,19 @@ func ApplyPatch(repoPath, patch string) error { return nil } +// PatchConflictError indicates the patch does not apply due to merge conflicts. +// Other errors (malformed patch, permission errors) are returned as plain errors. +type PatchConflictError struct { + Detail string +} + +func (e *PatchConflictError) Error() string { + return "patch conflict: " + e.Detail +} + // CheckPatch does a dry-run apply to check if a patch applies cleanly. +// Returns a *PatchConflictError when the patch fails due to conflicts, +// or a plain error for other failures (malformed patch, etc.). func CheckPatch(repoPath, patch string) error { if patch == "" { return nil @@ -143,7 +155,13 @@ func CheckPatch(repoPath, patch string) error { var stderr bytes.Buffer applyCmd.Stderr = &stderr if err := applyCmd.Run(); err != nil { - return fmt.Errorf("patch does not apply cleanly: %s", stderr.String()) + msg := stderr.String() + // "error: patch failed" and "does not apply" indicate merge conflicts. + // Other messages (e.g. "corrupt patch") are non-conflict errors. + if strings.Contains(msg, "patch failed") || strings.Contains(msg, "does not apply") { + return &PatchConflictError{Detail: msg} + } + return fmt.Errorf("patch check failed: %s", msg) } return nil } From 3205db1a40c284fab3bb986a7582b3d426ef5bbd Mon Sep 17 00:00:00 2001 From: Marius van Niekerk Date: Wed, 18 Feb 2026 10:45:30 -0500 Subject: [PATCH 17/49] fix: filter fix jobs from main queue view, show only in Tasks Fix jobs belong in the Tasks view, not the main job queue. Filter them out in handleJobsMsg to avoid confusion with regular reviews. Also fixes go fmt alignment in server.go struct tags. Co-Authored-By: Claude Opus 4.6 --- cmd/roborev/tui_handlers.go | 12 ++++++++++-- internal/daemon/server.go | 2 +- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/cmd/roborev/tui_handlers.go b/cmd/roborev/tui_handlers.go index 586ab755..d3099b66 100644 --- a/cmd/roborev/tui_handlers.go +++ b/cmd/roborev/tui_handlers.go @@ -1092,10 +1092,18 @@ func (m tuiModel) handleJobsMsg(msg tuiJobsMsg) (tea.Model, tea.Cmd) { m.updateDisplayNameCache(msg.jobs) + // Filter out fix jobs — they belong in the Tasks view, not the main queue + filtered := make([]storage.ReviewJob, 0, len(msg.jobs)) + for _, j := range msg.jobs { + if !j.IsFixJob() { + filtered = append(filtered, j) + } + } + if msg.append { - m.jobs = append(m.jobs, msg.jobs...) + m.jobs = append(m.jobs, filtered...) } else { - m.jobs = msg.jobs + m.jobs = filtered } // Clear pending addressed states that server has confirmed diff --git a/internal/daemon/server.go b/internal/daemon/server.go index de752092..4570a0b0 100644 --- a/internal/daemon/server.go +++ b/internal/daemon/server.go @@ -1639,7 +1639,7 @@ func (s *Server) handleFixJob(w http.ResponseWriter, r *http.Request) { r.Body = http.MaxBytesReader(w, r.Body, 50<<20) // 50MB limit var req struct { ParentJobID int64 `json:"parent_job_id"` - Prompt string `json:"prompt,omitempty"` // Optional custom prompt override + Prompt string `json:"prompt,omitempty"` // Optional custom prompt override StaleJobID int64 `json:"stale_job_id,omitempty"` // Optional: server looks up patch from this job for rebase } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { From c96e4ccdea71a04a9686917ae191451a5e38bf3f Mon Sep 17 00:00:00 2001 From: Marius van Niekerk Date: Wed, 18 Feb 2026 10:49:07 -0500 Subject: [PATCH 18/49] fix: improve agent failure reporting with stream errors and partial output - Capture error-type events from Claude Code's stream-json output - Include partial assistant output (truncated to 500 chars) in error messages when the agent process exits non-zero - Report stream errors separately from stderr for clearer diagnostics - Use agent name instead of hardcoded "claude" in error messages Co-Authored-By: Claude Opus 4.6 --- internal/agent/claude.go | 40 +++++++++++++++++++++++++++++++++++++--- 1 file changed, 37 insertions(+), 3 deletions(-) diff --git a/internal/agent/claude.go b/internal/agent/claude.go index bbc998e2..e2f89096 100644 --- a/internal/agent/claude.go +++ b/internal/agent/claude.go @@ -5,6 +5,7 @@ import ( "bytes" "context" "encoding/json" + "errors" "fmt" "io" "os" @@ -166,10 +167,24 @@ func (a *ClaudeAgent) Review(ctx context.Context, repoPath, commitSHA, prompt st result, err := parseStreamJSON(stdoutPipe, output) if waitErr := cmd.Wait(); waitErr != nil { + // Build a detailed error including any partial output and stream errors + var detail strings.Builder + fmt.Fprintf(&detail, "%s failed: %v", a.Name(), waitErr) if err != nil { - return "", fmt.Errorf("claude failed: %w (parse error: %v)\nstderr: %s", waitErr, err, stderr.String()) + fmt.Fprintf(&detail, "\nstream: %v", err) } - return "", fmt.Errorf("claude failed: %w\nstderr: %s", waitErr, stderr.String()) + if s := stderr.String(); s != "" { + fmt.Fprintf(&detail, "\nstderr: %s", s) + } + if result != "" { + // Truncate partial output to keep error messages readable + partial := result + if len(partial) > 500 { + partial = partial[:500] + "..." + } + fmt.Fprintf(&detail, "\npartial output: %s", partial) + } + return "", errors.New(detail.String()) } if err != nil { @@ -187,15 +202,21 @@ type claudeStreamMessage struct { Content string `json:"content,omitempty"` } `json:"message,omitempty"` Result string `json:"result,omitempty"` + Error struct { + Message string `json:"message,omitempty"` + } `json:"error,omitempty"` } // parseStreamJSON parses Claude's stream-json output and extracts the final result. // Uses bufio.Reader.ReadString to read lines without buffer size limits. -func parseStreamJSON(r io.Reader, output io.Writer) (string, error) { +// On success, returns (result, nil). On failure, returns (partialOutput, error) +// where partialOutput contains any assistant messages collected before the error. +func (a *ClaudeAgent) parseStreamJSON(r io.Reader, output io.Writer) (string, error) { br := bufio.NewReader(r) var lastResult string var assistantMessages []string + var errorMessages []string var validEventsParsed bool for { @@ -225,6 +246,11 @@ func parseStreamJSON(r io.Reader, output io.Writer) (string, error) { if msg.Type == "result" && msg.Result != "" { lastResult = msg.Result } + + // Capture error events from Claude Code + if msg.Type == "error" && msg.Error.Message != "" { + errorMessages = append(errorMessages, msg.Error.Message) + } } // Skip malformed JSON lines silently } @@ -239,6 +265,14 @@ func parseStreamJSON(r io.Reader, output io.Writer) (string, error) { return "", fmt.Errorf("no valid stream-json events parsed from output") } + // Build partial output for error context + partial := strings.Join(assistantMessages, "\n") + + // If error events were received, report them with any partial output + if len(errorMessages) > 0 { + return partial, fmt.Errorf("stream errors: %s", strings.Join(errorMessages, "; ")) + } + // Prefer the result field if present, otherwise join assistant messages if lastResult != "" { return lastResult, nil From 606ae0be1d3137c8ca34ecdf8573562fbeb66998 Mon Sep 17 00:00:00 2001 From: Marius van Niekerk Date: Wed, 18 Feb 2026 10:53:26 -0500 Subject: [PATCH 19/49] fix: auto-refresh Tasks view on tick when viewing or jobs are active Refresh fix jobs list on every tick cycle when the Tasks view is active or when any fix jobs are queued/running, matching the main queue's continuous refresh behavior. Co-Authored-By: Claude Opus 4.6 --- cmd/roborev/tui.go | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/cmd/roborev/tui.go b/cmd/roborev/tui.go index dbce1807..14c94c1d 100644 --- a/cmd/roborev/tui.go +++ b/cmd/roborev/tui.go @@ -1754,7 +1754,12 @@ func (m tuiModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if m.loadingMore || m.loadingJobs { return m, tea.Batch(m.tick(), m.fetchStatus()) } - return m, tea.Batch(m.tick(), m.fetchJobs(), m.fetchStatus()) + cmds := []tea.Cmd{m.tick(), m.fetchJobs(), m.fetchStatus()} + // Refresh fix jobs when viewing tasks or when fix jobs are in progress + if m.currentView == tuiViewTasks || m.hasActiveFixJobs() { + cmds = append(cmds, m.fetchFixJobs()) + } + return m, tea.Batch(cmds...) case tuiTailTickMsg: if m.currentView == tuiViewTail && m.tailStreaming && m.tailJobID > 0 { @@ -3490,6 +3495,16 @@ func (m tuiModel) fetchJobByID(jobID int64) (*storage.ReviewJob, error) { return nil, fmt.Errorf("job %d not found", jobID) } +// hasActiveFixJobs returns true if any fix jobs are queued or running. +func (m tuiModel) hasActiveFixJobs() bool { + for _, j := range m.fixJobs { + if j.Status == storage.JobStatusQueued || j.Status == storage.JobStatusRunning { + return true + } + } + return false +} + // fetchFixJobs fetches fix jobs from the daemon. func (m tuiModel) fetchFixJobs() tea.Cmd { return func() tea.Msg { From 2ee5b27f437c1346b55ed698dbe4fc7f6b07352c Mon Sep 17 00:00:00 2001 From: Marius van Niekerk Date: Wed, 18 Feb 2026 10:55:10 -0500 Subject: [PATCH 20/49] fix: add enter/t handlers for finished fix jobs in Tasks view - Enter on done fix job opens review output - Enter on failed fix job shows error in flash message - t on done fix job opens review output (was no-op) - t on failed fix job shows error (was no-op) - t on running fix job still tails live output Co-Authored-By: Claude Opus 4.6 --- cmd/roborev/tui_handlers.go | 35 +++++++++++++++++++++++++++++++++-- 1 file changed, 33 insertions(+), 2 deletions(-) diff --git a/cmd/roborev/tui_handlers.go b/cmd/roborev/tui_handlers.go index d3099b66..bb0340f1 100644 --- a/cmd/roborev/tui_handlers.go +++ b/cmd/roborev/tui_handlers.go @@ -1449,11 +1449,31 @@ func (m tuiModel) handleTasksKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { m.fixSelectedIdx++ } return m, nil + case "enter": + // Open review output for completed fix jobs + if len(m.fixJobs) > 0 && m.fixSelectedIdx < len(m.fixJobs) { + job := m.fixJobs[m.fixSelectedIdx] + if job.Status == storage.JobStatusDone { + m.selectedJobID = job.ID + return m, m.fetchReview(job.ID) + } + if job.Status == storage.JobStatusFailed { + errMsg := job.Error + if errMsg == "" { + errMsg = "unknown error" + } + m.flashMessage = fmt.Sprintf("Job #%d failed: %s", job.ID, errMsg) + m.flashExpiresAt = time.Now().Add(5 * time.Second) + m.flashView = tuiViewTasks + } + } + return m, nil case "t": - // Tail output of running fix job + // Tail output: live for running jobs, review for done, error for failed if len(m.fixJobs) > 0 && m.fixSelectedIdx < len(m.fixJobs) { job := m.fixJobs[m.fixSelectedIdx] - if job.Status == storage.JobStatusRunning { + switch job.Status { + case storage.JobStatusRunning: m.tailJobID = job.ID m.tailLines = nil m.tailScroll = 0 @@ -1462,6 +1482,17 @@ func (m tuiModel) handleTasksKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { m.tailFromView = tuiViewTasks m.currentView = tuiViewTail return m, tea.Batch(tea.ClearScreen, m.fetchTailOutput(job.ID)) + case storage.JobStatusDone: + m.selectedJobID = job.ID + return m, m.fetchReview(job.ID) + case storage.JobStatusFailed: + errMsg := job.Error + if errMsg == "" { + errMsg = "unknown error" + } + m.flashMessage = fmt.Sprintf("Job #%d failed: %s", job.ID, errMsg) + m.flashExpiresAt = time.Now().Add(5 * time.Second) + m.flashView = tuiViewTasks } } return m, nil From ed9108de4c7ce8f773db58ca46858118903b46f0 Mon Sep 17 00:00:00 2001 From: Marius van Niekerk Date: Wed, 18 Feb 2026 12:00:39 -0500 Subject: [PATCH 21/49] fix: apply roborev fix for e4b4130 (job #141) --- cmd/roborev/tui.go | 65 +++++++++++++++++++++++++++++++++++----- internal/agent/claude.go | 9 +++--- 2 files changed, 62 insertions(+), 12 deletions(-) diff --git a/cmd/roborev/tui.go b/cmd/roborev/tui.go index 14c94c1d..f08bf751 100644 --- a/cmd/roborev/tui.go +++ b/cmd/roborev/tui.go @@ -10,6 +10,7 @@ import ( "net/http" neturl "net/url" "os" + "os/exec" "path/filepath" "regexp" "slices" @@ -382,10 +383,11 @@ type tuiFixTriggerResultMsg struct { } type tuiApplyPatchResultMsg struct { - jobID int64 - success bool - err error - rebase bool // True if patch didn't apply and needs rebase + jobID int64 + parentJobID int64 // Parent review job (to mark addressed on success) + success bool + err error + rebase bool // True if patch didn't apply and needs rebase } // ClipboardWriter is an interface for clipboard operations (allows mocking in tests) @@ -1310,6 +1312,15 @@ func (m tuiModel) toggleAddressedForJob(jobID int64, currentState *bool) tea.Cmd } } +// markParentAddressed marks the parent review job as addressed after a fix is applied. +// Runs as a fire-and-forget command; errors are silently ignored since the fix already succeeded. +func (m tuiModel) markParentAddressed(parentJobID int64) tea.Cmd { + return func() tea.Msg { + _ = m.postAddressed(parentJobID, true, "") + return nil + } +} + // updateSelectedJobID updates the tracked job ID after navigation func (m *tuiModel) updateSelectedJobID() { if m.selectedIdx >= 0 && m.selectedIdx < len(m.jobs) { @@ -2078,16 +2089,24 @@ func (m tuiModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.flashMessage = fmt.Sprintf("Patch for job #%d doesn't apply cleanly - triggering rebase", msg.jobID) m.flashExpiresAt = time.Now().Add(5 * time.Second) m.flashView = tuiViewTasks - // Auto-trigger a new fix job with the stale patch as context return m, m.triggerRebase(msg.jobID) + } else if msg.success && msg.err != nil { + // Patch applied but commit failed + m.flashMessage = fmt.Sprintf("Job #%d: %v", msg.jobID, msg.err) + m.flashExpiresAt = time.Now().Add(5 * time.Second) + m.flashView = tuiViewTasks } else if msg.err != nil { m.flashMessage = fmt.Sprintf("Apply failed: %v", msg.err) m.flashExpiresAt = time.Now().Add(3 * time.Second) m.flashView = tuiViewTasks } else { - m.flashMessage = fmt.Sprintf("Patch from job #%d applied successfully", msg.jobID) + m.flashMessage = fmt.Sprintf("Patch from job #%d applied and committed", msg.jobID) m.flashExpiresAt = time.Now().Add(3 * time.Second) m.flashView = tuiViewTasks + // Mark the parent review as addressed + if msg.parentJobID > 0 { + return m, m.markParentAddressed(msg.parentJobID) + } } } @@ -3588,10 +3607,42 @@ func (m tuiModel) applyFixPatch(jobID int64) tea.Cmd { return tuiApplyPatchResultMsg{jobID: jobID, err: err} } - return tuiApplyPatchResultMsg{jobID: jobID, success: true} + var parentJobID int64 + if jobDetail.ParentJobID != nil { + parentJobID = *jobDetail.ParentJobID + } + + // Stage and commit + commitMsg := fmt.Sprintf("fix: apply roborev fix job #%d", jobID) + if parentJobID > 0 { + ref := jobDetail.GitRef + if len(ref) > 7 { + ref = ref[:7] + } + commitMsg = fmt.Sprintf("fix: apply roborev fix for %s (job #%d)", ref, jobID) + } + if err := commitPatch(jobDetail.RepoPath, commitMsg); err != nil { + return tuiApplyPatchResultMsg{jobID: jobID, parentJobID: parentJobID, success: true, + err: fmt.Errorf("patch applied but commit failed: %w", err)} + } + + return tuiApplyPatchResultMsg{jobID: jobID, parentJobID: parentJobID, success: true} } } +// commitPatch stages all changes and commits them with the given message. +func commitPatch(repoPath, message string) error { + addCmd := exec.Command("git", "-C", repoPath, "add", "-A") + if out, err := addCmd.CombinedOutput(); err != nil { + return fmt.Errorf("git add: %w: %s", err, out) + } + commitCmd := exec.Command("git", "-C", repoPath, "commit", "-m", message) + if out, err := commitCmd.CombinedOutput(); err != nil { + return fmt.Errorf("git commit: %w: %s", err, out) + } + return nil +} + // triggerRebase triggers a new fix job that re-applies a stale patch to the current HEAD. // The server looks up the stale patch from the DB, avoiding large client-to-server transfers. func (m tuiModel) triggerRebase(staleJobID int64) tea.Cmd { diff --git a/internal/agent/claude.go b/internal/agent/claude.go index e2f89096..bc17c1d5 100644 --- a/internal/agent/claude.go +++ b/internal/agent/claude.go @@ -5,7 +5,6 @@ import ( "bytes" "context" "encoding/json" - "errors" "fmt" "io" "os" @@ -169,7 +168,7 @@ func (a *ClaudeAgent) Review(ctx context.Context, repoPath, commitSHA, prompt st if waitErr := cmd.Wait(); waitErr != nil { // Build a detailed error including any partial output and stream errors var detail strings.Builder - fmt.Fprintf(&detail, "%s failed: %v", a.Name(), waitErr) + fmt.Fprintf(&detail, "%s failed", a.Name()) if err != nil { fmt.Fprintf(&detail, "\nstream: %v", err) } @@ -184,7 +183,7 @@ func (a *ClaudeAgent) Review(ctx context.Context, repoPath, commitSHA, prompt st } fmt.Fprintf(&detail, "\npartial output: %s", partial) } - return "", errors.New(detail.String()) + return "", fmt.Errorf("%s: %w", detail.String(), waitErr) } if err != nil { @@ -268,8 +267,8 @@ func (a *ClaudeAgent) parseStreamJSON(r io.Reader, output io.Writer) (string, er // Build partial output for error context partial := strings.Join(assistantMessages, "\n") - // If error events were received, report them with any partial output - if len(errorMessages) > 0 { + // If error events were received but we got no result, report them with any partial output + if len(errorMessages) > 0 && lastResult == "" { return partial, fmt.Errorf("stream errors: %s", strings.Join(errorMessages, "; ")) } From 52266fca2eeb023be50bb7c1528c8031f58d2d88 Mon Sep 17 00:00:00 2001 From: Marius van Niekerk Date: Wed, 18 Feb 2026 12:05:53 -0500 Subject: [PATCH 22/49] feat: patch viewer, apply+commit, ESC navigation, and tasks UI improvements - Add patch viewer (p key) with syntax-highlighted diff display - Apply (A) now stages, commits, and marks parent review as addressed - ESC from review returns to originating view (queue or tasks) - Tasks view: readable status labels, dynamic columns, help overlay (?) Co-Authored-By: Claude Opus 4.6 --- cmd/roborev/tui.go | 201 +++++++++++++++++++++++++++++++++--- cmd/roborev/tui_handlers.go | 83 +++++++++++++-- 2 files changed, 261 insertions(+), 23 deletions(-) diff --git a/cmd/roborev/tui.go b/cmd/roborev/tui.go index f08bf751..2b177237 100644 --- a/cmd/roborev/tui.go +++ b/cmd/roborev/tui.go @@ -109,6 +109,7 @@ const ( tuiViewTail tuiViewTasks // Background fix tasks view tuiViewFixPrompt // Fix prompt confirmation modal + tuiViewPatch // Patch viewer for fix jobs ) // queuePrefetchBuffer is the number of extra rows to fetch beyond what's visible, @@ -253,11 +254,18 @@ type tuiModel struct { clipboard ClipboardWriter + // Review view navigation + reviewFromView tuiView // View to return to when exiting review (queue or tasks) + // Fix task state fixJobs []storage.ReviewJob // Fix jobs for tasks view fixSelectedIdx int // Selected index in tasks view fixPromptText string // Editable fix prompt text fixPromptJobID int64 // Parent job ID for fix prompt modal + fixShowHelp bool // Show help overlay in tasks view + patchText string // Current patch text for patch viewer + patchScroll int // Scroll offset in patch viewer + patchJobID int64 // Job ID of the patch being viewed } // pendingState tracks a pending addressed toggle with sequence number @@ -390,6 +398,12 @@ type tuiApplyPatchResultMsg struct { rebase bool // True if patch didn't apply and needs rebase } +type tuiPatchMsg struct { + jobID int64 + patch string + err error +} + // ClipboardWriter is an interface for clipboard operations (allows mocking in tests) type ClipboardWriter interface { WriteText(text string) error @@ -2084,6 +2098,18 @@ func (m tuiModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, m.fetchFixJobs() } + case tuiPatchMsg: + if msg.err != nil { + m.flashMessage = fmt.Sprintf("Patch fetch failed: %v", msg.err) + m.flashExpiresAt = time.Now().Add(3 * time.Second) + m.flashView = tuiViewTasks + } else { + m.patchText = msg.patch + m.patchJobID = msg.jobID + m.patchScroll = 0 + m.currentView = tuiViewPatch + } + case tuiApplyPatchResultMsg: if msg.rebase { m.flashMessage = fmt.Sprintf("Patch for job #%d doesn't apply cleanly - triggering rebase", msg.jobID) @@ -2135,6 +2161,9 @@ func (m tuiModel) View() string { if m.currentView == tuiViewFixPrompt { return m.renderFixPromptView() } + if m.currentView == tuiViewPatch { + return m.renderPatchView() + } if m.currentView == tuiViewPrompt && m.currentReview != nil { return m.renderPromptView() } @@ -3403,6 +3432,11 @@ func (m tuiModel) renderTasksView() string { b.WriteString(tuiTitleStyle.Render("roborev tasks (background fixes)")) b.WriteString("\x1b[K\n") + // Help overlay + if m.fixShowHelp { + return m.renderTasksHelpOverlay(&b) + } + if len(m.fixJobs) == 0 { b.WriteString("\n No fix tasks. Press F on a review to trigger a background fix.\n") b.WriteString("\n") @@ -3411,6 +3445,19 @@ func (m tuiModel) renderTasksView() string { return b.String() } + // Dynamic column widths based on terminal width + // Columns: status(8) + id(6) + parent(varies) + ref(varies) + subject(rest) + statusW := 8 + idW := 6 + parentW := 16 + refW := 12 + padding := 10 // spaces between columns + subjectW := max(m.width-statusW-idW-parentW-refW-padding, 10) + if m.width > 120 { + refW = 20 + subjectW = max(m.width-statusW-idW-parentW-refW-padding, 20) + } + // Render each fix job visibleRows := m.height - 5 // title + help + padding visibleRows = max(visibleRows, 1) @@ -3422,40 +3469,53 @@ func (m tuiModel) renderTasksView() string { for i := startIdx; i < len(m.fixJobs) && i < startIdx+visibleRows; i++ { job := m.fixJobs[i] - // Status indicator - var statusIcon string + // Status label + var statusLabel string + var statusStyle lipgloss.Style switch job.Status { case storage.JobStatusQueued: - statusIcon = "..." + statusLabel = "queued" + statusStyle = tuiQueuedStyle case storage.JobStatusRunning: - statusIcon = ">>>" + statusLabel = "running" + statusStyle = tuiRunningStyle case storage.JobStatusDone: - statusIcon = "[+]" + statusLabel = "ready" + statusStyle = tuiDoneStyle case storage.JobStatusFailed: - statusIcon = "[!]" + statusLabel = "failed" + statusStyle = tuiFailedStyle case storage.JobStatusCanceled: - statusIcon = "[-]" + statusLabel = "canceled" + statusStyle = tuiCanceledStyle } // Parent job reference parentRef := "" if job.ParentJobID != nil { - parentRef = fmt.Sprintf("review #%d", *job.ParentJobID) + parentRef = fmt.Sprintf("fixes #%d", *job.ParentJobID) } - line := fmt.Sprintf(" %s #%-4d %-8s %-16s %s %s", - statusIcon, + line := fmt.Sprintf(" %-*s #%-4d %-*s %-*s %s", + statusW, statusLabel, job.ID, - job.Status, - parentRef, - truncateString(job.GitRef, 12), - truncateString(job.CommitSubject, 40), + parentW, truncateString(parentRef, parentW), + refW, truncateString(job.GitRef, refW), + truncateString(job.CommitSubject, subjectW), ) if i == m.fixSelectedIdx { b.WriteString(tuiSelectedStyle.Render(line)) } else { - b.WriteString(line) + // Apply status color to the status portion only + styledStatus := statusStyle.Render(fmt.Sprintf("%-*s", statusW, statusLabel)) + rest := fmt.Sprintf(" #%-4d %-*s %-*s %s", + job.ID, + parentW, truncateString(parentRef, parentW), + refW, truncateString(job.GitRef, refW), + truncateString(job.CommitSubject, subjectW), + ) + b.WriteString(" " + styledStatus + rest) } b.WriteString("\x1b[K\n") } @@ -3468,9 +3528,118 @@ func (m tuiModel) renderTasksView() string { b.WriteString("\x1b[K\n") // Help - b.WriteString(tuiHelpStyle.Render("A: apply | R: rebase | t: tail | x: cancel | r: refresh | T/esc: back")) + b.WriteString(tuiHelpStyle.Render("enter: view | p: patch | A: apply | t: tail | x: cancel | r: refresh | ?: help | T/esc: back")) + b.WriteString("\x1b[K\x1b[J") + + return b.String() +} + +func (m tuiModel) renderTasksHelpOverlay(b *strings.Builder) string { + help := []string{ + "", + " Task Status", + " queued Waiting for a worker to pick up the job", + " running Agent is working in an isolated worktree", + " ready Patch captured and ready to apply to your working tree", + " failed Agent failed (press enter or t to see error details)", + " canceled Job was canceled by user", + "", + " Keybindings", + " enter/t View review output (ready) or error (failed) or tail (running)", + " p View the patch diff for a ready job", + " A Apply patch from a ready job to your working tree", + " R Re-run fix against current HEAD (when patch is stale)", + " F Trigger a new fix from a review (from queue view)", + " x Cancel a queued or running job", + " r Refresh the task list", + " T/esc Return to the main queue view", + " ? Toggle this help", + "", + " Workflow", + " 1. Press F on a failing review to trigger a background fix", + " 2. The agent runs in an isolated worktree (your files are untouched)", + " 3. When status shows 'ready', press A to apply and commit the patch", + " 4. If the patch is stale (code changed since), press R to re-run", + "", + } + for _, line := range help { + b.WriteString(line) + b.WriteString("\x1b[K\n") + } + b.WriteString(tuiHelpStyle.Render("?: close help")) b.WriteString("\x1b[K\x1b[J") + return b.String() +} +// fetchPatch fetches the patch for a fix job from the daemon. +func (m tuiModel) fetchPatch(jobID int64) tea.Cmd { + return func() tea.Msg { + url := m.serverAddr + fmt.Sprintf("/api/job/patch?job_id=%d", jobID) + resp, err := m.client.Get(url) + if err != nil { + return tuiPatchMsg{jobID: jobID, err: err} + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return tuiPatchMsg{jobID: jobID, err: fmt.Errorf("no patch available (HTTP %d)", resp.StatusCode)} + } + data, err := io.ReadAll(resp.Body) + if err != nil { + return tuiPatchMsg{jobID: jobID, err: err} + } + return tuiPatchMsg{jobID: jobID, patch: string(data)} + } +} + +func (m tuiModel) renderPatchView() string { + var b strings.Builder + + b.WriteString(tuiTitleStyle.Render(fmt.Sprintf("patch for fix job #%d", m.patchJobID))) + b.WriteString("\x1b[K\n") + + if m.patchText == "" { + b.WriteString("\n No patch available.\n") + } else { + lines := strings.Split(m.patchText, "\n") + visibleRows := max(m.height-4, 1) + maxScroll := max(len(lines)-visibleRows, 0) + start := max(min(m.patchScroll, maxScroll), 0) + end := min(start+visibleRows, len(lines)) + + addStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("34")) // green + delStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("160")) // red + hdrStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("33")) // blue + metaStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("245")) // gray + + for _, line := range lines[start:end] { + display := line + switch { + case strings.HasPrefix(line, "+") && !strings.HasPrefix(line, "+++"): + display = addStyle.Render(line) + case strings.HasPrefix(line, "-") && !strings.HasPrefix(line, "---"): + display = delStyle.Render(line) + case strings.HasPrefix(line, "@@"): + display = hdrStyle.Render(line) + case strings.HasPrefix(line, "diff ") || strings.HasPrefix(line, "index ") || + strings.HasPrefix(line, "---") || strings.HasPrefix(line, "+++"): + display = metaStyle.Render(line) + } + b.WriteString(" " + display) + b.WriteString("\x1b[K\n") + } + + if len(lines) > visibleRows { + pct := 0 + if maxScroll > 0 { + pct = start * 100 / maxScroll + } + b.WriteString(tuiHelpStyle.Render(fmt.Sprintf(" [%d%%]", pct))) + b.WriteString("\x1b[K\n") + } + } + + b.WriteString(tuiHelpStyle.Render("j/k/up/down: scroll | esc: back to tasks")) + b.WriteString("\x1b[K\x1b[J") return b.String() } diff --git a/cmd/roborev/tui_handlers.go b/cmd/roborev/tui_handlers.go index bb0340f1..34f476ff 100644 --- a/cmd/roborev/tui_handlers.go +++ b/cmd/roborev/tui_handlers.go @@ -25,6 +25,8 @@ func (m tuiModel) handleKeyMsg(msg tea.KeyMsg) (tea.Model, tea.Cmd) { return m.handleFixPromptKey(msg) case tuiViewTasks: return m.handleTasksKey(msg) + case tuiViewPatch: + return m.handlePatchKey(msg) } // Global keys shared across queue/review/prompt/commitMsg/help views @@ -389,11 +391,17 @@ func (m tuiModel) handleGlobalKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { func (m tuiModel) handleQuitKey() (tea.Model, tea.Cmd) { if m.currentView == tuiViewReview { - m.currentView = tuiViewQueue + returnTo := m.reviewFromView + if returnTo == 0 { + returnTo = tuiViewQueue + } + m.currentView = returnTo m.currentReview = nil m.reviewScroll = 0 m.paginateNav = 0 - m.normalizeSelectionIfHidden() + if returnTo == tuiViewQueue { + m.normalizeSelectionIfHidden() + } return m, nil } if m.currentView == tuiViewPrompt { @@ -702,6 +710,7 @@ func (m tuiModel) handleEnterKey() (tea.Model, tea.Cmd) { job := m.jobs[m.selectedIdx] switch job.Status { case storage.JobStatusDone: + m.reviewFromView = tuiViewQueue return m, m.fetchReview(job.ID) case storage.JobStatusFailed: m.currentBranch = "" @@ -710,6 +719,7 @@ func (m tuiModel) handleEnterKey() (tea.Model, tea.Cmd) { Output: "Job failed:\n\n" + job.Error, Job: &job, } + m.reviewFromView = tuiViewQueue m.currentView = tuiViewReview m.reviewScroll = 0 return m, nil @@ -1042,14 +1052,20 @@ func (m tuiModel) handleEscKey() (tea.Model, tea.Cmd) { m.loadingJobs = true return m, m.fetchJobs() } else if m.currentView == tuiViewReview { - m.currentView = tuiViewQueue + returnTo := m.reviewFromView + if returnTo == 0 { + returnTo = tuiViewQueue + } + m.currentView = returnTo m.currentReview = nil m.reviewScroll = 0 m.paginateNav = 0 - m.normalizeSelectionIfHidden() - if m.hideAddressed && !m.loadingJobs { - m.loadingJobs = true - return m, m.fetchJobs() + if returnTo == tuiViewQueue { + m.normalizeSelectionIfHidden() + if m.hideAddressed && !m.loadingJobs { + m.loadingJobs = true + return m, m.fetchJobs() + } } } else if m.currentView == tuiViewPrompt { m.paginateNav = 0 @@ -1455,6 +1471,7 @@ func (m tuiModel) handleTasksKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { job := m.fixJobs[m.fixSelectedIdx] if job.Status == storage.JobStatusDone { m.selectedJobID = job.ID + m.reviewFromView = tuiViewTasks return m, m.fetchReview(job.ID) } if job.Status == storage.JobStatusFailed { @@ -1484,6 +1501,7 @@ func (m tuiModel) handleTasksKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { return m, tea.Batch(tea.ClearScreen, m.fetchTailOutput(job.ID)) case storage.JobStatusDone: m.selectedJobID = job.ID + m.reviewFromView = tuiViewTasks return m, m.fetchReview(job.ID) case storage.JobStatusFailed: errMsg := job.Error @@ -1528,9 +1546,60 @@ func (m tuiModel) handleTasksKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { } } return m, nil + case "p": + // View patch for completed fix jobs + if len(m.fixJobs) > 0 && m.fixSelectedIdx < len(m.fixJobs) { + job := m.fixJobs[m.fixSelectedIdx] + if job.Status == storage.JobStatusDone { + return m, m.fetchPatch(job.ID) + } + } + return m, nil case "r": // Refresh return m, m.fetchFixJobs() + case "?": + m.fixShowHelp = !m.fixShowHelp + return m, nil + } + return m, nil +} + +// handlePatchKey handles key input in the patch viewer. +func (m tuiModel) handlePatchKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + switch msg.String() { + case "ctrl+c": + return m, tea.Quit + case "esc", "q": + m.currentView = tuiViewTasks + m.patchText = "" + m.patchScroll = 0 + m.patchJobID = 0 + return m, nil + case "up", "k": + if m.patchScroll > 0 { + m.patchScroll-- + } + return m, nil + case "down", "j": + m.patchScroll++ + return m, nil + case "pgup": + visibleLines := max(m.height-4, 1) + m.patchScroll = max(0, m.patchScroll-visibleLines) + return m, tea.ClearScreen + case "pgdown": + visibleLines := max(m.height-4, 1) + m.patchScroll += visibleLines + return m, tea.ClearScreen + case "home", "g": + m.patchScroll = 0 + return m, nil + case "end", "G": + lines := strings.Split(m.patchText, "\n") + visibleRows := max(m.height-4, 1) + m.patchScroll = max(len(lines)-visibleRows, 0) + return m, nil } return m, nil } From af81286d9e019e032e16aa696a011f37509596e2 Mon Sep 17 00:00:00 2001 From: Marius van Niekerk Date: Wed, 18 Feb 2026 12:10:03 -0500 Subject: [PATCH 23/49] feat: tasks view dynamic columns with headers, patch viewer, ESC nav fix - Dynamic column widths sized to terminal width (matching main queue) - Header row with separator line for tasks table - Readable status labels (queued/running/ready/failed/canceled) with colors - Help overlay (?) with legend, keybindings, workflow - Patch viewer (p) with syntax-highlighted diff - ESC from review returns to originating view (queue or tasks) - Apply commits patch and marks parent review as addressed Co-Authored-By: Claude Opus 4.6 --- cmd/roborev/tui.go | 57 +++++++++++++++++++++++----------------------- 1 file changed, 28 insertions(+), 29 deletions(-) diff --git a/cmd/roborev/tui.go b/cmd/roborev/tui.go index 2b177237..0295063e 100644 --- a/cmd/roborev/tui.go +++ b/cmd/roborev/tui.go @@ -3445,21 +3445,27 @@ func (m tuiModel) renderTasksView() string { return b.String() } - // Dynamic column widths based on terminal width - // Columns: status(8) + id(6) + parent(varies) + ref(varies) + subject(rest) - statusW := 8 - idW := 6 - parentW := 16 - refW := 12 - padding := 10 // spaces between columns - subjectW := max(m.width-statusW-idW-parentW-refW-padding, 10) - if m.width > 120 { - refW = 20 - subjectW = max(m.width-statusW-idW-parentW-refW-padding, 20) - } + // Column layout: fixed columns + flexible subject + // Fixed: " " + Status(8) + " " + Job(5) + " " + Parent(11) + " " + Ref(refW) + " " + const statusW = 8 + const idW = 5 // "#" + 4-digit number + const parentW = 11 // "fixes #NNNN" + fixedW := 2 + statusW + 1 + idW + 1 + parentW + 1 + 1 // prefix spacing + trailing space + // Give ref and subject the remaining width: ref gets 15%, subject gets the rest + flexW := max(m.width-fixedW, 10) + refW := max(7, flexW*15/100) + subjectW := max(5, flexW-refW-1) // -1 for space between ref and subject + + // Header + header := fmt.Sprintf(" %-*s %-*s %-*s %-*s %s", + statusW, "Status", idW, "Job", parentW, "Parent", refW, "Ref", "Subject") + b.WriteString(tuiStatusStyle.Render(header)) + b.WriteString("\x1b[K\n") + b.WriteString(" " + strings.Repeat("-", min(m.width-4, 200))) + b.WriteString("\x1b[K\n") // Render each fix job - visibleRows := m.height - 5 // title + help + padding + visibleRows := m.height - 7 // title + header + separator + help + padding visibleRows = max(visibleRows, 1) startIdx := 0 if m.fixSelectedIdx >= visibleRows { @@ -3490,31 +3496,24 @@ func (m tuiModel) renderTasksView() string { statusStyle = tuiCanceledStyle } - // Parent job reference parentRef := "" if job.ParentJobID != nil { parentRef = fmt.Sprintf("fixes #%d", *job.ParentJobID) } - - line := fmt.Sprintf(" %-*s #%-4d %-*s %-*s %s", - statusW, statusLabel, - job.ID, - parentW, truncateString(parentRef, parentW), - refW, truncateString(job.GitRef, refW), - truncateString(job.CommitSubject, subjectW), - ) + ref := job.GitRef + if len(ref) > refW { + ref = ref[:max(1, refW-3)] + "..." + } + subject := truncateString(job.CommitSubject, subjectW) if i == m.fixSelectedIdx { + line := fmt.Sprintf(" %-*s #%-4d %-*s %-*s %s", + statusW, statusLabel, job.ID, parentW, parentRef, refW, ref, subject) b.WriteString(tuiSelectedStyle.Render(line)) } else { - // Apply status color to the status portion only styledStatus := statusStyle.Render(fmt.Sprintf("%-*s", statusW, statusLabel)) - rest := fmt.Sprintf(" #%-4d %-*s %-*s %s", - job.ID, - parentW, truncateString(parentRef, parentW), - refW, truncateString(job.GitRef, refW), - truncateString(job.CommitSubject, subjectW), - ) + rest := fmt.Sprintf(" #%-4d %-*s %-*s %s", + job.ID, parentW, parentRef, refW, ref, subject) b.WriteString(" " + styledStatus + rest) } b.WriteString("\x1b[K\n") From 534a5201580f6eaaabac90d87c9febf0624e61b5 Mon Sep 17 00:00:00 2001 From: Marius van Niekerk Date: Wed, 18 Feb 2026 12:12:04 -0500 Subject: [PATCH 24/49] fix: give subject column more space in tasks view Ref and subject now split remaining width (25%/75%) after fixed columns. Ref column adapts to terminal width instead of being hardcoded. Co-Authored-By: Claude Opus 4.6 --- cmd/roborev/tui.go | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/cmd/roborev/tui.go b/cmd/roborev/tui.go index 0295063e..b7cc8f1b 100644 --- a/cmd/roborev/tui.go +++ b/cmd/roborev/tui.go @@ -3445,16 +3445,15 @@ func (m tuiModel) renderTasksView() string { return b.String() } - // Column layout: fixed columns + flexible subject - // Fixed: " " + Status(8) + " " + Job(5) + " " + Parent(11) + " " + Ref(refW) + " " - const statusW = 8 + // Column layout: status, job, parent are fixed; ref and subject split remaining space. + const statusW = 8 // "canceled" is the longest const idW = 5 // "#" + 4-digit number const parentW = 11 // "fixes #NNNN" - fixedW := 2 + statusW + 1 + idW + 1 + parentW + 1 + 1 // prefix spacing + trailing space - // Give ref and subject the remaining width: ref gets 15%, subject gets the rest - flexW := max(m.width-fixedW, 10) - refW := max(7, flexW*15/100) - subjectW := max(5, flexW-refW-1) // -1 for space between ref and subject + fixedW := 2 + statusW + 1 + idW + 1 + parentW + 1 + 1 // prefix + inter-column spaces + flexW := max(m.width-fixedW, 15) + // Ref gets 25% of flexible space, subject gets 75% + refW := max(7, flexW*25/100) + subjectW := max(5, flexW-refW-1) // Header header := fmt.Sprintf(" %-*s %-*s %-*s %-*s %s", From 67c426f57c125f36ff1a215e68148905a64d3f60 Mon Sep 17 00:00:00 2001 From: Marius van Niekerk Date: Wed, 18 Feb 2026 12:18:04 -0500 Subject: [PATCH 25/49] feat: add applied status for fix jobs, hide verdict for fix reviews - New "applied" job status: done -> applied after successful patch apply - Server endpoint POST /api/job/applied to transition status - Tasks view shows "applied" with green styling, prevents re-applying - Fix job reviews no longer show "Verdict: Fail" (not meaningful for fixes) - Tasks list refreshes immediately after apply to reflect new status Co-Authored-By: Claude Opus 4.6 --- cmd/roborev/tui.go | 19 ++++++++++++++----- internal/daemon/server.go | 33 ++++++++++++++++++++++++++++++++- internal/storage/jobs.go | 21 +++++++++++++++++++++ internal/storage/models.go | 1 + 4 files changed, 68 insertions(+), 6 deletions(-) diff --git a/cmd/roborev/tui.go b/cmd/roborev/tui.go index b7cc8f1b..26d3cfe5 100644 --- a/cmd/roborev/tui.go +++ b/cmd/roborev/tui.go @@ -2129,10 +2129,12 @@ func (m tuiModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.flashMessage = fmt.Sprintf("Patch from job #%d applied and committed", msg.jobID) m.flashExpiresAt = time.Now().Add(3 * time.Second) m.flashView = tuiViewTasks - // Mark the parent review as addressed + // Refresh tasks list to show updated status, and mark parent addressed + cmds := []tea.Cmd{m.fetchFixJobs()} if msg.parentJobID > 0 { - return m, m.markParentAddressed(msg.parentJobID) + cmds = append(cmds, m.markParentAddressed(msg.parentJobID)) } + return m, tea.Batch(cmds...) } } @@ -2643,8 +2645,8 @@ func (m tuiModel) renderReviewView() string { b.WriteString(tuiStatusStyle.Render(locationLine)) b.WriteString("\x1b[K") // Clear to end of line - // Show verdict and addressed status on next line - hasVerdict := review.Job.Verdict != nil && *review.Job.Verdict != "" + // Show verdict and addressed status on next line (skip verdict for fix jobs) + hasVerdict := review.Job.Verdict != nil && *review.Job.Verdict != "" && !review.Job.IsFixJob() if hasVerdict || review.Addressed { b.WriteString("\n") if hasVerdict { @@ -2726,7 +2728,7 @@ func (m tuiModel) renderReviewView() string { // headerHeight = title + location line + status line (1) + help + verdict/addressed (0|1) headerHeight := titleLines + locationLines + 1 + helpLines - hasVerdict := review.Job != nil && review.Job.Verdict != nil && *review.Job.Verdict != "" + hasVerdict := review.Job != nil && review.Job.Verdict != nil && *review.Job.Verdict != "" && !review.Job.IsFixJob() if hasVerdict || review.Addressed { headerHeight++ // Add 1 for verdict/addressed line } @@ -3493,6 +3495,9 @@ func (m tuiModel) renderTasksView() string { case storage.JobStatusCanceled: statusLabel = "canceled" statusStyle = tuiCanceledStyle + case storage.JobStatusApplied: + statusLabel = "applied" + statusStyle = tuiDoneStyle } parentRef := "" @@ -3540,6 +3545,7 @@ func (m tuiModel) renderTasksHelpOverlay(b *strings.Builder) string { " running Agent is working in an isolated worktree", " ready Patch captured and ready to apply to your working tree", " failed Agent failed (press enter or t to see error details)", + " applied Patch was applied and committed to your working tree", " canceled Job was canceled by user", "", " Keybindings", @@ -3793,6 +3799,9 @@ func (m tuiModel) applyFixPatch(jobID int64) tea.Cmd { err: fmt.Errorf("patch applied but commit failed: %w", err)} } + // Mark the fix job as applied on the server + _ = m.postJSON("/api/job/applied", map[string]any{"job_id": jobID}, nil) + return tuiApplyPatchResultMsg{jobID: jobID, parentJobID: parentJobID, success: true} } } diff --git a/internal/daemon/server.go b/internal/daemon/server.go index 4570a0b0..fc619f56 100644 --- a/internal/daemon/server.go +++ b/internal/daemon/server.go @@ -91,6 +91,7 @@ func NewServer(db *storage.DB, cfg *config.Config, configPath string) *Server { mux.HandleFunc("/api/sync/status", s.handleSyncStatus) mux.HandleFunc("/api/job/fix", s.handleFixJob) mux.HandleFunc("/api/job/patch", s.handleGetPatch) + mux.HandleFunc("/api/job/applied", s.handleMarkJobApplied) s.httpServer = &http.Server{ Addr: cfg.ServerAddr, @@ -1738,7 +1739,7 @@ func (s *Server) handleGetPatch(w http.ResponseWriter, r *http.Request) { return } - if job.Status != storage.JobStatusDone || job.Patch == nil { + if (job.Status != storage.JobStatusDone && job.Status != storage.JobStatusApplied) || job.Patch == nil { writeError(w, http.StatusNotFound, "no patch available for this job") return } @@ -1748,6 +1749,36 @@ func (s *Server) handleGetPatch(w http.ResponseWriter, r *http.Request) { _, _ = w.Write([]byte(*job.Patch)) } +func (s *Server) handleMarkJobApplied(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + writeError(w, http.StatusMethodNotAllowed, "method not allowed") + return + } + + var req struct { + JobID int64 `json:"job_id"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "invalid request body") + return + } + if req.JobID == 0 { + writeError(w, http.StatusBadRequest, "job_id is required") + return + } + + if err := s.db.MarkJobApplied(req.JobID); err != nil { + if errors.Is(err, sql.ErrNoRows) { + writeError(w, http.StatusNotFound, "job not found or not in done state") + return + } + writeError(w, http.StatusInternalServerError, fmt.Sprintf("mark applied: %v", err)) + return + } + + writeJSON(w, map[string]string{"status": "applied"}) +} + // buildFixPrompt constructs a prompt for fixing review findings. func buildFixPrompt(reviewOutput string) string { return "# Fix Request\n\n" + diff --git a/internal/storage/jobs.go b/internal/storage/jobs.go index 7066a011..8724d22b 100644 --- a/internal/storage/jobs.go +++ b/internal/storage/jobs.go @@ -1141,6 +1141,27 @@ func (db *DB) CancelJob(jobID int64) error { return nil } +// MarkJobApplied transitions a fix job from done to applied. +func (db *DB) MarkJobApplied(jobID int64) error { + now := time.Now().Format(time.RFC3339) + result, err := db.Exec(` + UPDATE review_jobs + SET status = 'applied', updated_at = ? + WHERE id = ? AND status = 'done' + `, now, jobID) + if err != nil { + return err + } + rows, err := result.RowsAffected() + if err != nil { + return err + } + if rows == 0 { + return sql.ErrNoRows + } + return nil +} + // ReenqueueJob resets a completed, failed, or canceled job back to queued status. // This allows manual re-running of jobs to get a fresh review. // For done jobs, the existing review is deleted to avoid unique constraint violations. diff --git a/internal/storage/models.go b/internal/storage/models.go index 3abb0fc0..a30ae96b 100644 --- a/internal/storage/models.go +++ b/internal/storage/models.go @@ -31,6 +31,7 @@ const ( JobStatusDone JobStatus = "done" JobStatusFailed JobStatus = "failed" JobStatusCanceled JobStatus = "canceled" + JobStatusApplied JobStatus = "applied" ) // JobType classifies what kind of work a review job represents. From 1aab636737c3e284d4256c31978b3304bb7a66db Mon Sep 17 00:00:00 2001 From: Marius van Niekerk Date: Wed, 18 Feb 2026 12:27:26 -0500 Subject: [PATCH 26/49] feat: add rebased terminal status, running task viewing, tail from prompt - Add JobStatusRebased for stale fix jobs that triggered a rebase - Mark stale job as rebased when triggering rebase (terminal state) - Add /api/job/rebased endpoint and MarkJobRebased DB method - Allow viewing reviews and patches for rebased jobs - Enter on running tasks shows prompt view - Allow switching from prompt view to tail for running fix jobs Co-Authored-By: Claude Opus 4.6 --- cmd/roborev/tui.go | 7 +++++- cmd/roborev/tui_handlers.go | 49 ++++++++++++++++++++++++++++++++----- internal/daemon/server.go | 33 ++++++++++++++++++++++++- internal/storage/jobs.go | 22 +++++++++++++++++ internal/storage/models.go | 1 + 5 files changed, 104 insertions(+), 8 deletions(-) diff --git a/cmd/roborev/tui.go b/cmd/roborev/tui.go index 26d3cfe5..30a7fcc0 100644 --- a/cmd/roborev/tui.go +++ b/cmd/roborev/tui.go @@ -2115,7 +2115,9 @@ func (m tuiModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.flashMessage = fmt.Sprintf("Patch for job #%d doesn't apply cleanly - triggering rebase", msg.jobID) m.flashExpiresAt = time.Now().Add(5 * time.Second) m.flashView = tuiViewTasks - return m, m.triggerRebase(msg.jobID) + // Mark stale job as rebased and trigger rebase + _ = m.postJSON("/api/job/rebased", map[string]any{"job_id": msg.jobID}, nil) + return m, tea.Batch(m.triggerRebase(msg.jobID), m.fetchFixJobs()) } else if msg.success && msg.err != nil { // Patch applied but commit failed m.flashMessage = fmt.Sprintf("Job #%d: %v", msg.jobID, msg.err) @@ -3498,6 +3500,9 @@ func (m tuiModel) renderTasksView() string { case storage.JobStatusApplied: statusLabel = "applied" statusStyle = tuiDoneStyle + case storage.JobStatusRebased: + statusLabel = "rebased" + statusStyle = tuiCanceledStyle } parentRef := "" diff --git a/cmd/roborev/tui_handlers.go b/cmd/roborev/tui_handlers.go index 34f476ff..e2f1bf68 100644 --- a/cmd/roborev/tui_handlers.go +++ b/cmd/roborev/tui_handlers.go @@ -871,6 +871,21 @@ func (m tuiModel) handleRerunKey() (tea.Model, tea.Cmd) { } func (m tuiModel) handleTailKey2() (tea.Model, tea.Cmd) { + // From prompt view: allow tailing the running job being viewed + if m.currentView == tuiViewPrompt && m.currentReview != nil && m.currentReview.Job != nil { + job := m.currentReview.Job + if job.Status == storage.JobStatusRunning { + m.tailJobID = job.ID + m.tailLines = nil + m.tailScroll = 0 + m.tailStreaming = true + m.tailFollow = true + m.tailFromView = m.reviewFromView + m.currentView = tuiViewTail + return m, tea.Batch(tea.ClearScreen, m.fetchTailOutput(job.ID)) + } + } + if m.currentView != tuiViewQueue || len(m.jobs) == 0 || m.selectedIdx < 0 || m.selectedIdx >= len(m.jobs) { return m, nil } @@ -1466,15 +1481,37 @@ func (m tuiModel) handleTasksKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { } return m, nil case "enter": - // Open review output for completed fix jobs + // View task: prompt for running, review for done/applied, error for failed if len(m.fixJobs) > 0 && m.fixSelectedIdx < len(m.fixJobs) { job := m.fixJobs[m.fixSelectedIdx] - if job.Status == storage.JobStatusDone { + switch job.Status { + case storage.JobStatusRunning: + if job.Prompt != "" { + m.currentReview = &storage.Review{ + Agent: job.Agent, + Prompt: job.Prompt, + Job: &job, + } + m.reviewFromView = tuiViewTasks + m.currentView = tuiViewPrompt + m.promptScroll = 0 + m.promptFromQueue = false + return m, nil + } + // No prompt yet, go straight to tail + m.tailJobID = job.ID + m.tailLines = nil + m.tailScroll = 0 + m.tailStreaming = true + m.tailFollow = true + m.tailFromView = tuiViewTasks + m.currentView = tuiViewTail + return m, tea.Batch(tea.ClearScreen, m.fetchTailOutput(job.ID)) + case storage.JobStatusDone, storage.JobStatusApplied, storage.JobStatusRebased: m.selectedJobID = job.ID m.reviewFromView = tuiViewTasks return m, m.fetchReview(job.ID) - } - if job.Status == storage.JobStatusFailed { + case storage.JobStatusFailed: errMsg := job.Error if errMsg == "" { errMsg = "unknown error" @@ -1499,7 +1536,7 @@ func (m tuiModel) handleTasksKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { m.tailFromView = tuiViewTasks m.currentView = tuiViewTail return m, tea.Batch(tea.ClearScreen, m.fetchTailOutput(job.ID)) - case storage.JobStatusDone: + case storage.JobStatusDone, storage.JobStatusApplied, storage.JobStatusRebased: m.selectedJobID = job.ID m.reviewFromView = tuiViewTasks return m, m.fetchReview(job.ID) @@ -1550,7 +1587,7 @@ func (m tuiModel) handleTasksKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { // View patch for completed fix jobs if len(m.fixJobs) > 0 && m.fixSelectedIdx < len(m.fixJobs) { job := m.fixJobs[m.fixSelectedIdx] - if job.Status == storage.JobStatusDone { + if job.Status == storage.JobStatusDone || job.Status == storage.JobStatusApplied || job.Status == storage.JobStatusRebased { return m, m.fetchPatch(job.ID) } } diff --git a/internal/daemon/server.go b/internal/daemon/server.go index fc619f56..32be8c57 100644 --- a/internal/daemon/server.go +++ b/internal/daemon/server.go @@ -92,6 +92,7 @@ func NewServer(db *storage.DB, cfg *config.Config, configPath string) *Server { mux.HandleFunc("/api/job/fix", s.handleFixJob) mux.HandleFunc("/api/job/patch", s.handleGetPatch) mux.HandleFunc("/api/job/applied", s.handleMarkJobApplied) + mux.HandleFunc("/api/job/rebased", s.handleMarkJobRebased) s.httpServer = &http.Server{ Addr: cfg.ServerAddr, @@ -1739,7 +1740,7 @@ func (s *Server) handleGetPatch(w http.ResponseWriter, r *http.Request) { return } - if (job.Status != storage.JobStatusDone && job.Status != storage.JobStatusApplied) || job.Patch == nil { + if (job.Status != storage.JobStatusDone && job.Status != storage.JobStatusApplied && job.Status != storage.JobStatusRebased) || job.Patch == nil { writeError(w, http.StatusNotFound, "no patch available for this job") return } @@ -1779,6 +1780,36 @@ func (s *Server) handleMarkJobApplied(w http.ResponseWriter, r *http.Request) { writeJSON(w, map[string]string{"status": "applied"}) } +func (s *Server) handleMarkJobRebased(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + writeError(w, http.StatusMethodNotAllowed, "method not allowed") + return + } + + var req struct { + JobID int64 `json:"job_id"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "invalid request body") + return + } + if req.JobID == 0 { + writeError(w, http.StatusBadRequest, "job_id is required") + return + } + + if err := s.db.MarkJobRebased(req.JobID); err != nil { + if errors.Is(err, sql.ErrNoRows) { + writeError(w, http.StatusNotFound, "job not found or not in done state") + return + } + writeError(w, http.StatusInternalServerError, fmt.Sprintf("mark rebased: %v", err)) + return + } + + writeJSON(w, map[string]string{"status": "rebased"}) +} + // buildFixPrompt constructs a prompt for fixing review findings. func buildFixPrompt(reviewOutput string) string { return "# Fix Request\n\n" + diff --git a/internal/storage/jobs.go b/internal/storage/jobs.go index 8724d22b..35aa037a 100644 --- a/internal/storage/jobs.go +++ b/internal/storage/jobs.go @@ -1162,6 +1162,28 @@ func (db *DB) MarkJobApplied(jobID int64) error { return nil } +// MarkJobRebased transitions a done fix job to the "rebased" terminal state. +// This indicates the patch was stale and a new rebase job was triggered. +func (db *DB) MarkJobRebased(jobID int64) error { + now := time.Now().Format(time.RFC3339) + result, err := db.Exec(` + UPDATE review_jobs + SET status = 'rebased', updated_at = ? + WHERE id = ? AND status = 'done' + `, now, jobID) + if err != nil { + return err + } + rows, err := result.RowsAffected() + if err != nil { + return err + } + if rows == 0 { + return sql.ErrNoRows + } + return nil +} + // ReenqueueJob resets a completed, failed, or canceled job back to queued status. // This allows manual re-running of jobs to get a fresh review. // For done jobs, the existing review is deleted to avoid unique constraint violations. diff --git a/internal/storage/models.go b/internal/storage/models.go index a30ae96b..bb62c51c 100644 --- a/internal/storage/models.go +++ b/internal/storage/models.go @@ -32,6 +32,7 @@ const ( JobStatusFailed JobStatus = "failed" JobStatusCanceled JobStatus = "canceled" JobStatusApplied JobStatus = "applied" + JobStatusRebased JobStatus = "rebased" ) // JobType classifies what kind of work a review job represents. From f93935227edcfec36d2ad81f14df6123c11e3a83 Mon Sep 17 00:00:00 2001 From: Marius van Niekerk Date: Wed, 18 Feb 2026 12:29:08 -0500 Subject: [PATCH 27/49] refactor: add HasViewableOutput helper to simplify status checks Replaces repeated 3-way status comparisons (done/applied/rebased) with a single method on ReviewJob. Co-Authored-By: Claude Opus 4.6 --- cmd/roborev/tui_handlers.go | 18 +++++++++--------- internal/daemon/server.go | 2 +- internal/storage/models.go | 6 ++++++ 3 files changed, 16 insertions(+), 10 deletions(-) diff --git a/cmd/roborev/tui_handlers.go b/cmd/roborev/tui_handlers.go index e2f1bf68..6428d6fd 100644 --- a/cmd/roborev/tui_handlers.go +++ b/cmd/roborev/tui_handlers.go @@ -1484,8 +1484,8 @@ func (m tuiModel) handleTasksKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { // View task: prompt for running, review for done/applied, error for failed if len(m.fixJobs) > 0 && m.fixSelectedIdx < len(m.fixJobs) { job := m.fixJobs[m.fixSelectedIdx] - switch job.Status { - case storage.JobStatusRunning: + switch { + case job.Status == storage.JobStatusRunning: if job.Prompt != "" { m.currentReview = &storage.Review{ Agent: job.Agent, @@ -1507,11 +1507,11 @@ func (m tuiModel) handleTasksKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { m.tailFromView = tuiViewTasks m.currentView = tuiViewTail return m, tea.Batch(tea.ClearScreen, m.fetchTailOutput(job.ID)) - case storage.JobStatusDone, storage.JobStatusApplied, storage.JobStatusRebased: + case job.HasViewableOutput(): m.selectedJobID = job.ID m.reviewFromView = tuiViewTasks return m, m.fetchReview(job.ID) - case storage.JobStatusFailed: + case job.Status == storage.JobStatusFailed: errMsg := job.Error if errMsg == "" { errMsg = "unknown error" @@ -1526,8 +1526,8 @@ func (m tuiModel) handleTasksKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { // Tail output: live for running jobs, review for done, error for failed if len(m.fixJobs) > 0 && m.fixSelectedIdx < len(m.fixJobs) { job := m.fixJobs[m.fixSelectedIdx] - switch job.Status { - case storage.JobStatusRunning: + switch { + case job.Status == storage.JobStatusRunning: m.tailJobID = job.ID m.tailLines = nil m.tailScroll = 0 @@ -1536,11 +1536,11 @@ func (m tuiModel) handleTasksKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { m.tailFromView = tuiViewTasks m.currentView = tuiViewTail return m, tea.Batch(tea.ClearScreen, m.fetchTailOutput(job.ID)) - case storage.JobStatusDone, storage.JobStatusApplied, storage.JobStatusRebased: + case job.HasViewableOutput(): m.selectedJobID = job.ID m.reviewFromView = tuiViewTasks return m, m.fetchReview(job.ID) - case storage.JobStatusFailed: + case job.Status == storage.JobStatusFailed: errMsg := job.Error if errMsg == "" { errMsg = "unknown error" @@ -1587,7 +1587,7 @@ func (m tuiModel) handleTasksKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { // View patch for completed fix jobs if len(m.fixJobs) > 0 && m.fixSelectedIdx < len(m.fixJobs) { job := m.fixJobs[m.fixSelectedIdx] - if job.Status == storage.JobStatusDone || job.Status == storage.JobStatusApplied || job.Status == storage.JobStatusRebased { + if job.HasViewableOutput() { return m, m.fetchPatch(job.ID) } } diff --git a/internal/daemon/server.go b/internal/daemon/server.go index 32be8c57..e1ffedf7 100644 --- a/internal/daemon/server.go +++ b/internal/daemon/server.go @@ -1740,7 +1740,7 @@ func (s *Server) handleGetPatch(w http.ResponseWriter, r *http.Request) { return } - if (job.Status != storage.JobStatusDone && job.Status != storage.JobStatusApplied && job.Status != storage.JobStatusRebased) || job.Patch == nil { + if !job.HasViewableOutput() || job.Patch == nil { writeError(w, http.StatusNotFound, "no patch available for this job") return } diff --git a/internal/storage/models.go b/internal/storage/models.go index bb62c51c..513e6e45 100644 --- a/internal/storage/models.go +++ b/internal/storage/models.go @@ -131,6 +131,12 @@ func (j ReviewJob) IsFixJob() bool { return j.JobType == JobTypeFix } +// HasViewableOutput returns true if this job has completed and its review/patch +// can be viewed. This covers done, applied, and rebased terminal states. +func (j ReviewJob) HasViewableOutput() bool { + return j.Status == JobStatusDone || j.Status == JobStatusApplied || j.Status == JobStatusRebased +} + // JobWithReview pairs a job with its review for batch operations type JobWithReview struct { Job ReviewJob `json:"job"` From 8bb6154a3491f468ee4d37efd37dbd1211ae0f04 Mon Sep 17 00:00:00 2001 From: Marius van Niekerk Date: Wed, 18 Feb 2026 12:33:18 -0500 Subject: [PATCH 28/49] fix: apply roborev fix for b83dcf5 (job #144) --- cmd/roborev/tui.go | 35 +++++++++++++++++++++++++++++++---- 1 file changed, 31 insertions(+), 4 deletions(-) diff --git a/cmd/roborev/tui.go b/cmd/roborev/tui.go index 30a7fcc0..d6a24fd1 100644 --- a/cmd/roborev/tui.go +++ b/cmd/roborev/tui.go @@ -3799,7 +3799,7 @@ func (m tuiModel) applyFixPatch(jobID int64) tea.Cmd { } commitMsg = fmt.Sprintf("fix: apply roborev fix for %s (job #%d)", ref, jobID) } - if err := commitPatch(jobDetail.RepoPath, commitMsg); err != nil { + if err := commitPatch(jobDetail.RepoPath, patch, commitMsg); err != nil { return tuiApplyPatchResultMsg{jobID: jobID, parentJobID: parentJobID, success: true, err: fmt.Errorf("patch applied but commit failed: %w", err)} } @@ -3811,9 +3811,14 @@ func (m tuiModel) applyFixPatch(jobID int64) tea.Cmd { } } -// commitPatch stages all changes and commits them with the given message. -func commitPatch(repoPath, message string) error { - addCmd := exec.Command("git", "-C", repoPath, "add", "-A") +// commitPatch stages only the files touched by patch and commits them. +func commitPatch(repoPath, patch, message string) error { + files := patchFiles(patch) + if len(files) == 0 { + return fmt.Errorf("no files found in patch") + } + args := append([]string{"-C", repoPath, "add", "--"}, files...) + addCmd := exec.Command("git", args...) if out, err := addCmd.CombinedOutput(); err != nil { return fmt.Errorf("git add: %w: %s", err, out) } @@ -3824,6 +3829,28 @@ func commitPatch(repoPath, message string) error { return nil } +// patchFiles extracts the list of file paths touched by a unified diff. +func patchFiles(patch string) []string { + seen := map[string]bool{} + for _, line := range strings.Split(patch, "\n") { + // Match "diff --git a/path b/path" headers + if strings.HasPrefix(line, "diff --git ") { + parts := strings.SplitN(line, " b/", 2) + if len(parts) == 2 { + f := parts[1] + if f != "" && !seen[f] { + seen[f] = true + } + } + } + } + files := make([]string, 0, len(seen)) + for f := range seen { + files = append(files, f) + } + return files +} + // triggerRebase triggers a new fix job that re-applies a stale patch to the current HEAD. // The server looks up the stale patch from the DB, avoiding large client-to-server transfers. func (m tuiModel) triggerRebase(staleJobID int64) tea.Cmd { From 8bf7fac88f73b418eae11a222d57538ae2b0a759 Mon Sep 17 00:00:00 2001 From: Marius van Niekerk Date: Wed, 18 Feb 2026 12:36:13 -0500 Subject: [PATCH 29/49] fix: stop silently discarding errors in apply, rebase, and addressed flows Surface postJSON errors for mark-applied, mark-rebased, and mark-parent-addressed calls so failures are visible in the TUI flash message instead of silently ignored. Co-Authored-By: Claude Opus 4.6 --- cmd/roborev/tui.go | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/cmd/roborev/tui.go b/cmd/roborev/tui.go index d6a24fd1..95fe5a92 100644 --- a/cmd/roborev/tui.go +++ b/cmd/roborev/tui.go @@ -1327,10 +1327,12 @@ func (m tuiModel) toggleAddressedForJob(jobID int64, currentState *bool) tea.Cmd } // markParentAddressed marks the parent review job as addressed after a fix is applied. -// Runs as a fire-and-forget command; errors are silently ignored since the fix already succeeded. func (m tuiModel) markParentAddressed(parentJobID int64) tea.Cmd { return func() tea.Msg { - _ = m.postAddressed(parentJobID, true, "") + err := m.postAddressed(parentJobID, true, "parent review not found") + if err != nil { + return tuiErrMsg(err) + } return nil } } @@ -2116,7 +2118,9 @@ func (m tuiModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.flashExpiresAt = time.Now().Add(5 * time.Second) m.flashView = tuiViewTasks // Mark stale job as rebased and trigger rebase - _ = m.postJSON("/api/job/rebased", map[string]any{"job_id": msg.jobID}, nil) + if err := m.postJSON("/api/job/rebased", map[string]any{"job_id": msg.jobID}, nil); err != nil { + m.flashMessage = fmt.Sprintf("Rebase triggered but failed to mark job #%d as rebased: %v", msg.jobID, err) + } return m, tea.Batch(m.triggerRebase(msg.jobID), m.fetchFixJobs()) } else if msg.success && msg.err != nil { // Patch applied but commit failed @@ -3805,7 +3809,10 @@ func (m tuiModel) applyFixPatch(jobID int64) tea.Cmd { } // Mark the fix job as applied on the server - _ = m.postJSON("/api/job/applied", map[string]any{"job_id": jobID}, nil) + if err := m.postJSON("/api/job/applied", map[string]any{"job_id": jobID}, nil); err != nil { + return tuiApplyPatchResultMsg{jobID: jobID, parentJobID: parentJobID, success: true, + err: fmt.Errorf("patch applied and committed but failed to mark applied: %w", err)} + } return tuiApplyPatchResultMsg{jobID: jobID, parentJobID: parentJobID, success: true} } From 5977bc069086656001f75f139e6dc07837d88968 Mon Sep 17 00:00:00 2001 From: Marius van Niekerk Date: Wed, 18 Feb 2026 12:42:48 -0500 Subject: [PATCH 30/49] fix: add applied/rebased to CHECK constraint with DB migration The CHECK constraint on review_jobs.status only allowed the original 5 statuses, silently rejecting applied/rebased transitions. Add a migration that rebuilds the table with the updated constraint. Also adds tests for MarkJobApplied and MarkJobRebased, and verifies the migration path from the old schema supports the new statuses. Co-Authored-By: Claude Opus 4.6 --- internal/storage/db.go | 120 +++++++++++++++++++++++++++++++++- internal/storage/db_test.go | 96 +++++++++++++++++++++++++++ internal/storage/sync_test.go | 4 +- 3 files changed, 217 insertions(+), 3 deletions(-) diff --git a/internal/storage/db.go b/internal/storage/db.go index 25b1ff05..f009fc6f 100644 --- a/internal/storage/db.go +++ b/internal/storage/db.go @@ -40,7 +40,7 @@ CREATE TABLE IF NOT EXISTS review_jobs ( agent TEXT NOT NULL DEFAULT 'codex', model TEXT, reasoning TEXT NOT NULL DEFAULT 'thorough', - status TEXT NOT NULL CHECK(status IN ('queued','running','done','failed','canceled')) DEFAULT 'queued', + status TEXT NOT NULL CHECK(status IN ('queued','running','done','failed','canceled','applied','rebased')) DEFAULT 'queued', enqueued_at TEXT NOT NULL DEFAULT (datetime('now')), started_at TEXT, finished_at TEXT, @@ -312,7 +312,7 @@ func (db *DB) migrate() error { agent TEXT NOT NULL DEFAULT 'codex', model TEXT, reasoning TEXT NOT NULL DEFAULT 'thorough', - status TEXT NOT NULL CHECK(status IN ('queued','running','done','failed','canceled')) DEFAULT 'queued', + status TEXT NOT NULL CHECK(status IN ('queued','running','done','failed','canceled','applied','rebased')) DEFAULT 'queued', enqueued_at TEXT NOT NULL DEFAULT (datetime('now')), started_at TEXT, finished_at TEXT, @@ -430,6 +430,18 @@ func (db *DB) migrate() error { return fmt.Errorf("create branch index: %w", err) } + // Migration: update CHECK constraint to include 'applied' and 'rebased' statuses + // Re-read the table SQL since the previous migration may have rebuilt it + err = db.QueryRow(`SELECT sql FROM sqlite_master WHERE type='table' AND name='review_jobs'`).Scan(&tableSql) + if err != nil { + return fmt.Errorf("check review_jobs schema for applied/rebased: %w", err) + } + if !strings.Contains(tableSql, "'applied'") { + if err := db.migrateJobStatusConstraint(); err != nil { + return fmt.Errorf("migrate job status constraint: %w", err) + } + } + // Migration: make commit_id nullable in responses table (for job-based responses) // Check if commit_id is NOT NULL by examining the schema var responsesSql string @@ -686,6 +698,110 @@ func (db *DB) hasUniqueIndexOnShaOnly() (bool, error) { return false, rows.Err() } +// migrateJobStatusConstraint rebuilds the review_jobs table to update the +// CHECK constraint to include 'applied' and 'rebased' statuses. +func (db *DB) migrateJobStatusConstraint() error { + ctx := context.Background() + conn, err := db.Conn(ctx) + if err != nil { + return fmt.Errorf("get connection: %w", err) + } + defer conn.Close() + + if _, err := conn.ExecContext(ctx, `PRAGMA foreign_keys = OFF`); err != nil { + return fmt.Errorf("disable foreign keys: %w", err) + } + defer func() { _, _ = conn.ExecContext(ctx, `PRAGMA foreign_keys = ON`) }() + + tx, err := conn.BeginTx(ctx, nil) + if err != nil { + return err + } + defer func() { + if err := tx.Rollback(); err != nil && err != sql.ErrTxDone { + return + } + }() + + // Read existing columns dynamically + rows, err := tx.Query(`SELECT name FROM pragma_table_info('review_jobs')`) + if err != nil { + return fmt.Errorf("read columns: %w", err) + } + var cols []string + for rows.Next() { + var name string + if err := rows.Scan(&name); err != nil { + rows.Close() + return err + } + cols = append(cols, name) + } + rows.Close() + + // Read the current CREATE TABLE SQL and replace the old constraint + var origSQL string + if err := tx.QueryRow(`SELECT sql FROM sqlite_master WHERE type='table' AND name='review_jobs'`).Scan(&origSQL); err != nil { + return err + } + + // Replace old constraint with new one including applied and rebased + newSQL := strings.Replace(origSQL, + "CHECK(status IN ('queued','running','done','failed','canceled'))", + "CHECK(status IN ('queued','running','done','failed','canceled','applied','rebased'))", + 1) + // Handle the table name for the temp table + newSQL = strings.Replace(newSQL, "CREATE TABLE review_jobs", "CREATE TABLE review_jobs_new", 1) + + if _, err := tx.Exec(newSQL); err != nil { + return fmt.Errorf("create new table: %w", err) + } + + colList := strings.Join(cols, ", ") + if _, err := tx.Exec(fmt.Sprintf(`INSERT INTO review_jobs_new (%s) SELECT %s FROM review_jobs`, colList, colList)); err != nil { + return fmt.Errorf("copy data: %w", err) + } + + if _, err := tx.Exec(`DROP TABLE review_jobs`); err != nil { + return fmt.Errorf("drop old table: %w", err) + } + + if _, err := tx.Exec(`ALTER TABLE review_jobs_new RENAME TO review_jobs`); err != nil { + return fmt.Errorf("rename table: %w", err) + } + + // Recreate indexes + for _, idx := range []string{ + `CREATE INDEX IF NOT EXISTS idx_review_jobs_status ON review_jobs(status)`, + `CREATE INDEX IF NOT EXISTS idx_review_jobs_repo ON review_jobs(repo_id)`, + `CREATE INDEX IF NOT EXISTS idx_review_jobs_git_ref ON review_jobs(git_ref)`, + `CREATE INDEX IF NOT EXISTS idx_review_jobs_branch ON review_jobs(branch)`, + `CREATE UNIQUE INDEX IF NOT EXISTS idx_review_jobs_uuid ON review_jobs(uuid)`, + } { + if _, err := tx.Exec(idx); err != nil { + return fmt.Errorf("recreate index: %w", err) + } + } + + if err := tx.Commit(); err != nil { + return err + } + + if _, err := conn.ExecContext(ctx, `PRAGMA foreign_keys = ON`); err != nil { + return fmt.Errorf("re-enable foreign keys: %w", err) + } + + checkRows, err := conn.QueryContext(ctx, `PRAGMA foreign_key_check`) + if err != nil { + return fmt.Errorf("foreign key check: %w", err) + } + defer checkRows.Close() + if checkRows.Next() { + return fmt.Errorf("foreign key violations after migration") + } + return checkRows.Err() +} + // migrateSyncColumns adds columns needed for PostgreSQL sync functionality. // These migrations are idempotent - they check if columns exist before adding. func (db *DB) migrateSyncColumns() error { diff --git a/internal/storage/db_test.go b/internal/storage/db_test.go index 00619137..78d0f1a7 100644 --- a/internal/storage/db_test.go +++ b/internal/storage/db_test.go @@ -1222,6 +1222,84 @@ func TestCancelJob(t *testing.T) { }) } +func TestMarkJobApplied(t *testing.T) { + db := openTestDB(t) + defer db.Close() + + repo, _ := db.GetOrCreateRepo("/tmp/test-repo") + commit, _ := db.GetOrCreateCommit(repo.ID, "applied-test", "A", "S", time.Now()) + + t.Run("mark done job as applied", func(t *testing.T) { + job, _ := db.EnqueueJob(EnqueueOpts{RepoID: repo.ID, CommitID: commit.ID, GitRef: "applied-test", Agent: "codex"}) + db.ClaimJob("worker-1") + db.CompleteJob(job.ID, "codex", "prompt", "output") + + err := db.MarkJobApplied(job.ID) + if err != nil { + t.Fatalf("MarkJobApplied failed: %v", err) + } + + updated, _ := db.GetJobByID(job.ID) + if updated.Status != JobStatusApplied { + t.Errorf("Expected status 'applied', got '%s'", updated.Status) + } + }) + + t.Run("mark non-done job fails", func(t *testing.T) { + job, _ := db.EnqueueJob(EnqueueOpts{RepoID: repo.ID, CommitID: commit.ID, GitRef: "applied-test-q", Agent: "codex"}) + + err := db.MarkJobApplied(job.ID) + if err == nil { + t.Error("MarkJobApplied should fail for queued jobs") + } + }) + + t.Run("mark applied job again fails", func(t *testing.T) { + job, _ := db.EnqueueJob(EnqueueOpts{RepoID: repo.ID, CommitID: commit.ID, GitRef: "applied-test-2", Agent: "codex"}) + db.ClaimJob("worker-1") + db.CompleteJob(job.ID, "codex", "prompt", "output") + db.MarkJobApplied(job.ID) + + err := db.MarkJobApplied(job.ID) + if err == nil { + t.Error("MarkJobApplied should fail for already-applied jobs") + } + }) +} + +func TestMarkJobRebased(t *testing.T) { + db := openTestDB(t) + defer db.Close() + + repo, _ := db.GetOrCreateRepo("/tmp/test-repo") + commit, _ := db.GetOrCreateCommit(repo.ID, "rebased-test", "A", "S", time.Now()) + + t.Run("mark done job as rebased", func(t *testing.T) { + job, _ := db.EnqueueJob(EnqueueOpts{RepoID: repo.ID, CommitID: commit.ID, GitRef: "rebased-test", Agent: "codex"}) + db.ClaimJob("worker-1") + db.CompleteJob(job.ID, "codex", "prompt", "output") + + err := db.MarkJobRebased(job.ID) + if err != nil { + t.Fatalf("MarkJobRebased failed: %v", err) + } + + updated, _ := db.GetJobByID(job.ID) + if updated.Status != JobStatusRebased { + t.Errorf("Expected status 'rebased', got '%s'", updated.Status) + } + }) + + t.Run("mark non-done job fails", func(t *testing.T) { + job, _ := db.EnqueueJob(EnqueueOpts{RepoID: repo.ID, CommitID: commit.ID, GitRef: "rebased-test-q", Agent: "codex"}) + + err := db.MarkJobRebased(job.ID) + if err == nil { + t.Error("MarkJobRebased should fail for queued jobs") + } + }) +} + func TestMigrationFromOldSchema(t *testing.T) { tmpDir := t.TempDir() dbPath := filepath.Join(tmpDir, "old.db") @@ -1354,6 +1432,24 @@ func TestMigrationFromOldSchema(t *testing.T) { t.Errorf("Expected status 'canceled', got '%s'", status) } + // Verify 'applied' and 'rebased' statuses work after migration + _, err = db.Exec(`UPDATE review_jobs SET status = 'done' WHERE id = ?`, jobID) + if err != nil { + t.Fatalf("Failed to set done status: %v", err) + } + _, err = db.Exec(`UPDATE review_jobs SET status = 'applied' WHERE id = ?`, jobID) + if err != nil { + t.Fatalf("Setting applied status failed after migration: %v", err) + } + _, err = db.Exec(`UPDATE review_jobs SET status = 'done' WHERE id = ?`, jobID) + if err != nil { + t.Fatalf("Failed to reset to done: %v", err) + } + _, err = db.Exec(`UPDATE review_jobs SET status = 'rebased' WHERE id = ?`, jobID) + if err != nil { + t.Fatalf("Setting rebased status failed after migration: %v", err) + } + // Verify constraint still rejects invalid status _, err = db.Exec(`UPDATE review_jobs SET status = 'invalid' WHERE id = ?`, jobID) if err == nil { diff --git a/internal/storage/sync_test.go b/internal/storage/sync_test.go index a18c7c1f..0f08e0ac 100644 --- a/internal/storage/sync_test.go +++ b/internal/storage/sync_test.go @@ -659,7 +659,7 @@ func createLegacyCommonTables(t *testing.T, db *sql.DB) { git_ref TEXT NOT NULL, agent TEXT NOT NULL DEFAULT 'codex', reasoning TEXT NOT NULL DEFAULT 'thorough', - status TEXT NOT NULL CHECK(status IN ('queued','running','done','failed','canceled')) DEFAULT 'queued', + status TEXT NOT NULL CHECK(status IN ('queued','running','done','failed','canceled','applied','rebased')) DEFAULT 'queued', enqueued_at TEXT NOT NULL DEFAULT (datetime('now')), started_at TEXT, finished_at TEXT, @@ -822,6 +822,7 @@ func TestDuplicateRepoIdentity_MigrationSuccess(t *testing.T) { created_at TEXT NOT NULL DEFAULT (datetime('now')), UNIQUE(repo_id, sha) ); + CREATE INDEX idx_commits_sha ON commits(sha); `) if err != nil { @@ -892,6 +893,7 @@ func TestUniqueIndexMigration(t *testing.T) { created_at TEXT NOT NULL DEFAULT (datetime('now')), UNIQUE(repo_id, sha) ); + CREATE INDEX idx_commits_sha ON commits(sha); `) if err != nil { From 9ffa7029e391b6e5ad6f6bdc8f0519ee61d96141 Mon Sep 17 00:00:00 2001 From: Marius van Niekerk Date: Wed, 18 Feb 2026 12:52:43 -0500 Subject: [PATCH 31/49] refactor: use sourcegraph/go-diff for patch file extraction Replace hand-rolled string parsing in patchFiles with the standard go-diff library for parsing unified diffs. Co-Authored-By: Claude Opus 4.6 --- cmd/roborev/tui.go | 25 +++++++++++++++---------- go.mod | 1 + go.sum | 6 ++++++ 3 files changed, 22 insertions(+), 10 deletions(-) diff --git a/cmd/roborev/tui.go b/cmd/roborev/tui.go index 95fe5a92..1a86a2ac 100644 --- a/cmd/roborev/tui.go +++ b/cmd/roborev/tui.go @@ -32,6 +32,7 @@ import ( "github.com/roborev-dev/roborev/internal/update" "github.com/roborev-dev/roborev/internal/version" "github.com/roborev-dev/roborev/internal/worktree" + godiff "github.com/sourcegraph/go-diff/diff" "github.com/spf13/cobra" ) @@ -3838,17 +3839,21 @@ func commitPatch(repoPath, patch, message string) error { // patchFiles extracts the list of file paths touched by a unified diff. func patchFiles(patch string) []string { + fileDiffs, err := godiff.ParseMultiFileDiff([]byte(patch)) + if err != nil { + return nil + } seen := map[string]bool{} - for _, line := range strings.Split(patch, "\n") { - // Match "diff --git a/path b/path" headers - if strings.HasPrefix(line, "diff --git ") { - parts := strings.SplitN(line, " b/", 2) - if len(parts) == 2 { - f := parts[1] - if f != "" && !seen[f] { - seen[f] = true - } - } + for _, fd := range fileDiffs { + name := fd.NewName + if name == "" || name == "/dev/null" { + name = fd.OrigName + } + // Strip the a/ or b/ prefix that go-diff preserves + name = strings.TrimPrefix(name, "a/") + name = strings.TrimPrefix(name, "b/") + if name != "" && name != "/dev/null" && !seen[name] { + seen[name] = true } } files := make([]string, 0, len(seen)) diff --git a/go.mod b/go.mod index 326357b3..8d29d721 100644 --- a/go.mod +++ b/go.mod @@ -47,6 +47,7 @@ require ( github.com/ncruces/go-strftime v1.0.0 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/rivo/uniseg v0.4.7 // indirect + github.com/sourcegraph/go-diff v0.7.0 // indirect github.com/spf13/pflag v1.0.9 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect github.com/yuin/goldmark v1.7.8 // indirect diff --git a/go.sum b/go.sum index b70fb42b..e9c862d8 100644 --- a/go.sum +++ b/go.sum @@ -50,6 +50,7 @@ github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6 github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= +github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= @@ -100,6 +101,10 @@ github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJ github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/shurcooL/go v0.0.0-20180423040247-9e1955d9fb6e/go.mod h1:TDJrrUr11Vxrven61rcy3hJMUqaf/CLWYhHNPmT14Lk= +github.com/shurcooL/go-goon v0.0.0-20170922171312-37c2f522c041/go.mod h1:N5mDOmsrJOB+vfqUK+7DmDyjhSLIIBnXo9lvZJj3MWQ= +github.com/sourcegraph/go-diff v0.7.0 h1:9uLlrd5T46OXs5qpp8L/MTltk0zikUGi0sNNyCpA8G0= +github.com/sourcegraph/go-diff v0.7.0/go.mod h1:iBszgVvyxdc8SFZ7gm69go2KDdt3ag071iBaWPF6cjs= github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= @@ -135,6 +140,7 @@ golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc= golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= From 2aaacfde9a30ae653542a23b4f6b386a0abed892 Mon Sep 17 00:00:00 2001 From: Marius van Niekerk Date: Wed, 18 Feb 2026 12:54:45 -0500 Subject: [PATCH 32/49] fix: handle renames in patchFiles by adding both old and new paths For file moves/renames, git add needs both the old path (to stage the deletion) and the new path (to stage the addition). Co-Authored-By: Claude Opus 4.6 --- cmd/roborev/tui.go | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/cmd/roborev/tui.go b/cmd/roborev/tui.go index 1a86a2ac..c763e0c6 100644 --- a/cmd/roborev/tui.go +++ b/cmd/roborev/tui.go @@ -3844,18 +3844,17 @@ func patchFiles(patch string) []string { return nil } seen := map[string]bool{} - for _, fd := range fileDiffs { - name := fd.NewName - if name == "" || name == "/dev/null" { - name = fd.OrigName - } - // Strip the a/ or b/ prefix that go-diff preserves + addFile := func(name string) { name = strings.TrimPrefix(name, "a/") name = strings.TrimPrefix(name, "b/") - if name != "" && name != "/dev/null" && !seen[name] { + if name != "" && name != "/dev/null" { seen[name] = true } } + for _, fd := range fileDiffs { + addFile(fd.OrigName) // old path (stages deletion for renames) + addFile(fd.NewName) // new path (stages addition for renames) + } files := make([]string, 0, len(seen)) for f := range seen { files = append(files, f) From d85923ea5e3c7a24e9c9dfc87a930f4fce9f4582 Mon Sep 17 00:00:00 2001 From: Marius van Niekerk Date: Wed, 18 Feb 2026 13:08:27 -0500 Subject: [PATCH 33/49] fix: apply roborev fix for 81eb103 (job #149) --- cmd/roborev/tui.go | 28 ++++++++++++++++------------ cmd/roborev/tui_handlers.go | 6 +++++- internal/worktree/worktree.go | 9 +++++++-- 3 files changed, 28 insertions(+), 15 deletions(-) diff --git a/cmd/roborev/tui.go b/cmd/roborev/tui.go index c763e0c6..0f0f0d31 100644 --- a/cmd/roborev/tui.go +++ b/cmd/roborev/tui.go @@ -259,14 +259,15 @@ type tuiModel struct { reviewFromView tuiView // View to return to when exiting review (queue or tasks) // Fix task state - fixJobs []storage.ReviewJob // Fix jobs for tasks view - fixSelectedIdx int // Selected index in tasks view - fixPromptText string // Editable fix prompt text - fixPromptJobID int64 // Parent job ID for fix prompt modal - fixShowHelp bool // Show help overlay in tasks view - patchText string // Current patch text for patch viewer - patchScroll int // Scroll offset in patch viewer - patchJobID int64 // Job ID of the patch being viewed + fixJobs []storage.ReviewJob // Fix jobs for tasks view + fixSelectedIdx int // Selected index in tasks view + fixPromptText string // Editable fix prompt text + fixPromptJobID int64 // Parent job ID for fix prompt modal + fixPromptFromView tuiView // View to return to after fix prompt closes + fixShowHelp bool // Show help overlay in tasks view + patchText string // Current patch text for patch viewer + patchScroll int // Scroll offset in patch viewer + patchJobID int64 // Job ID of the patch being viewed } // pendingState tracks a pending addressed toggle with sequence number @@ -3821,7 +3822,10 @@ func (m tuiModel) applyFixPatch(jobID int64) tea.Cmd { // commitPatch stages only the files touched by patch and commits them. func commitPatch(repoPath, patch, message string) error { - files := patchFiles(patch) + files, err := patchFiles(patch) + if err != nil { + return err + } if len(files) == 0 { return fmt.Errorf("no files found in patch") } @@ -3838,10 +3842,10 @@ func commitPatch(repoPath, patch, message string) error { } // patchFiles extracts the list of file paths touched by a unified diff. -func patchFiles(patch string) []string { +func patchFiles(patch string) ([]string, error) { fileDiffs, err := godiff.ParseMultiFileDiff([]byte(patch)) if err != nil { - return nil + return nil, fmt.Errorf("parse patch: %w", err) } seen := map[string]bool{} addFile := func(name string) { @@ -3859,7 +3863,7 @@ func patchFiles(patch string) []string { for f := range seen { files = append(files, f) } - return files + return files, nil } // triggerRebase triggers a new fix job that re-applies a stale patch to the current HEAD. diff --git a/cmd/roborev/tui_handlers.go b/cmd/roborev/tui_handlers.go index 6428d6fd..0019b02c 100644 --- a/cmd/roborev/tui_handlers.go +++ b/cmd/roborev/tui_handlers.go @@ -1410,6 +1410,7 @@ func (m tuiModel) handleFixKey() (tea.Model, tea.Cmd) { // Open fix prompt modal m.fixPromptJobID = job.ID m.fixPromptText = "" // Empty means use default prompt from server + m.fixPromptFromView = m.currentView m.currentView = tuiViewFixPrompt return m, nil } @@ -1433,7 +1434,7 @@ func (m tuiModel) handleFixPromptKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { case "ctrl+c": return m, tea.Quit case "esc": - m.currentView = tuiViewQueue + m.currentView = m.fixPromptFromView m.fixPromptText = "" m.fixPromptJobID = 0 return m, nil @@ -1590,6 +1591,9 @@ func (m tuiModel) handleTasksKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { if job.HasViewableOutput() { return m, m.fetchPatch(job.ID) } + m.flashMessage = "Patch not yet available" + m.flashExpiresAt = time.Now().Add(2 * time.Second) + m.flashView = m.currentView } return m, nil case "r": diff --git a/internal/worktree/worktree.go b/internal/worktree/worktree.go index d22bedbf..1e3a5329 100644 --- a/internal/worktree/worktree.go +++ b/internal/worktree/worktree.go @@ -5,6 +5,7 @@ import ( "bytes" "fmt" "io/fs" + "log" "os" "os/exec" "path/filepath" @@ -99,11 +100,15 @@ func (w *Worktree) CapturePatch() (string, error) { // Create a temporary tree object from the index (staged state) treeCmd := exec.Command("git", "-C", w.Dir, "write-tree") treeOut, err := treeCmd.Output() - if err == nil { + if err != nil { + log.Printf("CapturePatch: write-tree failed, falling back to diff --cached: %v", err) + } else { tree := strings.TrimSpace(string(treeOut)) diffCmd := exec.Command("git", "-C", w.Dir, "diff-tree", "-p", "--binary", w.baseSHA, tree) diff, err := diffCmd.Output() - if err == nil && len(diff) > 0 { + if err != nil { + log.Printf("CapturePatch: diff-tree failed, falling back to diff --cached: %v", err) + } else if len(diff) > 0 { return string(diff), nil } } From f8a06da41ded991b7bcbe5a837d4ea2bc70a3ed0 Mon Sep 17 00:00:00 2001 From: Marius van Niekerk Date: Wed, 18 Feb 2026 13:22:40 -0500 Subject: [PATCH 34/49] fix: add missing SQL placeholder in EnqueueJob after rebase merge The merge resolution for patch_id + parent_job_id added a column but missed adding the corresponding ? placeholder in VALUES. Co-Authored-By: Claude Opus 4.6 --- internal/storage/jobs.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/storage/jobs.go b/internal/storage/jobs.go index 35aa037a..c3803df5 100644 --- a/internal/storage/jobs.go +++ b/internal/storage/jobs.go @@ -790,7 +790,7 @@ func (db *DB) EnqueueJob(opts EnqueueOpts) (*ReviewJob, error) { INSERT INTO review_jobs (repo_id, commit_id, git_ref, branch, agent, model, reasoning, status, job_type, review_type, patch_id, diff_content, prompt, agentic, output_prefix, parent_job_id, uuid, source_machine_id, updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?, 'queued', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + VALUES (?, ?, ?, ?, ?, ?, ?, 'queued', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, opts.RepoID, commitIDParam, gitRef, nullString(opts.Branch), opts.Agent, nullString(opts.Model), reasoning, jobType, opts.ReviewType, nullString(opts.PatchID), From 47989c2e1723f6a616d764d80000efa7c922feab Mon Sep 17 00:00:00 2001 From: Marius van Niekerk Date: Thu, 19 Feb 2026 00:04:31 -0500 Subject: [PATCH 35/49] fix: enforce job_type=fix for applied/rebased, check dirty files before apply - MarkJobApplied and MarkJobRebased now require job_type='fix' in the WHERE clause, preventing non-fix jobs from reaching fix-only terminal states. - Before applying a patch, check if any patch-touched files have uncommitted changes and abort with a clear error if so, preventing unrelated edits from being included in the fix commit. Co-Authored-By: Claude Opus 4.6 --- cmd/roborev/tui.go | 33 +++++++++++++++++++++++++++++++++ internal/storage/db_test.go | 36 +++++++++++++++++++++++++++++------- internal/storage/jobs.go | 4 ++-- 3 files changed, 64 insertions(+), 9 deletions(-) diff --git a/cmd/roborev/tui.go b/cmd/roborev/tui.go index 0f0f0d31..e8ca0593 100644 --- a/cmd/roborev/tui.go +++ b/cmd/roborev/tui.go @@ -3777,6 +3777,16 @@ func (m tuiModel) applyFixPatch(jobID int64) tea.Cmd { return tuiApplyPatchResultMsg{jobID: jobID, err: jErr} } + // Check for uncommitted changes in files the patch touches + patchedFiles, pfErr := patchFiles(patch) + if pfErr != nil { + return tuiApplyPatchResultMsg{jobID: jobID, err: pfErr} + } + if dirty := dirtyPatchFiles(jobDetail.RepoPath, patchedFiles); len(dirty) > 0 { + return tuiApplyPatchResultMsg{jobID: jobID, + err: fmt.Errorf("uncommitted changes in patch files: %s — stash or commit first", strings.Join(dirty, ", "))} + } + // Dry-run check — only trigger rebase on actual merge conflicts if err := worktree.CheckPatch(jobDetail.RepoPath, patch); err != nil { var conflictErr *worktree.PatchConflictError @@ -3841,6 +3851,29 @@ func commitPatch(repoPath, patch, message string) error { return nil } +// dirtyPatchFiles returns the subset of files that have uncommitted changes. +func dirtyPatchFiles(repoPath string, files []string) []string { + // git diff --name-only shows unstaged changes; --cached shows staged + cmd := exec.Command("git", "-C", repoPath, "diff", "--name-only", "HEAD", "--") + out, err := cmd.Output() + if err != nil { + return nil + } + dirty := map[string]bool{} + for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") { + if line != "" { + dirty[line] = true + } + } + var overlap []string + for _, f := range files { + if dirty[f] { + overlap = append(overlap, f) + } + } + return overlap +} + // patchFiles extracts the list of file paths touched by a unified diff. func patchFiles(patch string) ([]string, error) { fileDiffs, err := godiff.ParseMultiFileDiff([]byte(patch)) diff --git a/internal/storage/db_test.go b/internal/storage/db_test.go index 78d0f1a7..03fca717 100644 --- a/internal/storage/db_test.go +++ b/internal/storage/db_test.go @@ -1229,8 +1229,8 @@ func TestMarkJobApplied(t *testing.T) { repo, _ := db.GetOrCreateRepo("/tmp/test-repo") commit, _ := db.GetOrCreateCommit(repo.ID, "applied-test", "A", "S", time.Now()) - t.Run("mark done job as applied", func(t *testing.T) { - job, _ := db.EnqueueJob(EnqueueOpts{RepoID: repo.ID, CommitID: commit.ID, GitRef: "applied-test", Agent: "codex"}) + t.Run("mark done fix job as applied", func(t *testing.T) { + job, _ := db.EnqueueJob(EnqueueOpts{RepoID: repo.ID, CommitID: commit.ID, GitRef: "applied-test", Agent: "codex", JobType: JobTypeFix, ParentJobID: 1}) db.ClaimJob("worker-1") db.CompleteJob(job.ID, "codex", "prompt", "output") @@ -1246,7 +1246,7 @@ func TestMarkJobApplied(t *testing.T) { }) t.Run("mark non-done job fails", func(t *testing.T) { - job, _ := db.EnqueueJob(EnqueueOpts{RepoID: repo.ID, CommitID: commit.ID, GitRef: "applied-test-q", Agent: "codex"}) + job, _ := db.EnqueueJob(EnqueueOpts{RepoID: repo.ID, CommitID: commit.ID, GitRef: "applied-test-q", Agent: "codex", JobType: JobTypeFix, ParentJobID: 1}) err := db.MarkJobApplied(job.ID) if err == nil { @@ -1255,7 +1255,7 @@ func TestMarkJobApplied(t *testing.T) { }) t.Run("mark applied job again fails", func(t *testing.T) { - job, _ := db.EnqueueJob(EnqueueOpts{RepoID: repo.ID, CommitID: commit.ID, GitRef: "applied-test-2", Agent: "codex"}) + job, _ := db.EnqueueJob(EnqueueOpts{RepoID: repo.ID, CommitID: commit.ID, GitRef: "applied-test-2", Agent: "codex", JobType: JobTypeFix, ParentJobID: 1}) db.ClaimJob("worker-1") db.CompleteJob(job.ID, "codex", "prompt", "output") db.MarkJobApplied(job.ID) @@ -1265,6 +1265,17 @@ func TestMarkJobApplied(t *testing.T) { t.Error("MarkJobApplied should fail for already-applied jobs") } }) + + t.Run("mark non-fix job fails", func(t *testing.T) { + job, _ := db.EnqueueJob(EnqueueOpts{RepoID: repo.ID, CommitID: commit.ID, GitRef: "applied-review", Agent: "codex"}) + db.ClaimJob("worker-1") + db.CompleteJob(job.ID, "codex", "prompt", "output") + + err := db.MarkJobApplied(job.ID) + if err == nil { + t.Error("MarkJobApplied should fail for non-fix jobs") + } + }) } func TestMarkJobRebased(t *testing.T) { @@ -1274,8 +1285,8 @@ func TestMarkJobRebased(t *testing.T) { repo, _ := db.GetOrCreateRepo("/tmp/test-repo") commit, _ := db.GetOrCreateCommit(repo.ID, "rebased-test", "A", "S", time.Now()) - t.Run("mark done job as rebased", func(t *testing.T) { - job, _ := db.EnqueueJob(EnqueueOpts{RepoID: repo.ID, CommitID: commit.ID, GitRef: "rebased-test", Agent: "codex"}) + t.Run("mark done fix job as rebased", func(t *testing.T) { + job, _ := db.EnqueueJob(EnqueueOpts{RepoID: repo.ID, CommitID: commit.ID, GitRef: "rebased-test", Agent: "codex", JobType: JobTypeFix, ParentJobID: 1}) db.ClaimJob("worker-1") db.CompleteJob(job.ID, "codex", "prompt", "output") @@ -1291,13 +1302,24 @@ func TestMarkJobRebased(t *testing.T) { }) t.Run("mark non-done job fails", func(t *testing.T) { - job, _ := db.EnqueueJob(EnqueueOpts{RepoID: repo.ID, CommitID: commit.ID, GitRef: "rebased-test-q", Agent: "codex"}) + job, _ := db.EnqueueJob(EnqueueOpts{RepoID: repo.ID, CommitID: commit.ID, GitRef: "rebased-test-q", Agent: "codex", JobType: JobTypeFix, ParentJobID: 1}) err := db.MarkJobRebased(job.ID) if err == nil { t.Error("MarkJobRebased should fail for queued jobs") } }) + + t.Run("mark non-fix job fails", func(t *testing.T) { + job, _ := db.EnqueueJob(EnqueueOpts{RepoID: repo.ID, CommitID: commit.ID, GitRef: "rebased-review", Agent: "codex"}) + db.ClaimJob("worker-1") + db.CompleteJob(job.ID, "codex", "prompt", "output") + + err := db.MarkJobRebased(job.ID) + if err == nil { + t.Error("MarkJobRebased should fail for non-fix jobs") + } + }) } func TestMigrationFromOldSchema(t *testing.T) { diff --git a/internal/storage/jobs.go b/internal/storage/jobs.go index c3803df5..aac849dd 100644 --- a/internal/storage/jobs.go +++ b/internal/storage/jobs.go @@ -1147,7 +1147,7 @@ func (db *DB) MarkJobApplied(jobID int64) error { result, err := db.Exec(` UPDATE review_jobs SET status = 'applied', updated_at = ? - WHERE id = ? AND status = 'done' + WHERE id = ? AND status = 'done' AND job_type = 'fix' `, now, jobID) if err != nil { return err @@ -1169,7 +1169,7 @@ func (db *DB) MarkJobRebased(jobID int64) error { result, err := db.Exec(` UPDATE review_jobs SET status = 'rebased', updated_at = ? - WHERE id = ? AND status = 'done' + WHERE id = ? AND status = 'done' AND job_type = 'fix' `, now, jobID) if err != nil { return err From 3ad3bf93fd8660c0aa41a04905d2928489e0906a Mon Sep 17 00:00:00 2001 From: Marius van Niekerk Date: Thu, 19 Feb 2026 08:12:23 -0500 Subject: [PATCH 36/49] fix: resolve post-rebase compilation errors from upstream test refactoring - Restore parseStreamJSON as package-level function (method receiver was unneeded since body doesn't use the receiver; fixes cursor.go and tests) - Add missing gitCommitFile and installGitHook helpers to refine_test.go (lost during conflict resolution with upstream #305 test refactoring) - Fix validateRefineContext call sites to include new cwd parameter Co-Authored-By: Claude Sonnet 4.6 --- cmd/roborev/refine_test.go | 34 ++++++++++++++++++++++++++++------ internal/agent/claude.go | 2 +- 2 files changed, 29 insertions(+), 7 deletions(-) diff --git a/cmd/roborev/refine_test.go b/cmd/roborev/refine_test.go index f429e66d..d0ff6a39 100644 --- a/cmd/roborev/refine_test.go +++ b/cmd/roborev/refine_test.go @@ -725,6 +725,28 @@ func TestFindPendingJobForBranch_OldestFirst(t *testing.T) { } } +func installGitHook(t *testing.T, repoDir, name, script string) { + t.Helper() + hooksDir := filepath.Join(repoDir, ".git", "hooks") + if err := os.MkdirAll(hooksDir, 0755); err != nil { + t.Fatal(err) + } + hookPath := filepath.Join(hooksDir, name) + if err := os.WriteFile(hookPath, []byte(script), 0755); err != nil { + t.Fatal(err) + } +} + +func gitCommitFile(t *testing.T, repoDir string, runGit func(...string) string, filename, content, msg string) string { + t.Helper() + if err := os.WriteFile(filepath.Join(repoDir, filename), []byte(content), 0644); err != nil { + t.Fatal(err) + } + runGit("add", filename) + runGit("commit", "-m", msg) + return runGit("rev-parse", "HEAD") +} + // setupTestGitRepo creates a git repo for testing branch/--since behavior. // Returns the repo directory, base commit SHA, and a helper to run git commands. func setupTestGitRepo(t *testing.T) (repoDir string, baseSHA string, runGit func(args ...string) string) { @@ -770,7 +792,7 @@ func TestValidateRefineContext_RefusesMainBranchWithoutSince(t *testing.T) { defer os.Chdir(origDir) // Validating without --since on main should fail - _, _, _, _, err = validateRefineContext("", "") + _, _, _, _, err = validateRefineContext("", "", "") if err == nil { t.Fatal("expected error when validating on main without --since") } @@ -802,7 +824,7 @@ func TestValidateRefineContext_AllowsMainBranchWithSince(t *testing.T) { defer os.Chdir(origDir) // Validating with --since on main should pass - repoPath, currentBranch, _, mergeBase, err := validateRefineContext(baseSHA, "") + repoPath, currentBranch, _, mergeBase, err := validateRefineContext("", baseSHA, "") if err != nil { t.Fatalf("validation should pass with --since on main, got: %v", err) } @@ -838,7 +860,7 @@ func TestValidateRefineContext_SinceWorksOnFeatureBranch(t *testing.T) { defer os.Chdir(origDir) // --since should work on feature branch - repoPath, currentBranch, _, mergeBase, err := validateRefineContext(baseSHA, "") + repoPath, currentBranch, _, mergeBase, err := validateRefineContext("", baseSHA, "") if err != nil { t.Fatalf("--since should work on feature branch, got: %v", err) } @@ -870,7 +892,7 @@ func TestValidateRefineContext_InvalidSinceRef(t *testing.T) { defer os.Chdir(origDir) // Invalid --since ref should fail with clear error - _, _, _, _, err = validateRefineContext("nonexistent-ref-abc123", "") + _, _, _, _, err = validateRefineContext("", "nonexistent-ref-abc123", "") if err == nil { t.Fatal("expected error for invalid --since ref") } @@ -904,7 +926,7 @@ func TestValidateRefineContext_SinceNotAncestorOfHEAD(t *testing.T) { defer os.Chdir(origDir) // Using --since with a commit from a different branch (not ancestor of HEAD) should fail - _, _, _, _, err = validateRefineContext(otherBranchSHA, "") + _, _, _, _, err = validateRefineContext("", otherBranchSHA, "") if err == nil { t.Fatal("expected error when --since is not an ancestor of HEAD") } @@ -934,7 +956,7 @@ func TestValidateRefineContext_FeatureBranchWithoutSinceStillWorks(t *testing.T) defer os.Chdir(origDir) // Feature branch without --since should pass validation (uses merge-base) - repoPath, currentBranch, _, mergeBase, err := validateRefineContext("", "") + repoPath, currentBranch, _, mergeBase, err := validateRefineContext("", "", "") if err != nil { t.Fatalf("feature branch without --since should work, got: %v", err) } diff --git a/internal/agent/claude.go b/internal/agent/claude.go index bc17c1d5..2175d395 100644 --- a/internal/agent/claude.go +++ b/internal/agent/claude.go @@ -210,7 +210,7 @@ type claudeStreamMessage struct { // Uses bufio.Reader.ReadString to read lines without buffer size limits. // On success, returns (result, nil). On failure, returns (partialOutput, error) // where partialOutput contains any assistant messages collected before the error. -func (a *ClaudeAgent) parseStreamJSON(r io.Reader, output io.Writer) (string, error) { +func parseStreamJSON(r io.Reader, output io.Writer) (string, error) { br := bufio.NewReader(r) var lastResult string From 850af81a772fae7c8d0f544cf63c42a1ff01dbc9 Mon Sep 17 00:00:00 2001 From: Marius van Niekerk Date: Thu, 19 Feb 2026 08:15:39 -0500 Subject: [PATCH 37/49] fix: use strings.SplitSeq for efficient iteration (modernize lint) Co-Authored-By: Claude Sonnet 4.6 --- cmd/roborev/tui.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cmd/roborev/tui.go b/cmd/roborev/tui.go index e8ca0593..4d93032a 100644 --- a/cmd/roborev/tui.go +++ b/cmd/roborev/tui.go @@ -3860,7 +3860,7 @@ func dirtyPatchFiles(repoPath string, files []string) []string { return nil } dirty := map[string]bool{} - for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") { + for line := range strings.SplitSeq(strings.TrimSpace(string(out)), "\n") { if line != "" { dirty[line] = true } From f22609be841047739e879c84edaabe31ed414d8f Mon Sep 17 00:00:00 2001 From: Marius van Niekerk Date: Thu, 19 Feb 2026 08:18:28 -0500 Subject: [PATCH 38/49] fix: update nix vendorHash for sourcegraph/go-diff dependency Co-Authored-By: Claude Sonnet 4.6 --- flake.nix | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/flake.nix b/flake.nix index 0d9c2b05..95cf3ab5 100644 --- a/flake.nix +++ b/flake.nix @@ -19,7 +19,7 @@ src = ./.; - vendorHash = "sha256-hOnrKcNJ0SHLcVR6iwYEMtcT6PFovJjrDj/QxH+QHnY="; + vendorHash = "sha256-HzQUsFOOIxV0K5LGgMA4P7TWbLw7FT59BAfN9vncMfg="; subPackages = [ "cmd/roborev" ]; From e171471ba0bc185f8a63ccb2ffb5946590e3b442 Mon Sep 17 00:00:00 2001 From: Marius van Niekerk Date: Thu, 19 Feb 2026 08:27:34 -0500 Subject: [PATCH 39/49] fix: remove duplicate test functions and update integration tests to worktree.Create API Rebase conflict resolution accidentally duplicated TestWorktreeCleanupBetweenIterations, TestCreateTempWorktreeIgnoresHooks, and TestCreateTempWorktreeInitializesSubmodules into refine_test.go and main_test.go. These already exist in refine_integration_test.go (behind the integration build tag), causing redeclaration errors in CI. Also updates refine_integration_test.go to use the new worktree.Create API instead of the removed createTempWorktree function. Co-Authored-By: Claude Opus 4.6 --- cmd/roborev/main_test.go | 49 --------------- cmd/roborev/refine_integration_test.go | 33 +++++----- cmd/roborev/refine_test.go | 84 -------------------------- 3 files changed, 17 insertions(+), 149 deletions(-) diff --git a/cmd/roborev/main_test.go b/cmd/roborev/main_test.go index 4530a9a1..2ffad04a 100644 --- a/cmd/roborev/main_test.go +++ b/cmd/roborev/main_test.go @@ -24,7 +24,6 @@ import ( "github.com/roborev-dev/roborev/internal/git" "github.com/roborev-dev/roborev/internal/storage" "github.com/roborev-dev/roborev/internal/version" - "github.com/roborev-dev/roborev/internal/worktree" ) // ============================================================================ @@ -335,54 +334,6 @@ func TestRunRefineStopsLiveTimerOnAgentError(t *testing.T) { } } -func TestCreateTempWorktreeInitializesSubmodules(t *testing.T) { - submoduleRepo := t.TempDir() - runSubGit := func(args ...string) { - cmd := exec.Command("git", args...) - cmd.Dir = submoduleRepo - if out, err := cmd.CombinedOutput(); err != nil { - t.Fatalf("git %v failed: %v\n%s", args, err, out) - } - } - - runSubGit("init") - runSubGit("symbolic-ref", "HEAD", "refs/heads/main") - runSubGit("config", "user.email", "test@test.com") - runSubGit("config", "user.name", "Test") - if err := os.WriteFile(filepath.Join(submoduleRepo, "sub.txt"), []byte("sub"), 0644); err != nil { - t.Fatal(err) - } - runSubGit("add", "sub.txt") - runSubGit("commit", "-m", "submodule commit") - - mainRepo := t.TempDir() - runMainGit := func(args ...string) { - cmd := exec.Command("git", args...) - cmd.Dir = mainRepo - if out, err := cmd.CombinedOutput(); err != nil { - t.Fatalf("git %v failed: %v\n%s", args, err, out) - } - } - - runMainGit("init") - runMainGit("symbolic-ref", "HEAD", "refs/heads/main") - runMainGit("config", "user.email", "test@test.com") - runMainGit("config", "user.name", "Test") - runMainGit("config", "protocol.file.allow", "always") - runMainGit("-c", "protocol.file.allow=always", "submodule", "add", submoduleRepo, "deps/sub") - runMainGit("commit", "-m", "add submodule") - - wt, err := worktree.Create(mainRepo) - if err != nil { - t.Fatalf("worktree.Create failed: %v", err) - } - defer wt.Close() - - if _, err := os.Stat(filepath.Join(wt.Dir, "deps", "sub", "sub.txt")); err != nil { - t.Fatalf("expected submodule file in worktree: %v", err) - } -} - // ============================================================================ // Integration Tests for Refine Loop Business Logic // ============================================================================ diff --git a/cmd/roborev/refine_integration_test.go b/cmd/roborev/refine_integration_test.go index b3742e17..45be5e32 100644 --- a/cmd/roborev/refine_integration_test.go +++ b/cmd/roborev/refine_integration_test.go @@ -10,6 +10,7 @@ import ( "testing" "github.com/roborev-dev/roborev/internal/testutil" + "github.com/roborev-dev/roborev/internal/worktree" ) func TestValidateRefineContext(t *testing.T) { @@ -169,9 +170,9 @@ func TestWorktreeCleanupBetweenIterations(t *testing.T) { // before the next iteration. Verify the directory is removed each time. var prevPath string for i := range 3 { - worktreePath, cleanup, err := createTempWorktree(repo.Root) + wt, err := worktree.Create(repo.Root) if err != nil { - t.Fatalf("iteration %d: createTempWorktree failed: %v", i, err) + t.Fatalf("iteration %d: worktree.Create failed: %v", i, err) } // Verify previous worktree was cleaned up @@ -182,13 +183,13 @@ func TestWorktreeCleanupBetweenIterations(t *testing.T) { } // Verify current worktree exists - if _, err := os.Stat(worktreePath); err != nil { - t.Fatalf("iteration %d: worktree %s should exist: %v", i, worktreePath, err) + if _, err := os.Stat(wt.Dir); err != nil { + t.Fatalf("iteration %d: worktree %s should exist: %v", i, wt.Dir, err) } // Simulate the explicit cleanup call (as done on error/no-change paths) - cleanup() - prevPath = worktreePath + wt.Close() + prevPath = wt.Dir } // Verify the last worktree was also cleaned up @@ -221,19 +222,19 @@ func TestCreateTempWorktreeIgnoresHooks(t *testing.T) { _ = out } - // createTempWorktree should succeed because it suppresses hooks - worktreePath, cleanup, err := createTempWorktree(repo.Root) + // worktree.Create should succeed because it suppresses hooks + wt, err := worktree.Create(repo.Root) if err != nil { - t.Fatalf("createTempWorktree should succeed with failing hook: %v", err) + t.Fatalf("worktree.Create should succeed with failing hook: %v", err) } - defer cleanup() + defer wt.Close() // Verify the worktree directory exists and has the file from the repo - if _, err := os.Stat(worktreePath); err != nil { + if _, err := os.Stat(wt.Dir); err != nil { t.Fatalf("worktree directory should exist: %v", err) } - baseFile := filepath.Join(worktreePath, "base.txt") + baseFile := filepath.Join(wt.Dir, "base.txt") content, err := os.ReadFile(baseFile) if err != nil { t.Fatalf("expected base.txt in worktree: %v", err) @@ -280,13 +281,13 @@ func TestCreateTempWorktreeInitializesSubmodules(t *testing.T) { runMainGit("-c", "protocol.file.allow=always", "submodule", "add", submoduleRepo, "deps/sub") runMainGit("commit", "-m", "add submodule") - worktreePath, cleanup, err := createTempWorktree(mainRepo) + wt, err := worktree.Create(mainRepo) if err != nil { - t.Fatalf("createTempWorktree failed: %v", err) + t.Fatalf("worktree.Create failed: %v", err) } - defer cleanup() + defer wt.Close() - if _, err := os.Stat(filepath.Join(worktreePath, "deps", "sub", "sub.txt")); err != nil { + if _, err := os.Stat(filepath.Join(wt.Dir, "deps", "sub", "sub.txt")); err != nil { t.Fatalf("expected submodule file in worktree: %v", err) } } diff --git a/cmd/roborev/refine_test.go b/cmd/roborev/refine_test.go index d0ff6a39..7f6231c4 100644 --- a/cmd/roborev/refine_test.go +++ b/cmd/roborev/refine_test.go @@ -13,7 +13,6 @@ import ( "github.com/roborev-dev/roborev/internal/daemon" "github.com/roborev-dev/roborev/internal/storage" "github.com/roborev-dev/roborev/internal/testutil" - "github.com/roborev-dev/roborev/internal/worktree" ) // mockDaemonClient is a test implementation of daemon.Client @@ -972,89 +971,6 @@ func TestValidateRefineContext_FeatureBranchWithoutSinceStillWorks(t *testing.T) } } -func TestWorktreeCleanupBetweenIterations(t *testing.T) { - if _, err := exec.LookPath("git"); err != nil { - t.Skip("git not available") - } - - repoDir, _, _ := setupTestGitRepo(t) - - // Simulate the refine loop pattern: create a worktree, then clean it up - // before the next iteration. Verify the directory is removed each time. - var prevPath string - for i := range 3 { - wt, err := worktree.Create(repoDir) - if err != nil { - t.Fatalf("iteration %d: worktree.Create failed: %v", i, err) - } - - // Verify previous worktree was cleaned up - if prevPath != "" { - if _, err := os.Stat(prevPath); !os.IsNotExist(err) { - t.Fatalf("iteration %d: previous worktree %s still exists after cleanup", i, prevPath) - } - } - - // Verify current worktree exists - if _, err := os.Stat(wt.Dir); err != nil { - t.Fatalf("iteration %d: worktree %s should exist: %v", i, wt.Dir, err) - } - - // Simulate the explicit cleanup call (as done on error/no-change paths) - wt.Close() - prevPath = wt.Dir - } - - // Verify the last worktree was also cleaned up - if _, err := os.Stat(prevPath); !os.IsNotExist(err) { - t.Fatalf("last worktree %s still exists after cleanup", prevPath) - } -} - -func TestCreateTempWorktreeIgnoresHooks(t *testing.T) { - if _, err := exec.LookPath("git"); err != nil { - t.Skip("git not available") - } - - repoDir, _, runGit := setupTestGitRepo(t) - - installGitHook(t, repoDir, "post-checkout", "#!/bin/sh\nexit 1\n") - - // Verify the hook is active (a normal worktree add would fail) - failDir := t.TempDir() - cmd := exec.Command("git", "-C", repoDir, "worktree", "add", "--detach", failDir, "HEAD") - if out, err := cmd.CombinedOutput(); err == nil { - // Clean up the worktree before failing - exec.Command("git", "-C", repoDir, "worktree", "remove", "--force", failDir).Run() - // Some git versions don't fail on post-checkout hook errors. - // In that case, verify our approach still succeeds. - _ = out - } - - // worktree.Create should succeed because it suppresses hooks - wt, err := worktree.Create(repoDir) - if err != nil { - t.Fatalf("worktree.Create should succeed with failing hook: %v", err) - } - defer wt.Close() - - // Verify the worktree directory exists and has the file from the repo - if _, err := os.Stat(wt.Dir); err != nil { - t.Fatalf("worktree directory should exist: %v", err) - } - - baseFile := filepath.Join(wt.Dir, "base.txt") - content, err := os.ReadFile(baseFile) - if err != nil { - t.Fatalf("expected base.txt in worktree: %v", err) - } - if string(content) != "base" { - t.Errorf("expected content 'base', got %q", string(content)) - } - - _ = runGit // used by setupTestGitRepo -} - func TestCommitWithHookRetrySucceeds(t *testing.T) { if _, err := exec.LookPath("git"); err != nil { t.Skip("git not available") From 58d0ffd19cc9d5f376fda76e7f69f5b8c69fb9e3 Mon Sep 17 00:00:00 2001 From: Marius van Niekerk Date: Thu, 19 Feb 2026 08:29:36 -0500 Subject: [PATCH 40/49] fix: remove unused installGitHook helper (golangci-lint unused) Co-Authored-By: Claude Opus 4.6 --- cmd/roborev/refine_test.go | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/cmd/roborev/refine_test.go b/cmd/roborev/refine_test.go index 7f6231c4..78c5283f 100644 --- a/cmd/roborev/refine_test.go +++ b/cmd/roborev/refine_test.go @@ -724,18 +724,6 @@ func TestFindPendingJobForBranch_OldestFirst(t *testing.T) { } } -func installGitHook(t *testing.T, repoDir, name, script string) { - t.Helper() - hooksDir := filepath.Join(repoDir, ".git", "hooks") - if err := os.MkdirAll(hooksDir, 0755); err != nil { - t.Fatal(err) - } - hookPath := filepath.Join(hooksDir, name) - if err := os.WriteFile(hookPath, []byte(script), 0755); err != nil { - t.Fatal(err) - } -} - func gitCommitFile(t *testing.T, repoDir string, runGit func(...string) string, filename, content, msg string) string { t.Helper() if err := os.WriteFile(filepath.Join(repoDir, filename), []byte(content), 0644); err != nil { From 8ed213622a277381469231e861a64580357255b8 Mon Sep 17 00:00:00 2001 From: Wes McKinney Date: Thu, 19 Feb 2026 16:38:37 -0600 Subject: [PATCH 41/49] fix: harden TUI fix-job apply/rebase flow - commitPatch: use git commit --only to avoid bundling pre-staged files - triggerRebase: move mark-as-rebased into async cmd after successful enqueue so stale job stays done if enqueue fails - Allow R keybinding on rebased jobs for retry - GetJobCounts: include applied/rebased in return values and DaemonStatus - handleGetPatch: use strconv.ParseInt instead of fmt.Sscanf for strict job_id parsing Co-Authored-By: Claude Opus 4.6 --- cmd/roborev/tui.go | 18 +++++++++++++----- cmd/roborev/tui_handlers.go | 4 ++-- e2e_test.go | 10 ++++++---- internal/daemon/server.go | 8 +++++--- internal/daemon/server_test.go | 4 ++-- internal/storage/db_test.go | 4 ++-- internal/storage/jobs.go | 6 +++++- internal/storage/models.go | 2 ++ 8 files changed, 37 insertions(+), 19 deletions(-) diff --git a/cmd/roborev/tui.go b/cmd/roborev/tui.go index 4d93032a..e58bf15f 100644 --- a/cmd/roborev/tui.go +++ b/cmd/roborev/tui.go @@ -2119,10 +2119,6 @@ func (m tuiModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.flashMessage = fmt.Sprintf("Patch for job #%d doesn't apply cleanly - triggering rebase", msg.jobID) m.flashExpiresAt = time.Now().Add(5 * time.Second) m.flashView = tuiViewTasks - // Mark stale job as rebased and trigger rebase - if err := m.postJSON("/api/job/rebased", map[string]any{"job_id": msg.jobID}, nil); err != nil { - m.flashMessage = fmt.Sprintf("Rebase triggered but failed to mark job #%d as rebased: %v", msg.jobID, err) - } return m, tea.Batch(m.triggerRebase(msg.jobID), m.fetchFixJobs()) } else if msg.success && msg.err != nil { // Patch applied but commit failed @@ -3844,7 +3840,11 @@ func commitPatch(repoPath, patch, message string) error { if out, err := addCmd.CombinedOutput(); err != nil { return fmt.Errorf("git add: %w: %s", err, out) } - commitCmd := exec.Command("git", "-C", repoPath, "commit", "-m", message) + commitArgs := append( + []string{"-C", repoPath, "commit", "--only", "-m", message, "--"}, + files..., + ) + commitCmd := exec.Command("git", commitArgs...) if out, err := commitCmd.CombinedOutput(); err != nil { return fmt.Errorf("git commit: %w: %s", err, out) } @@ -3924,6 +3924,14 @@ func (m tuiModel) triggerRebase(staleJobID int64) tea.Cmd { if err := m.postJSON("/api/job/fix", req, &newJob); err != nil { return tuiFixTriggerResultMsg{err: fmt.Errorf("trigger rebase: %w", err)} } + // Mark the stale job as rebased now that the new job exists. + // Non-fatal: the new job is already enqueued; worst case the + // stale job stays "done" and the user can retry R. + _ = m.postJSON( + "/api/job/rebased", + map[string]any{"job_id": staleJobID}, + nil, + ) return tuiFixTriggerResultMsg{job: &newJob} } } diff --git a/cmd/roborev/tui_handlers.go b/cmd/roborev/tui_handlers.go index 0019b02c..ec718bf0 100644 --- a/cmd/roborev/tui_handlers.go +++ b/cmd/roborev/tui_handlers.go @@ -1562,10 +1562,10 @@ func (m tuiModel) handleTasksKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { } return m, nil case "R": - // Manually trigger rebase for a completed fix job + // Manually trigger rebase for a completed or rebased fix job if len(m.fixJobs) > 0 && m.fixSelectedIdx < len(m.fixJobs) { job := m.fixJobs[m.fixSelectedIdx] - if job.Status == storage.JobStatusDone { + if job.Status == storage.JobStatusDone || job.Status == storage.JobStatusRebased { return m, m.triggerRebase(job.ID) } } diff --git a/e2e_test.go b/e2e_test.go index eaa8ff65..e5305d3d 100644 --- a/e2e_test.go +++ b/e2e_test.go @@ -42,13 +42,15 @@ func TestE2EEnqueueAndReview(t *testing.T) { // Add handlers manually (simulating the server) mux.HandleFunc("/api/status", func(w http.ResponseWriter, r *http.Request) { - queued, running, done, failed, canceled, _ := db.GetJobCounts() + queued, running, done, failed, canceled, applied, rebased, _ := db.GetJobCounts() status := storage.DaemonStatus{ QueuedJobs: queued, RunningJobs: running, CompletedJobs: done, FailedJobs: failed, CanceledJobs: canceled, + AppliedJobs: applied, + RebasedJobs: rebased, MaxWorkers: 4, } w.Header().Set("Content-Type", "application/json") @@ -109,7 +111,7 @@ func TestDatabaseIntegration(t *testing.T) { } // Verify initial state - queued, running, done, _, _, _ := db.GetJobCounts() + queued, running, done, _, _, _, _, _ := db.GetJobCounts() if queued != 1 { t.Errorf("Expected 1 queued job, got %d", queued) } @@ -129,7 +131,7 @@ func TestDatabaseIntegration(t *testing.T) { } // Verify running state - _, running, _, _, _, _ = db.GetJobCounts() + _, running, _, _, _, _, _, _ = db.GetJobCounts() if running != 1 { t.Errorf("Expected 1 running job, got %d", running) } @@ -141,7 +143,7 @@ func TestDatabaseIntegration(t *testing.T) { } // Verify completed state - queued, running, done, _, _, _ = db.GetJobCounts() + queued, running, done, _, _, _, _, _ = db.GetJobCounts() if done != 1 { t.Errorf("Expected 1 completed job, got %d", done) } diff --git a/internal/daemon/server.go b/internal/daemon/server.go index e1ffedf7..959c281d 100644 --- a/internal/daemon/server.go +++ b/internal/daemon/server.go @@ -1321,7 +1321,7 @@ func (s *Server) handleStatus(w http.ResponseWriter, r *http.Request) { return } - queued, running, done, failed, canceled, err := s.db.GetJobCounts() + queued, running, done, failed, canceled, applied, rebased, err := s.db.GetJobCounts() if err != nil { s.writeInternalError(w, fmt.Sprintf("get counts: %v", err)) return @@ -1341,6 +1341,8 @@ func (s *Server) handleStatus(w http.ResponseWriter, r *http.Request) { CompletedJobs: done, FailedJobs: failed, CanceledJobs: canceled, + AppliedJobs: applied, + RebasedJobs: rebased, ActiveWorkers: s.workerPool.ActiveWorkers(), MaxWorkers: s.workerPool.MaxWorkers(), MachineID: s.getMachineID(), @@ -1728,8 +1730,8 @@ func (s *Server) handleGetPatch(w http.ResponseWriter, r *http.Request) { return } - var jobID int64 - if _, err := fmt.Sscanf(jobIDStr, "%d", &jobID); err != nil { + jobID, err := strconv.ParseInt(jobIDStr, 10, 64) + if err != nil { writeError(w, http.StatusBadRequest, "invalid job_id") return } diff --git a/internal/daemon/server_test.go b/internal/daemon/server_test.go index 6ddb8904..314ffe78 100644 --- a/internal/daemon/server_test.go +++ b/internal/daemon/server_test.go @@ -1737,7 +1737,7 @@ func TestHandleEnqueueExcludedBranch(t *testing.T) { } // Verify no job was created - queued, _, _, _, _, _ := db.GetJobCounts() + queued, _, _, _, _, _, _, _ := db.GetJobCounts() if queued != 0 { t.Errorf("Expected 0 queued jobs, got %d", queued) } @@ -1762,7 +1762,7 @@ func TestHandleEnqueueExcludedBranch(t *testing.T) { } // Verify job was created - queued, _, _, _, _, _ := db.GetJobCounts() + queued, _, _, _, _, _, _, _ := db.GetJobCounts() if queued != 1 { t.Errorf("Expected 1 queued job, got %d", queued) } diff --git a/internal/storage/db_test.go b/internal/storage/db_test.go index 03fca717..25f65303 100644 --- a/internal/storage/db_test.go +++ b/internal/storage/db_test.go @@ -695,7 +695,7 @@ func TestJobCounts(t *testing.T) { db.FailJob(claimed2.ID, "", "err") } - queued, running, done, failed, _, err := db.GetJobCounts() + queued, running, done, failed, _, _, _, err := db.GetJobCounts() if err != nil { t.Fatalf("GetJobCounts failed: %v", err) } @@ -1212,7 +1212,7 @@ func TestCancelJob(t *testing.T) { _, _, job := createJobChain(t, db, "/tmp/test-repo", "cancel-count") db.CancelJob(job.ID) - _, _, _, _, canceled, err := db.GetJobCounts() + _, _, _, _, canceled, _, _, err := db.GetJobCounts() if err != nil { t.Fatalf("GetJobCounts failed: %v", err) } diff --git a/internal/storage/jobs.go b/internal/storage/jobs.go index aac849dd..68d92e93 100644 --- a/internal/storage/jobs.go +++ b/internal/storage/jobs.go @@ -1626,7 +1626,7 @@ func (db *DB) GetJobByID(id int64) (*ReviewJob, error) { } // GetJobCounts returns counts of jobs by status -func (db *DB) GetJobCounts() (queued, running, done, failed, canceled int, err error) { +func (db *DB) GetJobCounts() (queued, running, done, failed, canceled, applied, rebased int, err error) { rows, err := db.Query(`SELECT status, COUNT(*) FROM review_jobs GROUP BY status`) if err != nil { return @@ -1650,6 +1650,10 @@ func (db *DB) GetJobCounts() (queued, running, done, failed, canceled int, err e failed = count case JobStatusCanceled: canceled = count + case JobStatusApplied: + applied = count + case JobStatusRebased: + rebased = count } } err = rows.Err() diff --git a/internal/storage/models.go b/internal/storage/models.go index 513e6e45..d8cc41cc 100644 --- a/internal/storage/models.go +++ b/internal/storage/models.go @@ -183,6 +183,8 @@ type DaemonStatus struct { CompletedJobs int `json:"completed_jobs"` FailedJobs int `json:"failed_jobs"` CanceledJobs int `json:"canceled_jobs"` + AppliedJobs int `json:"applied_jobs"` + RebasedJobs int `json:"rebased_jobs"` ActiveWorkers int `json:"active_workers"` MaxWorkers int `json:"max_workers"` MachineID string `json:"machine_id,omitempty"` // Local machine ID for remote job detection From ea5a04abaa01aa4f4b2e90ec7cba1212e68adc05 Mon Sep 17 00:00:00 2001 From: Wes McKinney Date: Thu, 19 Feb 2026 16:50:08 -0600 Subject: [PATCH 42/49] fix: patchFiles prefix bug, rebased flash warning, job_type filter - patchFiles: strip a/ only from OrigName and b/ only from NewName to avoid mangling files in b/ directories - triggerRebase: propagate mark-as-rebased failure as a flash warning instead of silently dropping the error - Add WithJobType ListJobsOption and job_type query param to /api/jobs; fetchFixJobs now uses server-side filtering with limit=200 instead of client-side filtering of the default 50-item page Co-Authored-By: Claude Opus 4.6 --- cmd/roborev/tui.go | 42 +++++++++++++++++++++------------------ internal/daemon/server.go | 3 +++ internal/storage/jobs.go | 10 ++++++++++ 3 files changed, 36 insertions(+), 19 deletions(-) diff --git a/cmd/roborev/tui.go b/cmd/roborev/tui.go index e58bf15f..1ec8d896 100644 --- a/cmd/roborev/tui.go +++ b/cmd/roborev/tui.go @@ -388,8 +388,9 @@ type tuiFixJobsMsg struct { } type tuiFixTriggerResultMsg struct { - job *storage.ReviewJob - err error + job *storage.ReviewJob + err error + warning string // non-fatal issue (e.g. failed to mark stale job) } type tuiApplyPatchResultMsg struct { @@ -2094,6 +2095,11 @@ func (m tuiModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.flashMessage = fmt.Sprintf("Fix failed: %v", msg.err) m.flashExpiresAt = time.Now().Add(3 * time.Second) m.flashView = tuiViewTasks + } else if msg.warning != "" { + m.flashMessage = msg.warning + m.flashExpiresAt = time.Now().Add(5 * time.Second) + m.flashView = tuiViewTasks + return m, m.fetchFixJobs() } else { m.flashMessage = fmt.Sprintf("Fix job #%d enqueued", msg.job.ID) m.flashExpiresAt = time.Now().Add(3 * time.Second) @@ -3710,18 +3716,11 @@ func (m tuiModel) fetchFixJobs() tea.Cmd { var result struct { Jobs []storage.ReviewJob `json:"jobs"` } - err := m.getJSON("/api/jobs", &result) + err := m.getJSON("/api/jobs?job_type=fix&limit=200", &result) if err != nil { return tuiFixJobsMsg{err: err} } - // Filter to only fix jobs - var fixJobs []storage.ReviewJob - for _, j := range result.Jobs { - if j.IsFixJob() { - fixJobs = append(fixJobs, j) - } - } - return tuiFixJobsMsg{jobs: fixJobs} + return tuiFixJobsMsg{jobs: result.Jobs} } } @@ -3881,16 +3880,15 @@ func patchFiles(patch string) ([]string, error) { return nil, fmt.Errorf("parse patch: %w", err) } seen := map[string]bool{} - addFile := func(name string) { - name = strings.TrimPrefix(name, "a/") - name = strings.TrimPrefix(name, "b/") + addFile := func(name, prefix string) { + name = strings.TrimPrefix(name, prefix) if name != "" && name != "/dev/null" { seen[name] = true } } for _, fd := range fileDiffs { - addFile(fd.OrigName) // old path (stages deletion for renames) - addFile(fd.NewName) // new path (stages addition for renames) + addFile(fd.OrigName, "a/") // old path (stages deletion for renames) + addFile(fd.NewName, "b/") // new path (stages addition for renames) } files := make([]string, 0, len(seen)) for f := range seen { @@ -3927,12 +3925,18 @@ func (m tuiModel) triggerRebase(staleJobID int64) tea.Cmd { // Mark the stale job as rebased now that the new job exists. // Non-fatal: the new job is already enqueued; worst case the // stale job stays "done" and the user can retry R. - _ = m.postJSON( + var warning string + if err := m.postJSON( "/api/job/rebased", map[string]any{"job_id": staleJobID}, nil, - ) - return tuiFixTriggerResultMsg{job: &newJob} + ); err != nil { + warning = fmt.Sprintf( + "rebase job #%d enqueued but failed to mark #%d as rebased: %v", + newJob.ID, staleJobID, err, + ) + } + return tuiFixTriggerResultMsg{job: &newJob, warning: warning} } } diff --git a/internal/daemon/server.go b/internal/daemon/server.go index 959c281d..26e23c2e 100644 --- a/internal/daemon/server.go +++ b/internal/daemon/server.go @@ -789,6 +789,9 @@ func (s *Server) handleListJobs(w http.ResponseWriter, r *http.Request) { if addrStr := r.URL.Query().Get("addressed"); addrStr == "true" || addrStr == "false" { listOpts = append(listOpts, storage.WithAddressed(addrStr == "true")) } + if jobType := r.URL.Query().Get("job_type"); jobType != "" { + listOpts = append(listOpts, storage.WithJobType(jobType)) + } jobs, err := s.db.ListJobs(status, repo, fetchLimit, offset, listOpts...) if err != nil { diff --git a/internal/storage/jobs.go b/internal/storage/jobs.go index 68d92e93..659569a7 100644 --- a/internal/storage/jobs.go +++ b/internal/storage/jobs.go @@ -1315,6 +1315,7 @@ type listJobsOptions struct { branch string branchIncludeEmpty bool addressed *bool + jobType string } // WithGitRef filters jobs by git ref. @@ -1341,6 +1342,11 @@ func WithAddressed(addressed bool) ListJobsOption { return func(o *listJobsOptions) { o.addressed = &addressed } } +// WithJobType filters jobs by job_type (e.g. "fix", "review"). +func WithJobType(jobType string) ListJobsOption { + return func(o *listJobsOptions) { o.jobType = jobType } +} + // ListJobs returns jobs with optional status, repo, branch, and addressed filters. // addressedFilter: nil = no filter, non-nil bool = filter by addressed state. func (db *DB) ListJobs(statusFilter string, repoFilter string, limit, offset int, opts ...ListJobsOption) ([]ReviewJob, error) { @@ -1389,6 +1395,10 @@ func (db *DB) ListJobs(statusFilter string, repoFilter string, limit, offset int conditions = append(conditions, "(rv.addressed IS NULL OR rv.addressed = 0)") } } + if o.jobType != "" { + conditions = append(conditions, "j.job_type = ?") + args = append(args, o.jobType) + } if len(conditions) > 0 { query += " WHERE " + strings.Join(conditions, " AND ") From da9c72152fa0fc23228dbdf39f743d6429ff1d4f Mon Sep 17 00:00:00 2001 From: Wes McKinney Date: Thu, 19 Feb 2026 16:58:40 -0600 Subject: [PATCH 43/49] test: add regression tests for patchFiles, job_type filter, rebase warning - patchFiles: test files in a/ and b/ directories aren't double-stripped, renames, new files, deleted files with /dev/null - ListJobs + handleListJobs: test WithJobType filter returns only matching jobs and no-filter returns all - TUI: test tuiFixTriggerResultMsg warning path shows flash and triggers refresh Co-Authored-By: Claude Opus 4.6 --- cmd/roborev/tui_test.go | 129 +++++++++++++++++++++++++++++++++ internal/daemon/server_test.go | 76 +++++++++++++++++++ internal/storage/db_test.go | 70 ++++++++++++++++++ 3 files changed, 275 insertions(+) diff --git a/cmd/roborev/tui_test.go b/cmd/roborev/tui_test.go index 87fce9b0..cccca61d 100644 --- a/cmd/roborev/tui_test.go +++ b/cmd/roborev/tui_test.go @@ -1442,3 +1442,132 @@ func TestTUIStatusDisplaysCorrectly(t *testing.T) { } } } + +func TestPatchFiles(t *testing.T) { + tests := []struct { + name string + patch string + want []string + }{ + { + name: "simple add", + patch: `diff --git a/main.go b/main.go +--- a/main.go ++++ b/main.go +@@ -1 +1,2 @@ + package main ++// new line +`, + want: []string{"main.go"}, + }, + { + name: "file in b/ directory not double-stripped", + patch: `diff --git a/b/main.go b/b/main.go +--- a/b/main.go ++++ b/b/main.go +@@ -1 +1,2 @@ + package main ++// new line +`, + want: []string{"b/main.go"}, + }, + { + name: "file in a/ directory not double-stripped", + patch: `diff --git a/a/utils.go b/a/utils.go +--- a/a/utils.go ++++ b/a/utils.go +@@ -1 +1,2 @@ + package a ++// new line +`, + want: []string{"a/utils.go"}, + }, + { + name: "new file with /dev/null", + patch: `diff --git a/new.go b/new.go +--- /dev/null ++++ b/new.go +@@ -0,0 +1 @@ ++package main +`, + want: []string{"new.go"}, + }, + { + name: "deleted file with /dev/null", + patch: `diff --git a/old.go b/old.go +--- a/old.go ++++ /dev/null +@@ -1 +0,0 @@ +-package main +`, + want: []string{"old.go"}, + }, + { + name: "rename", + patch: `diff --git a/old.go b/renamed.go +--- a/old.go ++++ b/renamed.go +@@ -1 +1 @@ +-package old ++package renamed +`, + want: []string{"old.go", "renamed.go"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := patchFiles(tt.patch) + if err != nil { + t.Fatalf("patchFiles returned error: %v", err) + } + wantSet := map[string]bool{} + for _, f := range tt.want { + wantSet[f] = true + } + gotSet := map[string]bool{} + for _, f := range got { + gotSet[f] = true + } + for f := range wantSet { + if !gotSet[f] { + t.Errorf("missing expected file %q", f) + } + } + for f := range gotSet { + if !wantSet[f] { + t.Errorf("unexpected file %q", f) + } + } + }) + } +} + +func TestTUIFixTriggerResultMsgWarning(t *testing.T) { + m := newTuiModel("http://localhost") + m.currentView = tuiViewTasks + m.width = 80 + m.height = 24 + + job := &storage.ReviewJob{ID: 42} + msg := tuiFixTriggerResultMsg{ + job: job, + warning: "rebase job #42 enqueued but failed to mark #10 as rebased: server error", + } + + result, cmd := m.Update(msg) + updated := result.(tuiModel) + + if !strings.Contains(updated.flashMessage, "failed to mark") { + t.Errorf( + "expected flash to contain warning, got %q", + updated.flashMessage, + ) + } + if updated.flashView != tuiViewTasks { + t.Errorf("expected flash view tasks, got %v", updated.flashView) + } + if cmd == nil { + t.Error("expected fetchFixJobs cmd, got nil") + } +} diff --git a/internal/daemon/server_test.go b/internal/daemon/server_test.go index 314ffe78..d75c378f 100644 --- a/internal/daemon/server_test.go +++ b/internal/daemon/server_test.go @@ -3461,3 +3461,79 @@ func TestHandleListCommentsJobIDParsing(t *testing.T) { }) } } + +func TestHandleListJobsJobTypeFilter(t *testing.T) { + server, db, tmpDir := newTestServer(t) + + repoDir := filepath.Join(tmpDir, "repo-jt") + testutil.InitTestGitRepo(t, repoDir) + repo, _ := db.GetOrCreateRepo(repoDir) + commit, _ := db.GetOrCreateCommit( + repo.ID, "jt-abc", "Author", "Subject", time.Now(), + ) + + // Create a review job + db.EnqueueJob(storage.EnqueueOpts{ + RepoID: repo.ID, + CommitID: commit.ID, + GitRef: "jt-abc", + Agent: "test", + }) + + // Create a fix job + db.EnqueueJob(storage.EnqueueOpts{ + RepoID: repo.ID, + CommitID: commit.ID, + GitRef: "jt-abc", + Agent: "test", + JobType: storage.JobTypeFix, + ParentJobID: 1, + }) + + t.Run("job_type=fix returns only fix jobs", func(t *testing.T) { + req := httptest.NewRequest( + http.MethodGet, "/api/jobs?job_type=fix", nil, + ) + w := httptest.NewRecorder() + server.handleListJobs(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("Expected 200, got %d: %s", w.Code, w.Body.String()) + } + + var resp struct { + Jobs []storage.ReviewJob `json:"jobs"` + } + testutil.DecodeJSON(t, w, &resp) + + if len(resp.Jobs) != 1 { + t.Fatalf("Expected 1 fix job, got %d", len(resp.Jobs)) + } + if resp.Jobs[0].JobType != storage.JobTypeFix { + t.Errorf( + "Expected job_type 'fix', got %q", resp.Jobs[0].JobType, + ) + } + }) + + t.Run("no job_type returns all jobs", func(t *testing.T) { + req := httptest.NewRequest( + http.MethodGet, "/api/jobs", nil, + ) + w := httptest.NewRecorder() + server.handleListJobs(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("Expected 200, got %d: %s", w.Code, w.Body.String()) + } + + var resp struct { + Jobs []storage.ReviewJob `json:"jobs"` + } + testutil.DecodeJSON(t, w, &resp) + + if len(resp.Jobs) != 2 { + t.Errorf("Expected 2 jobs total, got %d", len(resp.Jobs)) + } + }) +} diff --git a/internal/storage/db_test.go b/internal/storage/db_test.go index 25f65303..0140934f 100644 --- a/internal/storage/db_test.go +++ b/internal/storage/db_test.go @@ -3287,3 +3287,73 @@ func createJobChain(t *testing.T, db *DB, repoPath, sha string) (*Repo, *Commit, job := enqueueJob(t, db, repo.ID, commit.ID, sha) return repo, commit, job } + +func TestListJobsWithJobTypeFilter(t *testing.T) { + db := openTestDB(t) + defer db.Close() + + repo := createRepo(t, db, "/tmp/repo-jobtype") + commit := createCommit(t, db, repo.ID, "jt-sha") + + // Create a review job (default type) + enqueueJob(t, db, repo.ID, commit.ID, "jt-sha") + + // Create a fix job + _, err := db.EnqueueJob(EnqueueOpts{ + RepoID: repo.ID, + CommitID: commit.ID, + GitRef: "jt-sha", + Agent: "codex", + JobType: JobTypeFix, + ParentJobID: 1, + }) + if err != nil { + t.Fatalf("EnqueueJob fix failed: %v", err) + } + + t.Run("filter by fix returns only fix jobs", func(t *testing.T) { + jobs, err := db.ListJobs("", "", 50, 0, WithJobType("fix")) + if err != nil { + t.Fatalf("ListJobs failed: %v", err) + } + if len(jobs) != 1 { + t.Fatalf("Expected 1 fix job, got %d", len(jobs)) + } + if jobs[0].JobType != JobTypeFix { + t.Errorf("Expected job_type 'fix', got %q", jobs[0].JobType) + } + }) + + t.Run("filter by review returns only review jobs", func(t *testing.T) { + jobs, err := db.ListJobs("", "", 50, 0, WithJobType("review")) + if err != nil { + t.Fatalf("ListJobs failed: %v", err) + } + if len(jobs) != 1 { + t.Fatalf("Expected 1 review job, got %d", len(jobs)) + } + if jobs[0].JobType != JobTypeReview { + t.Errorf("Expected job_type 'review', got %q", jobs[0].JobType) + } + }) + + t.Run("no filter returns all jobs", func(t *testing.T) { + jobs, err := db.ListJobs("", "", 50, 0) + if err != nil { + t.Fatalf("ListJobs failed: %v", err) + } + if len(jobs) != 2 { + t.Errorf("Expected 2 jobs with no filter, got %d", len(jobs)) + } + }) + + t.Run("nonexistent type returns empty", func(t *testing.T) { + jobs, err := db.ListJobs("", "", 50, 0, WithJobType("nonexistent")) + if err != nil { + t.Fatalf("ListJobs failed: %v", err) + } + if len(jobs) != 0 { + t.Errorf("Expected 0 jobs for nonexistent type, got %d", len(jobs)) + } + }) +} From 22b403c8f1f15353d6d8703cbad238510e6e0dd3 Mon Sep 17 00:00:00 2001 From: Wes McKinney Date: Thu, 19 Feb 2026 17:16:44 -0600 Subject: [PATCH 44/49] fix: exclude fix jobs from queue server-side, suppress rebased warning - Add WithExcludeJobType ListJobsOption and exclude_job_type query param to /api/jobs - fetchJobs and fetchMoreJobs now request exclude_job_type=fix server-side instead of client-side filtering in handleJobsMsg, fixing empty queue when recent history is dominated by fix jobs - triggerRebase: skip mark-as-rebased call when stale job is already rebased, avoiding misleading warning on retry Co-Authored-By: Claude Opus 4.6 --- cmd/roborev/tui.go | 27 ++++++++++++++++----------- cmd/roborev/tui_handlers.go | 12 ++---------- internal/daemon/server.go | 3 +++ internal/daemon/server_test.go | 24 ++++++++++++++++++++++++ internal/storage/db_test.go | 26 ++++++++++++++++++++++++++ internal/storage/jobs.go | 10 ++++++++++ 6 files changed, 81 insertions(+), 21 deletions(-) diff --git a/cmd/roborev/tui.go b/cmd/roborev/tui.go index 1ec8d896..e40ac190 100644 --- a/cmd/roborev/tui.go +++ b/cmd/roborev/tui.go @@ -648,6 +648,9 @@ func (m tuiModel) fetchJobs() tea.Cmd { params.Set("addressed", "false") } + // Exclude fix jobs — they belong in the Tasks view, not the queue + params.Set("exclude_job_type", "fix") + // Set limit: use pagination unless we need client-side filtering (multi-repo) if needsAllJobs { params.Set("limit", "0") @@ -701,6 +704,7 @@ func (m tuiModel) fetchMoreJobs() tea.Cmd { if m.hideAddressed { params.Set("addressed", "false") } + params.Set("exclude_job_type", "fix") url := fmt.Sprintf("%s/api/jobs?%s", m.serverAddr, params.Encode()) resp, err := m.client.Get(url) if err != nil { @@ -3923,18 +3927,19 @@ func (m tuiModel) triggerRebase(staleJobID int64) tea.Cmd { return tuiFixTriggerResultMsg{err: fmt.Errorf("trigger rebase: %w", err)} } // Mark the stale job as rebased now that the new job exists. - // Non-fatal: the new job is already enqueued; worst case the - // stale job stays "done" and the user can retry R. + // Skip if already rebased (e.g. retry via R on a rebased job). var warning string - if err := m.postJSON( - "/api/job/rebased", - map[string]any{"job_id": staleJobID}, - nil, - ); err != nil { - warning = fmt.Sprintf( - "rebase job #%d enqueued but failed to mark #%d as rebased: %v", - newJob.ID, staleJobID, err, - ) + if staleJob.Status != storage.JobStatusRebased { + if err := m.postJSON( + "/api/job/rebased", + map[string]any{"job_id": staleJobID}, + nil, + ); err != nil { + warning = fmt.Sprintf( + "rebase job #%d enqueued but failed to mark #%d as rebased: %v", + newJob.ID, staleJobID, err, + ) + } } return tuiFixTriggerResultMsg{job: &newJob, warning: warning} } diff --git a/cmd/roborev/tui_handlers.go b/cmd/roborev/tui_handlers.go index ec718bf0..05f22755 100644 --- a/cmd/roborev/tui_handlers.go +++ b/cmd/roborev/tui_handlers.go @@ -1123,18 +1123,10 @@ func (m tuiModel) handleJobsMsg(msg tuiJobsMsg) (tea.Model, tea.Cmd) { m.updateDisplayNameCache(msg.jobs) - // Filter out fix jobs — they belong in the Tasks view, not the main queue - filtered := make([]storage.ReviewJob, 0, len(msg.jobs)) - for _, j := range msg.jobs { - if !j.IsFixJob() { - filtered = append(filtered, j) - } - } - if msg.append { - m.jobs = append(m.jobs, filtered...) + m.jobs = append(m.jobs, msg.jobs...) } else { - m.jobs = filtered + m.jobs = msg.jobs } // Clear pending addressed states that server has confirmed diff --git a/internal/daemon/server.go b/internal/daemon/server.go index 26e23c2e..3508cbe3 100644 --- a/internal/daemon/server.go +++ b/internal/daemon/server.go @@ -792,6 +792,9 @@ func (s *Server) handleListJobs(w http.ResponseWriter, r *http.Request) { if jobType := r.URL.Query().Get("job_type"); jobType != "" { listOpts = append(listOpts, storage.WithJobType(jobType)) } + if exJobType := r.URL.Query().Get("exclude_job_type"); exJobType != "" { + listOpts = append(listOpts, storage.WithExcludeJobType(exJobType)) + } jobs, err := s.db.ListJobs(status, repo, fetchLimit, offset, listOpts...) if err != nil { diff --git a/internal/daemon/server_test.go b/internal/daemon/server_test.go index d75c378f..b8427d12 100644 --- a/internal/daemon/server_test.go +++ b/internal/daemon/server_test.go @@ -3536,4 +3536,28 @@ func TestHandleListJobsJobTypeFilter(t *testing.T) { t.Errorf("Expected 2 jobs total, got %d", len(resp.Jobs)) } }) + + t.Run("exclude_job_type=fix returns only non-fix jobs", func(t *testing.T) { + req := httptest.NewRequest( + http.MethodGet, "/api/jobs?exclude_job_type=fix", nil, + ) + w := httptest.NewRecorder() + server.handleListJobs(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("Expected 200, got %d: %s", w.Code, w.Body.String()) + } + + var resp struct { + Jobs []storage.ReviewJob `json:"jobs"` + } + testutil.DecodeJSON(t, w, &resp) + + if len(resp.Jobs) != 1 { + t.Fatalf("Expected 1 non-fix job, got %d", len(resp.Jobs)) + } + if resp.Jobs[0].JobType == storage.JobTypeFix { + t.Error("Expected non-fix job, got fix") + } + }) } diff --git a/internal/storage/db_test.go b/internal/storage/db_test.go index 0140934f..63fc22ee 100644 --- a/internal/storage/db_test.go +++ b/internal/storage/db_test.go @@ -3356,4 +3356,30 @@ func TestListJobsWithJobTypeFilter(t *testing.T) { t.Errorf("Expected 0 jobs for nonexistent type, got %d", len(jobs)) } }) + + t.Run("exclude fix returns only non-fix jobs", func(t *testing.T) { + jobs, err := db.ListJobs("", "", 50, 0, WithExcludeJobType("fix")) + if err != nil { + t.Fatalf("ListJobs failed: %v", err) + } + if len(jobs) != 1 { + t.Fatalf("Expected 1 non-fix job, got %d", len(jobs)) + } + if jobs[0].JobType == JobTypeFix { + t.Error("Expected non-fix job, got fix") + } + }) + + t.Run("exclude review returns only non-review jobs", func(t *testing.T) { + jobs, err := db.ListJobs("", "", 50, 0, WithExcludeJobType("review")) + if err != nil { + t.Fatalf("ListJobs failed: %v", err) + } + if len(jobs) != 1 { + t.Fatalf("Expected 1 non-review job, got %d", len(jobs)) + } + if jobs[0].JobType != JobTypeFix { + t.Errorf("Expected fix job, got %q", jobs[0].JobType) + } + }) } diff --git a/internal/storage/jobs.go b/internal/storage/jobs.go index 659569a7..20ab066c 100644 --- a/internal/storage/jobs.go +++ b/internal/storage/jobs.go @@ -1316,6 +1316,7 @@ type listJobsOptions struct { branchIncludeEmpty bool addressed *bool jobType string + excludeJobType string } // WithGitRef filters jobs by git ref. @@ -1347,6 +1348,11 @@ func WithJobType(jobType string) ListJobsOption { return func(o *listJobsOptions) { o.jobType = jobType } } +// WithExcludeJobType excludes jobs of the given type. +func WithExcludeJobType(jobType string) ListJobsOption { + return func(o *listJobsOptions) { o.excludeJobType = jobType } +} + // ListJobs returns jobs with optional status, repo, branch, and addressed filters. // addressedFilter: nil = no filter, non-nil bool = filter by addressed state. func (db *DB) ListJobs(statusFilter string, repoFilter string, limit, offset int, opts ...ListJobsOption) ([]ReviewJob, error) { @@ -1399,6 +1405,10 @@ func (db *DB) ListJobs(statusFilter string, repoFilter string, limit, offset int conditions = append(conditions, "j.job_type = ?") args = append(args, o.jobType) } + if o.excludeJobType != "" { + conditions = append(conditions, "j.job_type != ?") + args = append(args, o.excludeJobType) + } if len(conditions) > 0 { query += " WHERE " + strings.Join(conditions, " AND ") From f45d77c95b00e71596e627dce645bab2377f88f6 Mon Sep 17 00:00:00 2001 From: Wes McKinney Date: Thu, 19 Feb 2026 17:26:07 -0600 Subject: [PATCH 45/49] fix: validate stale job in handleFixJob, harden tests - handleFixJob: reject stale_job_id that isn't a fix job or belongs to a different repo, preventing rebase with unrelated context - Fix hard-coded ParentJobID: 1 in tests; capture actual review job ID - patchFiles test: assert no duplicates and exact count - TUI fix trigger test: cover success, warning, and error paths - Add handleFixJob stale validation tests (non-fix type, cross-repo) Co-Authored-By: Claude Opus 4.6 --- cmd/roborev/tui_test.go | 94 +++++++++++++++++++++++++--------- internal/daemon/server.go | 8 +++ internal/daemon/server_test.go | 87 +++++++++++++++++++++++++++++-- internal/storage/db_test.go | 6 +-- 4 files changed, 165 insertions(+), 30 deletions(-) diff --git a/cmd/roborev/tui_test.go b/cmd/roborev/tui_test.go index cccca61d..df0f7f74 100644 --- a/cmd/roborev/tui_test.go +++ b/cmd/roborev/tui_test.go @@ -1525,8 +1525,14 @@ func TestPatchFiles(t *testing.T) { for _, f := range tt.want { wantSet[f] = true } + if len(got) != len(tt.want) { + t.Errorf("expected %d files, got %d: %v", len(tt.want), len(got), got) + } gotSet := map[string]bool{} for _, f := range got { + if gotSet[f] { + t.Errorf("duplicate file in output: %q", f) + } gotSet[f] = true } for f := range wantSet { @@ -1543,31 +1549,71 @@ func TestPatchFiles(t *testing.T) { } } -func TestTUIFixTriggerResultMsgWarning(t *testing.T) { - m := newTuiModel("http://localhost") - m.currentView = tuiViewTasks - m.width = 80 - m.height = 24 +func TestTUIFixTriggerResultMsg(t *testing.T) { + t.Run("warning shows flash and triggers refresh", func(t *testing.T) { + m := newTuiModel("http://localhost") + m.currentView = tuiViewTasks + m.width = 80 + m.height = 24 - job := &storage.ReviewJob{ID: 42} - msg := tuiFixTriggerResultMsg{ - job: job, - warning: "rebase job #42 enqueued but failed to mark #10 as rebased: server error", - } + msg := tuiFixTriggerResultMsg{ + job: &storage.ReviewJob{ID: 42}, + warning: "rebase job #42 enqueued but failed to mark #10 as rebased: server error", + } - result, cmd := m.Update(msg) - updated := result.(tuiModel) + result, cmd := m.Update(msg) + updated := result.(tuiModel) - if !strings.Contains(updated.flashMessage, "failed to mark") { - t.Errorf( - "expected flash to contain warning, got %q", - updated.flashMessage, - ) - } - if updated.flashView != tuiViewTasks { - t.Errorf("expected flash view tasks, got %v", updated.flashView) - } - if cmd == nil { - t.Error("expected fetchFixJobs cmd, got nil") - } + if !strings.Contains(updated.flashMessage, "failed to mark") { + t.Errorf("expected warning in flash, got %q", updated.flashMessage) + } + if updated.flashView != tuiViewTasks { + t.Errorf("expected flash view tasks, got %v", updated.flashView) + } + if cmd == nil { + t.Error("expected refresh cmd, got nil") + } + }) + + t.Run("success shows enqueued flash and triggers refresh", func(t *testing.T) { + m := newTuiModel("http://localhost") + m.currentView = tuiViewTasks + m.width = 80 + m.height = 24 + + msg := tuiFixTriggerResultMsg{ + job: &storage.ReviewJob{ID: 99}, + } + + result, cmd := m.Update(msg) + updated := result.(tuiModel) + + if !strings.Contains(updated.flashMessage, "#99 enqueued") { + t.Errorf("expected enqueued flash, got %q", updated.flashMessage) + } + if cmd == nil { + t.Error("expected refresh cmd, got nil") + } + }) + + t.Run("error shows failure flash with no refresh", func(t *testing.T) { + m := newTuiModel("http://localhost") + m.currentView = tuiViewTasks + m.width = 80 + m.height = 24 + + msg := tuiFixTriggerResultMsg{ + err: fmt.Errorf("connection refused"), + } + + result, cmd := m.Update(msg) + updated := result.(tuiModel) + + if !strings.Contains(updated.flashMessage, "Fix failed") { + t.Errorf("expected failure flash, got %q", updated.flashMessage) + } + if cmd != nil { + t.Error("expected no cmd on error, got non-nil") + } + }) } diff --git a/internal/daemon/server.go b/internal/daemon/server.go index 3508cbe3..ba8702a1 100644 --- a/internal/daemon/server.go +++ b/internal/daemon/server.go @@ -1677,6 +1677,14 @@ func (s *Server) handleFixJob(w http.ResponseWriter, r *http.Request) { writeError(w, http.StatusNotFound, "stale job not found") return } + if staleJob.JobType != storage.JobTypeFix { + writeError(w, http.StatusBadRequest, "stale job is not a fix job") + return + } + if staleJob.RepoID != parentJob.RepoID { + writeError(w, http.StatusBadRequest, "stale job belongs to a different repo") + return + } fixPrompt = buildRebasePrompt(staleJob.Patch) } if fixPrompt == "" { diff --git a/internal/daemon/server_test.go b/internal/daemon/server_test.go index b8427d12..2cede322 100644 --- a/internal/daemon/server_test.go +++ b/internal/daemon/server_test.go @@ -3473,21 +3473,21 @@ func TestHandleListJobsJobTypeFilter(t *testing.T) { ) // Create a review job - db.EnqueueJob(storage.EnqueueOpts{ + reviewJob, _ := db.EnqueueJob(storage.EnqueueOpts{ RepoID: repo.ID, CommitID: commit.ID, GitRef: "jt-abc", Agent: "test", }) - // Create a fix job + // Create a fix job parented to the review db.EnqueueJob(storage.EnqueueOpts{ RepoID: repo.ID, CommitID: commit.ID, GitRef: "jt-abc", Agent: "test", JobType: storage.JobTypeFix, - ParentJobID: 1, + ParentJobID: reviewJob.ID, }) t.Run("job_type=fix returns only fix jobs", func(t *testing.T) { @@ -3561,3 +3561,84 @@ func TestHandleListJobsJobTypeFilter(t *testing.T) { } }) } + +func TestHandleFixJobStaleValidation(t *testing.T) { + server, db, tmpDir := newTestServer(t) + + repoDir := filepath.Join(tmpDir, "repo-fix-val") + testutil.InitTestGitRepo(t, repoDir) + repo, _ := db.GetOrCreateRepo(repoDir) + commit, _ := db.GetOrCreateCommit( + repo.ID, "fix-val-abc", "Author", "Subject", time.Now(), + ) + + // Create a review job and complete it with output + reviewJob, _ := db.EnqueueJob(storage.EnqueueOpts{ + RepoID: repo.ID, + CommitID: commit.ID, + GitRef: "fix-val-abc", + Agent: "test", + }) + db.ClaimJob("w1") + db.CompleteJob(reviewJob.ID, "test", "prompt", "FAIL: issues found") + + t.Run("stale job that is not a fix job is rejected", func(t *testing.T) { + // reviewJob is a review, not a fix job + body := map[string]any{ + "parent_job_id": reviewJob.ID, + "stale_job_id": reviewJob.ID, + } + req := testutil.MakeJSONRequest( + t, http.MethodPost, "/api/job/fix", body, + ) + w := httptest.NewRecorder() + server.handleFixJob(w, req) + + if w.Code != http.StatusBadRequest { + t.Errorf("Expected 400 for non-fix stale job, got %d: %s", + w.Code, w.Body.String()) + } + }) + + t.Run("stale job from different repo is rejected", func(t *testing.T) { + // Create a fix job in a different repo + repo2Dir := filepath.Join(tmpDir, "repo-fix-val-2") + testutil.InitTestGitRepo(t, repo2Dir) + repo2, _ := db.GetOrCreateRepo(repo2Dir) + commit2, _ := db.GetOrCreateCommit( + repo2.ID, "other-sha", "Author", "Subject", time.Now(), + ) + otherReview, _ := db.EnqueueJob(storage.EnqueueOpts{ + RepoID: repo2.ID, + CommitID: commit2.ID, + GitRef: "other-sha", + Agent: "test", + }) + db.ClaimJob("w2") + db.CompleteJob(otherReview.ID, "test", "prompt", "FAIL") + + otherFix, _ := db.EnqueueJob(storage.EnqueueOpts{ + RepoID: repo2.ID, + CommitID: commit2.ID, + GitRef: "other-sha", + Agent: "test", + JobType: storage.JobTypeFix, + ParentJobID: otherReview.ID, + }) + + body := map[string]any{ + "parent_job_id": reviewJob.ID, + "stale_job_id": otherFix.ID, + } + req := testutil.MakeJSONRequest( + t, http.MethodPost, "/api/job/fix", body, + ) + w := httptest.NewRecorder() + server.handleFixJob(w, req) + + if w.Code != http.StatusBadRequest { + t.Errorf("Expected 400 for cross-repo stale job, got %d: %s", + w.Code, w.Body.String()) + } + }) +} diff --git a/internal/storage/db_test.go b/internal/storage/db_test.go index 63fc22ee..069ca147 100644 --- a/internal/storage/db_test.go +++ b/internal/storage/db_test.go @@ -3296,16 +3296,16 @@ func TestListJobsWithJobTypeFilter(t *testing.T) { commit := createCommit(t, db, repo.ID, "jt-sha") // Create a review job (default type) - enqueueJob(t, db, repo.ID, commit.ID, "jt-sha") + reviewJob := enqueueJob(t, db, repo.ID, commit.ID, "jt-sha") - // Create a fix job + // Create a fix job parented to the review _, err := db.EnqueueJob(EnqueueOpts{ RepoID: repo.ID, CommitID: commit.ID, GitRef: "jt-sha", Agent: "codex", JobType: JobTypeFix, - ParentJobID: 1, + ParentJobID: reviewJob.ID, }) if err != nil { t.Fatalf("EnqueueJob fix failed: %v", err) From 3e9221a1fef63523ccc0df2114364a9ac4b8a3e4 Mon Sep 17 00:00:00 2001 From: Wes McKinney Date: Thu, 19 Feb 2026 17:37:51 -0600 Subject: [PATCH 46/49] fix: harden patch apply and stale job rebase validation - commitPatch: set GIT_LITERAL_PATHSPECS=1 on git add/commit to prevent pathspec magic in patch-derived filenames - dirtyPatchFiles: return error instead of nil on git failure, caller aborts apply instead of proceeding - handleFixJob: validate stale job parent linkage matches parent_job_id, require terminal status (done/applied/rebased), and require non-nil patch before building rebase prompt - Add handler tests for wrong-parent and missing-patch stale jobs Co-Authored-By: Claude Opus 4.6 --- cmd/roborev/tui.go | 15 ++++++-- internal/daemon/server.go | 17 +++++++++ internal/daemon/server_test.go | 70 ++++++++++++++++++++++++++++++++++ 3 files changed, 98 insertions(+), 4 deletions(-) diff --git a/cmd/roborev/tui.go b/cmd/roborev/tui.go index e40ac190..75edcd74 100644 --- a/cmd/roborev/tui.go +++ b/cmd/roborev/tui.go @@ -3781,7 +3781,12 @@ func (m tuiModel) applyFixPatch(jobID int64) tea.Cmd { if pfErr != nil { return tuiApplyPatchResultMsg{jobID: jobID, err: pfErr} } - if dirty := dirtyPatchFiles(jobDetail.RepoPath, patchedFiles); len(dirty) > 0 { + dirty, dirtyErr := dirtyPatchFiles(jobDetail.RepoPath, patchedFiles) + if dirtyErr != nil { + return tuiApplyPatchResultMsg{jobID: jobID, + err: fmt.Errorf("checking dirty files: %w", dirtyErr)} + } + if len(dirty) > 0 { return tuiApplyPatchResultMsg{jobID: jobID, err: fmt.Errorf("uncommitted changes in patch files: %s — stash or commit first", strings.Join(dirty, ", "))} } @@ -3840,6 +3845,7 @@ func commitPatch(repoPath, patch, message string) error { } args := append([]string{"-C", repoPath, "add", "--"}, files...) addCmd := exec.Command("git", args...) + addCmd.Env = append(os.Environ(), "GIT_LITERAL_PATHSPECS=1") if out, err := addCmd.CombinedOutput(); err != nil { return fmt.Errorf("git add: %w: %s", err, out) } @@ -3848,6 +3854,7 @@ func commitPatch(repoPath, patch, message string) error { files..., ) commitCmd := exec.Command("git", commitArgs...) + commitCmd.Env = append(os.Environ(), "GIT_LITERAL_PATHSPECS=1") if out, err := commitCmd.CombinedOutput(); err != nil { return fmt.Errorf("git commit: %w: %s", err, out) } @@ -3855,12 +3862,12 @@ func commitPatch(repoPath, patch, message string) error { } // dirtyPatchFiles returns the subset of files that have uncommitted changes. -func dirtyPatchFiles(repoPath string, files []string) []string { +func dirtyPatchFiles(repoPath string, files []string) ([]string, error) { // git diff --name-only shows unstaged changes; --cached shows staged cmd := exec.Command("git", "-C", repoPath, "diff", "--name-only", "HEAD", "--") out, err := cmd.Output() if err != nil { - return nil + return nil, fmt.Errorf("git diff: %w", err) } dirty := map[string]bool{} for line := range strings.SplitSeq(strings.TrimSpace(string(out)), "\n") { @@ -3874,7 +3881,7 @@ func dirtyPatchFiles(repoPath string, files []string) []string { overlap = append(overlap, f) } } - return overlap + return overlap, nil } // patchFiles extracts the list of file paths touched by a unified diff. diff --git a/internal/daemon/server.go b/internal/daemon/server.go index ba8702a1..7dad2478 100644 --- a/internal/daemon/server.go +++ b/internal/daemon/server.go @@ -1685,6 +1685,23 @@ func (s *Server) handleFixJob(w http.ResponseWriter, r *http.Request) { writeError(w, http.StatusBadRequest, "stale job belongs to a different repo") return } + // Verify stale job is linked to the same parent + if staleJob.ParentJobID == nil || *staleJob.ParentJobID != req.ParentJobID { + writeError(w, http.StatusBadRequest, "stale job is not linked to the specified parent") + return + } + // Require terminal status with a usable patch + switch staleJob.Status { + case storage.JobStatusDone, storage.JobStatusApplied, storage.JobStatusRebased: + // OK + default: + writeError(w, http.StatusBadRequest, "stale job is not in a terminal state") + return + } + if staleJob.Patch == nil || *staleJob.Patch == "" { + writeError(w, http.StatusBadRequest, "stale job has no patch to rebase from") + return + } fixPrompt = buildRebasePrompt(staleJob.Patch) } if fixPrompt == "" { diff --git a/internal/daemon/server_test.go b/internal/daemon/server_test.go index 2cede322..be7501ea 100644 --- a/internal/daemon/server_test.go +++ b/internal/daemon/server_test.go @@ -3600,6 +3600,76 @@ func TestHandleFixJobStaleValidation(t *testing.T) { } }) + t.Run("stale job with wrong parent is rejected", func(t *testing.T) { + // Create a second review + fix in the SAME repo, linked to the other review + review2, _ := db.EnqueueJob(storage.EnqueueOpts{ + RepoID: repo.ID, + CommitID: commit.ID, + GitRef: "fix-val-def", + Agent: "test", + }) + db.ClaimJob("w3") + db.CompleteJob(review2.ID, "test", "prompt", "FAIL: other issues") + + wrongParentFix, _ := db.EnqueueJob(storage.EnqueueOpts{ + RepoID: repo.ID, + CommitID: commit.ID, + GitRef: "fix-val-def", + Agent: "test", + JobType: storage.JobTypeFix, + ParentJobID: review2.ID, + }) + // Complete it so it has terminal status + patch + db.ClaimJob("w4") + db.CompleteJob(wrongParentFix.ID, "test", "prompt", "done") + db.SaveJobPatch(wrongParentFix.ID, "--- a/f\n+++ b/f\n") + + body := map[string]any{ + "parent_job_id": reviewJob.ID, + "stale_job_id": wrongParentFix.ID, + } + req := testutil.MakeJSONRequest( + t, http.MethodPost, "/api/job/fix", body, + ) + w := httptest.NewRecorder() + server.handleFixJob(w, req) + + if w.Code != http.StatusBadRequest { + t.Errorf("Expected 400 for wrong-parent stale job, got %d: %s", + w.Code, w.Body.String()) + } + }) + + t.Run("stale job without patch is rejected", func(t *testing.T) { + // Create a fix job linked to reviewJob but with no patch + noPatchFix, _ := db.EnqueueJob(storage.EnqueueOpts{ + RepoID: repo.ID, + CommitID: commit.ID, + GitRef: "fix-val-abc", + Agent: "test", + JobType: storage.JobTypeFix, + ParentJobID: reviewJob.ID, + }) + // Complete it (terminal status) but don't set a patch + db.ClaimJob("w5") + db.CompleteJob(noPatchFix.ID, "test", "prompt", "done but no diff") + + body := map[string]any{ + "parent_job_id": reviewJob.ID, + "stale_job_id": noPatchFix.ID, + } + req := testutil.MakeJSONRequest( + t, http.MethodPost, "/api/job/fix", body, + ) + w := httptest.NewRecorder() + server.handleFixJob(w, req) + + if w.Code != http.StatusBadRequest { + t.Errorf("Expected 400 for patchless stale job, got %d: %s", + w.Code, w.Body.String()) + } + }) + t.Run("stale job from different repo is rejected", func(t *testing.T) { // Create a fix job in a different repo repo2Dir := filepath.Join(tmpDir, "repo-fix-val-2") From b7da9c3906a9545269405624573ece7de5222279 Mon Sep 17 00:00:00 2001 From: Wes McKinney Date: Thu, 19 Feb 2026 17:47:21 -0600 Subject: [PATCH 47/49] fix: skip TestDaemonSignalCleanup on Windows (file locking) The daemon's errors.log file stays locked on Windows, causing t.TempDir() cleanup to fail. The other two daemon integration tests already skip on Windows for the same reason. Co-Authored-By: Claude Opus 4.6 --- cmd/roborev/daemon_integration_test.go | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/cmd/roborev/daemon_integration_test.go b/cmd/roborev/daemon_integration_test.go index 1d55aae6..985bfe43 100644 --- a/cmd/roborev/daemon_integration_test.go +++ b/cmd/roborev/daemon_integration_test.go @@ -245,6 +245,10 @@ func TestDaemonShutdownBySignal(t *testing.T) { } func TestDaemonSignalCleanup(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("skipping daemon signal test on Windows due to file locking differences") + } + // Verify that signal.Stop is called when shutdown // is triggered by a signal. var cleanupCalled bool From 819bdb692a68004e972fbc53b8803474ff9a02b1 Mon Sep 17 00:00:00 2001 From: Wes McKinney Date: Thu, 19 Feb 2026 17:56:54 -0600 Subject: [PATCH 48/49] fix: prevent fix-of-fix chains and fix WorkerPool data race - Reject fix jobs as parents in both TUI (F key) and API (handleFixJob), preventing confusing fix-of-fix chains - Fix data race in WorkerPool.Start/Stop by calling wg.Add(n) once before spawning goroutines, instead of per-iteration Add(1) which races with Stop's wg.Wait() - Skip TestDaemonSignalCleanup on Windows (file locking) Co-Authored-By: Claude Opus 4.6 --- cmd/roborev/tui_handlers.go | 9 ++++++++- internal/daemon/server.go | 6 +++++- internal/daemon/server_test.go | 30 ++++++++++++++++++++++++++++++ internal/daemon/worker.go | 2 +- 4 files changed, 44 insertions(+), 3 deletions(-) diff --git a/cmd/roborev/tui_handlers.go b/cmd/roborev/tui_handlers.go index 05f22755..d8ac5fb5 100644 --- a/cmd/roborev/tui_handlers.go +++ b/cmd/roborev/tui_handlers.go @@ -1391,7 +1391,14 @@ func (m tuiModel) handleFixKey() (tea.Model, tea.Cmd) { job = m.jobs[m.selectedIdx] } - // Only allow fix on completed jobs with a failing verdict + // Only allow fix on completed review jobs (not fix jobs — + // fix-of-fix chains are not supported). + if job.IsFixJob() { + m.flashMessage = "Cannot fix a fix job" + m.flashExpiresAt = time.Now().Add(2 * time.Second) + m.flashView = m.currentView + return m, nil + } if job.Status != storage.JobStatusDone { m.flashMessage = "Can only fix completed reviews" m.flashExpiresAt = time.Now().Add(2 * time.Second) diff --git a/internal/daemon/server.go b/internal/daemon/server.go index 7dad2478..3c299505 100644 --- a/internal/daemon/server.go +++ b/internal/daemon/server.go @@ -1661,12 +1661,16 @@ func (s *Server) handleFixJob(w http.ResponseWriter, r *http.Request) { return } - // Fetch the parent job + // Fetch the parent job — must be a review (not a fix job) parentJob, err := s.db.GetJobByID(req.ParentJobID) if err != nil { writeError(w, http.StatusNotFound, "parent job not found") return } + if parentJob.IsFixJob() { + writeError(w, http.StatusBadRequest, "parent job must be a review, not a fix job") + return + } // Build the fix prompt fixPrompt := req.Prompt diff --git a/internal/daemon/server_test.go b/internal/daemon/server_test.go index be7501ea..6eb8319a 100644 --- a/internal/daemon/server_test.go +++ b/internal/daemon/server_test.go @@ -3582,6 +3582,36 @@ func TestHandleFixJobStaleValidation(t *testing.T) { db.ClaimJob("w1") db.CompleteJob(reviewJob.ID, "test", "prompt", "FAIL: issues found") + t.Run("fix job as parent is rejected", func(t *testing.T) { + // Create a fix job and try to use it as a parent + fixJob, _ := db.EnqueueJob(storage.EnqueueOpts{ + RepoID: repo.ID, + CommitID: commit.ID, + GitRef: "fix-val-abc", + Agent: "test", + JobType: storage.JobTypeFix, + ParentJobID: reviewJob.ID, + }) + db.ClaimJob("w-fix-parent") + db.CompleteJob(fixJob.ID, "test", "prompt", "done") + + body := map[string]any{ + "parent_job_id": fixJob.ID, + } + req := testutil.MakeJSONRequest( + t, http.MethodPost, "/api/job/fix", body, + ) + w := httptest.NewRecorder() + server.handleFixJob(w, req) + + if w.Code != http.StatusBadRequest { + t.Errorf( + "Expected 400 for fix-job parent, got %d: %s", + w.Code, w.Body.String(), + ) + } + }) + t.Run("stale job that is not a fix job is rejected", func(t *testing.T) { // reviewJob is a review, not a fix job body := map[string]any{ diff --git a/internal/daemon/worker.go b/internal/daemon/worker.go index 17c83881..e244b4ff 100644 --- a/internal/daemon/worker.go +++ b/internal/daemon/worker.go @@ -67,8 +67,8 @@ func NewWorkerPool(db *storage.DB, cfgGetter ConfigGetter, numWorkers int, broad func (wp *WorkerPool) Start() { log.Printf("Starting worker pool with %d workers", wp.numWorkers) + wp.wg.Add(wp.numWorkers) for i := 0; i < wp.numWorkers; i++ { - wp.wg.Add(1) go wp.worker(i) } } From 780d6c8f81fdbe16b70a7be3fc6842db165cef62 Mon Sep 17 00:00:00 2001 From: Wes McKinney Date: Thu, 19 Feb 2026 17:59:49 -0600 Subject: [PATCH 49/49] test: cover non-terminal stale status and dirtyPatchFiles error path - Add table-driven subtests for queued/running/failed/canceled stale jobs asserting 400 rejection from handleFixJob - Add TestDirtyPatchFilesError verifying git diff failure propagates as an error instead of returning nil Co-Authored-By: Claude Opus 4.6 --- cmd/roborev/tui_test.go | 12 +++++++++ internal/daemon/server_test.go | 48 ++++++++++++++++++++++++++++++++++ 2 files changed, 60 insertions(+) diff --git a/cmd/roborev/tui_test.go b/cmd/roborev/tui_test.go index df0f7f74..3da2af4b 100644 --- a/cmd/roborev/tui_test.go +++ b/cmd/roborev/tui_test.go @@ -1549,6 +1549,18 @@ func TestPatchFiles(t *testing.T) { } } +func TestDirtyPatchFilesError(t *testing.T) { + // dirtyPatchFiles should return an error when git diff fails + // (e.g., invalid repo path), not silently return nil. + _, err := dirtyPatchFiles("/nonexistent/repo/path", []string{"file.go"}) + if err == nil { + t.Fatal("expected error from dirtyPatchFiles with invalid repo, got nil") + } + if !strings.Contains(err.Error(), "git diff") { + t.Errorf("expected error to mention 'git diff', got: %v", err) + } +} + func TestTUIFixTriggerResultMsg(t *testing.T) { t.Run("warning shows flash and triggers refresh", func(t *testing.T) { m := newTuiModel("http://localhost") diff --git a/internal/daemon/server_test.go b/internal/daemon/server_test.go index 6eb8319a..6aeba17e 100644 --- a/internal/daemon/server_test.go +++ b/internal/daemon/server_test.go @@ -3700,6 +3700,54 @@ func TestHandleFixJobStaleValidation(t *testing.T) { } }) + t.Run("non-terminal stale job statuses are rejected", func(t *testing.T) { + for _, status := range []storage.JobStatus{ + storage.JobStatusQueued, + storage.JobStatusRunning, + storage.JobStatusFailed, + storage.JobStatusCanceled, + } { + t.Run(string(status), func(t *testing.T) { + fixJob, _ := db.EnqueueJob(storage.EnqueueOpts{ + RepoID: repo.ID, + CommitID: commit.ID, + GitRef: "fix-val-abc", + Agent: "test", + JobType: storage.JobTypeFix, + ParentJobID: reviewJob.ID, + }) + // Move to desired status + switch status { + case storage.JobStatusRunning: + db.ClaimJob("w-term") + case storage.JobStatusFailed: + db.ClaimJob("w-term") + db.FailJob(fixJob.ID, "w-term", "broke") + case storage.JobStatusCanceled: + db.CancelJob(fixJob.ID) + } + // queued needs no extra action + + body := map[string]any{ + "parent_job_id": reviewJob.ID, + "stale_job_id": fixJob.ID, + } + req := testutil.MakeJSONRequest( + t, http.MethodPost, "/api/job/fix", body, + ) + w := httptest.NewRecorder() + server.handleFixJob(w, req) + + if w.Code != http.StatusBadRequest { + t.Errorf( + "Expected 400 for %s stale job, got %d: %s", + status, w.Code, w.Body.String(), + ) + } + }) + } + }) + t.Run("stale job from different repo is rejected", func(t *testing.T) { // Create a fix job in a different repo repo2Dir := filepath.Join(tmpDir, "repo-fix-val-2")