Skip to content

fix: moving a blocked task to In Progress no longer bounces it to Backlog (#674)#676

Merged
bborn merged 2 commits into
mainfrom
fix/blocked-to-inprogress-674
Jul 21, 2026
Merged

fix: moving a blocked task to In Progress no longer bounces it to Backlog (#674)#676
bborn merged 2 commits into
mainfrom
fix/blocked-to-inprogress-674

Conversation

@bborn

@bborn bborn commented Jul 20, 2026

Copy link
Copy Markdown
Owner

Fixes #674.

Root cause

A blocked task's executor goroutine is parked inside pollTmuxSession — the tmux session idles at a prompt, so taskExecutor.Execute never returns and runningTasks[id] stays set the whole time the task is blocked. 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 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:

  • showChangeStatus maps "In Progress" → db.StatusQueued (internal/ui/app.go:3700)
  • pollTmuxSession returns Interrupted on queued (executor.go:4096)
  • executeTask writes StatusBacklog for any interrupt, as its first finalization branch (executor.go:1936)

queued is the only modal option that breaks: Blocked → Done is handled by the Success/currentStatus == done branches, and Blocked → Backlog writes 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.

  • Add Requeued to ExecResult and the internal execResult (added last, matching order — the two convert by direct struct conversion, so field order must stay in sync).
  • pollTmuxSession's queued branch now returns {Interrupted, Requeued}. The ctx-cancel and backlog branches stay plain {Interrupted} — those are genuine cancels that should land in backlog.
  • executeTask finalization gains a Requeued branch 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 via TriggerProcessing(). The running-task slot is freed in the goroutine's defer, 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 set queued.

Not addressed here (flagged)

executeTask releases the runningTasks/cancelFuncs slot 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:

queued  -> {Interrupted: true, Requeued: true}
backlog -> {Interrupted: true}   (no Requeued)

The executeTask finalization 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 ./..., and golangci-lint run ./... (v2.8.0, the pinned CI version) are all green.

🤖 Generated with Claude Code

…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>
@bborn
bborn merged commit ccea2e0 into main Jul 21, 2026
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Changing a blocked task to In Progress can immediately move it to Backlog

1 participant