diff --git a/internal/executor/executor.go b/internal/executor/executor.go index 002612bc..ea123f56 100644 --- a/internal/executor/executor.go +++ b/internal/executor/executor.go @@ -1933,7 +1933,16 @@ func (e *Executor) executeTask(ctx context.Context, task *db.Task) { // Update final status and trigger hooks // Respect status set by hooks - don't override blocked with done - if result.Interrupted { + if result.Requeued { + // The poller exited because the task was re-queued (e.g. the user flipped a + // blocked task back to "In Progress"), NOT because it was cancelled. Leave the + // task in 'queued' and let the worker pick it up for a fresh run — do NOT write + // backlog. Releasing this run's slot (via the deferred runningTasks cleanup) + // plus the worker's poll is enough; the fresh run's KillAllWindowsByNameAllSessions + // tears down the stale window, so no duplicate session lingers. + e.logLine(task.ID, "system", "Task re-queued - starting a fresh run") + e.TriggerProcessing() + } else if result.Interrupted { // Explicitly set to backlog - don't assume Interrupt() already did it, // as the interruption may have come from pollTmuxSession detecting a // stale status or context cancellation. @@ -2414,6 +2423,7 @@ type execResult struct { Success bool NeedsInput bool Interrupted bool + Requeued bool Message string } @@ -4094,9 +4104,12 @@ func (e *Executor) pollTmuxSession(ctx context.Context, taskID int64, sessionNam return execResult{Success: true} } if task.Status == db.StatusQueued { - // Task was re-queued (e.g. retry) - stop polling so the - // worker can pick it up fresh without a duplicate session - return execResult{Interrupted: true} + // Task was re-queued (e.g. retry, or the user manually flipped a + // blocked task back to "In Progress") - stop polling so the worker + // can pick it up fresh without a duplicate session. Report this as + // Requeued, NOT Interrupted: an interrupt is finalized to backlog + // (a cancellation), which would immediately undo the requeue. + return execResult{Requeued: true} } } diff --git a/internal/executor/executor_test.go b/internal/executor/executor_test.go index 254b5fa1..71a9588e 100644 --- a/internal/executor/executor_test.go +++ b/internal/executor/executor_test.go @@ -1936,3 +1936,50 @@ func TestBuildPromptUsesFileKindInstructions(t *testing.T) { t.Errorf("prompt should include the file kind's instructions; got:\n%s", prompt) } } + +// TestPollTmuxSessionRequeuesOnQueuedStatus verifies that when a task's status +// flips to "queued" while its poller is still alive (e.g. the user manually +// changes a blocked task back to "In Progress"), pollTmuxSession reports the +// exit as Requeued — NOT Interrupted. Reporting Interrupted would cause +// executeTask to finalize the task to backlog, immediately undoing the requeue +// (github.com/bborn/taskyou/issues/674). +func TestPollTmuxSessionRequeuesOnQueuedStatus(t *testing.T) { + tmpFile, err := os.CreateTemp("", "test-*.db") + if err != nil { + t.Fatal(err) + } + defer os.Remove(tmpFile.Name()) + tmpFile.Close() + + database, err := db.Open(tmpFile.Name()) + if err != nil { + t.Fatal(err) + } + defer database.Close() + + if err := database.CreateProject(&db.Project{Name: "test", Path: "/tmp/test"}); err != nil { + t.Fatal(err) + } + + exec := New(database, &config.Config{}) + + // Task is queued (as showChangeStatus writes when mapping "In Progress"). + task := &db.Task{Title: "Test task", Status: db.StatusQueued, Project: "test"} + if err := database.CreateTask(task); err != nil { + t.Fatal(err) + } + + // The DB-status check is the first thing pollTmuxSession does each tick, so it + // returns before touching tmux. Give it enough headroom for one tick. + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + result := exec.pollTmuxSession(ctx, task.ID, "nonexistent-session") + + if !result.Requeued { + t.Errorf("expected Requeued=true for a re-queued task, got %+v", result) + } + if result.Interrupted { + t.Error("expected Interrupted=false for a re-queued task; a re-queue must not be finalized to backlog") + } +} diff --git a/internal/executor/task_executor.go b/internal/executor/task_executor.go index 2a6a5122..5c435852 100644 --- a/internal/executor/task_executor.go +++ b/internal/executor/task_executor.go @@ -12,6 +12,7 @@ type ExecResult struct { Success bool // Task completed successfully NeedsInput bool // Task is waiting for user input Interrupted bool // Task was interrupted by user + Requeued bool // Task was re-queued (e.g. manual "In Progress") and should resume via a fresh run, not be treated as a cancellation Message string // Status message or error }