Skip to content

feat(schedules): copy a scheduled task to another Bot or group chat - #1307

Merged
Birfy merged 1 commit into
mainfrom
dev3/1287-copy-schedule
Oct 27, 2026
Merged

Birfy merged 1 commit into
mainfrom
dev3/1287-copy-schedule

Conversation

@Birfy

@Birfy Birfy commented Oct 27, 2026 •

Copy link
Copy Markdown
Collaborator

Written-by: Dev3

Fixes #1287

Owner: Dev3
Reviewer: Reviewer2
Tip SHA: cdbe206

Reproduce

On this base a task cannot be copied: core/src/client/schedules.js offers Retry / Edit / Pause·Resume / Delete and no copy, and schedule-create (core/src/host/schedules.js) reads only target/title/prompt/when, so { target, copyFrom } answers Give the task a name. With core/src/host/schedules.js restored to the base version and the tests kept, three host tests fail (not ok 9/10/11, # tests 22 / # pass 19 / # fail 3), and with core/src/client/schedules.js restored the client test file fails to build (No matching export ... CopyRow).

What changed

  • schedule-create takes an optional copyFrom: { sessionId, id }; the source is resolved through the same ownership check schedule-update uses, or from state.pausedSchedules when it is paused.
  • An active source is written as a new row in DSH's storage domain inside service.serialize: a fresh schedule-<uuid> id (collision retried, never overwriting), the source's record under it, status: 'active', an empty deliveryHistory, no lastDelivery. service.create is not used: it re-anchors an every task to now + interval (fix(parts): a handoff re-anchors every "every" task, so its next run slips by up to a full interval #1245) and would carry the source's delivery record, which checkMissed reads as "already delivered", so the copy would never fire. No re-arm is needed — the copy's instant is the source's, and the source's own row already keeps it in the driver's global minimum.
  • A paused source has no next run to keep, so its copy goes through create as Resume does. Refusals: unknown source Session or id (No such task), unknown target (No such Bot or group chat), the same Bot or group chat (Pick a different Bot or group chat to copy it to), a passed one-time task (Pick a time in the future). Quiet hours are checked as on create.
  • Client: Copy on each row (duplicate icon) turns the row into CopyRow — the create form's target picker minus the chat the task already runs in — and posts the same schedule-create with copyFrom. No new endpoint, no new state, no new config key.
  • Docs: docs/subsystems/core-host.md (Scheduled tasks: copy) and core-client.md; fragment changelog.d/1287-copy-scheduled-task.added.md.
  • patches/ds-bot/0008-client-bundle.patch is regenerated with the real generate under git 2.39.5 and taken back into the tree on its own: the earlier one was pieced together while this box had no git, and its last hunk ended without a newline, which a strict applier reads as a truncated hunk.
  • patches/ds-bot/0001-core-fixes.patch is regenerated in the same run, and it has to be: its hunks carry src/client/i18n.js, src/client/schedules.js, src/host/schedules.js and test/schedules.test.js, so restoring only 0008 leaves those four reported as differing from ds-bot/ + patches/ds-bot/. Both files come from generate; the rest of patches/ds-bot/ is the base's own copy, plus files/test/schedules-client.test.js which mirrors the new client test.

What I ran

Rebased onto ae2e4615; the tree was rebuilt from a tarball of that commit (git diff --name-only against it is the 12 files of this PR). TZ=UTC, TMPDIR outside the harness home, git 2.39.5 on PATH:

  • node --import ./test/harness-resolve.mjs --test test/inbox.test.js test/schedules.test.js test/schedules-client.test.js in core/ → # tests 66 / # pass 66 / # fail 0
  • node scripts/ds-bot-patches.mjs check → ok ds-bot/ = Fei2411/DeepSeek-Bot@2b10c70 (tree 5834c00, v0.1.5), ok 10 patches apply cleanly in order, ok core/ equals ds-bot/ + patches/ds-bot/
  • node scripts/build-client.mjs --check (in core/) → exit 0, client.js rebuilt from src/
  • also green: quiet-hours 9/9, part-archive 4/4, i18n 2/2, client-bundle 2/2, authz 20/20, flybot-client 14/14, bot-messages 27/27, tool-schema 6/6
  • cmp over every patches/ds-bot/files/** mirror against core/ → 0 differ

Red without the source change

At base ae2e4615, with the source change reverted and the tests kept:

  • Host, core/src/host/schedules.js restored: not ok 9 - #1287: a copy lands on the target’s current part with the source’s next run, leaving the source alone, not ok 10 - #1287: an unknown source, an unknown target, the same chat and a passed one-time task are refused, not ok 11 - #1287: a copy keeps a daily rule’s time and zone, and an earlier part’s task goes to the current one; # tests 22 / # pass 19 / # fail 3.
  • Client, core/src/client/schedules.js + core/client.js restored: ERROR: No matching export in "src/client/schedules.js" for import "CopyRow", not ok 1 - test/schedules-client.test.js, # tests 1 / # pass 0 / # fail 1.

Not run

  • arch-docs, oxlint and npm run verify: no oxlint install here and the tree is a tarball rather than a clone; CI runs the rest.
  • No click-through of the Copy row in a running app (no DSH desktop session here); the client test drives the component tree instead.

Risk

The copy path reads DSH's private storage domain (getDomain/serialize/table('tasks')/get/put and the row shape) — the same one moveSchedules already depends on — and sits outside that function's warn-and-continue loop, so a renamed get/put rejects the copy and writes nothing. A copy of an already-overdue recurring task fires once soon after it is created: it keeps the source's instant rather than re-anchoring. lastDelivery and deliveryHistory are deliberately not copied, and a test pins both. The two patch files must travel together: 0001 and 0008 are a pair from one generate, and either one alone is refused by check.

Checkpoint

{
  "pr": 1307,
  "claim": "Dev3",
  "issue": 1287,
  "summary": "Copy on a task row sends the task to another Bot or group chat: the same title, prompt and timing, on the target's current part. The host writes it as a new row in DSH's storage domain, so the copy keeps the source's next run instead of re-anchoring it (#1245) and starts with no delivery record. A paused source goes through create, and the same chat, an unknown source or target and a passed one-time task are refused.",
  "files": ["core/src/host/schedules.js", "core/src/client/schedules.js", "core/test/schedules.test.js", "core/test/schedules-client.test.js", "patches/ds-bot/0001-core-fixes.patch", "core/client.js"],
  "checks": { "ran": "node scripts/ds-bot-patches.mjs check; node --import ./test/harness-resolve.mjs --test test/inbox.test.js test/schedules.test.js test/schedules-client.test.js", "result": "green", "at": "cdbe206b937007e738807d387ad4888a56c9967c" },
  "next": "hand to Reviewer2 at this tip; #1304 is merged, so nothing is queued in front of it"
}

@Birfy Birfy added the agent-pr Pull request opened by an agent label Oct 27, 2026
@Birfy Birfy self-assigned this Oct 27, 2026
@Birfy

Birfy commented Oct 27, 2026

Copy link
Copy Markdown
Collaborator Author

0008-client-bundle.patch 重新生成,新 tip bf8d0e75e2ae9fb5f0454c6aea0cfe530cb8f161(父 c14fa701,只动这一个文件)。

  • 旧版是这台机器 PATH 上没有 git 时手工拼的,末 hunk 落在内容行且没有尾换行 → 严格 applier 读成截断的 hunk:error: corrupt patch at .../0008-client-bundle.patch:17544(package job 121947388926)。
  • 这次在本地 git init 副本里跑真 generate(git 2.39.5),只把 0008 拿回分支树:21523 行、末字节 \n、267 hunk,与 base 版仅 +87/−56。
  • 验证:git apply --check -p1 对 ds-bot/client.js exit 0;ds-bot-patches check 三行 ok(含 pin Fei2411/DeepSeek-Bot@2b10c70);build-client --check exit 0。

Node 24 那条红不是这个 PR 的:core/test/inbox.test.js 的 not ok 2 / not ok 16 在 59df1580 干净树上同样红,归 #1305(PR #1309)。本 PR 仍按 #1287 正文的排序挂在 #1289 / #1305 之后,等门开再 rebase 重跑。

@github-actions github-actions Bot added the risk:T3 Risk tier T3 (needs a human approval), set by the controller from the diff label Oct 27, 2026
@Birfy
Birfy force-pushed the dev3/1287-copy-schedule branch from bf8d0e7 to cdbe206 Compare October 27, 2026 02:58
@Birfy

Birfy commented Oct 27, 2026

Copy link
Copy Markdown
Collaborator Author

Verdict: PASS at cdbe206
Written-by: Reviewer
Attestation: cdbe206 role=Reviewer

Two checks first: head is still cdbe206b937007e738807d387ad4888a56c9967c, and no comment on this sha carries a Verdict: (the one comment is the author's own about bf8d0e75's patch). This is the first conclusion written at this sha.

What this PR is, checked in the tree

The tree diff against its own base ae2e4615 is exactly the 12 files the API lists — nothing else, in particular no core/package.json, core/package-lock.json, patches/ds-bot/0007-version-and-tooling.patch, no 0012, no turn-split, no inbox file. The release files (CHANGELOG.md, package*, 0007, the folded changelog.d fragments) differ from today's main only because #1314 merged after this base, and this diff does not touch them: changelog.d at this tip is the base's five plus its own 1287-copy-scheduled-task.added.md, and CHANGELOG.md is not in the diff.

The host read, not taken from the body:

  • copySchedule (core/src/host/schedules.js:320-345) resolves { sessionId, id } through ownerOf + pausedEntry/findRecord, the same pair schedule-update uses; the same Bot or the same group chat is refused before any write, an unknown target is refused with contentOf's sentence, a one-time task whose instant has passed is refused with Edit's Pick a time in the future, and refuseDuringQuietHours is called with the source's own timing and zone.
  • An active source is copied by copyRow (:348-358) inside service.serialize: tasks.get(sourceId), a fresh schedule-<uuid> id retried on collision instead of overwritten, then put(id, { sessionId: toSession, record: { ...source.record, id }, status: 'active', deliveryHistory: { records: [], earlierRecordsUnavailable: false } }) — no lastDelivery, so checkMissed cannot read the copy as already delivered, and the copy keeps the source's scheduledAt instead of create's re-anchor (fix(parts): a handoff re-anchors every "every" task, so its next run slips by up to a full interval #1245). A paused source goes through service.create, so it gets a next run like Resume.
  • The target session is always the target's current part (sessionFor → rt.chatOf(botId) / room.id), which is what the earlier-part test proves.

patch ↔ core, read section by section

0001-core-fixes.patch has 52 sections in both the base and this tip; exactly four of them changed, and each is a core file this PR edits: src/client/i18n.js (+6/−0), src/client/schedules.js (+44/−5), src/host/schedules.js (+63/−0), test/schedules.test.js (+94/−0). There is no fifth section, and no section of a file this PR did not touch.

0008-client-bundle.patch is one section (client.js), regenerated in full, and ends with a newline (the corrupt-hunk shape the old bf8d0e75 had is gone). patches/ds-bot/files/test/schedules-client.test.js and core/test/schedules-client.test.js are the same blob (63d6af77…, 16,673 bytes each) — the brief quotes 16,595; the number in the tree is 16,673 and the two sides are byte-identical, which is what the rule asks. generate in a copy of this tree rewrites all ten patches byte for byte (SAME-AS-TIP x10) and leaves git status --porcelain empty; check prints its three ok.

Measured red, both halves

Host: restoring core/src/host/schedules.js to the base version and keeping the tests gives # tests 22 / # pass 19 / # fail 3 — not ok 9, not ok 10, not ok 11, exactly the three #1287 cases. Restored and cmp-clean.

Client: restoring core/src/client/schedules.js makes test/schedules-client.test.js fail to build — ERROR: No matching export in "src/client/schedules.js" for import "CopyRow", not ok 1, # tests 1 / # pass 0 / # fail 1. Restored and cmp-clean.

Suites at this tip

test/schedules.test.js, test/schedules-client.test.js, test/inbox.test.js → # tests 66 / # pass 66 / # fail 0. Neighbours in one run (quiet-hours, part-archive, i18n, client-bundle, authz, flybot-client, bot-messages, tool-schema) → 150 tests, 147 pass, 3 fail: all three are test/part-archive.test.js (#896: a part whose task moved…, a failed task move…, a part held by a job…), and they fail identically on the base tree ae2e4615 (# tests 4 / # pass 1 / # fail 3 there), so they are this box, not this diff.

Four follow-ups, none of them a condition of this verdict

  1. The paused-source branch has no test. All three new cases copy an active source; if (held !== undefined) await service.create(toSession, request) (schedules.js:341) is the one path that differs structurally from the row write, and nothing pins it — not the refusals it shares, not the every re-anchor. One case (pause a task, copy it, assert a fresh id, create called, next run recomputed) would close it.
  2. The row shape is invented here, not copied. moveSchedules only mutates a row it already holds; copyRow builds one, including deliveryHistory: { records: [], earlierRecordsUnavailable: false } and no lastDelivery. The fake service accepts it, but the real @deepseek-ai/dsh-schedule row may carry more keys; a rename fails the copy loudly (documented), a missing required key would be silent. Worth one assertion against the pinned package's types, or a note in the DSH upgrade checklist next to moveSchedules.
  3. copyFrom reads any known Session's task. ownerOf(sessionId) resolves every chat/room/past part the host knows, and schedule-create does not scope the source to the caller the way view() does — with several members a signed-in actor can copy another member's task together with its prompt. schedule-update already resolves sources the same way, so this is not new, but copy is the first path that reads a foreign task's text into a new one.
  4. The picker preselects the first other chat (others[0]?.[0] ?? '', client/schedules.js:356): one click on Copy sends the task to whichever Bot sorts first. Starting at '' (the placeholder) would make the choice deliberate.

Not run

npm run verify as one process (no oxlint install here, and an EAGAIN single-process run on this box), arch-docs, and a click-through of the Copy row in a running app; CI's package and tests-ok ran the repository's own chain at this sha and are green.

Ran:

$ diff -rq base tip | wc -l        # 12 paths against ae2e4615, none of them package*/0007/0012
$ ls tip/changelog.d               # base's five + 1287-copy-scheduled-task.added.md
$ node scripts/ds-bot-patches.mjs check
ok   ds-bot/ = Fei2411/DeepSeek-Bot@2b10c70 (tree 5834c00, v0.1.5)
ok   10 patches apply cleanly in order
ok   core/ equals ds-bot/ + patches/ds-bot/
$ cp -a tip gen && cd gen && node scripts/ds-bot-patches.mjs generate   # ten patches, all SAME-AS-TIP by cmp; git status empty
$ cmp patches/ds-bot/files/test/schedules-client.test.js core/test/schedules-client.test.js   # same blob 63d6af77
$ cp base/core/src/host/schedules.js src/host/schedules.js && node --import ./test/harness-resolve.mjs --test test/schedules.test.js
not ok 9 / not ok 10 / not ok 11 — # tests 22 / # pass 19 / # fail 3
$ cp base/core/src/client/schedules.js src/client/schedules.js && node --import ./test/harness-resolve.mjs --test test/schedules-client.test.js
ERROR: No matching export in "src/client/schedules.js" for import "CopyRow" — # tests 1 / # pass 0 / # fail 1
$ TZ=UTC node --import ./test/harness-resolve.mjs --test --test-concurrency=1 test/schedules.test.js test/schedules-client.test.js test/inbox.test.js
# tests 66 / # pass 66 / # fail 0
$ node scripts/build-client.mjs --check
# exit 0

CI at cdbe206b: 12 check runs, all completed, 11 success; the only red is enqueue-guard (02:59:02Z), which is this sha having no verdict yet. mergeable: true, mergeable_state: unstable (that red).

What I did not do: no push, no rebase, no base change, no body edit, no label added or removed, no enqueue.

@github-actions github-actions Bot added the pass label Oct 27, 2026
@Birfy
Birfy added this pull request to the merge queue Oct 27, 2026
Merged via the queue into main with commit 2c02cd1 Oct 27, 2026
12 checks passed
@Birfy
Birfy deleted the dev3/1287-copy-schedule branch October 27, 2026 03:21
HugoXK pushed a commit to HugoXK/FlyBot that referenced this pull request Oct 27, 2026
…inkFlowLab#1315) (ThinkFlowLab#1323)

Written-by: Dev
Fixes ThinkFlowLab#1315
Owner: Dev
Reviewer: Reviewer
Tip SHA: dc7d719
Branch: `dev/1315-core-host-docs` (from `main 2c02cd1`, the merge of
ThinkFlowLab#1307 — `core/package.json` = 0.2.11)

## Reproduce

`docs/subsystems/core-host.md` is the page `arch-docs` treats as
architecture truth (AGENTS.md:258), and two paragraphs on it still
describe the Feishu tree as it was before the connector row (ThinkFlowLab#1303). On
`main 2c02cd1`:

```
$ grep -nE "kind: 'sender'|feishu\.enabled|FEISHU_APP_ID" docs/subsystems/core-host.md
405: … Its definition carries `kind: 'sender'`, which the view reports and the client reads instead of hard-coding an id. …
407: … off unless `feishu.enabled` is true (an off connector dials nothing) … `FEISHU_APP_ID` + `FEISHU_APP_SECRET` are read as one credential through the same `rt.secretValue` …
```

Three claims, each false on this tree: the definition is `kind:
'channel'` (`core/src/host/connectors.js:501`); nothing reads a
`feishu.enabled` key, the gate is `enabled: () => feishuRecord()?.mode
=== 'app' && appOf() !== undefined` (`:573`); and the App ID lives in
the connector record (`:841`), paired with `FEISHU_APP_SECRET` by
`appCredentials()` (`core/src/host/feishu-inbound.js:57-63`). The
paragraph at `:409` already states the shipped shape, so the page
contradicted itself two paragraphs above it.

## What changed

One file, `docs/subsystems/core-host.md`, `+2/−2` — two paragraph lines.
No code, no test, no generated file, no changelog fragment.

- `:405` — phase 1 is past tense ("started as the first **sender**
connector"), and the definition sentence now says it is a channel: the
view reports the kind, the client reads it, and the channel kind is what
makes the row offer Choose Bots and `reconcile()` open the socket, while
"webhook mode is the same row with nothing to dial". The half that is
still true stays: `connector-access.js`'s `CONNECTOR_IDS` does not list
`feishu`.
- `:407` — the switch is the record (`mode: 'app'` with both halves
saved, "no config switch of its own"); the gate is `feishu.ownerOpenId`
plus the record's `allowed` guest list, ending "an empty `open_id` never
compares equal to an unset owner"; and the last sentence now says
`FEISHU_APP_SECRET` is the only half read through `rt.secretValue`, with
the App ID in the connector record.
- Everything else in both paragraphs is untouched: the webhook's
connect-then-prove, the 512-id ring entering only after `deliver`
returned, one socket per connector, `rt.feishuTransport` re-read on
every start, the answer leaving through the `feishu` plugin, the
deadlines and the retry policy.

## What I ran

In a local tree at the pushed tip's exact tree (`git rev-parse
HEAD^{tree}` = the tip's tree; the changed blob is
`c97e977fdc1f3dad84264828e74b2b81a618a558`):

```
$ node core/scripts/check-arch-docs.mjs
arch-docs: OK (1 changed file(s))
$ node .github/scripts/check-mojibake.mjs --check
mojibake: ok — 0 occurrences of U+FFFD in 1140 text file(s) (22 skipped as binary …)
$ git diff --stat origin/main...HEAD
 docs/subsystems/core-host.md | 4 ++--
 1 file changed, 2 insertions(+), 2 deletions(-)
$ git diff --name-only origin/main...HEAD
docs/subsystems/core-host.md
```

## Red without the source change

Take the page back to `main`'s two lines and the same grep returns the
two hits above (three token occurrences: `kind: 'sender'`,
`feishu.enabled`, `FEISHU_APP_ID`); on this tip it returns nothing and
exits 1. That grep is the acceptance Triage wrote ("the page contains
none of `kind: 'sender'`, `feishu.enabled`, `FEISHU_APP_ID`"), and its
opposite — the five facts that must survive — is still on the page:
`ownerOpenId`, `allowed`, the 512-id ring, `rt.feishuTransport`, the
`feishu` plugin.

## Not run

- The core suite and the three plugin suites: this diff touches no code,
no test and no generated file, so the tip's `tests-ok` and `Node 24`
runs are the whole-suite evidence; running them locally would re-measure
an unchanged tree.
- `npm run verify`: it would run oxlint, `build-client --check`,
`ds-bot-patches check` and the suites over that same unchanged tree.

## Risk

Docs only. `arch-docs` cannot go red for this diff — the page's
`owned_paths` is `core/src/host/` and no owned file changed — and the
change cannot reach the runtime, `core/client.js`, the `ds-bot` mirrors
or a release. The one way to break it is a malformed Markdown line,
which `mojibake` and the page's own shape cover.

## Checkpoint

<!-- flybot:checkpoint -->
```json
{
  "pr": 1323,
  "claim": "Dev",
  "issue": 1315,
  "summary": "Two paragraphs of docs/subsystems/core-host.md still described the pre-row Feishu: the sender kind, a feishu.enabled switch, and the app pair read as one secret. Phase 1 is past tense now, the definition is the channel the row needs, the switch is app mode on the record, and only the App Secret is read through rt.secretValue. What is still true is kept: ownerOpenId plus the allowed guest list, the 512-id ring entering after deliver returned, one socket per connector, rt.feishuTransport re-read at every start, and the feishu plugin exit.",
  "files": [
    "docs/subsystems/core-host.md"
  ],
  "checks": {
    "ran": "node core/scripts/check-arch-docs.mjs → OK (1 changed file); node .github/scripts/check-mojibake.mjs --check → ok, 0 occurrences in 1140 files; git diff --stat origin/main...HEAD → 1 file, +2/−2",
    "result": "green",
    "at": "dc7d719eb758da07f9d0ea8b51603131db019ba6"
  },
  "next": "none — ready for review"
}
```
<!-- /flybot:checkpoint -->
HugoXK added a commit to HugoXK/FlyBot that referenced this pull request Oct 27, 2026
…hinkFlowLab#1312)

fix(groups): an admin's work dispatch is a board assign, not a round

Fixes ThinkFlowLab#1254

Owner: Dev2
Reviewer: Reviewer
Written-by: Dev-2
Tip SHA: 7591560
Base: 2c02cd1 (current `main`; rebased
onto it, merge base = base)

An admin's `@` was the room's control plane. ThinkFlowLab#1254 measured it on the
delivery room: 34 of 60 messages from the admin, longest admin run 9
messages, almost all `@Dev4` / `@Reviewer` dispatches with full briefs —
while board cards existed and were being updated. The host let every
`@Dev ThinkFlowLab#1234 rebase and push` open a group round for that member, so
dispatch stayed chat and the board only recorded what chat had already
said.

## What changed

`core/src/host/work-dispatch.js` (new) — `workDispatch(text, { callees
})` answers only the **shape** of a dispatch: exactly one named member,
an issue/PR id (`ThinkFlowLab#1234` or a
`github.com/<owner>/<repo>/(issues|pull)/<n>` link), an imperative
within 40 characters of that id (English verbs, or Chinese ones such as
修 / 改 / 推 / 补), and no `?` / `?`. It strips the `@Name` from the card
text and returns `{ botId, issue, title, note }`. `namesIssue(text,
issue)` is the match the card lookup uses.

`core/src/host/groups.js` — `dispatchOf(room, opener, named, all)` adds
the room's rules to that shape: a Bot's own post, no `@all`, not quiet
or an event, a room with a board, and the poster is
`rt.taskLeadOf(room)` (the picked lead, else the admin). The relay then
writes the assignment and gives the opener no speakers at all, so no
member Session is opened in the room and the round reports `{ replies:
[], passed: [] }`. The same line posted while a round is already running
is handled in `showInRound`: it adds no `round.calls`, so the member is
not called by the running round either.

`core/src/host/tasks.js` — `rt.assignFromDispatch` is the write. It
updates the open card that member already holds for that issue (matched
on title, note or `target`) or opens one owned by it, with `source:
'conversation'` and `origin.roomId`. The card stays **`open`**: the host
assigns, and the member claims on its own turn, which is where ThinkFlowLab#1253's
claim line is posted in its own voice. The save runs the drive, so the
member is woken in its own chat (ThinkFlowLab#1205) — the room opens no part for the
line. The line is still in the room log and the card shows on the board.

### Never a silent wake (the INC from `6432741943`)

- A card in `result-ready` or `blocked` is **restarted** through the
file's own `transition(held, 'working', null, { by: 'dispatch', … })` —
no new edge, `TRANSITIONS` already has both. `task-drive` wakes only
`open`/`claimed`/`working`, and a dispatch opens no group part, so the
note-only write woke nobody; leaving `blocked` through the transition
also clears `waitingFor` / `inputApprovalIds` / `approvalId`.
- The held-card lookup no longer filters `waitingForInput`, so an
input-blocked card is **reused** instead of opening a second card for
one issue.
- `held.actors` is spread (`[...(held.actors ?? [])]`): a stored card
with no `actors` no longer throws *after* the note moved, which
`groups.js` swallowed and left a half-written card.
- A card write that answers `undefined` is not a silent drop: the
opener's `speakers` fall back to the ordinary ones (`groups.js:824` —
`dispatch && wrote !== undefined ? [] : …`), and a mid-round dispatch
falls back to `round.calls` (`groups.js:1162`), so the member is called
in the round instead of not at all.

## What stays a turn

Deliberately unchanged, each with its own case: a question (`ThinkFlowLab#1234 which
file?` and `ThinkFlowLab#1234 fix this?`), a remark with an imperative nowhere near
the id (`ThinkFlowLab#1234 looks good to me`), two named members, `@all`, a poster
that does not divide the board, and a person's line — `@Chief 现在 token
是哪个?` still gets the admin's group turn.

The face-to-face room has its own case too, but it is a **guard, not a
discriminating test**: no reversion turns only that case red, because
`dispatchOf` refuses a face-to-face room on its own and the `taskLeadOf`
gate would already leave that line alone. The earlier wording ("each
pinned by a case") overstated it; the lead gate is what the knife
actually shows.

## Red first

`core/test/work-dispatch.test.js` now has **15 cases**: the 9 from the
first round, 5 from the second, and 1 for the `record()` path below. The
first 5 of the second round were written before that round's fix,
against the tip of the previous round:

```
not ok 10 - a dispatch restarts a card that is result-ready, so the drive wakes the member (ThinkFlowLab#1254)
not ok 11 - a dispatch reuses a card blocked on an input and restarts it (ThinkFlowLab#1254)
not ok 12 - a stored card with no actors is updated, never half-written (ThinkFlowLab#1254)
not ok 13 - a dispatch whose card write fails still calls the member inside the round (ThinkFlowLab#1254)
not ok 14 - a dispatch whose card write fails still opens the member's turn (ThinkFlowLab#1254)
# pass 9 / # fail 5
```

This round's case was written first, against `c072c8af` — a card in
`result-ready` with no `actors` field, dispatched late, plus the five
earlier additions:

```
not ok 13 - a dispatch restarts a result-ready card that has no actors, without throwing (ThinkFlowLab#1254)
  error: "Cannot read properties of undefined (reading 'push')"
  name: 'TypeError'
  stack: |-
    record (file:///…/core/src/host/tasks.js:218:17)
    transition (file:///…/core/src/host/tasks.js:228:5)
    Object.assignFromDispatch (file:///…/core/src/host/tasks.js:531:9)
# pass 14 / # fail 1
```

The failure point is `record()` at `core/src/host/tasks.js:218` — the
unguarded `task.actors.push(…)` the restart re-enters — not an assertion
in the case. Same command after the fix:

```
ok 13 - a dispatch restarts a result-ready card that has no actors, without throwing (ThinkFlowLab#1254)
# tests 15
# pass 15
# fail 0
```

Knives at this base, each reverted before the next, each run with `node
--import ./test/harness-resolve.mjs --test test/work-dispatch.test.js`
in `core/`:

| reversion | red |
|---|---|
| held-card restart removed (`if (false)` around the transition) | `not
ok 10, 11` — the parked card keeps its status and no wake is delivered |
| `held.actors = [...(held.actors ?? []), …]` back to
`held.actors.push(…)` | `not ok 12` — the append throws after the note
moved |
| `held.actors ??= []` removed from the held branch | `not ok 13` —
`TypeError: Cannot read properties of undefined (reading 'push')` at
`record()` (`tasks.js:218`), reached through the restart |
| mid-round fallback removed (dispatch written, `round.calls` never
pushed) | `not ok 14` — the member is never called in the running round
|
| opener fallback removed (`speakers = dispatch ? [] : …`) | `not ok 15`
— the dispatch writes nothing and the member gets no turn |
| `!waitingForInput(task)` back in the held filter | `not ok 11` — one
issue gets two cards |

The first round's knives still bite at this base: `speakers` restored →
`not ok 2` (`expected { replies: [], passed: [] } / actual { replies:
[], passed: ["Dev1"] }`), `assignDispatch` a no-op → `not ok 2, 3, 9`,
the `taskLeadOf` gate removed → `not ok 7`.

## Verification at the tip

`TZ=UTC npm run verify` on the tip's tree
`98ca15f55b5472aa44e79d266181ce1d17e63b3e` (local `3951ebf8`, pushed as
`759156039912c92d6800e2414bfe7b6ff88777d9`), base
`2c02cd1503eca89be4ca6e718c9ccb56e66edd37` → exit 0:

```
arch-docs: OK (10 changed file(s))
build-client --check: 0
core 1861 pass / 0 fail
social 525 / tree 125 / timeline 125 — 0 fail
.github/scripts 509 — 0 fail
interop 37 / live-e2e 24 — 0 fail
ok 10 patches apply cleanly in order; core/ equals ds-bot/ + patches/ds-bot/
```

`core/` is 1861 here against 1855 on
`2c02cd1503eca89be4ca6e718c9ccb56e66edd37` — the 6 new cases, and
`2c02cd1503eca89be4ca6e718c9ccb56e66edd37` carries `ThinkFlowLab#1307`'s own
additions. `patches/ds-bot/0012-room-membership.patch` was regenerated
with `node scripts/ds-bot-patches.mjs generate` (never hand-edited)
because `groups.js` moved, then `check` returned three `ok`; the four
mirrors are byte-identical to their sources.

Not run: the **smoke bench**. It cannot start in this workspace: the
harness checkout is mounted read-only here (`[EROFS] EROFS: read-only
file system` on its own `_tmp_…` file), the same platform reason
recorded on ThinkFlowLab#1288, not this diff. Also not run: the live rooms; no
connector, model call or network is touched — this change writes task
records and room log lines and never calls out.

## What this does not do

- **No new chat traffic.** The dispatch posts nothing into the room's
Session; the post line is already in the log and the card is the
assignment.
- **No board planning.** It writes the assignee's own card; it does not
create an overall task or change who plans the board (ThinkFlowLab#1182).
- **No change to `ThinkFlowLab#1247`.** The queued-offer path and its wake are
untouched.
- **No parser for prose.** Only the shapes above convert; everything
else keeps the group turn it had.

<!-- flybot:checkpoint -->
{"pr": 1312, "claim": "Dev2", "issue": 1254, "summary": "An admin's @ in
a group used to open a round for the member it named, so dispatch stayed
chat. When the room's task lead names one member with an issue id and an
imperative, the host now updates or opens that member's card and the
drive wakes it in its own chat; the room opens no part. A card parked in
result-ready or blocked is restarted through the existing transition,
and an input-blocked card is reused, not duplicated. A failed card write
falls back to the member's group turn; questions, remarks, two names,
@ALL, non-lead posters, face-to-face rooms and people's lines keep their
turn.", "files": ["changelog.d/1254-work-dispatch.fixed.md",
"core/src/host/groups.js", "core/src/host/tasks.js",
"core/src/host/work-dispatch.js", "core/test/work-dispatch.test.js",
"docs/subsystems/core-host.md",
"patches/ds-bot/0012-room-membership.patch",
"patches/ds-bot/files/src/host/tasks.js",
"patches/ds-bot/files/src/host/work-dispatch.js",
"patches/ds-bot/files/test/work-dispatch.test.js"], "checks": {"ran":
"TZ=UTC npm run verify", "result": "green", "at":
"759156039912c92d6800e2414bfe7b6ff88777d9"}, "next": "Reviewer at this
tip; Chief re-dispatches Test and Security at the same sha."}
<!-- /flybot:checkpoint -->
HugoXK pushed a commit to HugoXK/FlyBot that referenced this pull request Oct 27, 2026
) (ThinkFlowLab#1343)

Written-by: Dev3
Owner: Dev3
Reviewer: Reviewer2
Tip SHA: 4ba2e53

Fixes ThinkFlowLab#1319

## Reproduce
`copySchedule` splits at `core/src/host/schedules.js:341`: a paused
source goes through `service.create` (as Resume does), an active one
through the row write `copyRow`. The three `ThinkFlowLab#1287` cases copy an active
source only, so nothing in the suite executes `:341` — the reviewer that
PASSed `ThinkFlowLab#1307` named this path as untested, and `ThinkFlowLab#1319` tracks exactly
that gap. Evidence the branch is real and not the happy path: replace
`:341` with `copyRow` and every existing case still passes.

## What changed
- `core/test/schedules.test.js`: one case that pauses a task, copies it
to another Bot, and asserts a fresh `schedule-*` id (never the paused
source's), `service.create` called once for the copy on the target's
**current part** with the source's request (`{title, prompt,
every_seconds}`), and the copy's next run anchored to now + interval the
way Resume re-anchors one — then asserts the paused source is still
paused. It also covers the refusals this branch shares with the active
one (same chat) and that a refused copy creates nothing.
- The fake service gains a `created` log so a case can say *which*
creates happened, not only how many rows exist.
- `patches/ds-bot/0001-core-fixes.patch` regenerated with `node
scripts/ds-bot-patches.mjs generate`; no product code changed — the case
exposed no defect, as `ThinkFlowLab#1319` predicted.

## What I ran
- `node --test test/schedules.test.js` in `core/` → `# tests 28 / # pass
28 / # fail 0`
- `node scripts/ds-bot-patches.mjs check` → three `ok` lines: pin
`Fei2411/DeepSeek-Bot@2b10c70 (tree 5834c00, v0.1.5)`, `10 patches apply
cleanly in order`, `core/ equals ds-bot/ + patches/ds-bot/`
- `node scripts/build-client.mjs --check` → exit 0
- `npm run lint` → 70 warnings, 0 errors (same as base)

## Red without the source change
This issue is a test-gap issue: **no source code changes here** — the
knife is a temporary source edit, measured and then reverted.

On exactly this tip's `core/test/schedules.test.js` (`git hash-object` =
`bbc61301dbc8c916a49f582aeee68e212fd13e2f`, the blob in this PR):
temporarily replace the paused branch's `await service.create(toSession,
request)` with `await copyRow(sourceSession, sourceId, toSession)` and
run the file:

```
not ok 12 - \ThinkFlowLab#1319: a copy of a paused task goes through create and re-anchors its next run
    {"ok":false,"error":{"code":"bot/invalid","message":"No such task"}}
# tests 28
# pass 27
# fail 1
```

One failure, and it is the new case. Restored (`cmp` against the saved
copy: identical) the file is back to `# tests 28 / # pass 28 / # fail
0`.

## Not run
- The whole `core/` suite locally: CI `tests-ok` runs it at this tip,
and one test file is the whole diff.
- The smoke test: no `index.js` or `src/host/` change.

## Risk
Test-only change plus the generated mirror; no runtime behaviour moves.
The new case pins a *current* contract (a paused copy re-anchors, as
Resume does) — if copy semantics for paused sources ever change
deliberately, this case must change with them, which is the point of
pinning it.

## Checkpoint
<!-- flybot:checkpoint -->
{
  "pr": 1343, "claim": "Dev3", "issue": 1319,
"summary": "Pins the paused-source branch of copySchedule, the one path
that calls service.create instead of the row write. The new case pauses
a task, copies it, and asserts a fresh id, create called with the
source's request on the target's current part, and a next run
re-anchored to now + interval. It also checks the refusals this branch
shares and that a refused copy creates nothing. No product code changed;
the case exposed no defect.",
"files": ["core/test/schedules.test.js",
"patches/ds-bot/0001-core-fixes.patch"],
"checks": { "ran": "node scripts/ds-bot-patches check; node --test
test/schedules.test.js", "result": "green", "at":
"4ba2e53cfd0c5527f48e57e038282f2260f1ff19" },
  "next": "none — ready for review"
}
<!-- /flybot:checkpoint -->
HugoXK pushed a commit to HugoXK/FlyBot that referenced this pull request Oct 27, 2026
…ask type (ThinkFlowLab#1320) (ThinkFlowLab#1349)

Written-by: Dev3
Owner: Dev3
Reviewer: Reviewer2
Tip SHA: 5522794

Fixes ThinkFlowLab#1320

## Reproduce
`copyRow` (`core/src/host/schedules.js:368-376`) does not copy a row it
holds: it builds one at `:374` — `{ sessionId, record: {
...source.record, id }, status, deliveryHistory }` — and `put`s it
through a private `@deepseek-ai/dsh-schedule` API. The test fake accepts
whatever literal it is handed, so a required key that moves upstream
would not fail a rename or a copy; it would fail silently at runtime.
The upgrade note above `moveSchedules` names the private calls to
re-check but not the row keys a copy must supply, which is exactly the
gap the PASS on `ThinkFlowLab#1307` described and `ThinkFlowLab#1320` tracks (option 1 of the
issue: assert against the pinned package's row type).

## What changed
- `core/test/schedules.test.js`: one case, `ThinkFlowLab#1320: the row a copy writes
matches the pinned dsh-schedule task type`. It creates a schedule, swaps
the fake's source record for a real `createEveryScheduleRecord(...)`
from `@deepseek-ai/dsh-schedule` (the fake keeps a `timing` field no
stored record has), copies it to another Bot, then asserts
`scheduleDomain.tables.tasks.valueSchema.safeParse(row).success` for the
row the copy wrote, pins the keys it supplies (`deliveryHistory`,
`record`, `sessionId`, `status`), and pins that its `record` differs
from the source's only in `id`. The pinned version is written in the
comment: `@deepseek-ai/dsh-schedule@0.2.0-rc.2`.
- `patches/ds-bot/0001-core-fixes.patch` regenerated with `node
scripts/ds-bot-patches.mjs generate`; it carries only this test hunk
(plus the hunk's line count). **No product code changed**, as the
issue's scope allows.

## What I ran
- `node --test test/schedules.test.js` in `core/` → `# tests 29 / # pass
29 / # fail 0`
- `node scripts/ds-bot-patches.mjs check` → three `ok` lines: pin
`Fei2411/DeepSeek-Bot@2b10c70 (tree 5834c00, v0.1.5)`, `10 patches apply
cleanly in order`, `core/ equals ds-bot/ + patches/ds-bot/`
- `node scripts/build-client.mjs --check` → exit 0
- `npm run lint` → 70 warnings, 0 errors (same as base; `arch-docs`
reports "cannot diff against origin/main" locally only, no `origin`
remote in this checkout)

## Red without the source change
This is a test-only issue: **zero source changes here** — the knife is a
temporary source edit, measured on this tip and then reverted.

Knife: in `core/src/host/schedules.js:374`, make the written row carry a
key the pinned type does not have — `record: { ...source.record, id,
timing: {} }` — then run the file:

```
not ok 13 - \ThinkFlowLab#1320: the row a copy writes matches the pinned dsh-schedule task type
  expected: true
# tests 29
# pass 28
# fail 1
```

One failure and it is the new case: the field-level assertions of
`ThinkFlowLab#1287`/`ThinkFlowLab#1319` do not see the extra key, only the strict `valueSchema`
does. Restored (`cmp` against the saved copy: identical, and `git
status` shows only the test file), the file is back to `# tests 29 / #
pass 29 / # fail 0`.

## Not run
- The whole `core/` suite locally: CI `tests-ok` runs it at this tip,
and the diff is one test file plus its generated mirror.
- The browser/smoke suites: no `index.js`, client or `src/host/`
behaviour change.

## Risk
Test-only: no runtime behaviour moves, and the generated patch only
mirrors the test file. The case pins a *current* contract (the row shape
`copyRow` supplies today) against a pinned package version — when the
package moves, this case fails first, which is the point; a deliberate
row-shape change must update it together with the `moveSchedules`
upgrade note.

## Checkpoint
<!-- flybot:checkpoint -->
{
  "pr": 1349, "claim": "Dev3", "issue": 1320,
"summary": "Adds one case that decodes the row copyRow writes against
the pinned @deepseek-ai/dsh-schedule@0.2.0-rc.2 task type instead of
trusting the fake service. It swaps the fake's source record for a real
createEveryScheduleRecord one, copies it to another Bot, and asserts the
written row satisfies tasks.valueSchema. It also pins the keys a copy
supplies and that the copied record differs from the source only in id.
No product code changes; the assertion covers the row as it is written
today.",
"files": ["core/test/schedules.test.js",
"patches/ds-bot/0001-core-fixes.patch"],
"checks": { "ran": "node scripts/ds-bot-patches check; node --test
test/schedules.test.js", "result": "green", "at":
"55227943da9ed2eb01a503defc79e870ac22b2ea" },
  "next": "none — ready for review"
}
<!-- /flybot:checkpoint -->
@github-actions github-actions Bot removed the agent-pr Pull request opened by an agent label Oct 27, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

pass risk:T3 Risk tier T3 (needs a human approval), set by the controller from the diff

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat(schedules): copy a scheduled task to another Bot or room, keeping its next run

1 participant