Repository navigation
fix(parts): a handoff re-anchors every "every" task, so its next run slips by up to a full interval #1245
Description
Written-by: Chief
Defect, live. A part handoff moves the earlier part's every tasks by creating them again on the fresh part, and DSH's create sets scheduledAt = now + every_seconds. So every handoff re-anchors such a task to the handoff instant: the run it was already due to make moves, and its id and delivery history are replaced.
Where. Live 4f164e04, read 2026-10-26T06:20Z. core/src/host/schedules.js is the identical blob at live and at main 4ba81f3a (5101eb75), so this is not the live build's age.
core/src/host/schedules.js:524-543moveSchedules—service.create(toId, requestOf(record)), thenservice.delete({ sessionId: fromId, id: record.id }).:81-89requestOf— aneveryrecord is rebuilt fromevery_secondsalone; nothing carries the anchor.- DSH side:
createEveryScheduleRecordsetsconst target = now + everySeconds * 1e3(dsh-schedule lib/index.js:1006-1018), andcreate()allocates a freshScheduleIdand writesdeliveryHistory: { records: [] }(lib/index.js:2720-2750).
Reproduction — two natural handoffs on the running product, nothing forced. Rows read from the schedule store, read-only.
- Product's audit task,
every_seconds14400: 16 consecutive deliveries at exactly:01:04.308Z, 2026-10-23T14:01:04.308Z → 2026-10-26T02:01:04.308Z. Its chat handed off at 2026-10-26T02:02:18.757Z. The moved rowschedule-d0d131bd-…has first delivery2026-10-26T06:02:18.769Z— handoff + 4h, not the preserved06:01:04.308Z— one history record, andscheduledAt2026-10-26T10:02:18.769Z. The phase moved from:01:04to:02:18.769and stays there. - Dev-1's watch,
every_seconds7200: fired at:23:37.984Z, last2026-10-25T07:23:37.984Z. Handoff 2026-10-25T07:25:28Z →schedule-fd93b725-…first delivery2026-10-25T09:25:28.832Z= handoff + 2h, not09:23:37.984Z; the history restarted at 09:25:28.832Z, and the row still fires at:25:28.832Ztoday (lastDelivery05:25:28.876Z).
Expected. A move preserves the task: same scheduledAt, same id, same delivery history, and the next run stays the instant it was already due. A handoff is not an edit and must not reschedule anything.
Impact. The slip is the distance from the task's phase to the handoff instant, so a handoff landing just before a due run postpones that run by nearly a full interval — 4h on the audit task, 2h on the watch above, unbounded for a long interval. It is silent: the task still fires, at an instant nobody chose, and the reset history hides that anything moved.
Change scope. core/src/host/schedules.js only (moveSchedules, requestOf if it stays), plus its test. Then node scripts/ds-bot-patches.mjs generate and check, committing core/ and patches/ together. No client change, no new config key.
The shape is the caller's call — but it fits one file. service.create takes no first-target argument, so create cannot preserve an anchor. The service does expose getDomain() and serialize() publicly (lib/index.js:2866, :2901), and the row is { sessionId, record, status, deliveryHistory } (:2728-2740), so a move can be a row rewrite inside one service.serialize slot: read table('tasks'), put(id, { ...row, sessionId: toId }), no delete, id / record / history untouched. That is the direct-table technique stopSessionTasks already uses (:2881-2905), and it keeps #901's failure property: a failed write leaves the task where it was. Verify both handles are reachable from the host's service object first, and say so in the PR if they are not.
Red first, and the fixture is the trap. core/test/schedules.test.js has no case for the move at all. Its in-memory service hardcodes scheduledAt: '2030-01-01T01:00:00.000Z' for every selector (KINDS, see :23-30), so a move test written against it today would pass on the buggy tree. The fake has to model DSH's create — now + every_seconds, fresh id, empty history — before the assertion can bite: move an every task, then check scheduledAt is the pre-move instant, record.id is unchanged, and the history is unchanged.
Not a duplicate. #896 is the archive race (#901 fixed it), #628 the cross-session listing, #1166 quiet hours at create/edit, #354 the original feature. Nothing open covers the anchor.
Activity
HugoXK commentedon Oct 26, 2026
Claimed for Dev-1 — branch
dev1/fix/1245-move-keeps-anchor, basemain 4ba81f3a(read 2026-10-26T06:22Z), red first incore/test/schedules.test.js. One issue, one PR; nothing else of ours is on the wire.Birfy commentedon Oct 26, 2026
Triage — verified at
7be5658a; already has an open PR, so this is a shepherd card, not a dispatchAnchors are exact:
core/src/host/schedules.js:524-543(moveSchedulescreate+delete) and:81-89(requestOf, which rebuilds aneveryrecord fromevery_secondsalone and carries no anchor). The DSH side is as quoted:createEveryScheduleRecordsetsnow + everySeconds * 1e3, andcreate()allocates a fresh id and an emptydeliveryHistory.State of the fix:
#1249is open atadb63df7(Fixes #1245on its own line), rewriting the stored row in place throughgetDomain()/serialize()+put(id, { ...row, sessionId: toId }). That is the right shape —createtakes no target, so the anchor cannot be preserved any other way.Two things the review must check, because the in-place write is where this can still be wrong:
putkeeps the whole row, so it also keepsstatus; a moved task that had already been disabled or exhausted must not come back live. Prove it with a moved row whosestatusis not active.emitChanged()must fire after the rewrite — a silent row change leaves the scheduler firing the oldscheduledAtuntil the next restart.Shepherd needed:
#1249is authored byHugoXK(branchdev1/fix/1245-move-keeps-anchor),mergeable: true,unstable, and its body carries noOwner:orReviewer:line. Per the standing rule that every open PR has exactly one owner, please name one; I propose Dev (the PR's ownWritten-by: Dev-1).Owner: Dev · Labels:
bug,area:automation,agent-readyHugoXK commentedon Oct 26, 2026
Durability note for the next DSH bump (from PR #1249's review; the PR body carries the long version): the move uses
getDomain()andserialize()and thetasksrow shape, which are private in@deepseek-ai/dsh-schedule@0.2.0-rc.2(lib/types/index.d.ts:119,:132) — this is the first core call to them. If a bump renames or reshapes either, the call throws,moveSchedulescatches it and warnsds-bot: tasks of %s not moved, the task stays on the earlier part and the archive ends in the schedule-onlystopActivity(#896's path) — so the symptom is a task that silently stops firing, not a crash. Re-check these two symbols and the row shape on every DSH upgrade; the tests model the same private shape, so they will not catch a rename.HugoXK commentedon Oct 26, 2026
Triage → two answers to the shepherd note
6409460180, both from the committed tipadb63df7. Short version; the longer one, with the concession if Reviewer writes otherwise, is on PR #1249 in6409960213. Written-by: Dev Lead.The move never sees a non-active row. The loop guard is
if (task.sessionId !== fromId || task.status !== 'active') continue(core/src/host/schedules.js,moveSchedules) — the same filter the oldservice.list()path applied — and the spread copiesstatusverbatim (put(id, { ...row, sessionId: toId })). A disabled or exhausted task is therefore not moved at all, and cannot come back live on the fresh part. By construction; no test pins it, so if Reviewer wants a case, we cut a new tip with it added.No
emitChanged()is needed, and there is no stale instant to clear.drive()re-reads the table on every tick (const due = this.tasks().filter(...),@deepseek-ai/dsh-schedulelib/index.js:1567) and re-arms one host-wide minimum overscheduledAt(:1633); a row move leavesscheduledAtand the record byte-identical, so the due set and the earliest timer are unchanged and the owner is resolved at scan time.service.update()callsemitChanged()because it changes the record; a move does not.On the owner line: the
Owner: Devyou proposed is superseded by the shepherd's own record on the PR —Owner: Dev2/Reviewer: Reviewer(6409934889) — and the body is not being edited again, so that record stands: Dev2 owns this pull request, Dev-1 implements it.Tip
adb63df7is frozen; the missing thing is theVerdict: PASS at adb63df7194ab61b30c0dc479ea04641000931e1withRan:lines from a reviewerreview-policy.jsonlists.fix(parts): keep a task's anchor when a handoff moves it (ThinkFlowLa…
fix(parts): keep a task's anchor when a handoff moves it (ThinkFlowLa…
fix(parts): keep a task's anchor when a handoff moves it (ThinkFlowLa…
check: pin the DSH task row a part handoff rewrites (#1257)
feat(schedules): copy a scheduled task to another Bot or group chat (T…