fix(ui): stop the Schedules list unmounting on refetch so toggling never scrolls to top (#1634) - #1939
Conversation
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>
|
Resolve by running |
AndriiPasternak31
left a comment
There was a problem hiding this comment.
/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()copiesenabled: schedule.enabled(:1258)saveSchedule()spreads the whole form into the PUT (:1198,:1201-1205)ScheduleUpdateRequest.enabledaccepts it (models.py:2384)model_dump(exclude_unset=True)retains it because the frontend sends every field explicitly (routers/schedules.py:338)db.update_scheduleapplies it and recomputesnext_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 alarm — origin/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.
waitForListSettledneeds the perf chip on every row, produced byloadPerf()— 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 = 2neutralizes the #1796 banner, but T1 toggles disabled→enabled, and enabling setsnext_run_at, which adds theNext: …/Will not fire — autonomy offchip (:359-381) into the sameflex-wraprow. 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 conditionalanimate-spinSVGs (:430trigger,:447toggle,:471delete). Passes only because exactly one is mounted; scope it togetByTestId('schedule-toggle'). SCHEDULES_TEST_AGENTdefaulting tousable[0]— your own follow-up #6, and the mitigations are genuine, but a human following the README'snpx 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 ondev. 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.
…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
|
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
Also took two of the optionals: T2's spinner locator is scoped to 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,
|
| 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).
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
left a comment
There was a problem hiding this comment.
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.
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.scrollYto 0.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.
With
SCHEDULES_TEST_AGENTunset, the spec'sAGENT_Adefaults tousable[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: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.v-if="loading"→v-if="loading && schedules.length === 0"schedules+perfBySchedulein theagentNamewatcherloadSeqstale-response guardawait loadSchedules()beforecloseForm()toggleLoadingref(null)→ref(new Set())finallythen 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.data-testidhooks (schedule-row,schedule-status,schedule-toggle,:data-schedule-id):disabled="deleteLoading === schedule.id"on Edit: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.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
formLoadingspinner) 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:
Why it is strictly better rather than a regression:
Cancel(:244-250) is not:disabled="formLoading"— onlySave(:251+) is. So the longer-lived modal can never trap the user.Saveholds 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 defaultoverflow-anchor: autodeliberately mutates the scroll offset when content above the anchor resizes, precisely to keep visible content still. The correct invariant is the toggled row's viewporty. Legitimate reflows remain in scope of normal behaviour: the #1796 autonomy banner appearing/disappearing as the enabled count crosses 0↔1; disabling clearingnext_run_atand removing theNext: …chip from aflex-wraprow; 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.
Manual matrix M1–M7 (outstanding — please paste results)
/agents/X?tab=schedules, then leave to Overview and return.v-ifremount)./agents/A?tab=schedules, switch to agent B using in-app navigation only.console.error; button clickable again.Correcting the dossier: "last holdout" is FALSE
The originating dossier claimed
SchedulesPanel.vue:272was the last un-gated panel. It is not. Independently counted:v-if="loading"gates across ~30 list-bearing files (CredentialsPanel,GitPanel,PublicLinksPanel,FilesPanel,InfoPanel,MetricsPanel,NeverminedPanel,DashboardPanel,CapacityPanel,GuardrailsPanel,ScheduleAnalyticsCard, …).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"whosev-elserenders av-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|disablereturns only{"status", "schedule_id"}(routers/schedules.py:379-414), andset_schedule_enabled(db/schedules/crud.py:541-570) also recomputesnext_run_atwhen 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 fireonUnmounted, 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-buildauto-fires onsrc/frontend/**and runs bothnpm run check:tokensandnpm run build. This is the real CI coverage for this PR.frontend-e2ealso auto-fires onsrc/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@smoketier only, and it is advisory, not a required merge gate (that workflow says so in its own header comment). Nouilabel is needed. The new spec is@interactive, deliberately local-only, and correctly excluded from@smoke.dev's four required checks areAnalyze (python),Analyze (javascript-typescript),schema-parity,verify-non-root— none 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-localrun — by decisionverify-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:
npm run build✓ built in 1.33s(the >500 kB chunk warning is pre-existing ondev)npm run check:tokensDesign-token check OK: 11 tokens equivalent to source palettes; all references resolveplaywright … --listplaywright --grep @smoke --listDocumentation 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.mdmakes 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:259catalog row for the new spec is intentionally not added —.claudeis a private submodule, so the edit would land in a different repo, sit outside this PR, and leave a strayM .claudein the worktree. Flagged for the human to add there separately. (Also noticed while there: that file documents annpm run test:unitvitest harness that does not exist ondev— pre-existing catalog drift, unrelated.)Follow-ups (recorded here as text — no issues filed)
v-if="loading"whosev-elserenders av-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).:key="agent.name"onAgentDetail.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.:471); Edit only goes:disabled. Cosmetic./agents/A→ Dashboard →/agents/Are-activates without remountingSchedulesPanel, 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 lateronActivated(loadSchedules)safe — only a silent refresh makes that addition non-jarring.webhook_enabled/max_retriesabsent fromScheduleResponse(src/backend/models.py) — pre-existing and outcome-identical here; noted only because it surfaced while tracing the toggle response shape.SCHEDULES_TEST_AGENTrequired rather than defaulting tousable[0], which is the fixture hazard at the top of this PR.🤖 Generated with Claude Code