From 036b46f082a1756c30789cb5963b1bf9a1377ab8 Mon Sep 17 00:00:00 2001 From: Day Dreamer <87640074+HugoXK@users.noreply.github.com> Date: Mon, 26 Oct 2026 15:33:58 +0800 Subject: [PATCH] fix(parts): keep a task's anchor when a handoff moves it (#1245) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A part handoff moved each task by creating it again on the fresh part: DSH's create anchors an `every` task to now + interval and gives it a fresh id and an empty delivery history, so the run it was already due to make slipped by up to a full interval and the reset history hid it. Rewrite the stored row in place inside `service.serialize` instead: id, record and history stay, only `sessionId` changes, and a failed write leaves the task on the earlier part. The row move reaches DSH's storage domain directly — `getDomain()`, `serialize()`, `table('tasks')`, `put` and the `tasks` row shape are private in @deepseek-ai/dsh-schedule@0.2.0-rc.2 — so the call site records both failure shapes for the next upgrade: a renamed method, table, `put` or missing `record` throws and warns, while a renamed row field the filter reads skips every row silently. The driver needs no re-arm: `drive()` re-scans storage on every tick and the timer is the global `scheduledAt` minimum, which the move leaves unchanged; if DSH ever turns it event-driven, a moved task needs an explicit `emitChanged()`. Tests: a handoff keeps the anchor, id and delivery history, a failed write changes nothing, and a disabled or exhausted row stays on the earlier part with its status untouched. part-archive's schedule fake models `getDomain()`/`serialize()`/`put`, so #896's archive-waits and failed-move-stops-activity properties still run. --- changelog.d/1245-schedule-anchor.fixed.md | 1 + core/src/host/schedules.js | 62 +++-- core/test/part-archive.test.js | 42 +++- core/test/schedules.test.js | 113 ++++++++- docs/subsystems/core-host.md | 5 +- patches/ds-bot/0001-core-fixes.patch | 215 ++++++++++++++++-- .../ds-bot/files/test/part-archive.test.js | 42 +++- 7 files changed, 420 insertions(+), 60 deletions(-) create mode 100644 changelog.d/1245-schedule-anchor.fixed.md diff --git a/changelog.d/1245-schedule-anchor.fixed.md b/changelog.d/1245-schedule-anchor.fixed.md new file mode 100644 index 000000000..4d0b0b6fd --- /dev/null +++ b/changelog.d/1245-schedule-anchor.fixed.md @@ -0,0 +1 @@ +A part handoff keeps a repeating task exactly where it was: the same id, the same delivery history and the same instant it was already due, instead of re-anchoring the next run to the handoff and hiding the move in a reset history. ([#1245](https://github.com/ThinkFlowLab/FlyBot/issues/1245)) diff --git a/core/src/host/schedules.js b/core/src/host/schedules.js index 5101eb753..ad6356f81 100644 --- a/core/src/host/schedules.js +++ b/core/src/host/schedules.js @@ -519,26 +519,58 @@ export function install(rt) { }, } - // A fresh part takes over the earlier part's active tasks: each is created again on - // the fresh Session before the old one goes, so a failure leaves it where it was. + // A fresh part takes over the earlier part's active tasks. A handoff is not an edit, so + // each row is rewritten in place with the fresh Session: the id, the delivery history and + // the instant it was already due all stay put, and a failed write leaves it where it was. + // Creating the task again would re-anchor it (DSH's create sets now + interval, #1245). + // + // This reaches DSH's storage domain directly: `getDomain()`, `serialize()`, + // `table('tasks')`, `put` and the `tasks` row shape are PRIVATE API of + // `@deepseek-ai/dsh-schedule@0.2.0-rc.2` (declared in `lib/types/index.d.ts:119` and + // `:132`), so re-check them on every DSH upgrade. + // A missing or renamed `getDomain()`, `serialize()` or `table('tasks')` throws before the + // loop runs: nothing moves, and `moveSchedules` warns `ds-bot: tasks of %s not moved`. + // A missing or renamed `record` is not read before the loop: `task.record.kind` throws on the + // first row that passes the filter, so rows already written in that call stay moved — a + // uniform drift moves nothing, a partial one leaves the move half done, and the outer + // `ds-bot: tasks of %s not moved` warn fires even though those earlier rows moved. A missing + // or renamed `put` throws per row instead: the per-row catch warns + // `ds-bot: task %s stayed with the earlier part`, and only that row stays behind while the + // rest of the loop continues. A renamed row field the filter reads (`sessionId`, `status`) + // throws nothing and warns nothing: every row is skipped silently, so a fake row carrying + // `state` instead of `status` is skipped and a reader who greps either warning finds nothing + // — those tasks silently stop firing rather than crash. A present `record` with a renamed + // `kind` reads as `kind: undefined`, so the one-shot test is false and an expired one-shot is + // moved to the fresh part; a renamed `scheduledAt` reads as undefined, so `Date.parse` gives + // NaN and the expiry check passes. Those rows do not stop firing: they move and can fire on + // the fresh part, an expired one-shot late. The tests fake the same private shape, so they + // do not catch any of it. The archive's schedule-only `stopActivity` removes the skipped rows + // only while no job, turn or subagent also holds the Session; when one does, the #901 guard + // refuses the archive and the rows leak instead. + // + // No re-arm is needed after a move at this DSH version: the driver's `drive()` re-scans + // storage on every tick and the timer is the global minimum over `scheduledAt`, so the + // moved row's due instant is already covered. If DSH ever turns the driver event-driven, + // a moved task needs an explicit `emitChanged()`. async function moveSchedules(fromId, toId) { if (service === null) return - let records try { - records = await service.list({ sessionId: fromId }) + await service.serialize(async () => { + const tasks = (await service.getDomain()).table('tasks') + for (const [id, task] of tasks.entries()) { + if (task.sessionId !== fromId || task.status !== 'active') continue + // A one-shot whose time has passed can never run on the fresh part. + const oneShot = task.record.kind === 'at' || task.record.kind === 'after' + if (oneShot && Date.parse(task.record.scheduledAt) <= Date.now()) continue + try { + await tasks.put(id, { ...task, sessionId: toId }) + } catch (error) { + rt.warn('ds-bot: task %s stayed with the earlier part: %s', task.record.title, error?.message ?? error) + } + } + }) } catch (error) { rt.warn('ds-bot: tasks of %s not moved: %s', fromId, error?.message ?? error) - return - } - for (const record of records) { - const request = requestOf(record) - if (request === null || (request.at !== undefined && Date.parse(request.at) <= Date.now())) continue - try { - await service.create(toId, request) - await service.delete({ sessionId: fromId, id: record.id }) - } catch (error) { - rt.warn('ds-bot: task %s stayed with the earlier part: %s', record.title, error?.message ?? error) - } } } diff --git a/core/test/part-archive.test.js b/core/test/part-archive.test.js index 875ce3f1e..95b90a625 100644 --- a/core/test/part-archive.test.js +++ b/core/test/part-archive.test.js @@ -26,31 +26,53 @@ const TEAM = { // DSH's schedule service, as the host sees it: tasks are stored per Session, and a // Session with an active task refuses to be archived unless its activity is stopped. -function scheduleService({ listDelay = 0, failCreate = false } = {}) { +function scheduleService({ delay = 0, failPut = false } = {}) { const tasks = new Map() let next = 1 - const armedOn = (sessionId) => [...tasks.entries()].filter(([, task]) => task.sessionId === sessionId).map(([id]) => id) + let chain = Promise.resolve() + const armedOn = (sessionId) => [...tasks.entries()].filter(([, task]) => task.sessionId === sessionId && task.status === 'active').map(([id]) => id) return { tasks, - listDelay, - failCreate, + delay, + failPut, armedOn, stop(sessionId) { for (const id of armedOn(sessionId)) tasks.delete(id) }, arm(sessionId, title = 'nightly check') { const id = `schedule-${next++}` - tasks.set(id, { sessionId, record: { id, title, prompt: 'Wake up.', kind: 'at', scheduledAt: '2030-01-01T01:00:00.000Z' } }) + tasks.set(id, { sessionId, status: 'active', record: { id, title, prompt: 'Wake up.', kind: 'at', scheduledAt: '2030-01-01T01:00:00.000Z' }, deliveryHistory: { records: [] } }) return id }, async list({ sessionId }) { - if (listDelay > 0) await new Promise(resolve => setTimeout(resolve, listDelay)) + if (delay > 0) await new Promise(resolve => setTimeout(resolve, delay)) return [...tasks.values()].filter(task => task.sessionId === sessionId).map(task => task.record) }, + // A part move rewrites the stored row (DSH's `getDomain`, inside `serialize`). + async getDomain() { + return { + table(name) { + assert.equal(name, 'tasks') + return { + entries: () => tasks.entries(), + get: id => tasks.get(id), + put(id, task) { + if (failPut) throw new Error('write failed') + tasks.set(id, task) + }, + } + }, + } + }, + serialize(work) { + // Queued behind whatever DSH is already running, like the real service. + const pending = chain.then(() => (delay > 0 ? new Promise(resolve => setTimeout(resolve, delay)) : undefined)).then(work) + chain = pending.catch(() => undefined) + return pending + }, async create(sessionId, request) { - if (failCreate) throw new Error('create failed') const id = `schedule-${next++}` - tasks.set(id, { sessionId, record: { id, title: request.title, prompt: request.prompt, kind: 'at', scheduledAt: request.at } }) + tasks.set(id, { sessionId, status: 'active', record: { id, title: request.title, prompt: request.prompt, kind: 'at', scheduledAt: request.at }, deliveryHistory: { records: [] } }) return tasks.get(id).record }, async delete({ sessionId, id }) { @@ -179,7 +201,7 @@ const drained = async () => { for (let i = 0; i < 25; i += 1) await settle() } const archivedEntry = (archived, sessionId) => archived.find(entry => (Array.isArray(entry) ? entry[0] : entry) === sessionId) test('#896: a part whose task moved is archived once the move finished', async (t) => { - const schedule = scheduleService({ listDelay: 40 }) + const schedule = scheduleService({ delay: 40 }) const h = await start({ schedule }) t.after(h.stop) schedule.arm('b') @@ -195,7 +217,7 @@ test('#896: a part whose task moved is archived once the move finished', async ( }) test('#896: a failed task move does not leave an armed earlier part active', async (t) => { - const schedule = scheduleService({ failCreate: true }) + const schedule = scheduleService({ failPut: true }) const h = await start({ schedule }) t.after(h.stop) schedule.arm('b') diff --git a/core/test/schedules.test.js b/core/test/schedules.test.js index f3ff81063..ccbae9bb6 100644 --- a/core/test/schedules.test.js +++ b/core/test/schedules.test.js @@ -3,7 +3,7 @@ import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { test } from 'node:test' -import { apply } from '../index.js' +import { createRuntime, provideFlyBot } from '../index.js' import { fakeContext } from './fake-ctx.js' import { reminderText } from '../src/host/schedules.js' @@ -22,9 +22,11 @@ const TEAM = { } // The record fields DSH stores for each create selector (see dsh-schedule's README). +// `every_seconds` is the one DSH anchors to the create instant (now + interval), so a +// move that went through create would re-anchor it; the others keep a fixed instant. const KINDS = { at: at => ({ kind: 'at', scheduledAt: at }), - every_seconds: seconds => ({ kind: 'every', everySeconds: seconds, scheduledAt: '2030-01-01T01:00:00.000Z' }), + every_seconds: seconds => ({ kind: 'every', everySeconds: seconds, scheduledAt: new Date(Date.now() + seconds * 1000).toISOString() }), daily: ({ time, time_zone }) => ({ kind: 'daily', time, timeZone: time_zone, scheduledAt: '2030-01-01T01:00:00.000Z' }), weekly: ({ time, time_zone, weekdays }) => ({ kind: 'weekly', time, timeZone: time_zone, weekdays, scheduledAt: '2030-01-01T01:00:00.000Z' }), cron: ({ expression, time_zone }) => ({ kind: 'cron', expression, timeZone: time_zone, scheduledAt: '2030-01-01T01:00:00.000Z' }), @@ -36,21 +38,45 @@ function scheduleService() { const tasks = new Map() const updates = [] let next = 1 - return { + // DSH runs management and delivery through one queue (`serialize`); the fake keeps one too. + let chain = Promise.resolve() + const service = { tasks, updates, failCreate: false, failDelete: false, + failPut: false, async create(sessionId, request) { if (this.failCreate) throw new Error('create failed') 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]) } - tasks.set(id, { sessionId, record, status: 'active' }) + tasks.set(id, { sessionId, record, status: 'active', deliveryHistory: { records: [], earlierRecordsUnavailable: false } }) return record }, async list({ sessionId }) { return [...tasks.values()].filter(task => task.sessionId === sessionId && task.status === 'active').map(task => task.record) }, async catalog() { return [...tasks.values()].map(task => ({ ...task.record, sessionId: task.sessionId, status: task.status })) }, + // DSH's storage domain, as `getDomain()` exposes it: the row itself is what a move rewrites. + async getDomain() { + return { + table(name) { + assert.equal(name, 'tasks') + return { + entries: () => tasks.entries(), + get: id => tasks.get(id), + put(id, task) { + if (service.failPut) throw new Error('write failed') + tasks.set(id, task) + }, + } + }, + } + }, + serialize(work) { + const pending = chain.then(work) + chain = pending.catch(() => undefined) + return pending + }, async update(request) { updates.push(request) const task = tasks.get(request.id) @@ -68,6 +94,7 @@ function scheduleService() { return { id, deleted: true } }, } + return service } function setup(services = {}, team = TEAM) { @@ -76,9 +103,10 @@ function setup(services = {}, team = TEAM) { const ctx = fakeContext({ services }) const register = ctx.connection.fetch.register ctx.connection.fetch.register = (route) => { ctx.route = route; return register(route) } - apply(ctx, { home }) + const rt = createRuntime(ctx, { home }) + provideFlyBot(ctx, rt.flybot) const call = async (endpoint, payload) => (await ctx.route.fetch(new Request('http://localhost/api/bot', { method: 'POST', body: JSON.stringify({ endpoint, payload }) }))).json() - return { ctx, home, call } + return { ctx, home, call, rt } } test('without the schedule service the page says so and creating is refused', async (t) => { @@ -212,6 +240,79 @@ test('editing a task onto another chat creates the new one before deleting the o assert.deepEqual(again.value.tasks.map(task => [task.prompt, task.status]), [['Again', 'active']]) }) +// #1245: a handoff is not an edit. The earlier part's task moves to the fresh part with its +// id, its delivery history and the instant it was already due — not re-anchored to the +// handoff, which would slip its next run by up to a full interval and hide it. +test('#1245: a handoff moves an “every” task without re-anchoring it', async (t) => { + const schedule = scheduleService() + const { ctx, home, rt } = setup({ schedule }) + t.after(async () => { await ctx.dispose(); rmSync(home, { recursive: true, force: true, maxRetries: 5 }) }) + // A handoff happens well after the task was created, so a create-based move would visibly + // re-anchor it: the fake's create samples this clock (`now + every_seconds`). + let clock = Date.parse('2030-01-01T00:00:00.000Z') + t.mock.method(Date, 'now', () => clock) + + const record = await schedule.create('m1', { title: 'Watch', prompt: 'Check the board', every_seconds: 7200 }) + const row = schedule.tasks.get(record.id) + row.deliveryHistory = { records: [{ scheduledAt: '2030-01-01T01:00:00.000Z', deliveredAt: '2030-01-01T01:00:01.000Z', messageId: 'msg-old' }], earlierRecordsUnavailable: false } + const anchor = row.record.scheduledAt + const history = structuredClone(row.deliveryHistory) + + clock += 45 * 60_000 + await rt.moveSchedules('m1', 'm1-fresh') + + const moved = [...schedule.tasks.values()].filter(task => task.sessionId === 'm1-fresh') + assert.equal(moved.length, 1, 'the task moved to the fresh part') + assert.equal(moved[0].record.scheduledAt, anchor, 'the instant it was already due is kept') + assert.equal(moved[0].record.id, record.id, 'the task keeps its id') + assert.deepEqual(moved[0].deliveryHistory, history, 'the delivery history is kept') + assert.equal(moved[0].record.everySeconds, 7200) + assert.equal(schedule.tasks.size, 1, 'the row was rewritten, not copied') + + // A failed write leaves the task where it was, with nothing half-deleted (#901). + schedule.failPut = true + await rt.moveSchedules('m1-fresh', 'm1-newer') + schedule.failPut = false + const [still] = [...schedule.tasks.values()] + assert.equal(still.sessionId, 'm1-fresh', 'a failed move changes nothing') + assert.equal(still.record.id, record.id) + assert.equal(still.record.scheduledAt, anchor) + + // A one-shot whose time has already passed cannot run on the fresh part, so it stays. + const once = await schedule.create('m1-fresh', { title: 'Once', prompt: 'Ping', at: '2030-01-01T00:00:00.000Z' }) + schedule.tasks.get(once.id).record = { ...schedule.tasks.get(once.id).record, scheduledAt: new Date(Date.now() - 1000).toISOString() } + await rt.moveSchedules('m1-fresh', 'm1-newer') + const byId = id => [...schedule.tasks.values()].find(task => task.record.id === id) + assert.equal(byId(once.id).sessionId, 'm1-fresh', 'a passed one-shot stays where it is') + assert.equal(byId(record.id).sessionId, 'm1-newer', 'the recurring task still moved') +}) + +// The move only takes active rows: a disabled task and an exhausted one-shot have no next run +// to follow the handoff, so they stay on the earlier part with their status untouched. +test('#1245: a handoff leaves a disabled and an exhausted row on the earlier part', async (t) => { + const schedule = scheduleService() + const { ctx, home, rt } = setup({ schedule }) + t.after(async () => { await ctx.dispose(); rmSync(home, { recursive: true, force: true, maxRetries: 5 }) }) + t.mock.method(Date, 'now', () => Date.parse('2030-01-01T00:00:00.000Z')) + + const disabled = await schedule.create('m1', { title: 'Disabled watch', prompt: 'Switched off', every_seconds: 7200 }) + const exhausted = await schedule.create('m1', { title: 'Exhausted ping', prompt: 'Ran once', at: '2030-01-01T00:10:00.000Z' }) + // DSH's only ended state is `inactive`: a disabled task and a finished one-shot both keep it. + schedule.tasks.get(disabled.id).status = 'inactive' + schedule.tasks.get(exhausted.id).status = 'inactive' + const active = await schedule.create('m1', { title: 'Live watch', prompt: 'Moves', every_seconds: 3600 }) + + await rt.moveSchedules('m1', 'm1-fresh') + + const row = id => [...schedule.tasks.values()].find(task => task.record.id === id) + assert.equal(row(disabled.id).sessionId, 'm1', 'a disabled task stays on the earlier part') + assert.equal(row(disabled.id).status, 'inactive', 'its status is untouched') + assert.equal(row(exhausted.id).sessionId, 'm1', 'an exhausted task stays on the earlier part') + assert.equal(row(exhausted.id).status, 'inactive', 'its status is untouched') + assert.equal(row(active.id).sessionId, 'm1-fresh', 'the active task still moves') + assert.deepEqual([...schedule.tasks.values()].filter(task => task.sessionId === 'm1-fresh').map(task => task.record.id), [active.id], 'the fresh part gets neither inactive row') +}) + 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/docs/subsystems/core-host.md b/docs/subsystems/core-host.md index a292e4528..4bf835a65 100644 --- a/docs/subsystems/core-host.md +++ b/docs/subsystems/core-host.md @@ -43,7 +43,7 @@ The server side of the patched `ds-bot` core (`core/`): the part of [ds-bot-core | `core/src/host/room-membership.js` | Typed room participants (human / local bot / granted remote), authorship, membership-window history (#50); rooms with friends' Bots or other owners take granted local Bots only and evict a Bot that stops qualifying (#90). Face-to-face rooms (#83) reuse this model (`origin: 'face-to-face'`, room-only remotes without a person-grant, `upsertFaceRoom` / `syncFaceParticipants` / `applyFaceRoomLine`); a face-room local Bot is `viaCode` (brought by number), `evictUnshared` skips face rooms (membership is the server's), and a Bot that returns to the member list drops the empty local `left` line (#236). Refusing an ungranted local Bot next to a friend's Bot throws `ungrantedRefusal`: a message that names the Bot, the friend and the two ways out, plus `error.details` `{ reason: 'ungranted-local', bots, remotes }`; `channel.js` `fail` forwards a plain-object `details` (empty otherwise) and `client/api.js` puts it on the thrown error, where `client/new-chat.js` draws a card (**Copy invite link for {Bot}** via `__flybot__.social.createInvite`, **Leave {Bot} out**) read through `ungrantedLocalFix` in `shared/remote-label.js`; the same file's `copiedToClipboard` reports whether the invite link really reached the clipboard (none, or a refused write, is `false`), and the card then shows the link to copy by hand instead of saying it was copied. | | `core/src/host/face-mentions.js` | Who "@" calls in a face-to-face room (#287): every alias of a participant (`faceAliases`, kept in `shared/remote-label.js` so the bubble chips the same names, #305; bare name, the picker label `'s Chief` / ` · 's Bot`, ` · `, handle), local and friend's Bots told apart. `faceMentions().handles` is the handle of every Bot a line names (the explicit mention list a sender attaches, #293) and `mentionedByHandle` resolves a received list to my Bots (`known` counts handles the room has). | | `core/src/host/reach.js` | Per-Bot grants and the owner-policy `reach()` check that hooks.js runs outside the plugin chain (#90); `rt.setGrants`, `rt.hasGrant`, `rt.hasAnyGrant`. A local Bot refused toward a friend's Bot is told that it is not shared with that friend and where to share it (Sharing, #273). `rt.reach` also reads the room a send already belongs to when the caller names none (#948): `sharedRoom` applies only to the `roomOnly` identity — the Bot holds no grant at all and came into that **face-to-face** room by its number (`isCodeJoined`) — and looks for the one such room where the **exact** target is still seated; a Bot that holds a grant, or a room of this home, is judged as it always was and keeps asking for an explicit `roomId`, so a Bot that joined a room by number — a `roomOnly` identity with no person-grant — may line that seat, which is the same allowance `reach()` already applies to an explicit `roomId`; the room itself is named outbound by the transport, never assumed here. A `user-mention` with no named room is also judged under `mentionRoom` (#1126): one live local or inbound group where that Bot is a member and the exact target is still a remote seat, including a seat a group invite admitted (`admittedBy: 'ticket'`). A plain line, `message_bot`, a seat that has left, and a stranger outside every such room stay on the grant rule. Face-to-face stays on `sharedRoom`. Tests: `core/test/reach-policy.test.js`, `core/test/flybot.test.js`. | -| `core/src/host/schedules.js` | Scheduled tasks through DSH's schedule service: list, create, edit, pause/resume, retry and delete for Settings → Scheduled tasks, each task's last run, and moving a Bot's tasks to its fresh part (#354, #473, see **Scheduled tasks: edit, pause, missed runs** and **Scheduled tasks: last run and Retry**). Also the `bot_schedules` tool: the Bot-wide list DSH's own `schedule_list` cannot give, group chats' own Sessions included (#628, see **Scheduled tasks: the Bot-wide list**). Tests: `core/test/schedules.test.js`. Tests for team quiet hours, its refusals and the settings write: `core/test/quiet-hours.test.js`; the rule itself is in `core/src/shared/quiet-hours.js` (#1166). | +| `core/src/host/schedules.js` | Scheduled tasks through DSH's schedule service: list, create, edit, pause/resume, retry and delete for Settings → Scheduled tasks, each task's last run, and moving a Bot's tasks to its fresh part, which rewrites the stored row in place so the task keeps its id, its delivery history and the instant it was already due (#354, #473, #1245, see **Scheduled tasks: edit, pause, missed runs** and **Scheduled tasks: last run and Retry**). Also the `bot_schedules` tool: the Bot-wide list DSH's own `schedule_list` cannot give, group chats' own Sessions included (#628, see **Scheduled tasks: the Bot-wide list**). Tests: `core/test/schedules.test.js`. Tests for team quiet hours, its refusals and the settings write: `core/test/quiet-hours.test.js`; the rule itself is in `core/src/shared/quiet-hours.js` (#1166). | | `core/src/host/automation-default.js` | Automation tasks on by default (#378): once per profile, `offerBundle` asks DSH's plugin manager to switch on `@deepseek-ai/dsh-experimental-schedule-bundle` and writes the offer to `dsh-defaults.json` next to the team. Tests: `core/test/automation-default.test.js`. | | `core/src/host/delivery-repo.js` | Which repository a delivery read talks to, and where the name came from (#1250). `resolveRepo({ setting, workspace, packageSlug })` answers first-wins: the instance setting, then the team workspace's `origin` read straight out of `.git/config` (a `.git` file is followed through its `gitdir:`; a linked worktree then follows its `commondir` to the shared config, and no `git` process is spawned), then the package's `repository.url`. A name is normalised to `owner/name` from `owner/name`, a GitHub URL, `git@github.com:owner/name.git` or an `ssh://` URL; anything on another host is refused rather than mined, and a `.` or `..` segment is refused because it is a path step rather than a repository name (#1258). Only `origin` counts — a union of disagreeing remotes would be a guess. `NO_REPO_MESSAGE` is the one wording the host, the view and `core/scripts/delivery-probe.mjs` use. Tests: `core/test/delivery-repo.test.js` (the knife for each level). | | `core/src/host/notify.js` | Out-of-app delivery, first half (#355): a transport-agnostic adapter with Slack's `chat.postMessage` shape, one key per reminder so a retried send cannot duplicate, exponential backoff that honours `Retry-After`, three attempts at most, a structural failure not retried, and zero transport calls while the channel is off. Ships `fakeSlack` for tests; nothing calls it until the real Slack transport lands (see **Out-of-app delivery, first half**). Tests: `core/test/notify.test.js`. | @@ -382,7 +382,8 @@ DSH's own `schedule_list` answers for the Session the call runs in, and its `[]` ### Scheduled tasks: edit, pause, missed runs (#354) -- **Edit** (`schedule-update`). On the same Session an active task goes through DSH's compare-and-update with the exact record `list` returned, so its id, next run and delivery records stay; the timing is sent only when it changed, so renaming a task does not move its next run. Moved to another Bot or group chat, or a finished one-time task, the new task is created first and the old one deleted after, the same order as a part move (`moveSchedules`), so a failure keeps the old task. `when: { kind: 'keep' }` keeps the timing (a cron rule the form cannot show). The edit needs the task's Session **and** its new target (`authz.js`), and its text gets the same chip check as `schedule-create`. +- **Edit** (`schedule-update`). On the same Session an active task goes through DSH's compare-and-update with the exact record `list` returned, so its id, next run and delivery records stay; the timing is sent only when it changed, so renaming a task does not move its next run. Moved to another Bot or group chat, or a finished one-time task, the new task is created first and the old one deleted after, so a failure keeps the old task (a part move needs no such create — it rewrites the stored row, below). `when: { kind: 'keep' }` keeps the timing (a cron rule the form cannot show). The edit needs the task's Session **and** its new target (`authz.js`), and its text gets the same chip check as `schedule-create`. +- **Part move** (`moveSchedules`, #1245). A handoff is not an edit, so the earlier part's active tasks are **rewritten in place** in DSH's storage domain, inside `service.serialize`: the row keeps its id, record and delivery history and only `sessionId` changes to the fresh Session. The next run therefore stays the instant it was already due — creating the task again would set `now + interval` (the slip and reset history #1245 documents) — and a one-shot whose time has passed stays where it is. The due set is unchanged, so the runtime needs no re-arm: it re-reads the table before every delivery. A failed write leaves the task on the earlier part, with nothing half-deleted, which is the property #901 relies on. The write reaches DSH's storage domain directly — `getDomain()`, `serialize()`, `table('tasks')`, `put` and the `tasks` row shape are **private** in `@deepseek-ai/dsh-schedule@0.2.0-rc.2`, so re-check them on every DSH upgrade. A missing or renamed `getDomain()`, `serialize()` or `table('tasks')` throws before the loop: nothing moves and `moveSchedules` warns `ds-bot: tasks of %s not moved`. A missing or renamed `record` throws inside the loop, on the first row that passes the filter, so rows already written in that call stay moved — a uniform drift moves nothing, a partial one leaves it half done (the same `ds-bot: tasks of %s not moved` warn fires). A missing or renamed `put` throws per row: the per-row catch warns `ds-bot: task %s stayed with the earlier part`, and only that row stays behind while the loop continues. A renamed row field the filter reads (`sessionId`, `status`) throws nothing and warns nothing — every row is skipped silently, so a fake row carrying `state` instead of `status` is skipped and grepping either warning finds nothing, and those tasks silently stop firing rather than crash. A present `record` with a renamed `kind` reads as `kind: undefined`, so the one-shot test is false and an expired one-shot is moved to the fresh part; a renamed `scheduledAt` reads as undefined, so `Date.parse` gives NaN and the expiry check passes — those rows do not stop firing, they move and can fire there, an expired one-shot late. The tests model the same private shape, so they will not catch any of it. - **Pause / Resume** (`schedule-pause`, `schedule-resume`). DSH has no pause (its README: "Pause ... not supported"). Pause writes the task's record to `state.pausedSchedules[]` (`{ id, sessionId, record, pausedAt }`) and saves the team before DSH deletes the task; a failed delete takes the entry back out, so the task keeps running. Resume creates the task again on the Bot's current part (or the group's Session) before the entry goes, so a failed create leaves it paused. A paused task lists with `status: 'paused'` and its id, can be edited (it stays paused) or deleted, and a one-time task whose time passed while paused is refused until it is edited. A recurring task's next run is computed afresh on Resume. - **Missed runs** (DSH's behavior, checked against the pinned `@deepseek-ai/dsh-schedule`). Tasks are stored by the host and survive a restart, but run only while the app is running. After the app was closed, a recurring task (every N minutes, daily, weekly, cron) runs **once**, for its latest missed time, and then waits for its next future time: it never runs once per missed time. A one-time task whose time passed while the app was closed runs once when the app starts again. A paused task runs no missed time at all. DSH notes one exception it does not rule out: a crash or shutdown between delivering a reminder and saving that it did can deliver that one reminder again. Test: `a recurring task missed while the app was closed runs once, for its latest time` in `core/test/schedules.test.js`. diff --git a/patches/ds-bot/0001-core-fixes.patch b/patches/ds-bot/0001-core-fixes.patch index 2f54fcd33..27b0064ae 100644 --- a/patches/ds-bot/0001-core-fixes.patch +++ b/patches/ds-bot/0001-core-fixes.patch @@ -4202,7 +4202,7 @@ diff --git a/src/host/schedules.js b/src/host/schedules.js // The settings form's timing as a create request selector. function selectorOf(when) { const zone = typeof when?.timeZone === 'string' && when.timeZone !== '' ? when.timeZone : 'UTC' -@@ -89,36 +89,360 @@ export function install(rt) { +@@ -89,63 +89,426 @@ export function install(rt) { return rt.groupOwners.get(sessionId) ?? rt.pastOwners.get(sessionId) } const known = owner => owner !== undefined && (owner.botId === undefined ? state.rooms[owner.roomId] !== undefined : state.bots[owner.botId] !== undefined) @@ -4368,13 +4368,14 @@ diff --git a/src/host/schedules.js b/src/host/schedules.js + if (!known(owner)) continue + const lastRun = lastRunOf({ id: entry.id }) + tasks.push({ ...entry.record, id: entry.id, sessionId: entry.sessionId, status: 'paused', pausedAt: entry.pausedAt, ...owner, ...(lastRun ? { lastRun } : {}) }) -+ } + } +- return { available: true, tasks } + // #1166: a repeat rule can still fire inside the window — the page says so instead of + // claiming a silence FlyBot cannot keep. + const unchecked = quietHours.on === true && tasks.some(task => task.kind === 'every' || task.kind === 'cron') + return { available: true, tasks, quietHours, ...(unchecked ? { quietUnchecked: true } : {}) } -+ } -+ + } + + // Edit: a paused task stays paused with its new content; an active one is updated in + // place on the same Session, otherwise created anew before the old one is deleted. + // `when: { kind: 'keep' }` keeps the timing (a cron rule the form cannot show). @@ -4483,8 +4484,7 @@ diff --git a/src/host/schedules.js b/src/host/schedules.js + void rt.save().catch(() => {}) + } + throw error - } -- return { available: true, tasks } ++ } + return schedulesView() + } + @@ -4506,8 +4506,8 @@ diff --git a/src/host/schedules.js b/src/host/schedules.js + const name = rt.roomOf(owner.roomId)?.name + const place = name === undefined ? 'a group chat' : `group chat "${name}"` + return { botId: owner.botId, place: live === undefined ? `${place}, earlier part` : place } - } - ++ } ++ + // DSH's own `schedule_list` answers for the Session the call runs in, and its empty + // answer is indistinguishable from "this Bot has nothing scheduled" (#628): a task the + // owner sees in the UI is invisible from another Session of the same Bot. This tool @@ -4577,7 +4577,75 @@ diff --git a/src/host/schedules.js b/src/host/schedules.js await service.delete({ sessionId: body.sessionId, id: body.id }) return schedulesView() }, -@@ -147,5 +147,12 @@ export function install(rt) { + } + +- // A fresh part takes over the earlier part's active tasks: each is created again on +- // the fresh Session before the old one goes, so a failure leaves it where it was. ++ // A fresh part takes over the earlier part's active tasks. A handoff is not an edit, so ++ // each row is rewritten in place with the fresh Session: the id, the delivery history and ++ // the instant it was already due all stay put, and a failed write leaves it where it was. ++ // Creating the task again would re-anchor it (DSH's create sets now + interval, #1245). ++ // ++ // This reaches DSH's storage domain directly: `getDomain()`, `serialize()`, ++ // `table('tasks')`, `put` and the `tasks` row shape are PRIVATE API of ++ // `@deepseek-ai/dsh-schedule@0.2.0-rc.2` (declared in `lib/types/index.d.ts:119` and ++ // `:132`), so re-check them on every DSH upgrade. ++ // A missing or renamed `getDomain()`, `serialize()` or `table('tasks')` throws before the ++ // loop runs: nothing moves, and `moveSchedules` warns `ds-bot: tasks of %s not moved`. ++ // A missing or renamed `record` is not read before the loop: `task.record.kind` throws on the ++ // first row that passes the filter, so rows already written in that call stay moved — a ++ // uniform drift moves nothing, a partial one leaves the move half done, and the outer ++ // `ds-bot: tasks of %s not moved` warn fires even though those earlier rows moved. A missing ++ // or renamed `put` throws per row instead: the per-row catch warns ++ // `ds-bot: task %s stayed with the earlier part`, and only that row stays behind while the ++ // rest of the loop continues. A renamed row field the filter reads (`sessionId`, `status`) ++ // throws nothing and warns nothing: every row is skipped silently, so a fake row carrying ++ // `state` instead of `status` is skipped and a reader who greps either warning finds nothing ++ // — those tasks silently stop firing rather than crash. A present `record` with a renamed ++ // `kind` reads as `kind: undefined`, so the one-shot test is false and an expired one-shot is ++ // moved to the fresh part; a renamed `scheduledAt` reads as undefined, so `Date.parse` gives ++ // NaN and the expiry check passes. Those rows do not stop firing: they move and can fire on ++ // the fresh part, an expired one-shot late. The tests fake the same private shape, so they ++ // do not catch any of it. The archive's schedule-only `stopActivity` removes the skipped rows ++ // only while no job, turn or subagent also holds the Session; when one does, the #901 guard ++ // refuses the archive and the rows leak instead. ++ // ++ // No re-arm is needed after a move at this DSH version: the driver's `drive()` re-scans ++ // storage on every tick and the timer is the global minimum over `scheduledAt`, so the ++ // moved row's due instant is already covered. If DSH ever turns the driver event-driven, ++ // a moved task needs an explicit `emitChanged()`. + async function moveSchedules(fromId, toId) { + if (service === null) return +- let records + try { +- records = await service.list({ sessionId: fromId }) ++ await service.serialize(async () => { ++ const tasks = (await service.getDomain()).table('tasks') ++ for (const [id, task] of tasks.entries()) { ++ if (task.sessionId !== fromId || task.status !== 'active') continue ++ // A one-shot whose time has passed can never run on the fresh part. ++ const oneShot = task.record.kind === 'at' || task.record.kind === 'after' ++ if (oneShot && Date.parse(task.record.scheduledAt) <= Date.now()) continue ++ try { ++ await tasks.put(id, { ...task, sessionId: toId }) ++ } catch (error) { ++ rt.warn('ds-bot: task %s stayed with the earlier part: %s', task.record.title, error?.message ?? error) ++ } ++ } ++ }) + } catch (error) { + rt.warn('ds-bot: tasks of %s not moved: %s', fromId, error?.message ?? error) +- return +- } +- for (const record of records) { +- const request = requestOf(record) +- if (request === null || (request.at !== undefined && Date.parse(request.at) <= Date.now())) continue +- try { +- await service.create(toId, request) +- await service.delete({ sessionId: fromId, id: record.id }) +- } catch (error) { +- rt.warn('ds-bot: task %s stayed with the earlier part: %s', record.title, error?.message ?? error) +- } } } @@ -8528,21 +8596,28 @@ diff --git a/test/prompt.test.js b/test/prompt.test.js diff --git a/test/schedules.test.js b/test/schedules.test.js --- a/test/schedules.test.js +++ b/test/schedules.test.js -@@ -1,5 +1,5 @@ +@@ -1,9 +1,9 @@ import assert from 'node:assert/strict' -import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { test } from 'node:test' -@@ -21,22 +21,48 @@ const TEAM = { +-import { apply } from '../index.js' ++import { createRuntime, provideFlyBot } from '../index.js' + import { fakeContext } from './fake-ctx.js' + import { reminderText } from '../src/host/schedules.js' + +@@ -21,44 +21,98 @@ const TEAM = { questions: {}, } +// The record fields DSH stores for each create selector (see dsh-schedule's README). ++// `every_seconds` is the one DSH anchors to the create instant (now + interval), so a ++// move that went through create would re-anchor it; the others keep a fixed instant. +const KINDS = { + at: at => ({ kind: 'at', scheduledAt: at }), -+ every_seconds: seconds => ({ kind: 'every', everySeconds: seconds, scheduledAt: '2030-01-01T01:00:00.000Z' }), ++ every_seconds: seconds => ({ kind: 'every', everySeconds: seconds, scheduledAt: new Date(Date.now() + seconds * 1000).toISOString() }), + daily: ({ time, time_zone }) => ({ kind: 'daily', time, timeZone: time_zone, scheduledAt: '2030-01-01T01:00:00.000Z' }), + weekly: ({ time, time_zone, weekdays }) => ({ kind: 'weekly', time, timeZone: time_zone, weekdays, scheduledAt: '2030-01-01T01:00:00.000Z' }), + cron: ({ expression, time_zone }) => ({ kind: 'cron', expression, timeZone: time_zone, scheduledAt: '2030-01-01T01:00:00.000Z' }), @@ -8554,11 +8629,15 @@ diff --git a/test/schedules.test.js b/test/schedules.test.js const tasks = new Map() + const updates = [] let next = 1 - return { +- return { ++ // DSH runs management and delivery through one queue (`serialize`); the fake keeps one too. ++ let chain = Promise.resolve() ++ const service = { tasks, + updates, + failCreate: false, + failDelete: false, ++ failPut: false, async create(sessionId, request) { + if (this.failCreate) throw new Error('create failed') const id = `schedule-${next++}` @@ -8566,13 +8645,34 @@ diff --git a/test/schedules.test.js b/test/schedules.test.js - const record = { id, kind, title: request.title, prompt: request.prompt, timing: request[kind], scheduledAt: '2030-01-01T01:00:00.000Z' } - tasks.set(id, { sessionId, record }) + const record = { id, title: request.title, prompt: request.prompt, timing: request[kind], ...KINDS[kind](request[kind]) } -+ tasks.set(id, { sessionId, record, status: 'active' }) ++ tasks.set(id, { sessionId, record, status: 'active', deliveryHistory: { records: [], earlierRecordsUnavailable: false } }) return record }, - async list({ sessionId }) { return [...tasks.values()].filter(task => task.sessionId === sessionId).map(task => task.record) }, - async catalog() { return [...tasks.values()].map(task => ({ ...task.record, sessionId: task.sessionId, status: 'active' })) }, + async list({ sessionId }) { return [...tasks.values()].filter(task => task.sessionId === sessionId && task.status === 'active').map(task => task.record) }, + async catalog() { return [...tasks.values()].map(task => ({ ...task.record, sessionId: task.sessionId, status: task.status })) }, ++ // DSH's storage domain, as `getDomain()` exposes it: the row itself is what a move rewrites. ++ async getDomain() { ++ return { ++ table(name) { ++ assert.equal(name, 'tasks') ++ return { ++ entries: () => tasks.entries(), ++ get: id => tasks.get(id), ++ put(id, task) { ++ if (service.failPut) throw new Error('write failed') ++ tasks.set(id, task) ++ }, ++ } ++ }, ++ } ++ }, ++ serialize(work) { ++ const pending = chain.then(work) ++ chain = pending.catch(() => undefined) ++ return pending ++ }, + async update(request) { + updates.push(request) + const task = tasks.get(request.id) @@ -8588,8 +8688,9 @@ diff --git a/test/schedules.test.js b/test/schedules.test.js if (tasks.get(id)?.sessionId !== sessionId) return { id, deleted: false, code: 'schedule_not_found' } tasks.delete(id) return { id, deleted: true } -@@ -44,9 +44,9 @@ function scheduleService() { + }, } ++ return service } -function setup(services = {}) { @@ -8600,7 +8701,14 @@ diff --git a/test/schedules.test.js b/test/schedules.test.js const ctx = fakeContext({ services }) const register = ctx.connection.fetch.register ctx.connection.fetch.register = (route) => { ctx.route = route; return register(route) } -@@ -58,7 +58,7 @@ function setup(services = {}) { +- apply(ctx, { home }) ++ const rt = createRuntime(ctx, { home }) ++ provideFlyBot(ctx, rt.flybot) + const call = async (endpoint, payload) => (await ctx.route.fetch(new Request('http://localhost/api/bot', { method: 'POST', body: JSON.stringify({ endpoint, payload }) }))).json() +- return { ctx, home, call } ++ return { ctx, home, call, rt } + } + test('without the schedule service the page says so and creating is refused', async (t) => { const { ctx, home, call } = setup() t.after(async () => { await ctx.dispose(); rmSync(home, { recursive: true, force: true, maxRetries: 5 }) }) @@ -8609,7 +8717,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,341 @@ test('a due reminder reaches a group as its prompts', () => { +@@ -121,3 +121,414 @@ 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') }) @@ -8678,6 +8786,79 @@ diff --git a/test/schedules.test.js b/test/schedules.test.js + assert.deepEqual(again.value.tasks.map(task => [task.prompt, task.status]), [['Again', 'active']]) +}) + ++// #1245: a handoff is not an edit. The earlier part's task moves to the fresh part with its ++// id, its delivery history and the instant it was already due — not re-anchored to the ++// handoff, which would slip its next run by up to a full interval and hide it. ++test('#1245: a handoff moves an “every” task without re-anchoring it', async (t) => { ++ const schedule = scheduleService() ++ const { ctx, home, rt } = setup({ schedule }) ++ t.after(async () => { await ctx.dispose(); rmSync(home, { recursive: true, force: true, maxRetries: 5 }) }) ++ // A handoff happens well after the task was created, so a create-based move would visibly ++ // re-anchor it: the fake's create samples this clock (`now + every_seconds`). ++ let clock = Date.parse('2030-01-01T00:00:00.000Z') ++ t.mock.method(Date, 'now', () => clock) ++ ++ const record = await schedule.create('m1', { title: 'Watch', prompt: 'Check the board', every_seconds: 7200 }) ++ const row = schedule.tasks.get(record.id) ++ row.deliveryHistory = { records: [{ scheduledAt: '2030-01-01T01:00:00.000Z', deliveredAt: '2030-01-01T01:00:01.000Z', messageId: 'msg-old' }], earlierRecordsUnavailable: false } ++ const anchor = row.record.scheduledAt ++ const history = structuredClone(row.deliveryHistory) ++ ++ clock += 45 * 60_000 ++ await rt.moveSchedules('m1', 'm1-fresh') ++ ++ const moved = [...schedule.tasks.values()].filter(task => task.sessionId === 'm1-fresh') ++ assert.equal(moved.length, 1, 'the task moved to the fresh part') ++ assert.equal(moved[0].record.scheduledAt, anchor, 'the instant it was already due is kept') ++ assert.equal(moved[0].record.id, record.id, 'the task keeps its id') ++ assert.deepEqual(moved[0].deliveryHistory, history, 'the delivery history is kept') ++ assert.equal(moved[0].record.everySeconds, 7200) ++ assert.equal(schedule.tasks.size, 1, 'the row was rewritten, not copied') ++ ++ // A failed write leaves the task where it was, with nothing half-deleted (#901). ++ schedule.failPut = true ++ await rt.moveSchedules('m1-fresh', 'm1-newer') ++ schedule.failPut = false ++ const [still] = [...schedule.tasks.values()] ++ assert.equal(still.sessionId, 'm1-fresh', 'a failed move changes nothing') ++ assert.equal(still.record.id, record.id) ++ assert.equal(still.record.scheduledAt, anchor) ++ ++ // A one-shot whose time has already passed cannot run on the fresh part, so it stays. ++ const once = await schedule.create('m1-fresh', { title: 'Once', prompt: 'Ping', at: '2030-01-01T00:00:00.000Z' }) ++ schedule.tasks.get(once.id).record = { ...schedule.tasks.get(once.id).record, scheduledAt: new Date(Date.now() - 1000).toISOString() } ++ await rt.moveSchedules('m1-fresh', 'm1-newer') ++ const byId = id => [...schedule.tasks.values()].find(task => task.record.id === id) ++ assert.equal(byId(once.id).sessionId, 'm1-fresh', 'a passed one-shot stays where it is') ++ assert.equal(byId(record.id).sessionId, 'm1-newer', 'the recurring task still moved') ++}) ++ ++// The move only takes active rows: a disabled task and an exhausted one-shot have no next run ++// to follow the handoff, so they stay on the earlier part with their status untouched. ++test('#1245: a handoff leaves a disabled and an exhausted row on the earlier part', async (t) => { ++ const schedule = scheduleService() ++ const { ctx, home, rt } = setup({ schedule }) ++ t.after(async () => { await ctx.dispose(); rmSync(home, { recursive: true, force: true, maxRetries: 5 }) }) ++ t.mock.method(Date, 'now', () => Date.parse('2030-01-01T00:00:00.000Z')) ++ ++ const disabled = await schedule.create('m1', { title: 'Disabled watch', prompt: 'Switched off', every_seconds: 7200 }) ++ const exhausted = await schedule.create('m1', { title: 'Exhausted ping', prompt: 'Ran once', at: '2030-01-01T00:10:00.000Z' }) ++ // DSH's only ended state is `inactive`: a disabled task and a finished one-shot both keep it. ++ schedule.tasks.get(disabled.id).status = 'inactive' ++ schedule.tasks.get(exhausted.id).status = 'inactive' ++ const active = await schedule.create('m1', { title: 'Live watch', prompt: 'Moves', every_seconds: 3600 }) ++ ++ await rt.moveSchedules('m1', 'm1-fresh') ++ ++ const row = id => [...schedule.tasks.values()].find(task => task.record.id === id) ++ assert.equal(row(disabled.id).sessionId, 'm1', 'a disabled task stays on the earlier part') ++ assert.equal(row(disabled.id).status, 'inactive', 'its status is untouched') ++ assert.equal(row(exhausted.id).sessionId, 'm1', 'an exhausted task stays on the earlier part') ++ assert.equal(row(exhausted.id).status, 'inactive', 'its status is untouched') ++ assert.equal(row(active.id).sessionId, 'm1-fresh', 'the active task still moves') ++ assert.deepEqual([...schedule.tasks.values()].filter(task => task.sessionId === 'm1-fresh').map(task => task.record.id), [active.id], 'the fresh part gets neither inactive row') ++}) ++ +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/files/test/part-archive.test.js b/patches/ds-bot/files/test/part-archive.test.js index 875ce3f1e..95b90a625 100644 --- a/patches/ds-bot/files/test/part-archive.test.js +++ b/patches/ds-bot/files/test/part-archive.test.js @@ -26,31 +26,53 @@ const TEAM = { // DSH's schedule service, as the host sees it: tasks are stored per Session, and a // Session with an active task refuses to be archived unless its activity is stopped. -function scheduleService({ listDelay = 0, failCreate = false } = {}) { +function scheduleService({ delay = 0, failPut = false } = {}) { const tasks = new Map() let next = 1 - const armedOn = (sessionId) => [...tasks.entries()].filter(([, task]) => task.sessionId === sessionId).map(([id]) => id) + let chain = Promise.resolve() + const armedOn = (sessionId) => [...tasks.entries()].filter(([, task]) => task.sessionId === sessionId && task.status === 'active').map(([id]) => id) return { tasks, - listDelay, - failCreate, + delay, + failPut, armedOn, stop(sessionId) { for (const id of armedOn(sessionId)) tasks.delete(id) }, arm(sessionId, title = 'nightly check') { const id = `schedule-${next++}` - tasks.set(id, { sessionId, record: { id, title, prompt: 'Wake up.', kind: 'at', scheduledAt: '2030-01-01T01:00:00.000Z' } }) + tasks.set(id, { sessionId, status: 'active', record: { id, title, prompt: 'Wake up.', kind: 'at', scheduledAt: '2030-01-01T01:00:00.000Z' }, deliveryHistory: { records: [] } }) return id }, async list({ sessionId }) { - if (listDelay > 0) await new Promise(resolve => setTimeout(resolve, listDelay)) + if (delay > 0) await new Promise(resolve => setTimeout(resolve, delay)) return [...tasks.values()].filter(task => task.sessionId === sessionId).map(task => task.record) }, + // A part move rewrites the stored row (DSH's `getDomain`, inside `serialize`). + async getDomain() { + return { + table(name) { + assert.equal(name, 'tasks') + return { + entries: () => tasks.entries(), + get: id => tasks.get(id), + put(id, task) { + if (failPut) throw new Error('write failed') + tasks.set(id, task) + }, + } + }, + } + }, + serialize(work) { + // Queued behind whatever DSH is already running, like the real service. + const pending = chain.then(() => (delay > 0 ? new Promise(resolve => setTimeout(resolve, delay)) : undefined)).then(work) + chain = pending.catch(() => undefined) + return pending + }, async create(sessionId, request) { - if (failCreate) throw new Error('create failed') const id = `schedule-${next++}` - tasks.set(id, { sessionId, record: { id, title: request.title, prompt: request.prompt, kind: 'at', scheduledAt: request.at } }) + tasks.set(id, { sessionId, status: 'active', record: { id, title: request.title, prompt: request.prompt, kind: 'at', scheduledAt: request.at }, deliveryHistory: { records: [] } }) return tasks.get(id).record }, async delete({ sessionId, id }) { @@ -179,7 +201,7 @@ const drained = async () => { for (let i = 0; i < 25; i += 1) await settle() } const archivedEntry = (archived, sessionId) => archived.find(entry => (Array.isArray(entry) ? entry[0] : entry) === sessionId) test('#896: a part whose task moved is archived once the move finished', async (t) => { - const schedule = scheduleService({ listDelay: 40 }) + const schedule = scheduleService({ delay: 40 }) const h = await start({ schedule }) t.after(h.stop) schedule.arm('b') @@ -195,7 +217,7 @@ test('#896: a part whose task moved is archived once the move finished', async ( }) test('#896: a failed task move does not leave an armed earlier part active', async (t) => { - const schedule = scheduleService({ failCreate: true }) + const schedule = scheduleService({ failPut: true }) const h = await start({ schedule }) t.after(h.stop) schedule.arm('b')