Skip to content

fix(masters): retry no-op action-button click, calibrate status timeout (#766, #764) - #771

Merged
axisrow merged 4 commits into
mainfrom
fix/766-764-suspend-timeout-and-click
Aug 6, 2026
Merged

fix(masters): retry no-op action-button click, calibrate status timeout (#766, #764)#771
axisrow merged 4 commits into
mainfrom
fix/766-764-suspend-timeout-and-click

Conversation

@axisrow

@axisrow axisrow commented Aug 5, 2026

Copy link
Copy Markdown
Owner

Что было

direct masters suspend не останавливал Мастер-кампании: клик не менял статус, команда падала по таймауту 60 с, retry не помогал (#766). Параллельно #764 просил измерить реальную задержку смены статуса вместо взятой наугад константы.

Что нашла живая разведка (2026-08-06, кампания 713277109)

Оба issue — один корень, и это не то, что предполагалось ни в одном из них:

Первый клик по кнопке на свежеотрисованной странице обзора — регулярно молчаливый no-op. Проверки actionability проходят, .click() возвращается без исключения, и — подтверждено перехватом всех запросов после клика — не уходит вообще ни одного сетевого запроса. React ещё не навесил свой обработчик. Никакое ожидание это состояние не чинит: поэтому повторный запуск команды не помогал, а 60-секундный бюджет из #758 лишь замедлял каждый отказ.

Промаха по подписи кнопки (гипотеза #766 со ссылкой на #630) не было: "Остановить кампанию" матчилась. Зато нашлись стабильные testid'ы, снятые с живого DOM.

Замер для #764 — 12 реальных переходов в двух прогонах: от результативного клика до обновления статуса на странице 1.64–2.28 с (среднее 1.8 с), без длинного хвоста. 60 с никогда не измеряли эту задержку — они маскировали баг выше.

Что сделано

  • Клик по кнопке действия ретраится (_STATUS_CLICK_MAX_ATTEMPTS = 4) со сверкой статуса перед каждой попыткой — результативный клик никогда не повторяется. То же для шага unarchive→SUSPENDED в resume. Это ровно то лечение, которое _click_and_wait_for_popup уже применял к «⋮»-меню и модалке переименования (masters copy: клик по меню-триггеру race — 'Клонировать' не находится сразу после _goto_overview_page #723/masters update --name: клик по карандаш-кнопке race, модалка переименования не открывается #725); suspend/resume его просто никогда не получал.
  • _STATUS_CHANGE_TIMEOUT_MS: 60 с → 8 с, теперь измеренное значение (~3.5× наблюдённого максимума).
  • Кнопки резолвятся по подтверждённым живьём testid: CampaignHeader.ActionButton.stop / .resume. Текстовые подписи остались фолбэком и теперь резолвят объемлющий <button> перед кликом — get_by_text матчит <span class="dc-Button__text"> внутри, из-за чего проверки disabled/aria-disabled раньше делались не по тому элементу.
  • Текущий статус поллится, а не читается однократно: одиночное чтение живьём оборвало реальный masters suspend ошибкой «unrecognised status text» на кампании, чей статус читался секундой позже.
  • Батч больше не обрывается на первом упавшем ID (masters suspend: клик не меняет статус, падение по таймауту 60s (не помогает retry) #766): проходятся все ID, по каждому отчёт (строка результата или Error), ненулевой exit — только после печати всех исходов. Так уже вели себя launch/archive (feat(masters): add direct masters archive (#633) #645); теперь все четыре команды используют общий _run_per_id.
  • Ошибка «не нашёл кнопку» называет и селектор, и искомые подписи, и перечисляет, что на странице реально есть, плюс прочитанный статус.

Верификация

Живьём на кампании 713277109 (по согласованному слоту):

Проверка Результат
masters resume (SUSPENDED→MODERATION) ✅ 19 с (было — падение по 60 с)
masters suspend (ACTIVE→SUSPENDED) ✅ 15.8 с
Батч 713277109,999999999 ✅ реальный ID обработан, плохой отчитался ошибкой, батч не оборвался, exit ≠ 0
Возврат в исходное состояние ✅ ACTIVE

Офлайн: 3238 passed, 23 skipped (весь сьют), black/flake8 чисто по изменённым файлам. Добавлено 8 регрессионных тестов: no-op-ретрай, отсутствие повторного клика после успеха, приоритет testid над текстом, клик по <button> а не <span>, содержимое ошибки, поллинг статуса, батч-устойчивость для suspend и resume.

Замечание к мержу

Ветка не содержит direct_cli/browser/_clock.py из #767/PR #770 (параллельный воркер). Мои poll-циклы используют time.monotonic() — как весь остальной модуль на этой ветке. После мержа #770 их стоит перевести на _clock.now(); снижение таймаута 60с→8с само по себе уже убирает основную часть busy-spin в этих тестах.

Closes #766
Closes #764

🤖 Generated with Claude Code

https://claude.ai/code/session_01H564VvyTSMJQ8BreL9bMn5

axisrow and others added 3 commits August 6, 2026 12:05
…ut (#766, #764)

`masters suspend`/`resume` never changed the campaign's status and failed by
timeout. Live diagnosis (2026-08-06, campaign 713277109) found the cause is
not a missed selector nor a too-small timeout: the FIRST click on a freshly
rendered overview page is frequently a silent no-op — Playwright's
actionability checks pass, `.click()` returns without raising, and no network
request is issued at all, because React's own click handler was not yet
attached. Waiting longer can never fix that state, which is why retrying the
CLI command never helped and why #758's 60s budget only made each failure
slower.

- Retry the action-button click (`_STATUS_CLICK_MAX_ATTEMPTS`, 4), checking
  the status before each attempt so an effective click is never repeated.
  Same for the unarchive->SUSPENDED leg of resume.
- Lower `_STATUS_CHANGE_TIMEOUT_MS` 60s -> 8s, now measured: over 12 real
  transitions the lag from an effective click to the status text updating
  was 1.64-2.28s (mean 1.8s), no long tail (#764).
- Resolve both buttons via live-confirmed testids
  (`CampaignHeader.ActionButton.stop`/`.resume`) instead of guessed Russian
  labels; keep the labels as a fallback that now resolves the enclosing
  <button> before clicking.
- Poll for a recognised status instead of reading once — a single read
  live-aborted a real suspend with "unrecognised status text".
- Don't abort the batch at the first failing ID: attempt every ID, report
  each outcome, exit non-zero afterwards. `suspend`/`resume`/`launch`/
  `archive` now share one `_run_per_id` helper.
- Name the selector, the labels searched, and what the page actually renders
  in the "could not find the button" error.

Live-verified end to end against campaign 713277109 (suspend, resume,
mixed-ID batch); returned to its original ACTIVE state afterwards.

Closes #766
Closes #764

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H564VvyTSMJQ8BreL9bMn5
The 60s -> 8s recalibration in #764 measured one quantity (the lag from an
effective click to the status text updating, 1.64-2.28s over 12 live
transitions) but was applied to two. `_wait_for_recognised_status` waits for
the status element to render at all after navigating -- a different, never
measured quantity that ran on the pre-#766 60s budget inline in
`resume_master` before this branch folded it into the shared constant.

That path branches: a campaign whose ARCHIVED status has not hydrated within
the budget reads as None, skips the unarchive step, and then hunts for a
resume button an archived page never renders -- leaving the campaign archived
behind a misleading "could not find the button" error.

Give it its own `_STATUS_HYDRATION_TIMEOUT_MS`, keeping the pre-#766 60s
until a real measurement replaces it. The measured 8s stays where it was
measured, post-click.

Also add a temporary, off-by-default timing scaffold: with
DIRECT_MASTERS_DEBUG_TIMING=1 each wait prints its actual duration and the
click count to stderr, so the remaining unmeasured budget gets set from live
runs instead of guessed a third time. Deliberately minimal -- one env var
read in one function, three call sites, no CLI flag and no logging config --
so removing it is a clean delete.

The new guard test asserts the two budgets stay separate; mutation-checked by
collapsing them, which turns it red.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H564VvyTSMJQ8BreL9bMn5
…d button

The retry loop clicked, polled, and — on a non-target read — clicked again
with no status read in between, while the module docstring claimed it was
"checking the status before each attempt so an already-effective click is
never repeated". The poll has a deadline, so that claim only held while the
status update arrived inside the 8s window.

Past it, the second click is actively harmful: for `suspend` it toggles the
campaign straight back to ACTIVE, and for `resume` the page has already
swapped `CampaignHeader.ActionButton.resume` for `.stop`, so the re-click
matches neither the testid nor the fallback labels and raises "could not
find an action button" -- reporting a hard failure for a mutation that
succeeded, which `_run_per_id` then surfaces as "Failed to resume 1 of N".

Re-read the status immediately before every retry (both the action-button
loop and the unarchive menu-item loop), and treat a button that vanished
between that read and the click as success when the status already reached
its target. Two tests cover the two guards, each mutation-checked by
removing the guard it covers.

Also routes this branch's poll deadlines through `_clock.now()` (#767/#770,
merged into main meanwhile) as the PR description anticipated, and gives
`_wait_for_recognised_status` the hydration budget rather than the
post-click one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H564VvyTSMJQ8BreL9bMn5
@axisrow
axisrow force-pushed the fix/766-764-suspend-timeout-and-click branch from 0e4465b to e7eec86 Compare August 6, 2026 05:13
…imeout-and-click

# Conflicts:
#	CHANGELOG.md
@axisrow
axisrow merged commit 21fe3a2 into main Aug 6, 2026
6 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

1 participant