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.