Skip to content

Deleting a Bot or group chat orphans its scheduled tasks: they stay armed in DSH, vanish from Settings, and schedule-delete refuses them #57

Closed

Description

@BlueX888

What happened?

Deleting a Bot or deleting a group chat never removes the scheduled tasks that DSH's schedule service holds for that Bot's or room's Session:

  • The task rows stay status: 'active' and keep delivering when due (CHANGELOG.md: "A task sends its message to the chat when it is due"), into a Session whose Bot or room no longer exists. The harness's session controller resumes the Session when the task delivers (packages/api/session-controller/src/agent.ts, resolveAgent() → resolve() → resume()), with no archived check, so the deleted Bot's model keeps running on schedule and burns tokens invisibly.
  • The Settings list hides them: schedulesView() drops every task whose owner is no longer in state.bots/state.rooms (ds-bot/src/host/schedules.js:91, the known() filter, applied at :98), so the task vanishes from Settings → Scheduled tasks instead of being offered for removal.
  • schedule-delete refuses them: ds-bot/src/host/schedules.js:121 throws 'No such task' when ownerOf(body.sessionId) is undefined, which it is once the Bot or room is gone. After the deletion there is no way left in DS Bot to see or remove the task.
  • The archive that deleteBot/deleteRoom attempt does not stop the tasks either: ds-bot/src/host/roster.js:391 and :332 call archiveSession(...) without { stopActivity: true } and swallow errors with .catch(() => {}). DSH reports armed reminders as session activity (packages/schedule/schedule/src/index.ts, the workspace/session-activity listener in archiveAdmission()), so the archive is refused with WorkspaceActiveSessionError before anything is written — and the refusal is silently dropped, so the Session is not even archived.

Expected: the module doc says this module "lists the team's tasks for Settings, creates and removes them there" (ds-bot/src/host/schedules.js:1-7), so a task's lifecycle should end with its Bot or room. DSH models exactly this cleanup: "an idle Session whose reminders are still armed refuses the archive, and an archiving stop deletes those rows instead of letting them deliver into a closed Session" (packages/schedule/schedule/src/index.ts, archiveAdmission()), and archiveSession(id, { stopActivity: true }) asks the workspace/session-stop providers, where the schedule service removes the Session's task rows. DS Bot already follows this invariant for conversation parts — moveSchedules() (ds-bot/src/host/schedules.js:129) moves a part's active tasks to the fresh Session before the old one ends (ds-bot/src/host/parts.js:732).

Root cause: deleteBot (ds-bot/src/host/roster.js:391) and deleteRoom (ds-bot/src/host/roster.js:332) archive the Session without stopActivity and never touch the schedule service, so DSH's workspace/session-stop hook that deletes the task rows is never triggered.

Steps to reproduce

In the UI: Settings → Scheduled tasks (or Scheduled task in the composer's + menu) → create a task for a Bot or a group chat → delete that Bot (Settings → Bots) or that group chat. The task is gone from the list but stays armed in DSH.

Scripted reproduction (the repo's test context test/fake-ctx.js plus an in-memory schedule service with the same shape as test/schedules.test.js; no API key, no model). Save as repro-schedules-orphan.mjs in ds-bot/:

// Repro: deleting a Bot (or a group chat) orphans its scheduled tasks — they stay
// active in DSH's schedule service (and keep firing on the session), but
// vanish from Settings and `schedule-delete` refuses to remove them.
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { apply } from './index.js'
import { fakeContext } from './test/fake-ctx.js'

const TEAM = {
  version: 1,
  mainBotIds: ['m1'],
  revision: 1,
  bots: {
    m1: { id: 'm1', sessionId: 'm1', name: 'Chief', role: 'Chief of Staff', instructions: '', createdAt: 1 },
    w1: { id: 'w1', sessionId: 'w2', name: 'Writer', role: '', instructions: '', createdAt: 2, segments: [{ sessionId: 'w1', endedAt: 3 }] },
  },
  rooms: { g1: { id: 'g1', name: 'Launch', named: true, members: ['m1', 'w1'], admin: 'm1', mode: 'admin', notice: '', createdAt: 4 } },
  exchanges: [],
  exchangeTurns: {},
  questions: {},
}

// In-memory stand-in for DSH's schedule service (same shape as test/schedules.test.js).
function scheduleService() {
  const tasks = new Map()
  let next = 1
  return {
    tasks,
    async create(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' }
      tasks.set(id, { sessionId, record, status: 'active' })
      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: task.status })) },
    async delete({ sessionId, id }) {
      if (tasks.get(id)?.sessionId !== sessionId) return { id, deleted: false, code: 'schedule_not_found' }
      tasks.delete(id)
      return { id, deleted: true }
    },
  }
}

const schedule = scheduleService()
const home = mkdtempSync(join(tmpdir(), 'repro-sched-'))
writeFileSync(join(home, 'state.json'), JSON.stringify(TEAM))
const ctx = fakeContext({ services: { schedule } })
const sent = []
Object.assign(ctx, {
  workspaceRegistry: { archiveSession: async () => {} },
  sessionController: {
    create: async () => ({ sessionId: 'fresh' }),
    resolveAgent: async sessionId => ({ agent: { session: { id: sessionId }, followup: message => sent.push({ sessionId, message }) } }),
    selectModel: async () => {},
  },
  sessionTitle: { rename: async () => {} },
  sessions: { flush: async () => {} },
})
let route
const register = ctx.connection.fetch.register
ctx.connection.fetch.register = (options) => { route = options; return register(options) }
apply(ctx, { home })
const call = async (endpoint, payload) => (await route.fetch(new Request('http://localhost/api/bot', { method: 'POST', body: JSON.stringify({ endpoint, payload }) }))).json()

// 1. A daily task on Writer (w1) and one on the group chat (g1).
const made = await call('schedule-create', { target: 'w1', title: 'Briefing', prompt: 'Sum up the day', when: { kind: 'daily', time: '09:00', timeZone: 'UTC' } })
console.log('create w1 task ok:', made.ok)
const madeRoom = await call('schedule-create', { target: 'g1', title: 'Standup', prompt: 'Where are we?', when: { kind: 'daily', time: '10:00', timeZone: 'UTC' } })
console.log('create g1 task ok:', madeRoom.ok)
const botTask = [...schedule.tasks.values()].find(task => task.sessionId === 'w2')
const roomTask = [...schedule.tasks.values()].find(task => task.sessionId === 'g1')
console.log('service holds:', [...schedule.tasks.values()].map(t => [t.sessionId, t.record.title, t.status]))

// 2. Delete the Bot and the group chat through the API.
console.log('delete-bot ok:', (await call('delete-bot', { id: 'w1' })).ok)
console.log('delete-room ok:', (await call('delete-room', { id: 'g1' })).ok)

// 3. What does the user see, and can they clean up?
const listed = await call('schedules', {})
console.log('schedules view after delete:', JSON.stringify(listed.value))
const tryDeleteBotTask = await call('schedule-delete', { sessionId: 'w2', id: botTask.record.id })
console.log('delete the Bot task via API:', JSON.stringify(tryDeleteBotTask))
const tryDeleteRoomTask = await call('schedule-delete', { sessionId: 'g1', id: roomTask.record.id })
console.log('delete the room task via API:', JSON.stringify(tryDeleteRoomTask))
console.log('service still holds:', [...schedule.tasks.values()].map(t => [t.sessionId, t.record.title, t.status]))

ctx.dispose()
rmSync(home, { recursive: true, force: true })

Run from ds-bot/ (the @deepseek-ai peers resolve through the repo's own test hook, against a deepseek-harness checkout at dsh-v0.2.1-alpha.1):

node --import ./test/harness-resolve.mjs repro-schedules-orphan.mjs

Observed output (verbatim, at bdc5fda64e2ea1e83a4d146440072a2413025df4 on main):

create w1 task ok: true
create g1 task ok: true
service holds: [ [ 'w2', 'Briefing', 'active' ], [ 'g1', 'Standup', 'active' ] ]
delete-bot ok: true
delete-room ok: true
schedules view after delete: {"available":true,"tasks":[]}
delete the Bot task via API: {"ok":false,"error":{"code":"bot/invalid","message":"No such task","details":{}}}
delete the room task via API: {"ok":false,"error":{"code":"bot/invalid","message":"No such task","details":{}}}
service still holds: [ [ 'w2', 'Briefing', 'active' ], [ 'g1', 'Standup', 'active' ] ]

An independent verification run of the same steps, which additionally traced what archiveSession does (without stopActivity), observed both archives refused and no session archived:

service before delete: [ [ 'w2', 'Briefing', 'active' ], [ 'g1', 'Standup', 'active' ] ]
delete-bot ok: true / delete-room ok: true
archive attempts: [] archive refusals: ["w2","g1"]
schedules view after delete: {"available":true,"tasks":[]}
schedule-delete bot/room task: {"code":"bot/invalid","message":"No such task"}
service after delete: [ [ 'w2', 'Briefing', 'active' ], [ 'g1', 'Standup', 'active' ] ]

Diagnostics

  • DS Bot 0.1.2, main at bdc5fda64e2ea1e83a4d146440072a2413025df4
  • DSH dsh-v0.2.1-alpha.1 (the harness version the plugin is tested with, per CONTRIBUTING), DSH Web
  • Node.js v22.23.2, macOS 15.6 (arm64)
  • Exact error text from schedule-delete: {"ok":false,"error":{"code":"bot/invalid","message":"No such task","details":{}}}
  • The reproduction is fully scripted (test context + in-memory schedule service): no API key, no real model, no tokens. Found during an assisted code review of ds-bot/src/host/schedules.js and roster.js.

How did you install DS Bot?

Git address

Privacy

  • I checked that this report has no API keys, tokens or personal data.

Happy to be assigned. The fix direction I verified against the code: stop the deleted Session's scheduled tasks on delete — archiveSession(id, { stopActivity: true }) lets DSH's workspace/session-stop hook delete the task rows, or a small helper in schedules.js (service.list({ sessionId }) + service.delete(...) per record) called from deleteBot/deleteRoom. I did not find an existing issue or PR about scheduled tasks surviving Bot/group deletion (searched open and closed issues for "schedule", "task", "delete").

Activity

  1. BlueX888 commented on Oct 12, 2026

    @BlueX888
    Author

    Reproduced this. Root cause: ds-bot/src/host/roster.js:332 and :391 archive the deleted room's and Bot's Session without stopActivity, so DSH's workspace/session-stop listener that deletes the session's task rows is never triggered.

    Minimal reproduction:

    node --import ./test/harness-resolve.mjs repro-schedules-orphan.mjs
    

    Observed output:

    service holds: [ [ 'w2', 'Briefing', 'active' ], [ 'g1', 'Standup', 'active' ] ]
    delete-bot ok: true
    delete-room ok: true
    schedules view after delete: {"available":true,"tasks":[]}
    delete the Bot task via API: {"ok":false,"error":{"code":"bot/invalid","message":"No such task","details":{}}}
    service still holds: [ [ 'w2', 'Briefing', 'active' ], [ 'g1', 'Standup', 'active' ] ]
    

    Fix is ready on BlueX888:fix/prep-schedule-tasks-orphaned-on-bot-or-room-delete (commit a8b8478d). Pass { stopActivity: true } to both archiveSession calls, so DSH's session-stop listener drops the deleted Session's task rows.
    Verified with: node --import ./test/harness-resolve.mjs --test test/schedules.test.js

    Happy to open the PR once you confirm the approach or assign this to me.

  2. Fei2411 commented on Oct 12, 2026

    @Fei2411
    Owner

    已完成修复

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions