Skip to content

core: a read-only window still offers every scheduled-task write, and a refused Pause/Resume/Delete says nothing #2232

Closed

Description

@HugoXK

Implementation: FlyBot (ours) — PR follows within 24h. Do not pick up without a pickup-ok comment on this issue.

Written-by: FlyBot Lead
Claimed-by: FlyBot Lead
Claim-Id: cf7d59a6986a47338e5efc88cb027219
Branch: dev/2-p451-schedules-readonly
Base: 9863992
At: 2026-11-02T23:37:09Z

Implementation: FlyBot (ours) — PR follows within 24h. Do not pick up without a pickup-ok comment.

Written-by: FlyBot Lead
Credit: found by FlyBot Product (P-451 audit on live main).
Kind: bug (a read-only window offers writes the host refuses, and the refusals are swallowed)
Source: FlyBot Product audit P-451; every line below re-read by Lead on live main 986399246ff8c26bd418978785ce1199dbd239ca.

Dedupe (searched before opening)

Open issues matching scheduled tasks read-only, schedule pause silent: none. #2199 (completed 2026-11-02T17:39:36Z) put "hide every write control on every page" explicitly out of scope; #2196 and #2197 were closed as duplicates of #2188/#2194, so nobody holds this. Adjacent but different: #2112 (list stuck on Loading), #2024/#2035/#2039 (empty/paused list wording), #2145 (the empty-list half of the hidden-member sentence).

What is wrong

core/src/client/schedules.js never reads the lock — grep -n 'readOnly\|locked' core/src/client/schedules.js returns nothing — so Settings → Scheduled tasks paints its whole write surface in a read-only window:

  • the Quiet hours card (:294): its Turn on/Turn off button (:325-329), the two time inputs (:333, :335) and Save window (:336) all call setPrefs → call('set-prefs', …) (core/src/client/index.js:589);
  • New task (:361) and the form it opens (:200);
  • per row (TaskRow, :120): Edit (:158), Copy to another Bot or group chat (:159), Pause/Resume (:160), Delete task (:161-164).

core/src/host/channel.js:227 lists only schedules as a read endpoint; :296 refuses every other endpoint in a read-only window with bot/read-only. That covers set-prefs and the four schedule-* writes alike — so in a locked window every one of those controls is dead, and the page then says nothing:

  • :128-132 remove — actions.scheduleDelete(...).then(onView, () => setBusy(false)): the refusal is dropped.
  • :133-137 toggle — schedulePause/scheduleResume .then((next) => { setBusy(false); onView(next) }, () => setBusy(false)): the refusal is dropped.
  • :139-143 retry is the one path that speaks (retryError, drawn at :155 with role="alert").
  • :337 Quiet hours draws its refusal as plain text with no role="alert"; :268 the form does the same. Only :155 and :412 (CopyRow) carry the role. So even the two paths that do print a sentence are not announced.

A click changes nothing and explains nothing. This is not read-only-only: a writable window whose rows went stale (another window deleted or moved the task; the host answers No such task) hits remove/toggle and is equally silent, and Quiet hours' refused save is silent to a screen reader on every window.

The Settings header's refusal line cannot cover it: core/src/client/settings.js:174-179 run() wraps only the promise its own page hands it, while these actions are wired straight through core/src/client/index.js:633-636 (and :589) as bare call(...). That is the same class of gap #2194 fixed for three other call sites, and why the sentence has to be drawn by this page.

Reproduction

A probe that mounts the real page with roster.readOnly set to a non-empty string, one active task, and typed stubs for the schedule actions:

PROBE_READONLY_WRITE_CONTROLS ["Turn on","New task","Edit","Pause","Delete task"]
PROBE_READONLY_DISABLED_COUNT 0
PROBE_READONLY_PAGE_SAYS_WHY false
# writable roster, schedulePause rejects with the host's sentence:
PROBE_SETS_AFTER_CLICK ["true","false"]        # only `busy`, twice
PROBE_SETTERS_CARRYING_SENTENCE []
PROBE_ALERT_NODES_AFTER []

Note the probe's "Turn on" is the Quiet hours toggle (:325-329), not the AutomationOff card (:283) — that card only appears when automation is off and its only button is Open Plugins (:288), which navigates and writes nothing, so it stays. The same empty readings come back on a writable roster with a stale row, which is the half that survives after the lock is respected.

Expected

  1. In a read-only window the page draws no write control and no write form: no Quiet hours toggle, time inputs or Save window, no New task, no Edit, Copy, Pause/Resume, Delete task. The AutomationOff card stays as it is. The page draws no read-only sentence of its own — the Settings header already carries it (settings.js:232-233, #2199), and a second copy would announce twice.
  2. In a writable window, a refused Pause, Resume or Delete shows the host's own sentence as exactly one role="alert" in that row, in the slot :155 already has, and clears it on the next attempt and on that row's next success.
  3. Quiet hours' refused save and the form's refused submit become role="alert" too (:337, :268): a visible sentence that a screen reader never announces is the same defect one step quieter.
  4. Success unchanged: a successful Pause still calls onView with the host's answer and speaks nothing; the failed-row Retry alert (:155) keeps working; the read-failure path (:27-28, publishFailure → :347-348) and the CopyRow alert (:412) are untouched.
  5. The lock is read the way the rest of the client reads it — typeof roster.readOnly === 'string' && roster.readOnly.trim() !== '' (sidebar.js:371, settings.js:232, workflows.js:570); a different shape in this page would disagree with the header that prints the sentence.

Red-first (capture on live main before the fix)

  1. read-only roster + one active task → the page tree holds zero write controls (today: the five the probe lists, plus more once Quiet hours is on).
  2. writable roster + schedulePause rejecting with a host sentence → that row holds exactly one role="alert" carrying the sentence (today: zero anywhere in the tree); the same for scheduleResume and scheduleDelete.
  3. reverse, must stay green: the failed-row Retry still draws its alert; a successful Pause still calls onView and the tree holds no alert.
  4. reverse, must stay green: the read-only page prints no copy of the read-only sentence (the header owns it) and still writes nothing.
  5. quiet hours' refused setPrefs in a writable window carries its sentence in a role="alert" (today: visible text, no role).

Where the fix goes

  • SchedulesPage (:343) reads the lock once (typeof roster.readOnly === 'string' && roster.readOnly.trim() !== '') and passes it down to QuietHoursCard (:294) and through TaskList (:374) to TaskRow (:120) — one place, not both;
  • TaskRow grows rowError next to retryError; remove (:128-132) and toggle (:133-137) set it from hostText(error?.message ?? String(error)) and draw it in the :155 slot's shape. One alert per row: the newest sentence, never two;
  • the write controls are not rendered in a locked page — hiding is not disabling here, because a disabled button still tells the reader the window can do it;
  • Quiet hours: no toggle, inputs or Save window when locked; its error span (:337) gains role="alert";
  • the form's error span (:268) gains role="alert" as well;
  • the same flag reaches the Bot details card, BotSchedules (:416): its onEdit (:436) must not open the inline form in a locked window. Its New task row (:439) only navigates (actions.openSettings('schedules', bot.id)) and the settings list it opens draws no create control once this fix lands, so it may stay — say which you chose in the PR body rather than leaving it ambiguous. SummaryLines (:446) rows are navigation only and are unaffected.

Scope

core/src/client/schedules.js; core/test/schedules-client.test.js (and core/test/schedules.test.js if it pins row markup) — assertions are added, never removed, and the existing read-failure, Retry and copy expectations stay green; docs/subsystems/core-client.md:43 (the schedules.js row, which is the only place this page's behaviour is written down: it must gain the locked-window sentence and the two new alert roles); the regenerated core/client.js (build-client --check must pass); one changelog.d/ entry. There is no patches/ds-bot/files/src/client/schedules.js mirror on this tip, so ds-bot-patches has nothing to update here.

The doc row already promises "the host's named refusal appears on the form's error line" — this change makes that line an alert, so extend that sentence rather than adding a contradiction.

Not in scope: the non-empty list's hidden-member sentence (#2145's symmetric half — a separate ticket), the workflow dialogs (P-445/P-449/P-450) and the plugin menus (P-447).

Tier: T1 by path (core/src/client/**, tests, changelog) — the generated core/client.js is the twin of the same change, so read the enqueue-guard result rather than trusting this line. Draft until FlyBot Test's advisory PASS lands (the team's T1 rule), then ready.

Activity

  1. HugoXK commented on Nov 3, 2026

    @HugoXK
    ContributorAuthor

    Release

    Written-by: FlyBot Lead
    Released: cf7d59a6986a47338e5efc88cb027219
    Reason: merged as 227b94b (T1)
    At: 2026-11-03T00:16:31Z

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions