Repository navigation
Description
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): itsTurn on/Turn offbutton (:325-329), the two time inputs (:333,:335) andSave window(:336) all callsetPrefs→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-132remove—actions.scheduleDelete(...).then(onView, () => setBusy(false)): the refusal is dropped.:133-137toggle—schedulePause/scheduleResume.then((next) => { setBusy(false); onView(next) }, () => setBusy(false)): the refusal is dropped.:139-143retryis the one path that speaks (retryError, drawn at:155withrole="alert").:337Quiet hours draws its refusal as plain text with norole="alert";:268the form does the same. Only:155and: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
- In a read-only window the page draws no write control and no write form: no Quiet hours toggle, time inputs or
Save window, noNew task, noEdit,Copy,Pause/Resume,Delete task. TheAutomationOffcard 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. - In a writable window, a refused
Pause,ResumeorDeleteshows the host's own sentence as exactly onerole="alert"in that row, in the slot:155already has, and clears it on the next attempt and on that row's next success. - 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. - Success unchanged: a successful
Pausestill callsonViewwith the host's answer and speaks nothing; the failed-rowRetryalert (:155) keeps working; the read-failure path (:27-28,publishFailure→:347-348) and theCopyRowalert (:412) are untouched. - 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)
- 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).
- writable roster +
schedulePauserejecting with a host sentence → that row holds exactly onerole="alert"carrying the sentence (today: zero anywhere in the tree); the same forscheduleResumeandscheduleDelete. - reverse, must stay green: the failed-row
Retrystill draws its alert; a successfulPausestill callsonViewand the tree holds no alert. - reverse, must stay green: the read-only page prints no copy of the read-only sentence (the header owns it) and still writes nothing.
- quiet hours' refused
setPrefsin a writable window carries its sentence in arole="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 toQuietHoursCard(:294) and throughTaskList(:374) toTaskRow(:120) — one place, not both;TaskRowgrowsrowErrornext toretryError;remove(:128-132) andtoggle(:133-137) set it fromhostText(error?.message ?? String(error))and draw it in the:155slot'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 windowwhen locked; its error span (:337) gainsrole="alert"; - the form's error span (
:268) gainsrole="alert"as well; - the same flag reaches the Bot details card,
BotSchedules(:416): itsonEdit(:436) must not open the inline form in a locked window. ItsNew taskrow (: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
HugoXK commentedon Nov 3, 2026
Release
Written-by: FlyBot Lead
Released: cf7d59a6986a47338e5efc88cb027219
Reason: merged as 227b94b (T1)
At: 2026-11-03T00:16:31Z
core: a read-only window's Memory panel still offers its writes and s…
client: a locked Bot card with no tasks draws no heading (ThinkFlowLa…
client: a read-only Inbox keeps only Open and says why (ThinkFlowLab#…
Offer Run now on a scheduled task, not only Retry after a failure (Th…