From 4501dc3f4e39c65b612d9c4ad59dd119ce74280e Mon Sep 17 00:00:00 2001
From: Day Dreamer <87640074+HugoXK@users.noreply.github.com>
Date: Tue, 10 Nov 2026 09:19:03 +0800
Subject: [PATCH] fix(host): print no next run for a non-active scheduled task
DSH keeps the originally due instant on a finished one-shot task, and the
Bot-wide list passed it to every row whatever its status, so a finished task
advertised `next ` while the settings page on
the same data said Finished (#2767). A row that is not active now prints the
paused rows' `no next run` and keeps its status word; the kept instant still
sorts the row, and an active row's line is unchanged.
---
.../2767-bot-schedules-next-run.fixed.md | 1 +
core/src/host/schedules.js | 2 +-
core/test/schedules.test.js | 22 ++++++++++++++++
docs/subsystems/core-host.md | 4 +--
patches/ds-bot/0001-core-fixes.patch | 26 +++++++++++++++++--
5 files changed, 50 insertions(+), 5 deletions(-)
create mode 100644 changelog.d/2767-bot-schedules-next-run.fixed.md
diff --git a/changelog.d/2767-bot-schedules-next-run.fixed.md b/changelog.d/2767-bot-schedules-next-run.fixed.md
new file mode 100644
index 000000000..7c7640e2b
--- /dev/null
+++ b/changelog.d/2767-bot-schedules-next-run.fixed.md
@@ -0,0 +1 @@
+`bot_schedules` no longer prints `next ` for a task whose status is not `active`: such a row reads `no next run` (the same sentence paused rows already used) and keeps its status word, while an `active` row is unchanged. The settings page already said `Finished` for the same row, so the tool and the page now agree. See [#2767](https://github.com/ThinkFlowLab/FlyBot/issues/2767).
diff --git a/core/src/host/schedules.js b/core/src/host/schedules.js
index 0addfd3c8..4f9737da6 100644
--- a/core/src/host/schedules.js
+++ b/core/src/host/schedules.js
@@ -635,7 +635,7 @@ export function install(rt) {
if (at?.botId !== botId) return
rows.push({
at: when ?? '',
- line: `- "${entry.title ?? 'untitled'}" (${entry.id}) — ${when === undefined ? 'no next run' : `next ${when}`}, ${status}, ${at.place}, session ${entry.sessionId}`,
+ line: `- "${entry.title ?? 'untitled'}" (${entry.id}) — ${status === 'active' && when !== undefined ? `next ${when}` : 'no next run'}, ${status}, ${at.place}, session ${entry.sessionId}`,
})
}
for (const entry of await service.catalog()) add(entry, entry.status ?? 'active', entry.scheduledAt)
diff --git a/core/test/schedules.test.js b/core/test/schedules.test.js
index c2fa354c3..8301ffcbd 100644
--- a/core/test/schedules.test.js
+++ b/core/test/schedules.test.js
@@ -794,6 +794,28 @@ test('#628: a Bot with no task in any Session gets an answer that is about the B
assert.match(answer, /none — no scheduled task in any of its Sessions, so this is the whole list for the Bot/)
})
+// #2767: a row that is not `active` has no next run, whatever instant DSH still keeps on it.
+// The settings page on the same data says `Finished` (src/client/schedules.js), and DSH keeps
+// the originally due instant on a finished one-shot task, so printing it read as a time to come.
+test('#2767: an inactive task prints no next run, and an active one keeps its line', async (t) => {
+ const schedule = scheduleService()
+ const team = JSON.parse(JSON.stringify(TEAM))
+ team.pausedSchedules = { 's-paused': { id: 's-paused', sessionId: 'w2', record: { title: 'Paused brief', prompt: 'Sum up' }, pausedAt: 6 } }
+ const { ctx, home } = setup({ schedule }, team)
+ t.after(async () => { await ctx.dispose(); rmSync(home, { recursive: true, force: true, maxRetries: 5 }) })
+ const task = (id, title, status, scheduledAt) => schedule.tasks.set(id, { sessionId: 'w2', status, record: { id, title, prompt: `${title} prompt`, scheduledAt } })
+ task('s-done', 'Finished one-shot', 'inactive', '2026-10-29T07:20:46.458Z')
+ task('s-live', 'Live brief', 'active', '2030-01-01T01:00:00.000Z')
+ const answer = await ctx.registeredTools.get('bot_schedules').execute({}, { agent: { id: 'w2' } })
+ // The finished one-shot keeps its status word and loses the past instant.
+ assert.ok(answer.includes('- "Finished one-shot" (s-done) — no next run, inactive, own chat, session w2'), answer)
+ assert.ok(!answer.includes('2026-10-29'), 'a time that has already passed is not a next run')
+ // Paused already read this way; the change must not disturb it.
+ assert.ok(answer.includes('- "Paused brief" (s-paused) — no next run, paused, own chat, session w2'), answer)
+ // An active row's line is byte-identical to the one the base prints for the same entry.
+ assert.ok(answer.includes('- "Live brief" (s-live) — next 2030-01-01T01:00:00.000Z, active, own chat, session w2'), answer)
+})
+
// #1321: two owners on one host. `copyFrom` and `schedule-update` resolve a source Session the
// way the page does, and the host knowing that Session is not the caller being allowed to read
// its task: a copy or an edit may only touch a task of the caller's own user.
diff --git a/docs/subsystems/core-host.md b/docs/subsystems/core-host.md
index 655bb1dde..ad01a91b7 100644
--- a/docs/subsystems/core-host.md
+++ b/docs/subsystems/core-host.md
@@ -556,9 +556,9 @@ DSH 0.2 offers the schedule service only through its optional bundle `@deepseek-
DSH's own `schedule_list` answers for the Session the call runs in, and its `[]` is indistinguishable from "this Bot has nothing scheduled": a task the owner sees in the UI is invisible from another Session of the same Bot. On 2026-10-21 that produced a written claim that a live schedule had never existed, and it came close to a Bot "cleaning up" a timer it could not see.
-- **`bot_schedules`** (no arguments) answers for the calling Bot. It reads the host-wide `catalog()` and keeps the Sessions that speak for it: its own chat (`chatOwners`), the archived parts of it (`pastOwners` with no `roomId`), its group Sessions, live and archived (`groupOwners` / `pastOwners` with a `roomId`, named by the group chat), and a **group chat's own Session** — the group's tasks live on the room's Session id, kept when the calling Bot is in `room.members`, so every member (the admin too) sees them and a Bot outside the group does not. Its paused tasks come from `state.pausedSchedules`, including those on the group's own Session. Each line carries the id, the title, the next run, the state and the owning Session; the answer says in so many words that it is Bot-wide and that `schedule_list` is per Session, so an empty answer cannot be read as a claim about another Session.
+- **`bot_schedules`** (no arguments) answers for the calling Bot. It reads the host-wide `catalog()` and keeps the Sessions that speak for it: its own chat (`chatOwners`), the archived parts of it (`pastOwners` with no `roomId`), its group Sessions, live and archived (`groupOwners` / `pastOwners` with a `roomId`, named by the group chat), and a **group chat's own Session** — the group's tasks live on the room's Session id, kept when the calling Bot is in `room.members`, so every member (the admin too) sees them and a Bot outside the group does not. Its paused tasks come from `state.pausedSchedules`, including those on the group's own Session. Each line carries the id, the title, the next run, the state and the owning Session; the answer says in so many words that it is Bot-wide and that `schedule_list` is per Session, so an empty answer cannot be read as a claim about another Session. Only an `active` row has a next run: a row with any other status prints `no next run` even though DSH keeps the instant it was once due there, because a finished one-shot otherwise advertised a moment that had already passed while the settings page on the same data said `Finished` (#2767); the kept instant still sorts the row, so the order does not move.
- **Read-only, and it changes nothing about the scoping of the rest.** `schedule_create` / `schedule_update` / `schedule_delete` and DSH's own tools keep their session rule; the tool adds a way to *see* the Bot's tasks, not a way to edit them without knowing the Session.
-- Tests: `core/test/schedules.test.js` (`#628: bot_schedules answers for the Bot…`, the group-chat-own-Session cases, and the service-off and empty-list cases).
+- Tests: `core/test/schedules.test.js` (`#628: bot_schedules answers for the Bot…`, the group-chat-own-Session cases, the service-off and empty-list cases, and the inactive-versus-active next-run case of #2767).
### Scheduled tasks: edit, pause, missed runs (#354)
diff --git a/patches/ds-bot/0001-core-fixes.patch b/patches/ds-bot/0001-core-fixes.patch
index 1c5907846..e36fbd06d 100644
--- a/patches/ds-bot/0001-core-fixes.patch
+++ b/patches/ds-bot/0001-core-fixes.patch
@@ -8123,7 +8123,7 @@ diff --git a/src/host/schedules.js b/src/host/schedules.js
+ if (at?.botId !== botId) return
+ rows.push({
+ at: when ?? '',
-+ line: `- "${entry.title ?? 'untitled'}" (${entry.id}) — ${when === undefined ? 'no next run' : `next ${when}`}, ${status}, ${at.place}, session ${entry.sessionId}`,
++ line: `- "${entry.title ?? 'untitled'}" (${entry.id}) — ${status === 'active' && when !== undefined ? `next ${when}` : 'no next run'}, ${status}, ${at.place}, session ${entry.sessionId}`,
+ })
+ }
+ for (const entry of await service.catalog()) add(entry, entry.status ?? 'active', entry.scheduledAt)
@@ -14841,7 +14841,7 @@ diff --git a/test/schedules.test.js b/test/schedules.test.js
test('a group chat task lives on the group Session and lists under the group', async (t) => {
const schedule = scheduleService()
const { ctx, home, call } = setup({ schedule })
-@@ -121,3 +121,860 @@ test('a due reminder reaches a group as its prompts', () => {
+@@ -121,3 +121,882 @@ test('a due reminder reaches a group as its prompts', () => {
assert.equal(reminderText(batch), '[Scheduled task] One\n[Scheduled task] Two')
assert.equal(reminderText('plain text'), 'plain text')
})
@@ -15427,6 +15427,28 @@ diff --git a/test/schedules.test.js b/test/schedules.test.js
+ assert.match(answer, /none — no scheduled task in any of its Sessions, so this is the whole list for the Bot/)
+})
+
++// #2767: a row that is not `active` has no next run, whatever instant DSH still keeps on it.
++// The settings page on the same data says `Finished` (src/client/schedules.js), and DSH keeps
++// the originally due instant on a finished one-shot task, so printing it read as a time to come.
++test('#2767: an inactive task prints no next run, and an active one keeps its line', async (t) => {
++ const schedule = scheduleService()
++ const team = JSON.parse(JSON.stringify(TEAM))
++ team.pausedSchedules = { 's-paused': { id: 's-paused', sessionId: 'w2', record: { title: 'Paused brief', prompt: 'Sum up' }, pausedAt: 6 } }
++ const { ctx, home } = setup({ schedule }, team)
++ t.after(async () => { await ctx.dispose(); rmSync(home, { recursive: true, force: true, maxRetries: 5 }) })
++ const task = (id, title, status, scheduledAt) => schedule.tasks.set(id, { sessionId: 'w2', status, record: { id, title, prompt: `${title} prompt`, scheduledAt } })
++ task('s-done', 'Finished one-shot', 'inactive', '2026-10-29T07:20:46.458Z')
++ task('s-live', 'Live brief', 'active', '2030-01-01T01:00:00.000Z')
++ const answer = await ctx.registeredTools.get('bot_schedules').execute({}, { agent: { id: 'w2' } })
++ // The finished one-shot keeps its status word and loses the past instant.
++ assert.ok(answer.includes('- "Finished one-shot" (s-done) — no next run, inactive, own chat, session w2'), answer)
++ assert.ok(!answer.includes('2026-10-29'), 'a time that has already passed is not a next run')
++ // Paused already read this way; the change must not disturb it.
++ assert.ok(answer.includes('- "Paused brief" (s-paused) — no next run, paused, own chat, session w2'), answer)
++ // An active row's line is byte-identical to the one the base prints for the same entry.
++ assert.ok(answer.includes('- "Live brief" (s-live) — next 2030-01-01T01:00:00.000Z, active, own chat, session w2'), answer)
++})
++
+// #1321: two owners on one host. `copyFrom` and `schedule-update` resolve a source Session the
+// way the page does, and the host knowing that Session is not the caller being allowed to read
+// its task: a copy or an edit may only touch a task of the caller's own user.