Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changelog.d/1199-quiet-unchecked-flag.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
The quiet-hours warning on the Scheduled tasks page now renders the host's `quietUnchecked` flag instead of scanning the task list a second time (#1199), so the line and the check cannot drift apart, and it still appears at once after the window is switched on. The subsystem page no longer calls the window one team-wide clock: a task's due time is read in that task's own zone.
13 changes: 11 additions & 2 deletions core/client.js
Original file line number Diff line number Diff line change
Expand Up @@ -11413,13 +11413,22 @@ function QuietHoursCard({ view, actions }) {
const [error, setError] = (0, import_react22.useState)("");
const [from, setFrom] = (0, import_react22.useState)(quiet.from ?? "22:00");
const [to, setTo] = (0, import_react22.useState)(quiet.to ?? "07:00");
const unchecked = view.tasks.some((task) => task.kind === "every" || task.kind === "cron");
const unchecked = view.quietUnchecked === true;
const refresh = () => {
void Promise.resolve(actions.schedules?.()).then((next) => {
if (next) publish(next);
}).catch(() => {
});
};
const save = (next) => {
if (busy) return;
setBusy(true);
setError("");
const shown = next.on ? { on: true, from: next.from ?? from, to: next.to ?? to } : { on: false };
void Promise.resolve(actions.setPrefs({ quietHours: next })).then(() => publish({ ...view, quietHours: shown }), (failure) => setError(hostText(failure?.message ?? String(failure)))).finally(() => setBusy(false));
void Promise.resolve(actions.setPrefs({ quietHours: next })).then(() => {
publish({ ...view, quietHours: shown });
refresh();
}, (failure) => setError(hostText(failure?.message ?? String(failure)))).finally(() => setBusy(false));
};
return (0, import_react22.createElement)(
"section",
Expand Down
13 changes: 11 additions & 2 deletions core/src/client/schedules.js
Original file line number Diff line number Diff line change
Expand Up @@ -260,14 +260,23 @@ function QuietHoursCard({ view, actions }) {
const [error, setError] = useState('')
const [from, setFrom] = useState(quiet.from ?? '22:00')
const [to, setTo] = useState(quiet.to ?? '07:00')
const unchecked = view.tasks.some(task => task.kind === 'every' || task.kind === 'cron')
// #1199: the one computation lives in the host's `schedulesView()`, which knows both the quiet
// guard and the stored task list. The page renders that flag instead of scanning the tasks again.
const unchecked = view.quietUnchecked === true
// The flag is the host's, so after a save the page asks again instead of predicting it: the line
// appears (or goes) in the same rendering, not at the next 30-second poll.
const refresh = () => {
void Promise.resolve(actions.schedules?.())
.then((next) => { if (next) publish(next) })
.catch(() => { /* the settings write above is what the owner sees */ })
}
const save = (next) => {
if (busy) return
setBusy(true)
setError('')
const shown = next.on ? { on: true, from: next.from ?? from, to: next.to ?? to } : { on: false }
void Promise.resolve(actions.setPrefs({ quietHours: next }))
.then(() => publish({ ...view, quietHours: shown }), (failure) => setError(hostText(failure?.message ?? String(failure))))
.then(() => { publish({ ...view, quietHours: shown }); refresh() }, (failure) => setError(hostText(failure?.message ?? String(failure))))
.finally(() => setBusy(false))
}
return h('section', { className: 'bt-set-section' },
Expand Down
30 changes: 30 additions & 0 deletions core/test/schedules-client.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -208,6 +208,36 @@ test('a once task sends its zone, so the host reads its wall clock (#1166)', asy
assert.notEqual(when.timeZone, '')
})

test('the repeat-task warning is the view flag, not the page scanning its tasks (#1199)', () => {
// The realistic drift: a repeat task was deleted between two polls, so the list says nothing while
// the host's flag still does. The page renders the host's line, not a line of its own.
seeded.push({ available: true, tasks: [{ ...ACTIVE }], quietHours: QUIET, quietUnchecked: true })
const flagged = text(render(SchedulesPage({ roster: ROSTER, actions: spy(), start: null })))
assert.ok(flagged.some(line => /still run inside quiet hours/.test(line)), 'the view flag alone renders the line')
// The other way: a repeat task in the list is not the page's business. Without the flag the host made
// no uncheckable-rule claim, so the page says nothing.
seeded.push({ available: true, tasks: [{ ...ACTIVE, kind: 'every', everySeconds: 3600 }], quietHours: QUIET })
const plain = text(render(SchedulesPage({ roster: ROSTER, actions: spy(), start: null })))
assert.ok(!plain.some(line => /still run inside quiet hours/.test(line)), 'a repeat task alone is not a claim')
})

test('after the quiet-hours write the page reads the host view again, not a predicted flag (#1199)', async () => {
let asked = 0
const actions = {
...spy(),
setPrefs: async () => ({}),
schedules: async () => { asked += 1; return { available: true, tasks: [{ ...ACTIVE, kind: 'every', everySeconds: 3600 }], quietHours: QUIET, quietUnchecked: true } },
}
seeded.push({ available: true, tasks: [], quietHours: { on: false } })
const page = render(SchedulesPage({ roster: ROSTER, actions, start: null }))
const on = buttonsOf(page).find(button => text(button).includes('Turn on'))
assert.ok(on)
on.props.onClick()
await new Promise(resolve => setImmediate(resolve))
await new Promise(resolve => setImmediate(resolve))
assert.ok(asked >= 1, 'the flag is asked for again, so the line appears now and not at the next 30-second poll')
})

test('the quiet-hours copy has Chinese entries, including the refusal with the window end (#1166)', () => {
const source = readFileSync(new URL('../src/client/i18n.js', import.meta.url), 'utf8')
assert.match(source, /'Quiet hours': '安静时段'/)
Expand Down
2 changes: 1 addition & 1 deletion docs/subsystems/core-client.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ The browser side of the patched `ds-bot` core (`core/`): sources in `core/src/cl
| `core/src/client/panes.js` | `PANE_MIN_WIDTH_CSS` (#705): every pane the multi-pane row flexes gets `min-width:0`, the conversation column is matched by its DSH class fragment, and the panel's long values (About text, `4.1M tok · Cache hit …`) wrap with `overflow-wrap:anywhere`, so a value that cannot wrap never widens the panel past the space reserved for it. The same sheet clips horizontal overflow on the document, the conversation column and the app frame (`overflow-x:clip`, #1086), and `watchShellLevel` / `levelShell` put a leftover `scrollLeft` back, so an error toast or dialog cannot leave the window shifted. Tests: `core/test/shell-level.test.js`. |
| `core/src/shared/approval-waiting.js` | The one rule for "this hold still waits for the owner's decision" (#734, #754): `isApprovalWaiting(status)` is true for `pending` and `pending_approval` and false for every settled spelling (`approved`, `auto`, `failed`, `rejected`, `stale`, missing). The host writes `pending`; Bot Social's own outbound hold arrives as `pending_approval`. Host and client both import this one file — the held-reply card, the approvals overlay and the chat bubble on this side, `hooks.js` (`decide()`, `heldOf()`, the cap, the in-flight filter, the cleanup) and `channel.js` (a row's wait badge) on the other — and the overlay asks the host for `[...WAITING_STATUSES, 'failed']`, so the two spellings cannot drift apart again on one side only. Tests: `core/test/approval-waiting.test.js`, `core/test/host-approval-status.test.js`. |
| `core/src/shared/held-retry.js` | The one rule for "this failure can still be retried" (#968): `canRetry(record)` is false for `retry: false`, for `noRetry: true`, and for a record whose stored `error`/`code` names a terminal reason (`no such remote bot` / `is not shared with your Bots` / `is no longer shared with you` / `has not granted chat.public` / `bot_not_shared` / `bot_not_found`; a bare `capability_not_granted` is not one, #988), true otherwise. The host states the same answer in two spellings (`noRetry` on an approval, `retry: false` on a held reply), so both cards and Bot Social's own dock ask this one function instead of keeping a copy each; a hold written before the flag existed is read from its stored sentence at render time, with no state migration. Tests: `core/test/held-retry.test.js`. |
| `core/src/client/schedules.js` | Settings → Scheduled tasks, a Bot's card and the details line. Each row has **Edit** (pencil; the row turns into the same form that creates a task, filled in, with **Save changes**; an edited task keeps its time zone, a cron rule it cannot show is kept) and **Pause** / **Resume**; a paused row reads **Paused** (#354). A row with a recorded run adds one line under it: "Running since …", "Last run …", "Last run … · Stopped" or, in red, "Last run … · Failed: <reason>"; a failed run of a task that is not paused gets **Retry**, which runs the task once now (`actions.scheduleRetry`, #473). With Automation tasks off, the hint reads "Switch on Automation tasks on the Plugins page." and the button **Open Plugins** closes the Settings dialog and opens DSH's Plugins panel (`actions.selectPanel('plugins')`); the Harness settings dialog has no such switch (#378). The page opens with the team's **Quiet hours** card (#1166): **Off** with a **Turn on** button, or **On, 22:00–07:00** with the window's two time fields and **Save window** (the settings write refuses an invalid window, and the host's named refusal appears on the form's error line). While quiet hours are on and an `every`/`cron` task exists, one line says such a task can still run inside the window — never a silent claim of silence, and no suppressed count. Tests: `core/test/schedules-client.test.js`. |
| `core/src/client/schedules.js` | Settings → Scheduled tasks, a Bot's card and the details line. Each row has **Edit** (pencil; the row turns into the same form that creates a task, filled in, with **Save changes**; an edited task keeps its time zone, a cron rule it cannot show is kept) and **Pause** / **Resume**; a paused row reads **Paused** (#354). A row with a recorded run adds one line under it: "Running since …", "Last run …", "Last run … · Stopped" or, in red, "Last run … · Failed: <reason>"; a failed run of a task that is not paused gets **Retry**, which runs the task once now (`actions.scheduleRetry`, #473). With Automation tasks off, the hint reads "Switch on Automation tasks on the Plugins page." and the button **Open Plugins** closes the Settings dialog and opens DSH's Plugins panel (`actions.selectPanel('plugins')`); the Harness settings dialog has no such switch (#378). The page opens with the team's **Quiet hours** card (#1166): **Off** with a **Turn on** button, or **On, 22:00–07:00** with the window's two time fields and **Save window** (the settings write refuses an invalid window, and the host's named refusal appears on the form's error line). While quiet hours are on and an `every`/`cron` task exists, the view's `quietUnchecked` flag draws one line saying such a task can still run inside the window: that flag is the only place the rule is computed — the page does not scan the task list itself (#1199) — and it asks for the view again right after the settings write, so the line does not wait for the 30-second poll. Never a silent claim of silence, and no suppressed count. Tests: `core/test/schedules-client.test.js`. |
| `core/src/client/inbox.js` | Agent inbox, step 1 (#349): the **Inbox** row at the top of the sidebar (`InboxRow`, count badge = cards in *Needs you*) and the inbox dialog (`InboxDialog`, `ui.inbox`, `actions.openInbox` / `closeInbox`) with the groups **Needs you**, **In progress** and **Done** (`inboxModel`); failed turns, a *Latest* line and a per-card `N tool calls` count (#471); ask_user cards (a group's and the Bot's own chat) answered in the inbox (#552, #1140); a *Needs you* title and reason wrap in full so the ask can be read on the card (#1177); a result card accepted (**Accept**) or sent back with a note (**Send back** → the box → **Send it back**), with its `result.links` evidence and the producing Bot's model, and a sent-back card reading *Sent back: …* (#401). Tests: `core/test/inbox.test.js`. |
| `core/src/client/turn-failures.js` | Dead-turn lines (#1044): a turn that died with no text draws one line above the composer (`flybot.session.dock`, `data-flybot=turn-failures`), newest last, at most 3, naming the provider error from the host's `ended` entry and the streak when it is above 1. Registered by `index.js` (`flybot-turn-failures`, order 20); `failureLines(snapshot, place, t)` lives in `core/src/shared/turn-failures.js` so the test reads it without React. It never posts, retries or counts as a reply. Tests: `core/test/dead-turn.test.js`. |
| `core/src/client/delivery-view.js` | The **Delivery** view of the task dialog (#1173): five columns (`raw`, `ready`, `working`, `review`, `done-today`) from one host snapshot, cards that link out, the verdict/CI badge, and the header numbers. It polls `actions.deliveryBoard` every 60s, shows `as of` with the snapshot time, and replaces the board with one honest line when there is no GitHub connection. Read-only: it renders, it never writes. Tests: `core/test/delivery-view.test.js`, `core/test/kanban.test.js`. |
Expand Down
4 changes: 2 additions & 2 deletions docs/subsystems/core-host.md
Original file line number Diff line number Diff line change
Expand Up @@ -375,11 +375,11 @@ DSH's own `schedule_list` answers for the Session the call runs in, and its `[]`

### Scheduled tasks: team quiet hours (#1166)

A window in the team's own clock that a **new or edited** task must fall outside — not a mute. `state.prefs.quietHours` holds `{ on, from, to }` beside the other team preferences, and its absence or `on: false` is off, which is the default (`src/shared/quiet-hours.js`, and `setPrefs` in `channel.js`).
A window that a **new or edited** task must fall outside — not a mute. The window itself is a wall clock, but the time a task would be due is read in **that task's own zone** (the next bullet), so the two are compared as instants rather than in one team-wide clock. `state.prefs.quietHours` holds `{ on, from, to }` beside the other team preferences, and its absence or `on: false` is off, which is the default (`src/shared/quiet-hours.js`, and `setPrefs` in `channel.js`).

- **Checked once, at the create/edit moment.** `schedule-create` and `schedule-update` refuse before any `service.create` / `service.update` / `service.delete`, with the named code `bot/quiet-hours` and a sentence naming when the window ends, so the form's existing error line shows it and nothing is created or moved. `when: { kind: keep }` changes no time and is never checked. The window is half-open `[from, to)` — `from` refuses, `to` allows — and a window whose end is earlier than its start crosses midnight.
- **What the clock is.** A one-shot `at` is an instant, read in the zone the form sends with the timing (the browser's), falling back to the host's when that zone is unreadable. `daily` and `weekly` carry their wall clock and zone with the time itself, so the check reads the clock as written.
- **`every` and `cron` are allowed, and said so.** DSH computes a repeat rule's next fire and `catalog()` answers only for a stored task, so FlyBot cannot check one without duplicating DSH's scheduler. The view carries `quietUnchecked: true` while quiet hours are on and such a task exists, and the page renders that as one line: visible, never a silent claim of silence.
- **`every` and `cron` are allowed, and said so.** DSH computes a repeat rule's next fire and `catalog()` answers only for a stored task, so FlyBot cannot check one without duplicating DSH's scheduler. The view carries `quietUnchecked: true` while quiet hours are on and such a task exists, and that flag is the **only** place the rule is computed: the page renders it as one line and does not scan the task list itself (`core/src/client/schedules.js`), so a repeat task deleted between two polls cannot leave the line behind, and a line cannot appear without the host's claim. Visible, never a silent claim of silence.
- **Nothing existing is touched.** A task that already fires inside the window keeps firing, and turning quiet hours on makes no `update` or `delete` call. The suppression half — muting a delivery and recording it, with a count on the team view — is **not implemented**: DSH has no pre-delivery veto and no `pause` (`ScheduleService` has `create`, `list`, `catalog`, `history`, `delete`, `update`), so it waits for a hook (#1166, tracked as blocked-on-DSH).
- **Not on other paths.** Direct messages, @-mentions and approval requests are untouched: the change reaches no turn or notify code.
- **Tests:** `core/test/quiet-hours.test.js` — the window and the zone reading, a refusal with no service write, the end minute allowed, a refused edit that leaves the task as it was, the two allowed rule kinds and their marker, an existing task left alone, and the settings write refusing an invalid window.
Expand Down
Loading
Loading