Skip to content

task-work / task-reviewer / task-recreate-worker sometimes leave focus on the new tab: the snap-back to the caller's tab races zellij's new-tab: re-check focus and retry go-to-tab (#311) - #312

Merged
martin-conur merged 2 commits into
mainfrom
task/tab-focus-race
Oct 22, 2026

Conversation

@martin-conur

Copy link
Copy Markdown
Owner

Summary

Launching a worker sometimes left you on the new worker's tab, because zellij opens tabs asynchronously and the snap-back go-to-tab could land before the new tab finished opening. The launcher now re-checks which tab is active for up to about 1.5s and sends go-to-tab again until yours is, and logs a tab-focus: line to radio's log whenever it had to retry or gave up.

Closes #311

Changes

  • lib/zellij-tab.sh: new aw_snap_back_to_tab, called from aw_launch_tab only on the stay-on-caller path. After the go-to-tab it reads list-tabs --json .active; while that is not the caller's position it sleeps 0.25s and issues go-to-tab again, up to 7 checks. The common case is one list-tabs with no sleep. An unusable list-tabs answer ends the loop quietly. It never fails the launch. On a retry it logs tab-focus: snap-back needed N go-to-tab(s) slug=… caller_position=…; on giving up it logs tab-focus: snap-back failed after N go-to-tab(s) … active_position=… plus one stderr line. AW_TAB_FOCUS_CHECKS / AW_TAB_FOCUS_INTERVAL are internal knobs for tests, not flags.
  • tests/helpers/stubs/zellij: STUB_ZELLIJ_TABS_JSON_QUEUE, a file of list-tabs answers consumed one per call, so a test can move focus between calls.
  • tests/zellij_tab_stay_focused.bats: five new tests for the race (2 go-to-tabs + 1 log line), caller active at once (1 go-to-tab, 2 list-tabs, no log), --focus (no go-to-tab), a caller that never comes back (bounded, logged, launch succeeds), and the default bound in wall time. The file now uses a private radio home so the log assertions don't see other tests' lines.
  • Docs: a troubleshooting row in the README, .claude/gh-workflow.md and the seven steering templates. CHANGELOG entry; no task-init re-run needed.

Verification

  • ./run_tests.sh: 1597 ok, 0 failures. Shellcheck clean, tools/check-drift.sh clean. After the full run I changed the default from 6 to 7 checks (6 gave only 1.25s of sleep) and fixed a flattened line continuation, then re-ran the focus suite: 20/20.
  • Mutation checks: removing the re-check turned 4 new tests red, the first among them; logging on every snap-back turned the no-log test red; ignoring the check bound turned both bound tests red.
  • Tested against the bats zellij stub only, per PM guardrail. I did not run a live zellij probe.

🤖 Generated with Claude Code

…s on the new tab: the snap-back to the caller's tab races zellij's new-tab: re-check focus and retry go-to-tab (#311)

zellij handles `new-tab` asynchronously and always focuses the new tab, so
the snap-back `go-to-tab` in `aw_launch_tab` could land before the tab had
finished opening. The new `aw_snap_back_to_tab` checks the active tab after
the `go-to-tab` and re-issues it every 0.25s, up to 7 checks (~1.5s). A
retry or a final failure writes one `tab-focus:` line to radio's log. The
`--focus` path is unchanged and a launch never fails over focus.

The zellij stub gains `STUB_ZELLIJ_TABS_JSON_QUEUE` so tests can move focus
between `list-tabs` calls.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@martin-conur

Copy link
Copy Markdown
Owner Author

Spec compliance

Checked against #311; all deliverables are present.

  • Re-check after the snap-back (list-tabs --json, .active), bounded to about 1.5s, stopping once the caller's tab is active: aw_snap_back_to_tab does this.
  • Runs only on the stay_on_caller_tab=1 path (--focus unchanged), adds no flags, and always return 0, so a launch never fails over focus.
  • Logs tab-focus: lines on a needed retry and on final failure, in the style of the tab-id: / tab-close: lines. The common case is a single list-tabs with no sleep and no log.
  • All four acceptance tests are present, plus a wall-time bound test. The CHANGELOG entry states that no task-init re-run is needed, which is correct because lib/ is read from the checkout.
  • Docs row added to the README, .claude/gh-workflow.md and all seven steering templates. The PR body opens with ## Summary.
  • CI: a run exists for head SHA 4050583 and all 8 checks are green.

Code-review findings

No blockers. I traced the loop: gotos tops out at checks, the (( i < checks )) || break guard avoids a trailing sleep and goto, an empty or non-JSON list-tabs returns quietly, and aw_radio_log is safe with no radio home. The stub queue consumes one answer per call and falls back to the fixed fixture once empty.

Nit (non-blocking, worth a sentence rather than code): the loop returns as soon as the first check sees the caller active. If the very first list-tabs is answered before the server has processed new-tab (the late-focus-steal ordering), it reports the caller active and returns, and the new tab then takes focus unseen. The issue's own spec ("stop as soon as the caller's tab is active") describes the current behaviour, so this is faithful to the spec. The PR body says no live zellij probe was run. If a live run shows focus is still lost after a clean first check, the fix is to require two consecutive active observations. Consider noting this limit in the function comment.

Verdict

clean-with-nits. The change is small and matches the spec. PM can merge, and the nit above doesn't need to block.

@martin-conur

Copy link
Copy Markdown
Owner Author

PM: one round of small items before merge

CI is green and the reviewer is clean-with-nits. Three things, all in this PR:

  1. Reviewer's nit: document the limit. In the aw_snap_back_to_tab comment, add a sentence saying that a first list-tabs answered before zellij has processed new-tab reports the caller active, so a later focus steal goes unseen. The fix, should a live run ever show it, is to require two consecutive active observations. Comment only; leave the behaviour as specified.

  2. Untested stderr warning. I deleted the Could not keep focus on your tab echo and the whole file stayed green. In the "caller never comes back" test, assert that warning is on stderr (run --separate-stderr, or assert on $output if bats merges the streams there), and assert it is absent in the race-recovered and first-check-clean tests.

  3. Untested "quiet on an unusable list-tabs" path. I changed [[ -n "$active" ]] || return 0 to || break (an empty answer then logs snap-back failed and warns). Every test still passed. The existing empty-list test only covers the lookup before new-tab. Add one that empties the list-tabs answer mid-loop: the queue gives the caller-pos lookup, then a new-tab-active answer, then an empty or non-JSON line. Assert no tab-focus: log line and no warning.

Please mutation-check 2 and 3 yourself: revert each mutation above and watch the test go red. Then send re-review-requested.

…s on the new tab: the snap-back to the caller's tab races zellij's new-tab: test the warning and the quiet stop, note the first-check limit (#311)

Review round on PR #312:
- `aw_snap_back_to_tab`'s comment states the known limit: a first
  `list-tabs` answered before zellij processed `new-tab` reports the caller
  active, so a later focus steal goes unseen; the fix would be two
  consecutive observations.
- The never-comes-back test asserts the stderr warning; the recovered and
  clean tests assert it is absent.
- New test: `list-tabs` turning unusable mid-loop stops with no log line and
  no warning.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@martin-conur
martin-conur merged commit 0cca717 into main Oct 22, 2026
8 checks passed
@martin-conur
martin-conur deleted the task/tab-focus-race branch October 22, 2026 19:46
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.

task-work / task-reviewer / task-recreate-worker sometimes leave focus on the new tab: the snap-back to the caller's tab races zellij's new-tab

1 participant