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/2767-bot-schedules-next-run.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
`bot_schedules` no longer prints `next <a time that has already passed>` 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).
2 changes: 1 addition & 1 deletion core/src/host/schedules.js
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
22 changes: 22 additions & 0 deletions core/test/schedules.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.
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 @@ -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)

Expand Down
26 changes: 24 additions & 2 deletions patches/ds-bot/0001-core-fixes.patch
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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')
})
Expand Down Expand Up @@ -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.
Expand Down
Loading