Skip to content

fix(ui): stop the Schedules list unmounting on refetch so toggling never scrolls to top (#1634) - #1939

Merged
vybe merged 9 commits into
devfrom
vybe/issue-1634
Aug 8, 2026
Merged

fix(ui): stop the Schedules list unmounting on refetch so toggling never scrolls to top (#1634)#1939
vybe merged 9 commits into
devfrom
vybe/issue-1634

Conversation

@vybe

@vybe vybe commented Aug 1, 2026

Copy link
Copy Markdown
Contributor

Fixes #1634

Toggling a schedule on Agent Detail → Schedules no longer throws the page back to the top. The panel-wide spinner is re-gated to mean "no data yet" rather than "a fetch is in flight", so the list stops unmounting on every refetch, the document stops collapsing to spinner height, and the browser stops clamping window.scrollY to 0.


⚠️ UNVERIFIED-BY-AGENT — read this first

No Playwright test in this PR has been executed. The spec was validated by collect-only (--list) and static review only; no isolated stack was available (the only agent-hosting instance was the operator's live one, and the spec seeds and deletes real schedules). The red-first matrix and the manual matrix M1–M7 are both outstanding and are the load-bearing verification for this fix — CI proves nothing here beyond frontend-build + check:tokens.

Runtime behaviour of this fix has not been observed by any agent. Please run M1–M7 and the red-first matrix below before flipping this out of draft.


⚠️ FIXTURE HAZARD — pin the agent before running the spec

With SCHEDULES_TEST_AGENT unset, the spec's AGENT_A defaults to usable[0]the first non-system agent returned by /api/agents, potentially a real production agent — which then receives 14 seeded schedules and a prefix-scoped delete sweep.

Mitigated by the far-future cron 0 4 29 2 * (next occurrence 2028-02-29, so a seeded schedule can never dispatch a Claude turn) plus per-id teardown of every seeded row. But the run command must always pin the agent:

ADMIN_PASSWORD=… SCHEDULES_TEST_AGENT=<throwaway-agent> \
  npx playwright test e2e/schedules-toggle-scroll.spec.js

Never run it unset against an instance whose first non-system agent matters.


The change — 7 hunks, one file

src/frontend/src/components/SchedulesPanel.vue (+48/−8). Five hunks trace to an acceptance criterion or to a regression Hunk 1 itself opens; two are separable.

# Hunk Why Separable?
1 Spinner gate v-if="loading"v-if="loading && schedules.length === 0" The fix. The list never unmounts on refetch → document height never collapses → no scroll clamp. Matches ExecutionsPanel / LoopsPanel / TasksPanel / ReportsPanel / RoomsRail / CompatibilityPanel. No
2 Clear schedules + perfBySchedule in the agentName watcher With the spinner now gated on "no data yet", stale rows would (a) suppress the new agent's first-load spinner and (b) render agent A's rows under agent B's header. AC #4. No
3 loadSeq stale-response guard A superseded GET must not paint over a newer one. Hunk 2 cannot stop a late response: switching A→B while A's GET is in flight lands A's rows under B's header whenever A resolves last. AC #3/#4. No
4 await loadSchedules() before closeForm() Fixes a race Hunk 1 itself opens — see below. No
5 toggleLoading ref(null)ref(new Set()) One id meant clicking row B while A was in flight overwrote it, and A's unconditional finally then re-enabled B's control mid-request. AC #6's literal subject is rapid multi-row toggling, and Hunk 1 widens that window by keeping the list interactive during the refetch. No
6 data-testid hooks (schedule-row, schedule-status, schedule-toggle, :data-schedule-id) Pure test affordance, zero runtime behaviour — but load-bearing for the new spec. Yes
7 :disabled="deleteLoading === schedule.id" on Edit The adjacent Delete button already self-gates (:457/:467); Edit did not. Hunk 1 keeps the row interactive during the delete refetch, so Edit could open a modal on a row that is disappearing (save → 404). One attribute, mirrors its sibling. Yes

Hunk 1 is only safe with Hunk 4

Keeping the list mounted during the post-save refetch leaves the stale row's ungated Edit button live. A click there opens the modal pre-filled from pre-save data, and saving it silently reverts the edit just made. Hunk 4 removes the interactive-stale window entirely by holding the modal up (it already shows the sanctioned in-button formLoading spinner) until the list has refreshed.

Do not land Hunk 1 without Hunk 4.


Blast radius — disclosed, not hidden

The gate is on the render, not the caller. So create / edit / delete refetches also go silent, not just the toggle path:

  • Delete — explicitly requested by the issue ("Worth checking whether the same silent-refresh treatment should apply to the delete path").
  • Create / edit — an unrequested but strictly-better side effect that no AC covers. Called out so it is a reviewed decision, not an accident.

Why it is strictly better rather than a regression:

  • Cancel (:244-250) is not :disabled="formLoading" — only Save (:251+) is. So the longer-lived modal can never trap the user.
  • Save holds its "Saving…" state throughout, so the create path gains a progress signal rather than losing one.

What the fix does not promise

Toggling does not guarantee an unchanged window.scrollY, and claiming so would be wrong as physics. Chrome's default overflow-anchor: auto deliberately mutates the scroll offset when content above the anchor resizes, precisely to keep visible content still. The correct invariant is the toggled row's viewport y. Legitimate reflows remain in scope of normal behaviour: the #1796 autonomy banner appearing/disappearing as the enabled count crosses 0↔1; disabling clearing next_run_at and removing the Next: … chip from a flex-wrap row; deleting the last schedule swapping in the empty state.

Precise claim: the fix removes the unmount-on-refetch — the only cause of movement when the data has not changed.


Verification the human still owes

Red-first matrix (outstanding — CLAUDE.md Rule #6)

Paste both the red and the green output for each row.

Test Baseline it must be RED against Proves
T1 the unmodified tree Hunk 1 is load-bearing
T4 Hunk 1 only (Hunk 2 stashed) Hunk 2 is load-bearing
T5 Hunks 1+2 (Hunk 3 stashed) Hunk 3 is load-bearing (answers "untested defensive code")
T2 declared green on every baseline; a guard, not a red-first test
T3 declared green on every baseline; a guard, not a red-first test

A green T4/T5 against its stated baseline means the test is a tautology and must be rewritten, not shipped.

This is not hypothetical: review already caught and fixed one such tautology. T5 previously held agent A's GET behind a fixed 2500 ms sleep — a timing bet. If the click-through were slower, A would resolve while still newest, B's watcher would reload cleanly, and T5 would have passed with the loadSeq guard deleted. It is now gated on an explicit release fired only after B's load visibly completes. An earlier draft of T4 had already been sunk by the same failure mode.

Manual matrix M1–M7 (outstanding — please paste results)

ID Steps Expected
M1 Agent with ≥8 schedules → Schedules tab → scroll a mid-list row to centre → toggle it. Repeat on 3 rows. Page does not move. Only that row's icon spins; its badge flips. Pre-fix this jumps to the top every time.
M2 Reload /agents/X?tab=schedules, then leave to Overview and return. Panel-wide spinner on both (real v-if remount).
M3 From /agents/A?tab=schedules, switch to agent B using in-app navigation only. Spinner appears; agent A's schedule names never render under agent B's header.
M4 DevTools offline → toggle. Row spinner appears then clears; badge unchanged; console.error; button clickable again.
M5 Delete, then create, from a scrolled position. Neither unmounts the list. On create the modal stays up with "Saving…" until the list refreshes, then closes onto the new row (Hunk 4).
M6 Agent with zero schedules. "No schedules configured" empty state renders.
M7 Click toggle on row A then immediately row B. Both rows spin independently; neither control re-enables until its own request finishes (Hunk 5).

M2 and M3 fail silently if done via URL edit. The ?tab=schedules on M2 is required — activeTab is a plain ref and is never written to the URL, so a bare reload lands on Overview. And a URL edit is a full remount, not a same-document switch, so it does not exercise the M3 path at all (the watcher never fires and the test passes with Hunk 2 deleted).


Correcting the dossier: "last holdout" is FALSE

The originating dossier claimed SchedulesPanel.vue:272 was the last un-gated panel. It is not. Independently counted:

  • 42 bare v-if="loading" gates across ~30 list-bearing files (CredentialsPanel, GitPanel, PublicLinksPanel, FilesPanel, InfoPanel, MetricsPanel, NeverminedPanel, DashboardPanel, CapacityPanel, GuardrailsPanel, ScheduleAnalyticsCard, …).
  • Seven panels have adopted the gate; ~20+ have not.
  • bug: background polls re-flash loaded content and reset UI state (design-system p13/p14) #1927 — the open sweep issue for this class — names only 4 surfaces, so the sweep issue understates its own scope.

That measurement is the most useful by-product of this work and is recorded here as data for whoever picks up #1927.

Proposed mechanical follow-up: an ESLint rule forbidding a bare v-if="loading" whose v-else renders a v-for. That converts a ~30-file manual sweep into a lint-enforced invariant.


Alternatives rejected (with reasons)

"Just update the row locally" — not viable. POST .../enable|disable returns only {"status", "schedule_id"} (routers/schedules.py:379-414), and set_schedule_enabled (db/schedules/crud.py:541-570) also recomputes next_run_at when enabling and clears it when disabling. isOverdue() (SchedulesPanel.vue:1409-1412) reads exactly that field, so a local flip would paint a false "Overdue" badge and violate AC #3 ("the row reflects the new state after the request completes"). The full refetch is load-bearing.

Taste 1 — :key="agent.name" on <AgentDetail> — strictly more complete than Hunk 2 (it would clear all ~8 per-agent refs and fire onUnmounted, killing the stale execution-poll timer), but it crosses the scope fence into a file touched by two open PRs (#1628 a2a inbound server, #1915 agent not-found state), and it force-remounts the entire agent-detail subtree on every agent switch — a far broader behavioural change than a scroll fix. Listed as a follow-up below.


CI reality — what actually runs

  • frontend-build auto-fires on src/frontend/** and runs both npm run check:tokens and npm run build. This is the real CI coverage for this PR.
  • frontend-e2e also auto-fires on src/frontend/** (per test(ci): frontend-e2e only runs on ui-labeled PRs — specs rot silently and the suite sits red on dev #1526) but runs the @smoke tier only, and it is advisory, not a required merge gate (that workflow says so in its own header comment). No ui label is needed. The new spec is @interactive, deliberately local-only, and correctly excluded from @smoke.
  • dev's four required checks are Analyze (python), Analyze (javascript-typescript), schema-parity, verify-non-rootnone of which exercise the frontend at all.

Net: CI coverage for this fix is frontend-build (check:tokens + build) and nothing more. That is precisely why M1–M7 and the red-first matrix are load-bearing.

No verify-local run — by decision

verify-local's gates are the backend image (import main) and the agent base image (import agent_server); it has no frontend stage. This diff contains zero backend, docker, migration, or requirements files, so verify-local could only re-prove an untouched surface.

Local-execution proof recorded instead:

Gate Result
npm run build exit 0 — ✓ built in 1.33s (the >500 kB chunk warning is pre-existing on dev)
npm run check:tokens Design-token check OK: 11 tokens equivalent to source palettes; all references resolve
playwright … --list collects 5 tests (+1 auth setup)
playwright --grep @smoke --list 0 matches for this spec — correctly excluded from CI smoke

Documentation delta: zero, deliberately

Per CLAUDE.md Rule #4 (Tiered Documentation Updates), a bug fix is commit-message-tier. Independently verified: docs/memory/feature-flows/scheduling.md makes zero loading / spinner / refetch / scroll / unmount claims (grep returns nothing) — its §4 "Enable/Disable Flow" block documents the backend chain only (POST → enable_schedule()/disable_schedule()set_schedule_enabled() → scheduler _sync_schedules()), and this diff changes none of that. No doc churn was manufactured.

Uncommitted by design

The .claude/agents/test-runner.md:259 catalog row for the new spec is intentionally not added.claude is a private submodule, so the edit would land in a different repo, sit outside this PR, and leave a stray M .claude in the worktree. Flagged for the human to add there separately. (Also noticed while there: that file documents an npm run test:unit vitest harness that does not exist on dev — pre-existing catalog drift, unrelated.)


Follow-ups (recorded here as text — no issues filed)

  1. ESLint rule — forbid a bare v-if="loading" whose v-else renders a v-for; the mechanical form of the ~30-file sweep tracked by bug: background polls re-flash loaded content and reset UI state (design-system p13/p14) #1927 (which currently names only 4 of them).
  2. :key="agent.name" on AgentDetail.vue — the strictly-more-complete version of Hunk 2; deferred because it collides with open PRs feat(a2a): A2A interoperability — inbound server (well-known + JSON-RPC/SSE) + control MCP tools (ent#157/#160) #1628 and fix(ui): show a not-found state instead of a blank page for an unknown agent (#1914) #1915.
  3. Hunk 7's Edit button has no disabled affordance — its Delete sibling swaps to a spinner (:471); Edit only goes :disabled. Cosmetic.
  4. KeepAlive back-nav staleness/agents/A → Dashboard → /agents/A re-activates without remounting SchedulesPanel, and the name is unchanged so the watcher never fires → the list is served stale. Pre-existing and deliberately unfixed (Rule Feature/gemini runtime support #2). Noted because Hunk 1 is the prerequisite that would make a later onActivated(loadSchedules) safe — only a silent refresh makes that addition non-jarring.
  5. webhook_enabled / max_retries absent from ScheduleResponse (src/backend/models.py) — pre-existing and outcome-identical here; noted only because it surfaced while tracing the toggle response shape.
  6. Spec hygiene — consider making SCHEDULES_TEST_AGENT required rather than defaulting to usable[0], which is the fixture hazard at the top of this PR.

🤖 Generated with Claude Code

Eugene Vyborov and others added 7 commits August 1, 2026 23:05
Toggling a schedule enable/disable threw the page back to the top. The
panel-wide spinner was gated on the in-flight flag alone, so every
`loadSchedules()` refetch unmounted the entire list: the document
collapsed to spinner height and the browser clamped `window.scrollY` to
0. With more than a couple of schedules the row you just clicked was
pushed off-screen.

Gate the spinner on "no data yet" instead — `loading && schedules.length
=== 0` — the shape already used by ExecutionsPanel, LoopsPanel,
TasksPanel, ReportsPanel, RoomsRail and CompatibilityPanel, and the one
design-system.md p4/p5/p13/p14 mandates ("Loading means 'no data yet',
never 'fetch in flight'"; "nothing shifts when content arrives";
"state changes preserve scroll position").

The gate lives on the render, not the caller, so create/edit/delete
refetches stop unmounting the list too. Delete is explicitly requested
by the issue and keeps its own per-row `deleteLoading` spinner.

Because the gate is a pure render change, the agent-switch watcher must
now clear `schedules` + `perfBySchedule`: the panel is never unmounted
on an agent switch (AgentDetail's `loadAgent()` never nulls `agent`, and
the two `v-if`s are independent), only `:agent-name` flips. Leaving the
previous agent's rows in place would both suppress the first-load
spinner AC #4 requires and render agent A's schedules under agent B's
header.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Clearing the list in the agent-name watcher stops the previous agent's
rows rendering under the new agent's header, but it cannot stop a LATE
response. Switching A→B while A's GET is still in flight lands A's rows
under B's header — and clears the first-load spinner early — whenever A
resolves last. Two same-agent refreshes racing (toggle then delete) can
reorder the same way, leaving the older payload rendered (AC #3).

Add a monotonic `loadSeq`: each `loadSchedules()` captures its sequence
number, and a superseded call returns before writing `schedules`, before
logging, and without clearing `loading` — the newer call owns the flag.
`return` inside try/catch still runs `finally`, so the trailing
`loadPerf()` is bypassed for superseded loads too.

`loadPerf()` carries the same guard. The previous claim that "only the
newest load owns schedules, loading and perfBySchedule" was false for
`perfBySchedule` — it had no guard at all. Claim and code now agree.

No stuck-loading path exists: only a superseded call declines to clear
the flag, and "superseded" means a newer call owns it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
)

Not optional polish — this closes a race the render gate itself opens.

Previously `saveSchedule()` called `closeForm()` and then refetched. That
was harmless while the refetch unmounted the whole list: there was
nothing to click. Now that the list stays mounted, closing first leaves
the *stale* row fully interactive for the duration of the refetch, and
its Edit button is ungated. A click there opens the modal pre-filled
from pre-save data; saving that silently reverts the edit just made.

Swap the order. The modal already renders the design-system-sanctioned
in-button spinner (`formLoading` → "Saving…"), so keeping it up until
the list refreshes removes the interactive-stale window entirely and
restores the progress signal the create path otherwise loses.

Safe by construction: `loadSchedules()` never throws (it owns its
try/catch), so `closeForm()` always runs. On a POST/PUT failure
`closeForm()` is unreached and the modal keeps `formError` — identical
to today.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`toggleLoading` held a single schedule id. Clicking row B while row A's
POST was still in flight overwrote it, and A's unconditional `finally`
then nulled it — re-enabling B's control while B's request was still
running. A third click double-POSTs, and because `toggleSchedule()`
reads `schedule.enabled` at call time, a click on a not-yet-refreshed
row re-sends the *same* endpoint instead of toggling back: a visibly
dead click. The server is idempotent, so nothing corrupts, but the
affordance lies.

AC #6's literal subject is rapid multi-row toggling, and keeping the
list mounted during the refetch widens the window in which a second row
is clickable — so this stops being cosmetic the moment the render gate
lands.

Convert to `ref(new Set())`, adding and deleting the row's own id. Vue 3
wraps the Set in `reactive()`, which instruments it: `.has()` tracks,
`.add()`/`.delete()` trigger. All four sites move together — the
template reads `toggleLoading.has(id)` (refs auto-unwrap there), the
script `toggleLoading.value.add/delete(id)`.

`triggerLoading` and `deleteLoading` stay single-id: no AC covers
concurrent triggers or deletes, so widening them would be scope creep,
not consistency.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two small attribute-only changes on the schedule row.

data-testid hooks (`schedule-row` + `data-schedule-id`,
`schedule-status`, `schedule-toggle`). SchedulesPanel had none; 21 exist
frontend-wide. learnings.md #1500 mandates them after `nav.-mb-px` rotted
into a strict-mode violation: "prefer data-testid over bare structural
selectors". The scroll spec needs a row-scoped locator for the status
pill in particular — 12 of 14 fixture rows read "Disabled", so an
unscoped text locator is ambiguous by construction. `check:tokens` scans
status-*/action-* token references and is unaffected by plain attributes.

Gate Edit on `deleteLoading`. The adjacent Delete button already
self-gates; Edit did not. That was harmless while a delete refetch
unmounted the list, but the render gate keeps the row interactive during
it, so Edit can now open a modal on a row that is disappearing —
saving it 404s. One attribute, mirroring its sibling.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Five @Interactive Playwright tests, one per behaviour the fix rests on:

  T1  toggling does not move the page          the fix itself (AC #1/#3/#6)
  T2  per-row spinner + disabled control       regression guard (AC #2)
  T3  panel spinner on genuine first load      regression guard (AC #4 mount)
  T4  agent switch still shows the spinner     the watcher clear (AC #4 switch)
  T5  a superseded load never paints           the loadSeq guard

Measurement: the primary invariant is the toggled row's viewport `y`, not
`window.scrollY`. Chrome's default `overflow-anchor: auto` deliberately
mutates the scroll offset when content above the anchor resizes — exactly
so visible content stays still — so asserting `scrollY` parity would fail
on correct behaviour. `scrollY` is kept as a coarse secondary signal only
(the bug moves it by hundreds of px, not units).

T4/T5 switch agents via same-document router pushes (Dashboard nav link →
the agent's router-link in List mode). `page.goto()` would be a full
document navigation that remounts the panel, so the watcher would never
fire and both tests would pass with their hunk deleted. `goBack()` between
two gotos has the same defect — two gotos are two documents. The Dashboard
view mode is seeded into localStorage via addInitScript because the store
reads it at init and only List mode renders per-agent links.

Fixture safety: schedules are created on a LIVE agent against a LIVE
scheduler, so every one uses a far-future cron (Feb 29) and can never
dispatch a real Claude turn or pollute the executions table this panel
reads. `beforeAll` sweeps leftovers from a crashed run; `afterAll` deletes
each id. `timeout_seconds` is omitted so it inherits the agent cap and
cannot trip #929. Two of the 14 rows are seeded enabled so the #1796
autonomy banner can never cross its 0↔1 visibility boundary mid-test — a
banner appearing would shift every row and be indistinguishable from the
bug.

Tagged @Interactive, not @smoke: CI runs test:e2e:smoke against a
zero-agent stack, which cannot host a schedule. Verified excluded from
the smoke grep.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Three review findings on the new Schedules scroll-stability spec, all in
the spec itself — no production behaviour changes.

1. `switchAgentInSpa` located agent B by link text. `AgentListPanel`
   renders `agentDisplayName(agent)`, which is the owner-settable
   `display_label` when one is set (ent#181/#1640), so the locator would
   silently stop matching the moment anyone labels the agent. Switched to
   the documented house rule already stated in `dashboard-list-view.spec.js`
   — address the row by `data-agent` and the link by `href`, never by
   display text — and added a positive wait on the List-mode toolbar so a
   slow agent fetch can't read as a missing row.

2. T5 held agent A's schedules GET open with a fixed 2500ms sleep, which
   only *probably* outlives the Dashboard-to-B click-through. If the
   click-through were slower, A would resolve while it was still the
   newest load, B's watcher would reload cleanly, and T5 would go green
   with the `loadSeq` guard deleted — the same tautology that sank the
   first draft of T4. A's response is now gated on an explicit release
   fired only after B's load has visibly finished, so "A resolves last"
   is a certainty rather than a timing bet.

3. T1's `beforeScroll > 0` check is a fixture precondition, not the
   assertion under test — it now says so, and documents why the seeded
   6/7/8 index window always lands mid-list on a scrollable document.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@github-actions

github-actions Bot commented Aug 2, 2026

Copy link
Copy Markdown

⚠️ Nightly unit-suite check skipped — merge conflict against dev.

Resolve by running git merge dev locally and pushing the result. The next nightly run will re-test once the conflict is gone.

@AndriiPasternak31
AndriiPasternak31 marked this pull request as ready for review August 2, 2026 11:56

@AndriiPasternak31 AndriiPasternak31 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

/review + /validate-pr, read-only. All dev-side claims re-derived from git show origin/dev:<path>, PR-side from head b644c5eb.

The production fix is right, and better-reasoned than most things I review. The mechanism matches all six sibling panels, every one of the five loadSchedules() call sites keeps an in-flight affordance, ref(new Set()) is correct Vue 3.5 collection reactivity, and the loadSeq guard has no stuck-spinner path (a superseded load leaves loading alone; ownership always terminates at the newest load's finally). Security, packaging, and config checks are all clean; no doc tier is triggered.

Two things before merge.


Required

1. Gate Edit on the toggle refetch, not just the delete one

SchedulesPanel.vue:457 is :disabled="deleteLoading === schedule.id". Chain verified end-to-end:

  • editSchedule() copies enabled: schedule.enabled (:1258)
  • saveSchedule() spreads the whole form into the PUT (:1198, :1201-1205)
  • ScheduleUpdateRequest.enabled accepts it (models.py:2384)
  • model_dump(exclude_unset=True) retains it because the frontend sends every field explicitly (routers/schedules.py:338)
  • db.update_schedule applies it and recomputes next_run_at (db/schedules/crud.py:346-384)

So an Edit+Save inside the toggle's refetch window doesn't just flip the badge back — it rewrites the scheduler's fire time.

:disabled="deleteLoading === schedule.id || toggleLoading.has(schedule.id)"

One correction to my earlier pass, which called this "newly reachable because of Hunk 1" — that was wrong. On dev, toggleSchedule already awaits the POST with the list mounted and loading still false (dev:1263-1269), and dev's Edit button carries no :disabled at all (dev:445-449). The stale-Edit window already exists for the POST duration; Hunk 1 widens it to POST+GET. So this isn't a regression you're introducing — it's that the PR identifies this exact class and closes it on the save path (Hunk 4) and the delete path (Hunk 7) while leaving its own subject open and wider than before.

2. The red-first matrix can silently not run

AGENT_B = usable.find((n) => n !== AGENT_A) || '' (spec:112), then test.skip(!AGENT_B, …) at :396 and :438. On a single-agent instance Playwright reports "3 passed, 2 skipped" — and T4/T5 are the only evidence that Hunks 2 and 3 aren't dead defensive code. A skip is not a pass, and it's easy to record the matrix as satisfied off that output.

Suggest making a missing AGENT_B a hard failure, or stating the two-agent requirement in the run command next to the SCHEDULES_TEST_AGENT warning.

Related: the PR is out of draft while the body still says "Please run M1–M7 and the red-first matrix below before flipping this out of draft." No matrix output is posted on the PR. Flagging in case the flip was accidental.


Verified sound — no action needed

I checked the premise both T4 and T5 rest on and that nothing in the PR states: that the A→Dashboard→B leg preserves the component rather than remounting it. It does — App.vue:5 wraps <router-view> in <KeepAlive :include="['SystemAgent','AgentDetail']">, AgentDetail.vue:313 sets defineOptions({ name: 'AgentDetail' }) so the include matches, and :1126-1127 is the route.params.name watcher with the newName !== oldName guard your comment describes. Had AgentDetail remounted, both tests would pass with Hunks 2 and 3 deleted — the exact tautology your comments warn about twice. Worth a line in switchAgentInSpa's docblock, since the whole helper is load-bearing on it.

Also confirmed: the 7d success anchor exists (:392); zero-run schedules do get summary rows so waitForListSettled can settle (db/schedules/analytics.py:305-311); 0 4 29 2 * next occurs 2028-02-29; no overflow-anchor override exists in the frontend, so your measurement rationale holds.

The nightly bot's "merge conflict against dev" comment is a false alarmorigin/dev is an ancestor of the head, 0 commits behind, MERGEABLE, and 21 checks ran green (a genuinely conflicting PR produces zero checks). No git merge dev needed.


Optional

  • Every test hard-gates on a best-effort call. waitForListSettled needs the perf chip on every row, produced by loadPerf() — explicitly "best-effort … never blocks the list" with a swallowing catch (:1170). A summary failure becomes five identical 20s timeouts reading "perf chips never finished rendering", pointing at the wrong subsystem.
  • T1 defuses one of the two reflow sources you name, not both. SEED_ENABLED = 2 neutralizes the #1796 banner, but T1 toggles disabled→enabled, and enabling sets next_run_at, which adds the Next: … / Will not fire — autonomy off chip (:359-381) into the same flex-wrap row. It probably survives the ≤2px tolerance since the chip grows the row downward from the measured top edge — but that's an undocumented dependency on Chrome's scroll-anchor selection, and it's the likeliest flake source in a spec nobody has run.
  • T2's spinner locator (spec.js:350) resolves against a row holding three conditional animate-spin SVGs (:430 trigger, :447 toggle, :471 delete). Passes only because exactly one is mounted; scope it to getByTestId('schedule-toggle').
  • SCHEDULES_TEST_AGENT defaulting to usable[0] — your own follow-up #6, and the mitigations are genuine, but a human following the README's npx playwright test <file> habit still seeds 14 schedules onto whatever agent sorts first.
  • Pre-existing, not yours: POST-succeeds/GET-fails leaves the pill showing the old state (loadSchedules's catch is console-only, :1150-1152) — AC #3 violated on that path, structurally identical on dev. M4 covers only the POST-fails direction. Worth its own issue rather than scope creep here.

I could not close the execution gap myself — no Docker daemon available on this machine, so there was no stack to run the @interactive suite against. That verification is still genuinely outstanding, and CI proves nothing here: e2e runs --grep @smoke and all five tests are @interactive, so zero of them executed.

trinity-ability and others added 2 commits August 7, 2026 10:07
…quirement hard (#1634)

Review items from AndriiPasternak31 on PR #1939:

- Edit is now disabled while THIS row's toggle refetch is in flight. An
  Edit+Save inside that window copies the pre-toggle `enabled` into the
  form and PUTs it back, which also recomputes next_run_at — rewriting
  the scheduler's fire time, not just flipping the badge back.
- A missing second agent is a hard beforeAll failure instead of
  test.skip: T4/T5 are the only evidence the watcher clear and loadSeq
  guard aren't dead code, and '3 passed, 2 skipped' reads as green while
  the red-first matrix silently never ran.
- T2's spinner locator is scoped to the toggle button (the row holds
  three conditional animate-spin SVGs).
- switchAgentInSpa documents the KeepAlive premise T4/T5 rest on.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
# Conflicts:
#	src/frontend/src/components/SchedulesPanel.vue
@vybe

vybe commented Aug 7, 2026

Copy link
Copy Markdown
Contributor Author

Both required items addressed in 0941db7, dev merged in 5dada28, and — the outstanding piece — the suite has now actually been run, including the full red-first matrix.

Required items

  1. Edit gated on the toggle refetch:disabled="deleteLoading === schedule.id || toggleLoading.has(schedule.id)", with a comment naming the next_run_at-rewrite hazard you traced.
  2. Missing AGENT_B is now a hard beforeAll failure, not a skip — the error names the two-agent requirement and points at SCHEDULES_TEST_AGENT. The two test.skip gates are gone (unreachable).

Also took two of the optionals: T2's spinner locator is scoped to getByTestId('schedule-toggle'), and switchAgentInSpa's docblock now states the KeepAlive premise you verified (App.vue KeepAlive :include + defineOptions({ name: 'AgentDetail' })).

Merge with dev (#1926 collision)

The conflict was with #1926's error states in the same file. Semantics reconciled, not just markers:

Execution evidence (local stack, 5 usable agents, SCHEDULES_TEST_AGENT=acme-scout)

Green — full suite on the merged branch:

Running 6 tests using 1 worker
  6 passed (13.8s)

Red-first matrix (each baseline built by stashing exactly the stated hunk from the served file, per the PR-body matrix):

Test Baseline Result
T1 Hunk 1 reverted (v-if="loading") 🔴 Error: row e2e-1634-06 moved in the viewport after toggling — the exact symptom
T4 Hunk 2 stashed (watcher clear removed) 🔴 spinner never appears (toBeVisible fails — suppressed by stale rows)
T5 Hunk 3 stashed (loadSeq guards removed) 🔴 stale agent-A rows paint under B (toHaveCount fails)

All three go red for the stated reason and green with the fix — none of the tests is a tautology.

SFC compile-checked via @vue/compiler-sfc (script + template, clean).

@vybe
vybe requested a review from AndriiPasternak31 August 7, 2026 09:15
@vybe
vybe dismissed AndriiPasternak31’s stale review August 8, 2026 17:14

Both required items landed in 0941db7 (Edit gated on toggleLoading; missing AGENT_B now a hard beforeAll failure), full red-first matrix run and posted, dev merged, 21/21 checks green. Dismissing the stale block; fresh re-review requested.

@trinity-ability trinity-ability left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approving per validated re-review: both required items from the 2026-08-02 review verified on the branch (Edit gated on the toggle refetch at SchedulesPanel.vue:499; missing AGENT_B is a hard beforeAll failure), red-first matrix executed and posted (T1/T4/T5 red for the stated reasons, 6/6 green with the fix), dev merged, 21/21 checks green. /validate-pr clean: Fixes #1634 closing keyword, security sweep clean, bug-fix doc tier satisfied, named regression spec present.

@vybe
vybe merged commit c0f81c9 into dev Aug 8, 2026
21 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.

3 participants