fix: moving a blocked task to In Progress no longer bounces it to Backlog (#674)#676
Merged
Conversation
…klog (#674) A blocked task's executor goroutine stays parked inside pollTmuxSession — the tmux session idles at a prompt, so taskExecutor.Execute never returns and runningTasks[id] stays set. When a human moved the task to "In Progress" (which writes status `queued`), the parked poller saw `queued`, returned `Interrupted: true`, and executeTask's finalization unconditionally wrote `StatusBacklog`, clobbering the requeue. The worker then never ran it because the status was backlog. Because a blocked task's poller is essentially always parked, this was closer to deterministic than racy. Fix: distinguish a deliberate re-queue from a genuine interrupt/cancel. - Add `Requeued` to ExecResult / execResult (same field order — the two convert by direct struct conversion). - pollTmuxSession's `queued` branch now returns {Interrupted, Requeued}; the ctx-cancel and `backlog` branches stay plain {Interrupted} (genuine cancels that should still land in backlog). - executeTask finalization gains a Requeued branch that runs BEFORE the generic interrupt handler: it preserves the queued status (writes no status), kills the stale session, and wakes the worker. The running-task slot is freed in the goroutine's defer, which the worker's admission gate waits on, so the fresh spawn is sequential — no double-session. The TUI modal is unchanged: it already sets `queued`, which now behaves correctly. Same fix covers the retry and web-requeue paths, which also set queued. Not addressed here: executeTask releases the runningTasks/cancelFuncs slot by bare task ID with no ownership token. It is not reachable today (the slot-gate keeps executions strictly sequential), but it is a latent footgun if a future path ever overlaps executions for one task. Flagged for a separate hardening. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Adopts the cleaner signal from the parallel fix (#677): a re-queue is not a cancellation, so pollTmuxSession now returns {Requeued: true} rather than {Interrupted: true, Requeued: true}. Verified safe — the only reader of Interrupted is executeTask's finalizer else-if, which the Requeued branch already short-circuits (it kills the stale session and returns). Keeps #676's explicit Kill + early return, which close the brief window where the stale process would otherwise outlive the handoff. Test updated to assert the two signals are mutually exclusive. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #674.
Root cause
A blocked task's executor goroutine is parked inside
pollTmuxSession— the tmux session idles at a prompt, sotaskExecutor.Executenever returns andrunningTasks[id]stays set the whole time the task is blocked. When a human moved the task to "In Progress" (which writes statusqueued), the parked poller sawqueued, returnedInterrupted: true, andexecuteTask's finalization unconditionally wroteStatusBacklog, clobbering the requeue. The worker then never ran the task because its status was backlog.The reporter hedged that this is timing-dependent. It's closer to deterministic: a blocked task's poller is essentially always parked, so the requeue reliably gets clobbered. Verified still present on current
main.The three-step chain in the issue is accurate:
showChangeStatusmaps "In Progress" →db.StatusQueued(internal/ui/app.go:3700)pollTmuxSessionreturnsInterruptedonqueued(executor.go:4096)executeTaskwritesStatusBacklogfor any interrupt, as its first finalization branch (executor.go:1936)queuedis the only modal option that breaks:Blocked → Doneis handled by theSuccess/currentStatus == donebranches, andBlocked → Backlogwrites the same status the user asked for.Fix
Distinguish a deliberate re-queue from a genuine interrupt/cancel, which is exactly the reporter's suggested approach.
RequeuedtoExecResultand the internalexecResult(added last, matching order — the two convert by direct struct conversion, so field order must stay in sync).pollTmuxSession'squeuedbranch now returns{Interrupted, Requeued}. The ctx-cancel andbacklogbranches stay plain{Interrupted}— those are genuine cancels that should land in backlog.executeTaskfinalization gains aRequeuedbranch before the generic interrupt handler. It preserves the queued status (writes no status), kills the stale session so the fresh run starts clean, and wakes the worker viaTriggerProcessing(). The running-task slot is freed in the goroutine'sdefer, which the worker's admission gate (if e.runningTasks[id]) waits on — so the handoff is strictly sequential and there's no double-session.The TUI modal is unchanged: it already sets
queued, which now behaves correctly. The same fix also covers the retry and web-requeue paths, which likewise setqueued.Not addressed here (flagged)
executeTaskreleases therunningTasks/cancelFuncsslot by bare task ID with no ownership token. I traced whether a stale poller could free a newer run's slot (the second bug hinted at in analysis) — it is not reachable today, because the slot-gate keeps executions for one task strictly sequential (the parked poller holds the slot until it fully exits). It remains a latent footgun if a future path ever overlaps executions for a single task. Worth a separate hardening (a per-execution generation token), deliberately kept out of this fix to avoid adding concurrency machinery to this file for an unreachable state.QA
Executor-internal change, no visible surface. New deterministic test
TestPollTmuxSessionRequeueVsBacklog(internal/executor/poll_tmux_requeue_test.go) asserts the poll contract the fix keys off:The
executeTaskfinalization branch itself isn't unit-tested — it spawns a real backend, git worktrees, and a live tmux session, none stubbable with current helpers — and the test file says so explicitly.go build ./...,go test ./..., andgolangci-lint run ./...(v2.8.0, the pinned CI version) are all green.🤖 Generated with Claude Code