diff --git a/core/test/schedules.test.js b/core/test/schedules.test.js index 5316953f9..bbc61301d 100644 --- a/core/test/schedules.test.js +++ b/core/test/schedules.test.js @@ -37,17 +37,21 @@ const CHANGE = { at: 'at', every: 'every_seconds', daily: 'daily', weekly: 'week function scheduleService() { const tasks = new Map() const updates = [] + // Every create in order: the paused-copy branch is the one path that must land here (#1319). + const created = [] let next = 1 // DSH runs management and delivery through one queue (`serialize`); the fake keeps one too. let chain = Promise.resolve() const service = { tasks, updates, + created, failCreate: false, failDelete: false, failPut: false, async create(sessionId, request) { if (this.failCreate) throw new Error('create failed') + created.push({ sessionId, request }) const id = `schedule-${next++}` const kind = Object.keys(request).find(key => !['title', 'prompt'].includes(key)) const record = { id, title: request.title, prompt: request.prompt, timing: request[kind], ...KINDS[kind](request[kind]) } @@ -407,6 +411,37 @@ test('#1287: a copy keeps a daily rule’s time and zone, and an earlier part’ assert.equal(groupCopy.record.scheduledAt, schedule.tasks.get(oldId).record.scheduledAt, 'and keeps the next run') }) +// #1319: a paused source has no next run to keep, so its copy takes the other branch of +// `copySchedule` — DSH's `create`, as Resume does — and gets a fresh interval anchored to now. +test('#1319: a copy of a paused task goes through create and re-anchors its next run', async (t) => { + const schedule = scheduleService() + const { ctx, home, call } = setup({ schedule }) + t.after(async () => { await ctx.dispose(); rmSync(home, { recursive: true, force: true, maxRetries: 5 }) }) + let clock = Date.parse('2030-01-01T12:00:00.000Z') + t.mock.method(Date, 'now', () => clock) + + await call('schedule-create', { target: 'm1', title: 'Watch', prompt: 'Check the board', when: { kind: 'every', minutes: 120 } }) + const [sourceId] = schedule.tasks.keys() + assert.equal((await call('schedule-pause', { sessionId: 'm1', id: sourceId })).ok, true) + assert.equal(schedule.tasks.size, 0, 'the source left DSH, so it has no next run to keep') + schedule.created.length = 0 + + // This branch shares the refusals of the active one, and they run before it. + assert.match((await call('schedule-create', { target: 'm1', copyFrom: { sessionId: 'm1', id: sourceId } })).error.message, /different Bot or group chat/) + assert.deepEqual(schedule.created, [], 'a refused copy creates nothing') + + clock += 10 * 60_000 + const copied = await call('schedule-create', { target: 'w1', copyFrom: { sessionId: 'm1', id: sourceId } }) + assert.equal(copied.ok, true, JSON.stringify(copied)) + assert.deepEqual(schedule.created.map(entry => [entry.sessionId, entry.request]), [['w2', { title: 'Watch', prompt: 'Check the board', every_seconds: 7200 }]], 'a paused copy is created, on the target’s current part, with the source’s request') + const [copyId, copy] = [...schedule.tasks.entries()][0] + assert.match(copyId, /^schedule-/, 'the copy gets DSH’s own id shape') + assert.notEqual(copyId, sourceId, 'never the paused source’s id') + assert.equal(copy.sessionId, 'w2') + assert.equal(copy.record.scheduledAt, new Date(clock + 7200_000).toISOString(), 'its next run is anchored to now + interval, as Resume re-anchors one') + assert.equal(JSON.parse(readFileSync(join(home, 'state.json'), 'utf8')).pausedSchedules[sourceId].record.title, 'Watch', 'the paused source stays paused') +}) + test('pause keeps the task in FlyBot and removes it from DSH; resume brings it back', async (t) => { const schedule = scheduleService() const { ctx, home, call } = setup({ schedule }) diff --git a/patches/ds-bot/0001-core-fixes.patch b/patches/ds-bot/0001-core-fixes.patch index 2b5c594b9..f88ce02a1 100644 --- a/patches/ds-bot/0001-core-fixes.patch +++ b/patches/ds-bot/0001-core-fixes.patch @@ -9551,7 +9551,7 @@ diff --git a/test/schedules.test.js b/test/schedules.test.js import { fakeContext } from './fake-ctx.js' import { reminderText } from '../src/host/schedules.js' -@@ -21,44 +21,98 @@ const TEAM = { +@@ -21,44 +21,102 @@ const TEAM = { questions: {}, } @@ -9571,6 +9571,8 @@ diff --git a/test/schedules.test.js b/test/schedules.test.js function scheduleService() { const tasks = new Map() + const updates = [] ++ // Every create in order: the paused-copy branch is the one path that must land here (#1319). ++ const created = [] let next = 1 - return { + // DSH runs management and delivery through one queue (`serialize`); the fake keeps one too. @@ -9578,11 +9580,13 @@ diff --git a/test/schedules.test.js b/test/schedules.test.js + const service = { tasks, + updates, ++ created, + failCreate: false, + failDelete: false, + failPut: false, async create(sessionId, request) { + if (this.failCreate) throw new Error('create failed') ++ created.push({ sessionId, request }) const id = `schedule-${next++}` const kind = Object.keys(request).find(key => !['title', 'prompt'].includes(key)) - const record = { id, kind, title: request.title, prompt: request.prompt, timing: request[kind], scheduledAt: '2030-01-01T01:00:00.000Z' } @@ -9660,7 +9664,7 @@ diff --git a/test/schedules.test.js b/test/schedules.test.js const refused = await call('schedule-create', { target: 'm1', title: 'Briefing', prompt: 'Sum up', when: { kind: 'daily', time: '09:00', timeZone: 'Asia/Shanghai' } }) assert.equal(refused.ok, false) assert.match(refused.error.message, /Automation tasks are off/) -@@ -121,3 +121,654 @@ test('a due reminder reaches a group as its prompts', () => { +@@ -121,3 +121,685 @@ 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') }) @@ -9896,6 +9900,37 @@ diff --git a/test/schedules.test.js b/test/schedules.test.js + assert.equal(groupCopy.record.scheduledAt, schedule.tasks.get(oldId).record.scheduledAt, 'and keeps the next run') +}) + ++// #1319: a paused source has no next run to keep, so its copy takes the other branch of ++// `copySchedule` — DSH's `create`, as Resume does — and gets a fresh interval anchored to now. ++test('#1319: a copy of a paused task goes through create and re-anchors its next run', async (t) => { ++ const schedule = scheduleService() ++ const { ctx, home, call } = setup({ schedule }) ++ t.after(async () => { await ctx.dispose(); rmSync(home, { recursive: true, force: true, maxRetries: 5 }) }) ++ let clock = Date.parse('2030-01-01T12:00:00.000Z') ++ t.mock.method(Date, 'now', () => clock) ++ ++ await call('schedule-create', { target: 'm1', title: 'Watch', prompt: 'Check the board', when: { kind: 'every', minutes: 120 } }) ++ const [sourceId] = schedule.tasks.keys() ++ assert.equal((await call('schedule-pause', { sessionId: 'm1', id: sourceId })).ok, true) ++ assert.equal(schedule.tasks.size, 0, 'the source left DSH, so it has no next run to keep') ++ schedule.created.length = 0 ++ ++ // This branch shares the refusals of the active one, and they run before it. ++ assert.match((await call('schedule-create', { target: 'm1', copyFrom: { sessionId: 'm1', id: sourceId } })).error.message, /different Bot or group chat/) ++ assert.deepEqual(schedule.created, [], 'a refused copy creates nothing') ++ ++ clock += 10 * 60_000 ++ const copied = await call('schedule-create', { target: 'w1', copyFrom: { sessionId: 'm1', id: sourceId } }) ++ assert.equal(copied.ok, true, JSON.stringify(copied)) ++ assert.deepEqual(schedule.created.map(entry => [entry.sessionId, entry.request]), [['w2', { title: 'Watch', prompt: 'Check the board', every_seconds: 7200 }]], 'a paused copy is created, on the target’s current part, with the source’s request') ++ const [copyId, copy] = [...schedule.tasks.entries()][0] ++ assert.match(copyId, /^schedule-/, 'the copy gets DSH’s own id shape') ++ assert.notEqual(copyId, sourceId, 'never the paused source’s id') ++ assert.equal(copy.sessionId, 'w2') ++ assert.equal(copy.record.scheduledAt, new Date(clock + 7200_000).toISOString(), 'its next run is anchored to now + interval, as Resume re-anchors one') ++ assert.equal(JSON.parse(readFileSync(join(home, 'state.json'), 'utf8')).pausedSchedules[sourceId].record.title, 'Watch', 'the paused source stays paused') ++}) ++ +test('pause keeps the task in FlyBot and removes it from DSH; resume brings it back', async (t) => { + const schedule = scheduleService() + const { ctx, home, call } = setup({ schedule })