Repository navigation
feat(schedules): copy a scheduled task to another Bot or group chat - #1307
Conversation
|
|
bf8d0e7 to
cdbe206
Compare
|
Verdict: PASS at cdbe206 Two checks first: head is still What this PR is, checked in the treeThe tree diff against its own base The host read, not taken from the body:
patch ↔ core, read section by section
Measured red, both halvesHost: restoring Client: restoring Suites at this tip
Four follow-ups, none of them a condition of this verdict
Not run
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 0CI at What I did not do: no push, no rebase, no base change, no body edit, no label added or removed, no enqueue. |
…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 -->
…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 -->
) (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 -->
…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 -->
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.jsoffers Retry / Edit / Pause·Resume / Delete and no copy, andschedule-create(core/src/host/schedules.js) reads onlytarget/title/prompt/when, so{ target, copyFrom }answersGive the task a name. Withcore/src/host/schedules.jsrestored to the base version and the tests kept, three host tests fail (not ok 9/10/11,# tests 22 / # pass 19 / # fail 3), and withcore/src/client/schedules.jsrestored the client test file fails to build (No matching export ... CopyRow).What changed
schedule-createtakes an optionalcopyFrom: { sessionId, id }; the source is resolved through the same ownership checkschedule-updateuses, or fromstate.pausedScheduleswhen it is paused.service.serialize: a freshschedule-<uuid>id (collision retried, never overwriting), the source's record under it,status: 'active', an emptydeliveryHistory, nolastDelivery.service.createis not used: it re-anchors aneverytask tonow + 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, whichcheckMissedreads 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.createas 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.CopyRow— the create form's target picker minus the chat the task already runs in — and posts the sameschedule-createwithcopyFrom. No new endpoint, no new state, no new config key.docs/subsystems/core-host.md(Scheduled tasks: copy) andcore-client.md; fragmentchangelog.d/1287-copy-scheduled-task.added.md.patches/ds-bot/0008-client-bundle.patchis regenerated with the realgenerateunder 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.patchis regenerated in the same run, and it has to be: its hunks carrysrc/client/i18n.js,src/client/schedules.js,src/host/schedules.jsandtest/schedules.test.js, so restoring only0008leaves those four reported as differing fromds-bot/ + patches/ds-bot/. Both files come fromgenerate; the rest ofpatches/ds-bot/is the base's own copy, plusfiles/test/schedules-client.test.jswhich mirrors the new client test.What I ran
Rebased onto
ae2e4615; the tree was rebuilt from a tarball of that commit (git diff --name-onlyagainst it is the 12 files of this PR).TZ=UTC,TMPDIRoutside the harness home, git 2.39.5 onPATH:node --import ./test/harness-resolve.mjs --test test/inbox.test.js test/schedules.test.js test/schedules-client.test.jsincore/→# tests 66 / # pass 66 / # fail 0node 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(incore/) → exit 0,client.jsrebuilt fromsrc/quiet-hours9/9,part-archive4/4,i18n2/2,client-bundle2/2,authz20/20,flybot-client14/14,bot-messages27/27,tool-schema6/6cmpover everypatches/ds-bot/files/**mirror againstcore/→ 0 differRed without the source change
At base
ae2e4615, with the source change reverted and the tests kept:core/src/host/schedules.jsrestored: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.core/src/client/schedules.js+core/client.jsrestored: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,oxlintandnpm run verify: no oxlint install here and the tree is a tarball rather than a clone; CI runs the rest.Risk
The copy path reads DSH's private storage domain (
getDomain/serialize/table('tasks')/get/putand the row shape) — the same onemoveSchedulesalready depends on — and sits outside that function's warn-and-continue loop, so a renamedget/putrejects 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.lastDeliveryanddeliveryHistoryare deliberately not copied, and a test pins both. The two patch files must travel together:0001and0008are a pair from onegenerate, and either one alone is refused bycheck.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" }