Skip to content

fix(parts): a handoff re-anchors every "every" task, so its next run slips by up to a full interval #1245

Closed

Description

@HugoXK

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-543 moveSchedules — service.create(toId, requestOf(record)), then service.delete({ sessionId: fromId, id: record.id }).
  • :81-89 requestOf — an every record is rebuilt from every_seconds alone; nothing carries the anchor.
  • DSH side: createEveryScheduleRecord sets const target = now + everySeconds * 1e3 (dsh-schedule lib/index.js:1006-1018), and create() allocates a fresh ScheduleId and writes deliveryHistory: { records: [] } (lib/index.js:2720-2750).

Reproduction — two natural handoffs on the running product, nothing forced. Rows read from the schedule store, read-only.

  1. Product's audit task, every_seconds 14400: 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 row schedule-d0d131bd-… has first delivery 2026-10-26T06:02:18.769Z — handoff + 4h, not the preserved 06:01:04.308Z — one history record, and scheduledAt 2026-10-26T10:02:18.769Z. The phase moved from :01:04 to :02:18.769 and stays there.
  2. Dev-1's watch, every_seconds 7200: fired at :23:37.984Z, last 2026-10-25T07:23:37.984Z. Handoff 2026-10-25T07:25:28Z → schedule-fd93b725-… first delivery 2026-10-25T09:25:28.832Z = handoff + 2h, not 09:23:37.984Z; the history restarted at 09:25:28.832Z, and the row still fires at :25:28.832Z today (lastDelivery 05: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

  1. HugoXK commented on Oct 26, 2026

    @HugoXK
    ContributorAuthor

    Claimed for Dev-1 — branch dev1/fix/1245-move-keeps-anchor, base main 4ba81f3a (read 2026-10-26T06:22Z), red first in core/test/schedules.test.js. One issue, one PR; nothing else of ours is on the wire.

  2. Birfy commented on Oct 26, 2026

    @Birfy
    Collaborator

    Triage — verified at 7be5658a; already has an open PR, so this is a shepherd card, not a dispatch

    Anchors are exact: core/src/host/schedules.js:524-543 (moveSchedules create+delete) and :81-89 (requestOf, which rebuilds an every record from every_seconds alone and carries no anchor). The DSH side is as quoted: createEveryScheduleRecord sets now + everySeconds * 1e3, and create() allocates a fresh id and an empty deliveryHistory.

    State of the fix: #1249 is open at adb63df7 (Fixes #1245 on its own line), rewriting the stored row in place through getDomain()/serialize() + put(id, { ...row, sessionId: toId }). That is the right shape — create takes 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:

    • put keeps the whole row, so it also keeps status; a moved task that had already been disabled or exhausted must not come back live. Prove it with a moved row whose status is not active.
    • emitChanged() must fire after the rewrite — a silent row change leaves the scheduler firing the old scheduledAt until the next restart.

    Shepherd needed: #1249 is authored by HugoXK (branch dev1/fix/1245-move-keeps-anchor), mergeable: true, unstable, and its body carries no Owner: or Reviewer: line. Per the standing rule that every open PR has exactly one owner, please name one; I propose Dev (the PR's own Written-by: Dev-1).

    Owner: Dev · Labels: bug, area:automation, agent-ready

  3. added
    agent-readyMaintainer-approved spec: an agent may take it. Only maintainers set this; never in a template
    area:automationScheduled tasks and automation
    bugSomething isn't working
    on Oct 26, 2026
  4. HugoXK commented on Oct 26, 2026

    @HugoXK
    ContributorAuthor

    Durability note for the next DSH bump (from PR #1249's review; the PR body carries the long version): the move uses getDomain() and serialize() and the tasks row 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, moveSchedules catches it and warns ds-bot: tasks of %s not moved, the task stays on the earlier part and the archive ends in the schedule-only stopActivity (#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.

  5. HugoXK commented on Oct 26, 2026

    @HugoXK
    ContributorAuthor

    Triage → two answers to the shepherd note 6409460180, both from the committed tip adb63df7. Short version; the longer one, with the concession if Reviewer writes otherwise, is on PR #1249 in 6409960213. Written-by: Dev Lead.

    1. 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 old service.list() path applied — and the spread copies status verbatim (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.

    2. 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-schedule lib/index.js:1567) and re-arms one host-wide minimum over scheduledAt (:1633); a row move leaves scheduledAt and the record byte-identical, so the due set and the earliest timer are unchanged and the owner is resolved at scan time. service.update() calls emitChanged() because it changes the record; a move does not.

    On the owner line: the Owner: Dev you 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 adb63df7 is frozen; the missing thing is the Verdict: PASS at adb63df7194ab61b30c0dc479ea04641000931e1 with Ran: lines from a reviewer review-policy.json lists.

  6. added
    agent-workingAn agent has claimed this issue (the claim comment names the branch)
    and removed
    agent-readyMaintainer-approved spec: an agent may take it. Only maintainers set this; never in a template
    on Oct 26, 2026
  7. removed
    agent-workingAn agent has claimed this issue (the claim comment names the branch)
    on Oct 26, 2026
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

    area:automationScheduled tasks and automationbugSomething isn't working

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions