diff --git a/.qwen/e2e-tests/scheduled-channel-delivery.md b/.qwen/e2e-tests/scheduled-channel-delivery.md new file mode 100644 index 00000000000..6a65947a483 --- /dev/null +++ b/.qwen/e2e-tests/scheduled-channel-delivery.md @@ -0,0 +1,75 @@ +# Daemon Scheduled Channel Delivery E2E + +## Scope + +This E2E covers the minimal daemon-owned contract: + +```json +{ + "kind": "channel", + "channelName": "e2e-dingtalk", + "target": { "type": "chat", "id": "" } +} +``` + +`target.type` is either `chat` or `user`. The target is explicit and is not +admitted through the observed-contact graph. Topic/thread targets, mentions, +the Web Shell picker, and standalone `qwen channel start` behavior are outside +this change. + +## Real DingTalk result — 2026-07-19 (Asia/Shanghai) + +An isolated `QWEN_HOME` and runtime directory were used. `qwen serve` started +one daemon-managed `e2e-dingtalk` worker for the repository workspace. The +daemon advertised `scheduled_task_channel_delivery`, the worker connected over +DingTalk Stream mode, and the temporary configuration was deleted after the +run. + +### Chat target + +- Task: `ngh4wqzr` +- Delivery: `ngh4wqzr:1784391780000` +- Marker: `QWEN-SCHED-E2E-20260719-0023-CHAT` +- Target type: `chat` +- Result: `delivered`, `attempts=1` + +The one-shot task fired at its scheduled minute, disappeared from the task +list, produced only the requested final marker, persisted that marker in the +workspace outbox, and received a successful DingTalk group-send acknowledgement. + +### User target + +- Task: `dz1vu8i2` +- Delivery: `dz1vu8i2:1784391900000` +- Marker: `QWEN-SCHED-E2E-20260719-0025-USER` +- Target: `{ "type": "user", "id": "" }` +- Result: `delivered`, `attempts=1` + +The direct send used the stable DingTalk staff ID, not the inbound conversation +ID. The adapter accepted the platform response only after checking that the +recipient was absent from DingTalk's invalid and rate-limited user lists. + +## Verified boundaries + +- The daemon owns the timer, task store, final-answer capture, outbox, retries, + and Channel Worker dispatch. +- The task's immutable workspace owns both its outbox and its Channel Worker; + a later session `/cd` cannot move delivery to another workspace. +- Omitting `delivery` preserves the existing scheduled-task behavior. +- Only a clean, non-empty terminal model answer is enqueued. Thoughts, tool + output, interrupted turns, errors, and Todo Stop Guard drafts are excluded. +- Delivery retries reuse the persisted final answer and do not rerun the Agent. +- Outbox directories/files use owner-only POSIX permissions. +- Standalone loop target validation and Feishu direct proactive mapping remain + unchanged; daemon delivery uses dedicated validation/mapping hooks. + +## Automated regression evidence + +- Core scheduler/task/outbox: 195 tests passed. +- ChannelBase: 495 tests passed. +- DingTalk: 72 tests passed; Feishu: 76; WeCom: 135; Telegram: 15. +- Session final-answer capture: 368 tests passed. +- Focused CLI scheduled-delivery routes/IPC/worker/controller: 177 tests passed. +- CLI daemon/worker/server regression group: 1,105 tests passed; one transient + parallel socket case passed on isolated retry. +- CLI typecheck and serve fast-path bundle closure check passed. diff --git a/docs/developers/qwen-serve-protocol.md b/docs/developers/qwen-serve-protocol.md index c635ffcc72f..4f8ff0b491c 100644 --- a/docs/developers/qwen-serve-protocol.md +++ b/docs/developers/qwen-serve-protocol.md @@ -433,6 +433,7 @@ operator diagnostic snapshot documented below. | `workspace_reload` | workspace reload support is available in the embedded route configuration. | | `channel_reload` | a daemon-managed channel worker manager is enabled and can reload its current selection. | | `channel_control` | daemon-managed channel worker runtime control is wired. | +| `scheduled_task_channel_delivery` | the daemon's post-run scheduled-task delivery pipeline is wired, so an optional typed `chat` or `user` target can be dispatched through the exact task workspace's Channel worker. | | `multi_workspace_sessions` | more than one workspace runtime is registered, so session creation can select a trusted runtime by cwd. | | `multi_workspace_session_rewind` | more than one workspace runtime is registered; singular live-session rewind routes resolve the owning runtime. | | `multi_workspace_session_shell` | more than one workspace runtime is registered and session shell execution is explicitly enabled; singular REST shell resolves the owning runtime. | diff --git a/docs/superpowers/plans/2026-07-18-daemon-scheduled-channel-delivery.md b/docs/superpowers/plans/2026-07-18-daemon-scheduled-channel-delivery.md new file mode 100644 index 00000000000..3d0e709f049 --- /dev/null +++ b/docs/superpowers/plans/2026-07-18-daemon-scheduled-channel-delivery.md @@ -0,0 +1,797 @@ +# Daemon Scheduled Channel Delivery Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Let a daemon-managed scheduled task optionally deliver its successful final Agent answer to one workspace-local Channel `user` or `chat` target without changing tasks that omit delivery. + +**Architecture:** The scheduled task persists a typed `{ type, id }` target and a sibling `channelName`. After a successful Agent run, the session writes the final answer to a per-workspace durable outbox; the daemon dispatcher retries that payload and routes it through the exact workspace's Channel Worker, which performs a code-driven proactive send through the adapter. + +**Tech Stack:** TypeScript, Node.js child-process IPC, Express REST routes, Vitest, existing Qwen Channel adapters, durable JSON files with file locks and atomic replacement. + +## Global Constraints + +- Rebase the feature branch onto the latest `origin/main` before implementation. +- Scheduled delivery supports exactly `user` and `chat`; topic/thread delivery is rejected. +- User mentions are not supported. +- Task execution, outbox storage, and Channel Worker routing remain workspace-local; never fall back to another workspace. +- Observed contacts are optional discovery data and are not an admission or freshness gate. +- The Agent prompt produces only the message content; destination choice and sending are code-driven. +- No Web Shell destination picker, ordinary CLI `/loop` syntax, or standalone `qwen channel start` behavior change is included. +- Tasks without `delivery` must not write an outbox record or require a Channel Worker. +- Preserve the user-owned untracked `CHANNEL_DM_POLICY_SUMMARY.md` file. +- Write each behavioral test first, run it to observe RED, then implement and run GREEN. + +--- + +### Task 1: Rebase and establish the typed durable contract + +**Files:** + +- Modify: `packages/core/src/services/cronTasksFile.ts` +- Modify: `packages/core/src/services/cronTasksFile.test.ts` +- Modify: `packages/core/src/services/scheduled-delivery-outbox.ts` +- Modify: `packages/core/src/services/scheduled-delivery-outbox.test.ts` +- Modify: `packages/core/src/index.ts` + +**Interfaces:** + +- Produces: `CronTaskChannelTarget`, `CronTaskDelivery`, `ScheduledDeliveryRecord`, and `EnqueueScheduledDeliveryInput` used by every later task. +- Contract: + +```ts +export type CronTaskChannelTarget = + | { type: 'user'; id: string } + | { type: 'chat'; id: string }; + +export interface CronTaskDelivery { + kind: 'channel'; + channelName: string; + target: CronTaskChannelTarget; +} +``` + +- [ ] **Step 1: Rebase onto latest main and confirm the user file survives** + +Run: + +```bash +git fetch origin main +git rebase origin/main +git status --short --branch +test -f CHANNEL_DM_POLICY_SUMMARY.md +``` + +Expected: rebase completes; the branch is based on `origin/main`; the only +untracked user file remains `CHANNEL_DM_POLICY_SUMMARY.md`. Resolve conflicts +without staging that file. + +- [ ] **Step 2: Write failing durable-task contract tests** + +Replace old `chatId/threadId/isGroup` fixtures with these accepted variants: + +```ts +const userDelivery: CronTaskDelivery = { + kind: 'channel', + channelName: 'dingtalk', + target: { type: 'user', id: 'staff-42' }, +}; +const chatDelivery: CronTaskDelivery = { + kind: 'channel', + channelName: 'dingtalk', + target: { type: 'chat', id: 'cid-group-42' }, +}; +``` + +Add table cases that reject empty `channelName`, empty `id`, unknown `type`, +`threadId`, `topicId`, and the obsolete `{ chatId, isGroup }` target. + +- [ ] **Step 3: Run the core task-file test and verify RED** + +Run: + +```bash +cd packages/core && npx vitest run src/services/cronTasksFile.test.ts +``` + +Expected: FAIL because the current contract stores `channelName` inside a +`chatId`-based target. + +- [ ] **Step 4: Implement exact delivery validation** + +Define the union above. Make `isValidDelivery()` require a non-empty sibling +`channelName` and a target with exactly one allowed `type` plus non-empty `id`: + +```ts +function isValidChannelTarget(value: unknown): value is CronTaskChannelTarget { + if (typeof value !== 'object' || value === null) return false; + const target = value as Record; + return ( + (target['type'] === 'user' || target['type'] === 'chat') && + typeof target['id'] === 'string' && + target['id'].trim().length > 0 && + Object.keys(target).every((key) => key === 'type' || key === 'id') + ); +} +``` + +Keep absent `delivery` valid and preserve existing durable-task semantics. + +- [ ] **Step 5: Run the task-file test and verify GREEN** + +Run the Step 3 command. Expected: PASS. + +- [ ] **Step 6: Write failing outbox tests for separated Channel and target** + +Use this enqueue input in success, idempotency, claim, and retry tests: + +```ts +await enqueueScheduledDelivery(workspace, { + deliveryId: 'task-1:1000', + taskId: 'task-1', + firedAt: 1000, + channelName: 'dingtalk', + target: { type: 'chat', id: 'cid-group-42' }, + text: 'inspection result', + createdAt: 1000, +}); +``` + +Assert the record persists `channelName` separately and rejects the obsolete +target shape, overlong IDs, empty IDs, and malformed records. + +- [ ] **Step 7: Run the outbox test and verify RED** + +Run: + +```bash +cd packages/core && npx vitest run src/services/scheduled-delivery-outbox.test.ts +``` + +Expected: FAIL because records currently embed `channelName` in the target. + +- [ ] **Step 8: Implement the separated outbox record** + +Use these fields in both `ScheduledDeliveryRecord` and +`EnqueueScheduledDeliveryInput`: + +```ts +channelName: string; +target: CronTaskChannelTarget; +``` + +Bound `channelName` and `target.id` with `MAX_TARGET_FIELD_LENGTH`, validate the +two target types exactly, include `channelName` in idempotency conflict checks, +and retain the existing lock, lease, size, and atomic-write behavior. + +- [ ] **Step 9: Run both focused core tests and commit** + +Run: + +```bash +cd packages/core && npx vitest run src/services/cronTasksFile.test.ts src/services/scheduled-delivery-outbox.test.ts +``` + +Expected: PASS. + +Commit: + +```bash +git add packages/core/src/services/cronTasksFile.ts packages/core/src/services/cronTasksFile.test.ts packages/core/src/services/scheduled-delivery-outbox.ts packages/core/src/services/scheduled-delivery-outbox.test.ts packages/core/src/index.ts +git commit -m "refactor(scheduler): type channel delivery targets" +``` + +### Task 2: Make REST mutation structural and remove graph admission + +**Files:** + +- Modify: `packages/cli/src/serve/routes/scheduled-tasks.ts` +- Modify: `packages/cli/src/serve/routes/scheduled-tasks.test.ts` +- Delete: `packages/cli/src/serve/scheduled-task-channel-admission.ts` +- Delete: `packages/cli/src/serve/scheduled-task-channel-admission.test.ts` +- Modify: `packages/cli/src/serve/server.ts` +- Modify: `packages/cli/src/serve/server.test.ts` +- Modify: `packages/cli/src/serve/capabilities.ts` +- Modify: `packages/cli/src/serve/run-qwen-serve.ts` + +**Interfaces:** + +- Consumes: Task 1 `CronTaskDelivery`. +- Produces: primary and workspace-qualified create/update routes accepting the + same structurally validated payload without an observed-contact provider. + +- [ ] **Step 1: Write failing REST tests for the final wire contract** + +Create tasks through both route families with: + +```ts +delivery: { + kind: 'channel', + channelName: 'dingtalk', + target: { type: 'chat', id: 'cid-group-42' }, +} +``` + +Assert HTTP 201 without injecting `admitChannelTarget`. Add PATCH coverage for +switching to `{ type: 'user', id: 'staff-42' }`, clearing with `null`, and HTTP +400 `invalid_delivery` for empty IDs, unknown types, topic/thread fields, and +the obsolete `isGroup` shape. + +- [ ] **Step 2: Run route tests and verify RED** + +Run: + +```bash +cd packages/cli && npx vitest run src/serve/routes/scheduled-tasks.test.ts +``` + +Expected: FAIL with the current 501/403 admission behavior or old target parser. + +- [ ] **Step 3: Replace the parser and delete admission hooks** + +Make `parseDeliveryField()` return this normalized value: + +```ts +{ + kind: 'channel', + channelName: delivery['channelName'].trim(), + target: { + type: target['type'], + id: target['id'].trim(), + }, +} +``` + +Require only `kind`, `channelName`, and `target` on delivery and only `type` and +`id` on target. Remove `AdmitScheduledTaskChannelTarget`, every +`admitChannelTarget` dependency, and all create/PATCH 501/403/503 admission +branches. Delete the admission module and its tests. + +- [ ] **Step 4: Make capability reflect the installed delivery pipeline only** + +Keep `scheduledTaskChannelDeliveryAvailable` as the runtime toggle, but change +capability computation to: + +```ts +scheduledTaskChannelDeliveryAvailable: + deps.scheduledTaskChannelDeliveryAvailable === true, +``` + +Remove `admitScheduledTaskChannelTarget` from `ServeAppDeps`, server route +registration, `loadServeRuntimeModules()`, and the real daemon dependency +object. Update server tests so the capability is advertised when the delivery +pipeline is installed regardless of observed contacts. + +- [ ] **Step 5: Run REST, server, and capability tests and commit** + +Run: + +```bash +cd packages/cli && npx vitest run src/serve/routes/scheduled-tasks.test.ts src/serve/server.test.ts src/serve/capabilities.test.ts +``` + +Expected: PASS. + +Commit: + +```bash +git add packages/cli/src/serve/routes/scheduled-tasks.ts packages/cli/src/serve/routes/scheduled-tasks.test.ts packages/cli/src/serve/server.ts packages/cli/src/serve/server.test.ts packages/cli/src/serve/capabilities.ts packages/cli/src/serve/run-qwen-serve.ts +git add -u packages/cli/src/serve/scheduled-task-channel-admission.ts packages/cli/src/serve/scheduled-task-channel-admission.test.ts +git commit -m "refactor(daemon): accept explicit delivery targets" +``` + +### Task 3: Carry the typed target through proactive-delivery IPC + +**Files:** + +- Modify: `packages/channels/base/src/types.ts` +- Modify: `packages/channels/base/src/ChannelBase.ts` +- Modify: `packages/channels/base/src/ChannelBase.test.ts` +- Modify: `packages/channels/base/src/index.ts` +- Modify: `packages/cli/src/serve/channel-delivery-ipc.ts` +- Modify: `packages/cli/src/serve/channel-delivery-ipc.test.ts` +- Modify: `packages/cli/src/commands/channel/daemon-worker.ts` +- Modify: `packages/cli/src/commands/channel/daemon-worker.test.ts` + +**Interfaces:** + +- Produces: + +```ts +export type ChannelProactiveTarget = + | { channelName: string; type: 'user'; id: string } + | { channelName: string; type: 'chat'; id: string }; + +export interface ChannelDeliveryRequest { + deliveryId: string; + channelName: string; + target: { type: 'user' | 'chat'; id: string }; + text: string; +} +``` + +- [ ] **Step 1: Write failing ChannelBase tests** + +Invoke: + +```ts +await channel.deliverProactive( + { channelName: 'test', type: 'chat', id: 'group-1' }, + 'inspection result', +); +``` + +Assert the protected adapter call receives a `SessionTarget` with +`chatId/senderId = 'group-1'` and `isGroup = true`. Repeat for `type: 'user'` +with `isGroup = false`. Preserve mismatch, unsupported adapter, empty text, and +adapter rejection tests; remove thread-target coverage from this boundary. + +- [ ] **Step 2: Run ChannelBase tests and verify RED** + +Run: + +```bash +cd packages/channels/base && npx vitest run src/ChannelBase.test.ts +``` + +Expected: FAIL because the current public target expects `chatId/isGroup`. + +- [ ] **Step 3: Implement the typed public boundary** + +In `deliverProactive()`, validate `channelName`, proactive support, and target +type, then derive the legacy adapter `SessionTarget` without exposing a public +boolean contract: + +```ts +const sessionTarget: SessionTarget = { + channelName: target.channelName, + senderId: target.id, + chatId: target.id, + isGroup: target.type === 'chat', +}; +``` + +Use existing `supportsProactiveTarget()` and `pushProactive()` hooks so +standalone Channel loops remain unchanged. + +- [ ] **Step 4: Write failing IPC and worker tests** + +Use requests whose target has only `type` and `id`. Assert the IPC guard rejects +empty IDs, unknown target types, `threadId`, `topicId`, `chatId`, and `isGroup`. +Assert the worker chooses `request.channelName` and calls: + +```ts +channel.deliverProactive( + { channelName: request.channelName, ...request.target }, + request.text, +); +``` + +- [ ] **Step 5: Run IPC and worker tests and verify RED** + +Run: + +```bash +cd packages/cli && npx vitest run src/serve/channel-delivery-ipc.test.ts src/commands/channel/daemon-worker.test.ts +``` + +Expected: FAIL on the old nested target validator and worker call. + +- [ ] **Step 6: Implement IPC validation and worker composition** + +Keep the existing correlation ID, expiry, queue limit, timeout, sanitized error +codes, and shutdown draining. Change only the nested target guard and the +worker-to-ChannelBase call shown in Step 4. + +- [ ] **Step 7: Run all Task 3 tests and commit** + +Run: + +```bash +cd packages/channels/base && npx vitest run src/ChannelBase.test.ts +cd packages/cli && npx vitest run src/serve/channel-delivery-ipc.test.ts src/commands/channel/daemon-worker.test.ts +``` + +Expected: PASS. + +Commit: + +```bash +git add packages/channels/base/src/types.ts packages/channels/base/src/ChannelBase.ts packages/channels/base/src/ChannelBase.test.ts packages/channels/base/src/index.ts packages/cli/src/serve/channel-delivery-ipc.ts packages/cli/src/serve/channel-delivery-ipc.test.ts packages/cli/src/commands/channel/daemon-worker.ts packages/cli/src/commands/channel/daemon-worker.test.ts +git commit -m "refactor(channels): route typed proactive targets" +``` + +### Task 4: Map `user` and `chat` in every proactive adapter + +**Files:** + +- Modify: `packages/channels/dingtalk/src/DingtalkAdapter.ts` +- Modify: `packages/channels/dingtalk/src/DingtalkAdapter.test.ts` +- Modify: `packages/channels/feishu/src/FeishuAdapter.ts` +- Modify: `packages/channels/feishu/src/adapter.test.ts` +- Modify: `packages/channels/wecom/src/WeComAdapter.test.ts` +- Modify: `packages/channels/telegram/src/TelegramAdapter.test.ts` + +**Interfaces:** + +- Consumes: Task 3's derived `SessionTarget`, where `isGroup` is always a + boolean for scheduled proactive delivery. +- Produces: verified native mapping for both supported target types. + +- [ ] **Step 1: Update adapter tests first** + +For DingTalk assert: + +```ts +// chat +expect(body).toMatchObject({ openConversationId: 'cid-group-42' }); +// user +expect(body).toMatchObject({ userIds: ['staff-42'] }); +``` + +For Feishu assert the request URLs use +`receive_id_type=chat_id` for chat and `receive_id_type=open_id` for user, with +the same `receive_id` body field. For WeCom assert the SDK receives the target +ID for both variants. For Telegram assert `bot.api.sendMessage()` receives the +numeric/string target ID for both variants and no topic option. + +- [ ] **Step 2: Run adapter tests and verify RED** + +Run: + +```bash +cd packages/channels/dingtalk && npx vitest run src/DingtalkAdapter.test.ts +cd packages/channels/feishu && npx vitest run src/adapter.test.ts +cd packages/channels/wecom && npx vitest run src/WeComAdapter.test.ts +cd packages/channels/telegram && npx vitest run src/TelegramAdapter.test.ts +``` + +Expected: DingTalk mappings may already pass after fixture conversion; Feishu +user delivery fails because proactive sends currently force `chat_id`; all +packages must still be observed before production changes. + +- [ ] **Step 3: Implement only the missing native mappings** + +Preserve DingTalk's existing group/direct endpoints and WeCom/Telegram's shared +send APIs. Refactor Feishu proactive send to select the receive-ID type: + +```ts +const receiveIdType = target.isGroup === true ? 'chat_id' : 'open_id'; +await this.sendMessageInternal(target.chatId, text, true, receiveIdType); +``` + +Keep ordinary Feishu replies on `chat_id`. Do not add mention markup or topic +parameters to any adapter. + +- [ ] **Step 4: Run all adapter tests and commit** + +Run the Step 2 commands. Expected: PASS. + +Commit: + +```bash +git add packages/channels/dingtalk/src/DingtalkAdapter.ts packages/channels/dingtalk/src/DingtalkAdapter.test.ts packages/channels/feishu/src/FeishuAdapter.ts packages/channels/feishu/src/adapter.test.ts packages/channels/wecom/src/WeComAdapter.test.ts packages/channels/telegram/src/TelegramAdapter.test.ts +git commit -m "feat(channels): map scheduled user and chat targets" +``` + +### Task 5: Enqueue and dispatch final answers without cross-workspace fallback + +**Files:** + +- Modify: `packages/cli/src/acp-integration/session/Session.ts` +- Modify: `packages/cli/src/acp-integration/session/Session.test.ts` +- Modify: `packages/cli/src/serve/scheduled-delivery-dispatcher.ts` +- Modify: `packages/cli/src/serve/scheduled-delivery-dispatcher.test.ts` +- Modify: `packages/cli/src/serve/channel-worker-manager.ts` +- Modify: `packages/cli/src/serve/channel-worker-manager.test.ts` +- Modify: `packages/cli/src/serve/channel-worker-group.ts` +- Modify: `packages/cli/src/serve/channel-worker-group.test.ts` +- Modify: `packages/cli/src/commands/channel/durable-loop-controller.ts` +- Modify: `packages/cli/src/commands/channel/durable-loop-controller.test.ts` +- Verify: `packages/core/src/services/cronScheduler.ts` +- Verify: `packages/core/src/services/cronScheduler.test.ts` + +**Interfaces:** + +- Consumes: Task 1 outbox input and Task 3 IPC request. +- Produces: durable final-answer delivery and exact workspace routing. + +- [ ] **Step 1: Write failing Session and dispatcher tests** + +Assert successful cron completion enqueues: + +```ts +expect(enqueueScheduledDelivery).toHaveBeenCalledWith(workspace, { + deliveryId: `${taskId}:${firedAt}`, + taskId, + firedAt, + channelName: 'dingtalk', + target: { type: 'chat', id: 'cid-group-42' }, + text: 'final answer', +}); +``` + +Retain negative tests for no delivery, abort, Agent failure, missing identity, +and empty final answer. In dispatcher tests assert `toDeliveryRequest()` copies +`channelName` and target separately, retries transient failures, and does not +invoke any Agent/session method. + +- [ ] **Step 2: Run Session and dispatcher tests and verify RED** + +Run: + +```bash +cd packages/cli && npx vitest run src/acp-integration/session/Session.test.ts src/serve/scheduled-delivery-dispatcher.test.ts +``` + +Expected: FAIL because current enqueue/request shapes embed Channel metadata in +the old target. + +- [ ] **Step 3: Update Session enqueue and dispatcher request creation** + +Pass `item.delivery.channelName` and `item.delivery.target` separately while +preserving the existing clean-completion condition and deduplicated +`taskId:firedAt` identity. Do not change scheduler fire/catch-up semantics. + +- [ ] **Step 4: Write exact-workspace routing tests** + +Create group entries where workspace A owns `dingtalk` and workspace B owns no +such Channel. Assert: + +```ts +await expect( + manager.deliverChannelMessage(WORKSPACE_B, request), +).rejects.toMatchObject({ code: 'channel_worker_unavailable' }); +expect(workspaceASupervisor.deliverChannelMessage).not.toHaveBeenCalled(); +``` + +Also retain the success case where workspace B owns the named Channel. Do not +use the workspace-omitted `routeEntry()` fallback for scheduled delivery. + +- [ ] **Step 5: Run manager/group tests and verify behavior** + +Run: + +```bash +cd packages/cli && npx vitest run src/serve/channel-worker-manager.test.ts src/serve/channel-worker-group.test.ts +``` + +Expected: PASS if existing exact-workspace routing survived the contract +change; otherwise RED until the request plumbing is corrected. + +- [ ] **Step 6: Convert daemon Channel `/loop` persistence to the same contract** + +When the loop target is group-like, persist: + +```ts +delivery: { + kind: 'channel', + channelName: input.channelName, + target: { type: 'chat', id: input.target.chatId }, +} +``` + +For direct targets persist `type: 'user'` and the stable delivery ID already +selected by `ChannelBase.loopTargetFromEnvelope()`. Update equality and +task-to-loop conversion accordingly. Do not add a target-selection argument to +`/loop` and do not change standalone `qwen channel start`. + +- [ ] **Step 7: Run runtime tests and commit** + +Run: + +```bash +cd packages/cli && npx vitest run src/acp-integration/session/Session.test.ts src/serve/scheduled-delivery-dispatcher.test.ts src/serve/channel-worker-manager.test.ts src/serve/channel-worker-group.test.ts src/commands/channel/durable-loop-controller.test.ts +cd packages/core && npx vitest run src/services/cronScheduler.test.ts +``` + +Expected: PASS. + +Commit: + +```bash +git add packages/cli/src/acp-integration/session/Session.ts packages/cli/src/acp-integration/session/Session.test.ts packages/cli/src/serve/scheduled-delivery-dispatcher.ts packages/cli/src/serve/scheduled-delivery-dispatcher.test.ts packages/cli/src/serve/channel-worker-manager.ts packages/cli/src/serve/channel-worker-manager.test.ts packages/cli/src/serve/channel-worker-group.ts packages/cli/src/serve/channel-worker-group.test.ts packages/cli/src/commands/channel/durable-loop-controller.ts packages/cli/src/commands/channel/durable-loop-controller.test.ts packages/core/src/services/cronScheduler.ts packages/core/src/services/cronScheduler.test.ts +git commit -m "feat(daemon): dispatch scheduled final answers" +``` + +### Task 6: Remove deferred UI/graph work and restore the serve fast path + +**Files:** + +- Delete: `packages/web-shell/client/components/dialogs/scheduledTaskDeliveryTargets.ts` +- Delete: `packages/web-shell/client/components/dialogs/scheduledTaskDeliveryTargets.test.ts` +- Modify: `packages/web-shell/client/App.tsx` +- Modify: `packages/web-shell/client/components/dialogs/ScheduledTasksDialog.tsx` +- Modify: `packages/web-shell/client/components/dialogs/ScheduledTasksDialog.test.tsx` +- Modify: `packages/web-shell/client/i18n.tsx` +- Modify: `packages/webui/src/daemon-react-sdk.ts` +- Modify: `packages/webui/src/daemon/index.ts` +- Modify: `packages/webui/src/daemon/workspace/index.ts` +- Modify: `packages/webui/src/daemon/workspace/types.ts` +- Modify: `packages/webui/src/daemon/workspace/scheduledTasks.actions.test.ts` +- Modify: `packages/cli/src/commands/channel/observed-contact-store.ts` +- Modify: `packages/cli/src/commands/channel/observed-contact-store.test.ts` +- Modify: `packages/cli/src/serve/routes/workspace-channel-observed-contacts.test.ts` +- Modify: `packages/cli/src/serve/run-qwen-serve.ts` +- Delete: `docs/superpowers/plans/2026-07-18-web-shell-scheduled-delivery-picker.md` +- Delete: `docs/superpowers/plans/2026-07-18-scheduled-channel-delivery-runtime.md` +- Delete: `docs/superpowers/plans/2026-07-18-scheduled-channel-delivery-transport.md` +- Delete: `docs/design/2026-07-18-scheduled-channel-delivery.md` + +**Interfaces:** + +- Produces: a minimal daemon-only diff; existing mainline observed-contact APIs + remain unchanged and unrelated CLI startup does not statically load delivery + runtime dependencies. + +- [ ] **Step 1: Remove Web Shell destination UI and draft-only SDK exports** + +Delete the picker helper files. Remove only branch-added delivery picker props, +state, fetches, labels, and tests from `ScheduledTasksDialog`, `App`, i18n, and +the WebUI workspace client. Preserve every `origin/main` change in those files. +After editing, this command must produce no Web Shell/WebUI diff: + +```bash +git diff --name-only origin/main...HEAD -- packages/web-shell packages/webui +``` + +Expected: no output. + +- [ ] **Step 2: Remove graph mutations used only by the old picker/admission** + +Restore `ObservedChannelContactStore` and its route tests to the current +`origin/main` behavior: do not persist a direct-chat `chatId` extension solely +for scheduled delivery. Keep #7109's workspace observed graph intact. Verify: + +```bash +git diff --name-only origin/main...HEAD -- packages/cli/src/commands/channel/observed-contact-store.ts packages/cli/src/commands/channel/observed-contact-store.test.ts packages/cli/src/serve/routes/workspace-channel-observed-contacts.test.ts +``` + +Expected: no output. + +- [ ] **Step 3: Add a fast-path regression test before changing imports** + +Run the existing bundle check once and retain its failure output: + +```bash +npm run check:serve-fast-path-bundle +``` + +Expected before the fix: FAIL naming delivery-imported runtime modules such as +glob, TOML, shell, chokidar, or fzf in the pre-listen serve bundle. + +- [ ] **Step 4: Lazy-load the dispatcher at runtime startup** + +Remove the static value import of `createScheduledDeliveryDispatcher`. Keep an +import-only type alias and load the factory inside `completeRuntimeStartup()`: + +```ts +type ScheduledDeliveryDispatcher = + import('./scheduled-delivery-dispatcher.js').ScheduledDeliveryDispatcher; + +const { createScheduledDeliveryDispatcher } = await import( + './scheduled-delivery-dispatcher.js' +); +scheduledDeliveryDispatcher ??= createScheduledDeliveryDispatcher({ + listWorkspaces: () => + registry?.list().map((runtime) => runtime.workspaceCwd) ?? [boundWorkspace], + deliver: async (workspaceCwd, request) => { + const manager = + channelWorkerManager ?? (await ensureChannelWorkerManager?.()); + if (!manager) { + throw new ChannelDeliveryError( + 'channel_worker_unavailable', + 'Channel worker manager is unavailable.', + ); + } + return manager.deliverChannelMessage(workspaceCwd, request); + }, + onError: (error) => { + daemonLog.warn('scheduled Channel delivery dispatcher error', { + error: error instanceof Error ? error.message : String(error), + }); + }, +}); +``` + +Do not change dispatcher startup/shutdown ordering. + +- [ ] **Step 5: Run scope and fast-path checks** + +Run: + +```bash +npm run check:serve-fast-path-bundle +cd packages/web-shell && npx vitest run client/components/dialogs/ScheduledTasksDialog.test.tsx +cd packages/cli && npx vitest run src/commands/channel/observed-contact-store.test.ts src/serve/routes/workspace-channel-observed-contacts.test.ts src/serve/run-qwen-serve.test.ts +``` + +Expected: PASS; the scheduled tasks dialog retains legacy non-delivery behavior. + +- [ ] **Step 6: Commit scope trimming** + +```bash +git add -u packages/web-shell packages/webui packages/cli/src/commands/channel/observed-contact-store.ts packages/cli/src/commands/channel/observed-contact-store.test.ts packages/cli/src/serve/routes/workspace-channel-observed-contacts.test.ts docs/design/2026-07-18-scheduled-channel-delivery.md docs/superpowers/plans/2026-07-18-web-shell-scheduled-delivery-picker.md docs/superpowers/plans/2026-07-18-scheduled-channel-delivery-runtime.md docs/superpowers/plans/2026-07-18-scheduled-channel-delivery-transport.md +git add packages/cli/src/serve/run-qwen-serve.ts +git commit -m "refactor(daemon): narrow scheduled delivery scope" +``` + +### Task 7: Verify end to end and update the PR + +**Files:** + +- Modify: `.qwen/e2e-tests/scheduled-channel-delivery.md` +- Retain: `docs/superpowers/specs/2026-07-18-daemon-scheduled-channel-delivery-design.md` +- Retain: `docs/superpowers/plans/2026-07-18-daemon-scheduled-channel-delivery.md` + +**Interfaces:** + +- Produces: merge-ready evidence for the daemon-only contract and an updated + Draft PR description. + +- [ ] **Step 1: Run focused package verification** + +Run: + +```bash +cd packages/core && npx vitest run src/services/cronTasksFile.test.ts src/services/cronScheduler.test.ts src/services/scheduled-delivery-outbox.test.ts +cd packages/channels/base && npx vitest run src/ChannelBase.test.ts +cd packages/channels/dingtalk && npx vitest run src/DingtalkAdapter.test.ts +cd packages/channels/feishu && npx vitest run src/adapter.test.ts +cd packages/channels/wecom && npx vitest run src/WeComAdapter.test.ts +cd packages/channels/telegram && npx vitest run src/TelegramAdapter.test.ts +cd packages/cli && npx vitest run src/acp-integration/session/Session.test.ts src/commands/channel/daemon-worker.test.ts src/commands/channel/durable-loop-controller.test.ts src/serve/channel-delivery-ipc.test.ts src/serve/channel-worker-supervisor.test.ts src/serve/channel-worker-group.test.ts src/serve/channel-worker-manager.test.ts src/serve/routes/scheduled-tasks.test.ts src/serve/scheduled-delivery-dispatcher.test.ts src/serve/server.test.ts src/serve/run-qwen-serve.test.ts +``` + +Expected: PASS. + +- [ ] **Step 2: Run static and bundle checks** + +Run: + +```bash +npm run typecheck --workspace @qwen-code/qwen-code-core +npm run typecheck --workspace @qwen-code/channel-base +npm run typecheck --workspace @qwen-code/channel-dingtalk +npm run typecheck --workspace @qwen-code/channel-feishu +npm run typecheck --workspace @qwen-code/channel-wecom +npm run typecheck --workspace @qwen-code/channel-telegram +npm run typecheck --workspace @qwen-code/qwen-code +npm run check:serve-fast-path-bundle +git diff --check origin/main...HEAD +``` + +Expected: PASS with no whitespace errors. + +- [ ] **Step 3: Run isolated real DingTalk daemon E2E** + +Start a daemon with the existing local DingTalk Channel configuration, create +one immediate workspace task with a `chat` target and one with a `user` target, +then verify exactly one generated result reaches each destination. Confirm the +outbox records become `delivered`, restart around one controlled transient +failure if practical, and verify delivery retry does not execute the Agent a +second time. Never print credentials or tokens in logs or the E2E document. + +- [ ] **Step 4: Record sanitized E2E evidence** + +Update `.qwen/e2e-tests/scheduled-channel-delivery.md` with date, commit, daemon +mode, target types, task IDs, delivery IDs, status transitions, and redacted +platform result. Do not include group IDs, user IDs, secrets, API keys, or raw +tokens. + +- [ ] **Step 5: Commit evidence and request final review** + +```bash +git add .qwen/e2e-tests/scheduled-channel-delivery.md +git commit -m "test(channels): verify typed scheduled delivery" +``` + +Run `superpowers:requesting-code-review`, address actionable findings, then run +`superpowers:verification-before-completion` before claiming success. + +- [ ] **Step 6: Push and update Draft PR #7153** + +Push the rebased branch with the safest required lease protection, then update +the PR title/body to describe only: optional daemon scheduled delivery, +workspace-local routing, `user|chat` target contract, durable outbox/retry, no +graph admission, and unchanged behavior without delivery. Remove Web Shell, +topic, mention, and ordinary CLI claims from the PR text. diff --git a/docs/superpowers/plans/2026-07-19-bounded-scheduled-delivery-text.md b/docs/superpowers/plans/2026-07-19-bounded-scheduled-delivery-text.md new file mode 100644 index 00000000000..3ed987ac2d1 --- /dev/null +++ b/docs/superpowers/plans/2026-07-19-bounded-scheduled-delivery-text.md @@ -0,0 +1,155 @@ +# Bounded Scheduled Delivery Text Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Persist and deliver an explicit bounded snapshot when a scheduled Agent answer exceeds the 100,000-code-unit outbox record limit. + +**Architecture:** Keep normalization inside the core outbox enqueue boundary so every caller shares the same behavior. Normalize once before validation and idempotency comparison; leave Session, dispatcher, IPC, and Channel adapters unchanged. + +**Tech Stack:** TypeScript, Node.js, Vitest, existing JSON outbox and atomic-write helpers. + +## Global Constraints + +- `ScheduledDeliveryRecord.text` remains bounded to 100,000 JavaScript UTF-16 code units. +- Text within the bound is preserved exactly. +- Oversized text receives `\n\n[Channel delivery truncated because the result exceeded the outbox size limit.]` inside the bound. +- Truncation must not leave a dangling UTF-16 high surrogate. +- Candidate validation and duplicate-delivery comparison must use the same normalized text. +- Do not change outbox schema, dispatcher IPC, adapters, or Session behavior. +- Preserve the unrelated untracked `CHANNEL_DM_POLICY_SUMMARY.md` file. + +--- + +### Task 1: Normalize oversized outbox text + +**Files:** + +- Modify: `packages/core/src/services/scheduled-delivery-outbox.ts:78-84,258-314` +- Test: `packages/core/src/services/scheduled-delivery-outbox.test.ts:25-120` + +**Interfaces:** + +- Consumes: `enqueueScheduledDelivery(projectRoot, input)` and the existing 100,000-unit record validator. +- Produces: the unchanged `enqueueScheduledDelivery` signature, returning a record whose `text` is normalized before persistence and idempotency comparison. + +- [ ] **Step 1: Write failing boundary and idempotency tests** + +Add the following cases after `enqueues an idempotent pending record`: + +```ts +const truncationMarker = + '\n\n[Channel delivery truncated because the result exceeded the outbox size limit.]'; + +it('preserves delivery text exactly at the outbox limit', async () => { + const text = 'x'.repeat(100_000); + + const record = await enqueue({ text }); + + expect(record.text).toBe(text); +}); + +it('truncates oversized delivery text without splitting a surrogate pair', async () => { + const prefixLimit = 100_000 - truncationMarker.length; + const text = `${'x'.repeat(prefixLimit - 1)}😀${'y'.repeat( + truncationMarker.length + 1, + )}`; + + const record = await enqueue({ text }); + + expect(record.text.length).toBeLessThanOrEqual(100_000); + expect(record.text).toBe(`${'x'.repeat(prefixLimit - 1)}${truncationMarker}`); +}); + +it('keeps repeated oversized enqueue idempotent', async () => { + const text = 'x'.repeat(100_001); + + const first = await enqueue({ text }); + const second = await enqueue({ text }); + + expect(second).toEqual(first); + expect(await readScheduledDeliveryOutbox(workspace)).toEqual([first]); +}); +``` + +- [ ] **Step 2: Run the focused test and verify RED** + +Run: + +```bash +cd packages/core +npx vitest run src/services/scheduled-delivery-outbox.test.ts +``` + +Expected: the exact-limit test passes; the oversized tests fail because `enqueueScheduledDelivery` currently rejects text longer than 100,000 units. + +- [ ] **Step 3: Implement one normalization boundary** + +Add beside the existing limits: + +```ts +const TRUNCATED_TEXT_SUFFIX = + '\n\n[Channel delivery truncated because the result exceeded the outbox size limit.]'; +``` + +Add before `sameEnqueue`: + +```ts +function normalizeDeliveryText(text: string): string { + if (text.length <= MAX_TEXT_LENGTH) return text; + const prefixLimit = MAX_TEXT_LENGTH - TRUNCATED_TEXT_SUFFIX.length; + let prefix = text.slice(0, prefixLimit); + const lastCodeUnit = prefix.charCodeAt(prefix.length - 1); + if (lastCodeUnit >= 0xd800 && lastCodeUnit <= 0xdbff) { + prefix = prefix.slice(0, -1); + } + return `${prefix}${TRUNCATED_TEXT_SUFFIX}`; +} +``` + +At the start of `enqueueScheduledDelivery`, normalize the input once and use it for both the candidate record and duplicate comparison: + +```ts +const normalizedInput: EnqueueScheduledDeliveryInput = { + ...input, + text: normalizeDeliveryText(input.text), +}; +const createdAt = normalizedInput.createdAt ?? Date.now(); +``` + +Replace candidate reads of `input` with `normalizedInput`, locate the existing record by `normalizedInput.deliveryId`, and call `sameEnqueue(existing, normalizedInput)`. + +- [ ] **Step 4: Run the focused test and verify GREEN** + +Run: + +```bash +cd packages/core +npx vitest run src/services/scheduled-delivery-outbox.test.ts +``` + +Expected: all scheduled-delivery outbox tests pass. + +- [ ] **Step 5: Run type, lint, and related regression checks** + +Run: + +```bash +cd packages/core +npm run typecheck +npx eslint src/services/scheduled-delivery-outbox.ts src/services/scheduled-delivery-outbox.test.ts +npx vitest run src/services/cronTasksFile.test.ts src/services/cronScheduler.test.ts src/services/scheduled-delivery-outbox.test.ts +``` + +Expected: every command exits 0 with no test failures or lint errors. + +- [ ] **Step 6: Commit and push the scoped change** + +Run: + +```bash +git add docs/superpowers/plans/2026-07-19-bounded-scheduled-delivery-text.md packages/core/src/services/scheduled-delivery-outbox.ts packages/core/src/services/scheduled-delivery-outbox.test.ts +git commit -m "fix(scheduler): bound oversized delivery text" +git push +``` + +Expected: the existing PR head advances; `CHANNEL_DM_POLICY_SUMMARY.md` remains untracked. diff --git a/docs/superpowers/specs/2026-07-18-daemon-scheduled-channel-delivery-design.md b/docs/superpowers/specs/2026-07-18-daemon-scheduled-channel-delivery-design.md new file mode 100644 index 00000000000..c7051331bce --- /dev/null +++ b/docs/superpowers/specs/2026-07-18-daemon-scheduled-channel-delivery-design.md @@ -0,0 +1,266 @@ +# Daemon Scheduled Channel Delivery Design + +## Summary + +Daemon-managed scheduled tasks may optionally deliver a successful final Agent +answer through a Channel owned by the same workspace. Delivery is explicit, +durable, and code-driven: the Agent produces the answer, while the daemon and +Channel adapter select the destination and perform the send. + +The public target contract supports exactly two destination kinds: + +- `user`: a platform-native user identifier for a direct message. +- `chat`: a platform-native conversation identifier for a group or room. + +Topics and user mentions are out of scope. A task without `delivery` keeps the +existing scheduling and result behavior unchanged. + +## Goals + +- Let a daemon scheduled task optionally send its successful final answer to a + specific Channel user or chat. +- Keep task execution, outbox storage, and Channel Worker routing isolated by + workspace. +- Use a clear, structurally validated target contract instead of overloading a + `chatId` field with user identifiers. +- Keep destination discovery optional. Observed contacts may populate a picker, + but are not an authorization requirement for task creation or update. +- Persist delivery work so a transient Channel failure does not rerun the Agent. +- Preserve all behavior for tasks that omit `delivery`. + +## Non-goals + +- Topic or thread delivery. +- Mentioning a user inside a group message. +- Cross-workspace delivery. A task in workspace B cannot use a Channel Worker + owned by workspace A. +- Adding a Channel destination picker to Web Shell in this change. +- Extending ordinary CLI `/loop` syntax. +- Changing the standalone `qwen channel start` loop scheduler. +- Using an LLM prompt or tool call to decide whether or where to send. + +## API contract + +The optional scheduled-task field is: + +```ts +interface CronTaskDelivery { + kind: 'channel'; + channelName: string; + target: ChannelDeliveryTarget; +} + +type ChannelDeliveryTarget = + | { type: 'user'; id: string } + | { type: 'chat'; id: string }; +``` + +`channelName` identifies a Channel configured for the task's workspace. +`target.id` is platform-native and its meaning is fixed by `target.type`: + +- `user`: DingTalk `userId`, Feishu `open_id`, WeCom `userid`, Telegram user + numeric ID, or the equivalent identity accepted by another adapter. +- `chat`: DingTalk `openConversationId`, Feishu `chat_id`, WeCom `chatid`, + Telegram group numeric ID, or the equivalent conversation identity accepted + by another adapter. + +The daemon does not translate identities between Channel providers. + +### Chat delivery example + +```json +{ + "name": "daily inspection", + "cron": "0 9 * * *", + "prompt": "Inspect the service and summarize any problems.", + "recurring": true, + "enabled": true, + "delivery": { + "kind": "channel", + "channelName": "dingtalk", + "target": { + "type": "chat", + "id": "cid_xxx" + } + } +} +``` + +### User delivery example + +```json +{ + "name": "personal inspection", + "cron": "0 9 * * *", + "prompt": "Inspect the service and summarize any problems.", + "recurring": true, + "enabled": true, + "delivery": { + "kind": "channel", + "channelName": "dingtalk", + "target": { + "type": "user", + "id": "user_xxx" + } + } +} +``` + +Both the primary scheduled-task routes and the workspace-qualified routes use +this contract. The selected task workspace continues to come from the route: + +- Primary: `POST /scheduled-tasks` +- Explicit workspace: `POST /workspaces/:workspace/scheduled-tasks` + +Create and update perform structural validation only. They do not require the +target to be present in the observed-contact graph. An empty ID, unknown target +type, topic/thread field, or obsolete `isGroup` shape is rejected as an invalid +delivery request. + +## Workspace ownership + +Scheduled delivery inherits the scheduled task's workspace; the request does +not carry a second workspace selector. + +For a task stored in workspace B: + +1. Agent execution uses workspace B's scheduled-task runtime. +2. The delivery record is written to workspace B's outbox. +3. The daemon dispatcher passes workspace B to the Channel Worker manager. +4. The manager selects only a workspace B worker that owns `channelName`. +5. A same-named worker in workspace A is not eligible. + +Global or user-scope settings are configuration sources, not daemon-global +Channel ownership. Existing Channel workspace resolution rules decide which +workspace owns the worker. Ambiguous global configuration remains an explicit +Channel startup/configuration error. + +## Runtime data flow + +```text +workspace scheduler fires task prompt + -> Agent runs normally and produces finalAnswer + -> Session checks successful completion and optional delivery + -> Session appends target + finalAnswer to workspace outbox + -> daemon dispatcher claims pending outbox record + -> Channel Worker manager routes by task workspace + channelName + -> workspace Channel Worker calls deliverProactive(target, text) + -> adapter maps user/chat target to the platform API + -> dispatcher records delivered, retryable, or failed +``` + +The destination is never injected into the Agent prompt. The Agent cannot +silently change it, and a prompt-injection response cannot redirect delivery. + +The durable delivery identity is derived from the task ID and fire timestamp so +the same scheduled fire cannot enqueue conflicting duplicate work. Delivery +failure never reruns the Agent; only the persisted final answer is retried. + +### Bounded outbox text + +Each delivery record stores at most 100,000 JavaScript UTF-16 code units in its +`text` field. This is an internal per-record outbox bound, not an Agent output +limit or an IM platform limit. + +`enqueueScheduledDelivery` normalizes text before record validation and +idempotency comparison. Text within the bound is preserved exactly. Text over +the bound is truncated on a Unicode-safe boundary and receives the stable +suffix: + +```text +[Channel delivery truncated because the result exceeded the outbox size limit.] +``` + +The suffix and its preceding separator count toward the 100,000-unit bound. +The normalized text is used both for the candidate record and for comparison +with an existing record carrying the same delivery ID, so retrying an enqueue +with the same oversized answer remains idempotent. This behavior does not add a +new outbox field or change the dispatcher, IPC, or adapter contracts. + +## Adapter boundary + +The public task contract keeps `channelName` beside `target`. The dispatcher +and IPC request preserve those fields separately. After the Channel Worker +selects the named adapter, it constructs the internal proactive target: + +```ts +type ChannelProactiveTarget = ChannelDeliveryTarget & { + channelName: string; +}; +``` + +`ChannelBase` validates that the selected adapter owns this `channelName`, then +delegates the typed `id` to the adapter. This is an internal transport boundary, +not a second public task representation. + +Each proactive adapter maps the target explicitly: + +| Adapter | `user` | `chat` | +| -------- | --------------------------------------- | -------------------------------------- | +| DingTalk | direct-message API `userIds` | group-message API `openConversationId` | +| Feishu | message API with a user receive-ID type | message API with `chat_id` | +| WeCom | SDK send target `userid` | SDK send target `chatid` | +| Telegram | private chat user ID | group chat ID | + +An adapter that cannot deliver a supported contract target rejects it as a +permanent invalid-target error. It must not reinterpret a `user` ID as a chat +ID or report success without sending. + +## Failure behavior + +- Invalid contract or adapter-unsupported target: permanent delivery failure. +- Missing, stopped, or temporarily unhealthy workspace Channel Worker: + retryable delivery failure. +- Platform timeout, rate limit, or transient API error: retryable delivery + failure, subject to the dispatcher's bounded exponential backoff. +- Platform rejection of the ID or credentials: classified by the adapter; + permanent errors stop retrying, transient errors retry. +- An oversized successful answer is persisted and delivered as the bounded + snapshot described above, with an explicit truncation marker. +- Outbox persistence failure: the task run remains complete, the failure is + logged, and the Agent is not rerun. + +Delivery status is independent of the scheduled Agent run status. A successful +Agent run can therefore have a failed post-run delivery. + +## Compatibility and scope trimming + +- A task with no `delivery` does not write to the outbox and does not require a + running Channel Worker. +- Existing task CRUD, run history, catch-up, recurring, and one-shot semantics + remain unchanged. +- The earlier draft-only `chatId`/`threadId`/`isGroup` delivery shape is not a + released public contract and is replaced rather than retained as a second + representation. +- Existing observed-contact storage and read APIs remain available for future + discovery UI, but scheduled-task mutation no longer depends on graph + freshness or membership. +- Web Shell destination UI already added on the feature branch is removed from + this minimal change and can be proposed separately against the final API. + +## Verification + +The implementation plan must cover: + +1. Core serialization and validation for both target variants, including + rejection of empty IDs, unknown types, topic fields, and obsolete shapes. +2. Primary and workspace-qualified REST create/update/list behavior without an + observed-contact admission provider. +3. No-delivery compatibility: no outbox record and no Channel dependency. +4. Successful Agent final-answer enqueue, durable delivery identity, and no + enqueue on abort, Agent error, or empty final answer. +5. Dispatcher delivered/retry/permanent-failure behavior without rerunning the + task. +6. Exact workspace routing, including rejection when only another workspace + owns the selected Channel. +7. Adapter unit tests for `user` and `chat` mapping on each adapter that claims + proactive support. +8. Real DingTalk daemon E2E for one group and one direct user, because DingTalk + is the currently exercised production path. +9. Fast-path bundle and existing scheduled-task regression checks so the daemon + delivery implementation does not load heavyweight runtime modules into + unrelated CLI startup paths. +10. Outbox boundary tests proving that text at the limit is unchanged, + oversized text is Unicode-safely truncated with the marker inside the + limit, and repeated enqueue of the same oversized answer remains + idempotent. diff --git a/packages/channels/base/src/ChannelBase.test.ts b/packages/channels/base/src/ChannelBase.test.ts index b20737aab39..3f35738c741 100644 --- a/packages/channels/base/src/ChannelBase.test.ts +++ b/packages/channels/base/src/ChannelBase.test.ts @@ -26,6 +26,7 @@ import type { ChannelWebhookTask, } from './ChannelWebhookTask.js'; import { SessionRouter } from './SessionRouter.js'; +import { isChannelProactiveDeliveryError } from './ChannelProactiveDeliveryError.js'; // Concrete test implementation class TestChannel extends ChannelBase { @@ -353,6 +354,112 @@ describe('ChannelBase', () => { ); } + describe('proactive delivery boundary', () => { + it('recognizes a typed delivery error from another module instance', () => { + expect( + isChannelProactiveDeliveryError({ + code: 'channel_proactive_delivery_error', + disposition: 'permanent', + message: 'recipient is invalid', + }), + ).toBe(true); + expect( + isChannelProactiveDeliveryError({ + code: 'channel_proactive_delivery_error', + disposition: 'unknown', + message: 'recipient is invalid', + }), + ).toBe(false); + }); + + it('derives a group session target for a chat delivery', async () => { + const ch = createChannel(); + ch.proactiveSupported = true; + ch.proactiveTargetSupported = true; + + await ch.deliverProactive( + { + channelName: 'test-chan', + type: 'chat', + id: 'group-1', + }, + 'inspection result', + ); + + expect(ch.proactive).toEqual([ + { chatId: 'group-1', text: 'inspection result' }, + ]); + expect(ch.proactiveTargets).toEqual([ + { + channelName: 'test-chan', + senderId: 'group-1', + chatId: 'group-1', + isGroup: true, + }, + ]); + }); + + it('derives a direct session target for a user delivery', async () => { + const ch = createChannel(); + ch.proactiveSupported = true; + ch.proactiveTargetSupported = true; + + await ch.deliverProactive( + { channelName: 'test-chan', type: 'user', id: 'user-1' }, + 'inspection result', + ); + + expect(ch.proactiveTargets).toEqual([ + { + channelName: 'test-chan', + senderId: 'user-1', + chatId: 'user-1', + isGroup: false, + }, + ]); + }); + + it('rejects a target owned by another channel', async () => { + const ch = createChannel(); + ch.proactiveSupported = true; + ch.proactiveTargetSupported = true; + + await expect( + ch.deliverProactive( + { channelName: 'other', type: 'chat', id: 'group-1' }, + 'inspection result', + ), + ).rejects.toThrow('does not own delivery target'); + expect(ch.proactive).toEqual([]); + }); + + it('rejects delivery when the adapter has no proactive support', async () => { + const ch = createChannel(); + + await expect( + ch.deliverProactive( + { channelName: 'test-chan', type: 'chat', id: 'group-1' }, + 'inspection result', + ), + ).rejects.toThrow('does not support proactive delivery'); + expect(ch.proactive).toEqual([]); + }); + + it('rejects a proactive target unsupported by the adapter', async () => { + const ch = createChannel(); + ch.proactiveSupported = true; + ch.proactiveTargetSupported = false; + + await expect( + ch.deliverProactive( + { channelName: 'test-chan', type: 'chat', id: 'group-1' }, + 'inspection result', + ), + ).rejects.toThrow('does not support this proactive target'); + expect(ch.proactive).toEqual([]); + }); + }); + describe('gate integration', () => { it('silently drops group messages when groupPolicy=disabled', async () => { const ch = createChannel(); @@ -5749,12 +5856,19 @@ describe('ChannelBase', () => { consecutiveFailures: 0, runCount: 0, }; - const createLoop = vi.fn(async (_input: ChannelLoopInput) => created); + const createForSession = vi.fn( + async ( + _input: ChannelLoopInput, + _maxEnabledLoops: number, + _sessionId: string, + ) => created, + ); const ch = createChannel( {}, { loopController: { - create: createLoop, + create: vi.fn(), + createForSession, listForTarget: vi.fn().mockResolvedValue([]), disable: vi.fn(), validateCron: vi.fn(), @@ -5767,24 +5881,88 @@ describe('ChannelBase', () => { envelope({ text: '/loop add "0 9 * * *" post summary' }), ); - expect(createLoop).toHaveBeenCalledWith({ + expect(createForSession).toHaveBeenCalledWith( + { + channelName: 'test-chan', + target: { + channelName: 'test-chan', + senderId: 'user1', + chatId: 'user1', + threadId: undefined, + isGroup: false, + }, + cwd: '/tmp', + cron: '0 9 * * *', + prompt: 'post summary', + label: 'post summary', + recurring: true, + createdBy: 'User 1', + }, + 10, + 's-1', + ); + expect(ch.sent[0]!.text).toContain('Loop job-1'); + expect(bridge.prompt).not.toHaveBeenCalled(); + }); + + it('/loop add uses the direct delivery id without changing session routing', async () => { + const createForSession = vi.fn().mockResolvedValue({ + id: 'job-1', channelName: 'test-chan', target: { channelName: 'test-chan', senderId: 'user1', - chatId: 'chat1', - threadId: undefined, + chatId: 'routable-user-id', isGroup: false, }, cwd: '/tmp', cron: '0 9 * * *', prompt: 'post summary', - label: 'post summary', recurring: true, + enabled: true, createdBy: 'User 1', - }); - expect(ch.sent[0]!.text).toContain('Loop job-1'); - expect(bridge.prompt).not.toHaveBeenCalled(); + createdAt: '2026-06-30T01:02:03.000Z', + consecutiveFailures: 0, + runCount: 0, + } satisfies ChannelLoop); + const ch = createChannel( + {}, + { + loopController: { + create: vi.fn(), + createForSession, + listForTarget: vi.fn().mockResolvedValue([]), + disable: vi.fn(), + validateCron: vi.fn(), + }, + }, + ); + ch.proactiveSupported = true; + + await ch.handleInbound( + envelope({ + chatId: 'reply-conversation-id', + deliveryChatId: 'routable-user-id', + text: '/loop add "0 9 * * *" post summary', + }), + ); + + expect(createForSession).toHaveBeenCalledWith( + expect.objectContaining({ + target: expect.objectContaining({ chatId: 'routable-user-id' }), + }), + 10, + 's-1', + ); + const sessionCreateCount = vi.mocked(bridge.newSession).mock.calls.length; + await ch.handleInbound( + envelope({ + chatId: 'reply-conversation-id', + deliveryChatId: 'routable-user-id', + text: 'hello', + }), + ); + expect(bridge.newSession).toHaveBeenCalledTimes(sessionCreateCount); }); it('/loop add rejects single-scope sessions', async () => { @@ -5831,13 +6009,13 @@ describe('ChannelBase', () => { consecutiveFailures: 0, runCount: 0, }; - const createForTarget = vi.fn().mockResolvedValue(created); + const createForSession = vi.fn().mockResolvedValue(created); const ch = createChannel( {}, { loopController: { create: vi.fn(), - createForTarget, + createForSession, listForTarget: vi.fn().mockResolvedValue([]), disable: vi.fn(), validateCron: vi.fn(), @@ -5858,13 +6036,13 @@ describe('ChannelBase', () => { recurring: false, }); - expect(createForTarget).toHaveBeenCalledWith( + expect(createForSession).toHaveBeenCalledWith( { channelName: 'test-chan', target: { channelName: 'test-chan', senderId: 'user1', - chatId: 'chat1', + chatId: 'user1', threadId: undefined, isGroup: false, }, @@ -5876,6 +6054,7 @@ describe('ChannelBase', () => { createdBy: 'user1', }, 10, + 's-1', ); expect(result).toBe('Loop job-1: */5 * * * *'); }); diff --git a/packages/channels/base/src/ChannelBase.ts b/packages/channels/base/src/ChannelBase.ts index 21302f46bdd..3939cbceb58 100644 --- a/packages/channels/base/src/ChannelBase.ts +++ b/packages/channels/base/src/ChannelBase.ts @@ -5,6 +5,7 @@ import type { ChannelMemoryEntry, ChannelMemoryIntentClassifier, ChannelMemoryTarget, + ChannelProactiveTarget, ChannelRuntimeIdentity, ChannelRuntimeMemoryScope, ChannelTaskCancellationReason, @@ -17,6 +18,7 @@ import type { SessionTarget, } from './types.js'; import { BlockStreamer } from './BlockStreamer.js'; +import { ChannelProactiveDeliveryError } from './ChannelProactiveDeliveryError.js'; import { GroupGate } from './GroupGate.js'; import { DmGate } from './DmGate.js'; import { GroupHistoryStore } from './group-history-store.js'; @@ -185,6 +187,16 @@ export interface ChannelBaseOptions { export interface ChannelLoopController { create(input: ChannelLoopInput): Promise; + /** + * Create a loop bound to an already-resolved agent session. Daemon-backed + * controllers use this to persist a durable task without taking ownership of + * the conversation session that originated the loop. + */ + createForSession?( + input: ChannelLoopInput, + maxEnabledLoops: number, + sessionId: string, + ): Promise; createForTarget?( input: ChannelLoopInput, maxEnabledLoops: number, @@ -683,6 +695,58 @@ export abstract class ChannelBase { return false; } + async deliverProactive( + target: ChannelProactiveTarget, + text: string, + ): Promise { + if (target.channelName !== this.name) { + throw new ChannelProactiveDeliveryError( + 'permanent', + `Channel "${this.name}" does not own delivery target.`, + ); + } + if (!this.supportsProactiveSend()) { + throw new ChannelProactiveDeliveryError( + 'permanent', + `Channel "${this.name}" does not support proactive delivery.`, + ); + } + if ( + (target.type !== 'user' && target.type !== 'chat') || + typeof target.id !== 'string' || + target.id.trim().length === 0 + ) { + throw new ChannelProactiveDeliveryError( + 'permanent', + `Channel "${this.name}" received an invalid proactive target.`, + ); + } + const sessionTarget: SessionTarget = { + channelName: target.channelName, + senderId: target.id, + chatId: target.id, + isGroup: target.type === 'chat', + }; + if (!this.supportsScheduledDeliveryTarget(sessionTarget)) { + throw new ChannelProactiveDeliveryError( + 'permanent', + `Channel "${this.name}" does not support this proactive target.`, + ); + } + await this.pushScheduledDelivery(sessionTarget, text); + } + + protected supportsScheduledDeliveryTarget(target: SessionTarget): boolean { + return this.supportsProactiveTarget(target); + } + + protected pushScheduledDelivery( + target: SessionTarget, + text: string, + ): Promise { + return this.pushProactive(target, text); + } + protected supportsProactiveTarget(target: SessionTarget): boolean { return target.threadId === undefined; } @@ -2525,8 +2589,8 @@ export abstract class ChannelBase { return true; } - const target = this.loopTargetFromEnvelope(envelope); - if (!this.supportsProactiveTarget(target)) { + const target = this.loopControllerTargetFromEnvelope(envelope); + if (!this.supportsLoopTarget(target)) { await this.sendMessage( envelope.chatId, 'This channel does not support proactive loop messages for this chat target.', @@ -2554,7 +2618,21 @@ export abstract class ChannelBase { ), }; let job: ChannelLoop | undefined; - if (this.loopController.createForTarget) { + if (this.loopController.createForSession) { + const sessionId = await this.router.resolve( + this.name, + envelope.senderId, + envelope.chatId, + envelope.threadId, + input.cwd, + envelope.isGroup === true, + ); + job = await this.loopController.createForSession( + input, + MAX_LOOP_JOBS_PER_TARGET, + sessionId, + ); + } else if (this.loopController.createForTarget) { job = await this.loopController.createForTarget( input, MAX_LOOP_JOBS_PER_TARGET, @@ -2604,7 +2682,7 @@ export abstract class ChannelBase { } const target = this.loopToolTarget(sessionId); if (typeof target === 'string') return { text: target, isError: true }; - if (!this.supportsProactiveTarget(target)) { + if (!this.supportsLoopTarget(target)) { return { text: 'This channel does not support proactive loop messages for this chat target.', isError: true, @@ -2640,7 +2718,13 @@ export abstract class ChannelBase { createdBy: sanitizeSenderName(this.toolCallerName(sessionId, target)), }; let job: ChannelLoop | undefined; - if (this.loopController.createForTarget) { + if (this.loopController.createForSession) { + job = await this.loopController.createForSession( + loopInput, + MAX_LOOP_JOBS_PER_TARGET, + sessionId, + ); + } else if (this.loopController.createForTarget) { job = await this.loopController.createForTarget( loopInput, MAX_LOOP_JOBS_PER_TARGET, @@ -2702,7 +2786,7 @@ export abstract class ChannelBase { if (!this.loopController) return true; const jobs = await this.loopController.listForTarget( this.name, - this.loopTargetFromEnvelope(envelope), + this.loopControllerTargetFromEnvelope(envelope), ); if (jobs.length === 0) { await this.sendMessage(envelope.chatId, 'No loops.'); @@ -2726,7 +2810,7 @@ export abstract class ChannelBase { } const jobs = await this.loopController.listForTarget( this.name, - this.loopTargetFromEnvelope(envelope), + this.loopControllerTargetFromEnvelope(envelope), ); const job = jobs.find((candidate) => candidate.id === id); if (!job) { @@ -2794,7 +2878,7 @@ export abstract class ChannelBase { } const jobs = await this.loopController.listForTarget( this.name, - this.loopTargetFromEnvelope(envelope), + this.loopControllerTargetFromEnvelope(envelope), ); const match = jobs.find((job) => job.id === id); if (!match) { @@ -2819,6 +2903,24 @@ export abstract class ChannelBase { }); } + private loopControllerTargetFromEnvelope(envelope: Envelope): SessionTarget { + if (!this.loopController?.createForSession) { + return this.loopTargetFromEnvelope(envelope); + } + const target = this.loopTargetFromEnvelope(envelope); + if (target.isGroup === true) return target; + return { + ...target, + chatId: envelope.deliveryChatId ?? envelope.senderId, + }; + } + + private supportsLoopTarget(target: SessionTarget): boolean { + return this.loopController?.createForSession + ? this.supportsScheduledDeliveryTarget(target) + : this.supportsProactiveTarget(target); + } + private normalizeLoopTarget( target: SessionTarget, ): SessionTarget & { isGroup: boolean } { @@ -2835,7 +2937,16 @@ export abstract class ChannelBase { return 'Only authorized members can use loops in this shared session.'; } const senderId = this.activePrompts.get(sessionId)?.senderId; - const normalizedTarget = this.normalizeLoopTarget(target); + let normalizedTarget = this.normalizeLoopTarget(target); + if ( + this.loopController?.createForSession && + normalizedTarget.isGroup !== true + ) { + normalizedTarget = { + ...normalizedTarget, + chatId: normalizedTarget.senderId, + }; + } if (senderId && this.isSharedSessionTarget(normalizedTarget)) { return { ...normalizedTarget, senderId }; } diff --git a/packages/channels/base/src/ChannelProactiveDeliveryError.ts b/packages/channels/base/src/ChannelProactiveDeliveryError.ts new file mode 100644 index 00000000000..d71aca94de4 --- /dev/null +++ b/packages/channels/base/src/ChannelProactiveDeliveryError.ts @@ -0,0 +1,39 @@ +export type ChannelProactiveDeliveryDisposition = 'permanent' | 'transient'; + +export const CHANNEL_PROACTIVE_DELIVERY_ERROR_CODE = + 'channel_proactive_delivery_error' as const; + +/** + * Stable adapter-to-daemon delivery classification. Messages are diagnostics; + * callers must branch on disposition rather than matching platform text. + */ +export class ChannelProactiveDeliveryError extends Error { + readonly code = CHANNEL_PROACTIVE_DELIVERY_ERROR_CODE; + + constructor( + readonly disposition: ChannelProactiveDeliveryDisposition, + message: string, + options?: ErrorOptions, + ) { + super(message, options); + this.name = 'ChannelProactiveDeliveryError'; + } +} + +/** Recognizes errors across separately installed Channel extension packages. */ +export function isChannelProactiveDeliveryError( + error: unknown, +): error is ChannelProactiveDeliveryError { + if (typeof error !== 'object' || error === null) return false; + const candidate = error as { + code?: unknown; + disposition?: unknown; + message?: unknown; + }; + return ( + candidate.code === CHANNEL_PROACTIVE_DELIVERY_ERROR_CODE && + (candidate.disposition === 'permanent' || + candidate.disposition === 'transient') && + typeof candidate.message === 'string' + ); +} diff --git a/packages/channels/base/src/index.ts b/packages/channels/base/src/index.ts index a91a2ac3f67..97a7a52f49c 100644 --- a/packages/channels/base/src/index.ts +++ b/packages/channels/base/src/index.ts @@ -27,6 +27,12 @@ export type { export { BlockStreamer } from './BlockStreamer.js'; export type { BlockStreamerOptions } from './BlockStreamer.js'; export { ChannelBase } from './ChannelBase.js'; +export { + CHANNEL_PROACTIVE_DELIVERY_ERROR_CODE, + ChannelProactiveDeliveryError, + isChannelProactiveDeliveryError, +} from './ChannelProactiveDeliveryError.js'; +export type { ChannelProactiveDeliveryDisposition } from './ChannelProactiveDeliveryError.js'; export type { ChannelBaseOptions, ChannelLoopController, @@ -81,6 +87,7 @@ export type { ChannelMemoryScopeConfig, ChannelMemoryScopeMode, ChannelPlugin, + ChannelProactiveTarget, ChannelRuntimeIdentity, ChannelRuntimeMemoryScope, ChannelTaskCancellationReason, diff --git a/packages/channels/base/src/types.ts b/packages/channels/base/src/types.ts index 877d3dc349b..c06855b15c2 100644 --- a/packages/channels/base/src/types.ts +++ b/packages/channels/base/src/types.ts @@ -101,6 +101,11 @@ export interface Envelope { senderName: string; chatId: string; chatName?: string; + /** + * Stable platform id for a later direct proactive send when it differs from + * the conversation id used for replying to this inbound turn. + */ + deliveryChatId?: string; text: string; threadId?: string; /** Platform-specific message ID for response correlation. */ @@ -170,6 +175,10 @@ export interface ObservedChannelContactGraph { groups: ObservedChannelGroup[]; } +export type ChannelProactiveTarget = + | { channelName: string; type: 'user'; id: string } + | { channelName: string; type: 'chat'; id: string }; + export interface ChannelTaskLifecycleBase { channelName: string; chatId: string; diff --git a/packages/channels/dingtalk/src/DingtalkAdapter.test.ts b/packages/channels/dingtalk/src/DingtalkAdapter.test.ts index d1111323eeb..1797dffd8ff 100644 --- a/packages/channels/dingtalk/src/DingtalkAdapter.test.ts +++ b/packages/channels/dingtalk/src/DingtalkAdapter.test.ts @@ -9,6 +9,7 @@ import type { Envelope, SessionTarget, } from '@qwen-code/channel-base'; +import type { ChannelProactiveDeliveryError } from '@qwen-code/channel-base'; type LifecycleBase = Omit< Extract, @@ -91,6 +92,7 @@ vi.mock('@qwen-code/channel-base', async () => { '@qwen-code/channel-base', ); return { + ChannelProactiveDeliveryError: real.ChannelProactiveDeliveryError, ChannelBase: class { protected config: Record; protected name: string; @@ -1500,6 +1502,7 @@ describe('DingtalkChannel sender attribution', () => { expect(handleInbound).toHaveBeenCalledWith( expect.objectContaining({ messageId: 'header-m1', + deliveryChatId: 'staff-1', }), ); }); @@ -2054,6 +2057,7 @@ describe('DingtalkChannel proactive send', () => { function proactive(channel: DingtalkChannelInstance) { return channel as unknown as { supportsProactiveTarget(target: SessionTarget): boolean; + supportsScheduledDeliveryTarget(target: SessionTarget): boolean; supportsProactiveWebhookTarget(target: SessionTarget): boolean; pushProactive(target: SessionTarget, text: string): Promise; }; @@ -2103,10 +2107,12 @@ describe('DingtalkChannel proactive send', () => { expect(createChannel().supportsProactiveSend()).toBe(true); }); - it('accepts direct-message targets only for webhooks', () => { + it('keeps standalone loops group-only while scheduled delivery accepts users', () => { const channel = proactive(createChannel()); expect(channel.supportsProactiveTarget(groupTarget)).toBe(true); expect(channel.supportsProactiveTarget(directTarget)).toBe(false); + expect(channel.supportsScheduledDeliveryTarget(groupTarget)).toBe(true); + expect(channel.supportsScheduledDeliveryTarget(directTarget)).toBe(true); expect(channel.supportsProactiveWebhookTarget(groupTarget)).toBe(true); expect(channel.supportsProactiveWebhookTarget(directTarget)).toBe(true); expect( @@ -2291,7 +2297,7 @@ describe('DingtalkChannel proactive send', () => { expect(directSendCalls()).toHaveLength(1); }); - it('surfaces API detail in the error and log on failure', async () => { + it('keeps API detail in local logs but out of the propagated error', async () => { const channel = proactive(createChannel()); const writeSpy = vi .spyOn(process.stderr, 'write') @@ -2299,7 +2305,17 @@ describe('DingtalkChannel proactive send', () => { stubProactiveFetch(() => new Response('perm denied', { status: 403 })); await expect(channel.pushProactive(groupTarget, 'hello')).rejects.toThrow( - 'DingTalk proactive send failed: HTTP 403 perm denied', + 'DingTalk proactive send failed: HTTP 403', + ); + + await expect( + channel.pushProactive(groupTarget, 'hello'), + ).rejects.not.toThrow(/perm denied/); + await expect(channel.pushProactive(groupTarget, 'hello')).rejects.toEqual( + expect.objectContaining>({ + disposition: 'permanent', + name: 'ChannelProactiveDeliveryError', + }), ); const logged = writeSpy.mock.calls.map((c) => String(c[0])).join(''); diff --git a/packages/channels/dingtalk/src/DingtalkAdapter.ts b/packages/channels/dingtalk/src/DingtalkAdapter.ts index 0ff623767da..bd48611ec6d 100644 --- a/packages/channels/dingtalk/src/DingtalkAdapter.ts +++ b/packages/channels/dingtalk/src/DingtalkAdapter.ts @@ -7,6 +7,7 @@ import { DWClient, TOPIC_ROBOT, EventAck } from 'dingtalk-stream-sdk-nodejs'; import type { DWClientDownStream } from 'dingtalk-stream-sdk-nodejs'; import { ChannelBase, + ChannelProactiveDeliveryError, isTerminalTaskLifecycleType, sanitizeLogText, sanitizeSenderName, @@ -490,8 +491,6 @@ export class DingtalkChannel extends ChannelBase { return true; } - // Regular proactive paths accept only group targets; webhook tasks may use - // DMs through the one-to-one API. protected override supportsProactiveTarget(target: SessionTarget): boolean { return ( target.isGroup === true && @@ -500,6 +499,16 @@ export class DingtalkChannel extends ChannelBase { ); } + protected override supportsScheduledDeliveryTarget( + target: SessionTarget, + ): boolean { + return ( + typeof target.isGroup === 'boolean' && + target.threadId === undefined && + this.isStableTargetId(target.chatId) + ); + } + protected override supportsProactiveWebhookTarget( target: SessionTarget, ): boolean { @@ -622,8 +631,11 @@ export class DingtalkChannel extends ChannelBase { process.stderr.write( `[DingTalk:${this.name}] proactive send failed (${targetKind}, ${chunkLabel}): HTTP ${resp.status} ${detail}\n`, ); - throw new Error( - `DingTalk proactive send failed: HTTP ${resp.status}${detail ? ` ${detail}` : ''}`, + throw new ChannelProactiveDeliveryError( + resp.status === 408 || resp.status === 429 || resp.status >= 500 + ? 'transient' + : 'permanent', + `DingTalk proactive send failed: HTTP ${resp.status}`, ); } if (target.isGroup === false) { @@ -634,7 +646,8 @@ export class DingtalkChannel extends ChannelBase { process.stderr.write( `[DingTalk:${this.name}] proactive send failed (${targetKind}, ${chunkLabel}): invalid JSON response\n`, ); - throw new Error( + throw new ChannelProactiveDeliveryError( + 'transient', 'DingTalk proactive send failed: invalid JSON response', ); } @@ -642,7 +655,8 @@ export class DingtalkChannel extends ChannelBase { process.stderr.write( `[DingTalk:${this.name}] proactive send failed (${targetKind}, ${chunkLabel}): invalid direct recipient\n`, ); - throw new Error( + throw new ChannelProactiveDeliveryError( + 'permanent', 'DingTalk proactive send failed: invalid direct recipient', ); } @@ -650,7 +664,8 @@ export class DingtalkChannel extends ChannelBase { process.stderr.write( `[DingTalk:${this.name}] proactive send failed (${targetKind}, ${chunkLabel}): direct recipient rate limited\n`, ); - throw new Error( + throw new ChannelProactiveDeliveryError( + 'transient', 'DingTalk proactive send failed: direct recipient rate limited', ); } @@ -1318,6 +1333,7 @@ export class DingtalkChannel extends ChannelBase { ...(isGroup && conversationTitle ? { chatName: conversationTitle } : {}), + ...(!isGroup && senderStaffId ? { deliveryChatId: senderStaffId } : {}), text: envelopeText, isGroup, isMentioned, diff --git a/packages/channels/feishu/src/FeishuAdapter.ts b/packages/channels/feishu/src/FeishuAdapter.ts index de797c5d6fb..e73279fd9d1 100644 --- a/packages/channels/feishu/src/FeishuAdapter.ts +++ b/packages/channels/feishu/src/FeishuAdapter.ts @@ -7,6 +7,7 @@ import { tmpdir } from 'node:os'; import * as lark from '@larksuiteoapi/node-sdk'; import { ChannelBase, + ChannelProactiveDeliveryError, isTerminalTaskLifecycleType, } from '@qwen-code/channel-base'; import { buildCardContent, extractTitle, splitChunks } from './markdown.js'; @@ -685,12 +686,25 @@ export class FeishuChannel extends ChannelBase { await this.sendMessageInternal(target.chatId, text, true); } + protected override async pushScheduledDelivery( + target: SessionTarget, + text: string, + ): Promise { + await this.sendMessageInternal( + target.chatId, + text, + true, + target.isGroup === false ? 'open_id' : 'chat_id', + ); + } + private async sendMessageInternal( chatId: string, text: string, throwOnFailure: boolean, + receiveIdType: 'chat_id' | 'open_id' = 'chat_id', ): Promise { - const token = await this.getTenantAccessToken(); + let token = await this.getTenantAccessToken(); if (!token) { process.stderr.write( `[Feishu:${this.name}] Cannot send: no access token.\n`, @@ -719,43 +733,65 @@ export class FeishuChannel extends ChannelBase { content: JSON.stringify(card), }; - try { - const resp = await fetch( - `${BASE_URL}/im/v1/messages?receive_id_type=chat_id`, - { - method: 'POST', - headers: { - Authorization: `Bearer ${token}`, - 'Content-Type': 'application/json', + for (let attempt = 0; attempt < 2; attempt++) { + try { + const resp = await fetch( + `${BASE_URL}/im/v1/messages?receive_id_type=${receiveIdType}`, + { + method: 'POST', + headers: { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify(body), + signal: AbortSignal.timeout(15_000), }, - body: JSON.stringify(body), - signal: AbortSignal.timeout(15_000), - }, - ); + ); + + if (resp.ok) break; - if (!resp.ok) { - if (resp.status === 401) this.tokenCache = undefined; const detail = await resp.text().catch(() => ''); process.stderr.write( `[Feishu:${this.name}] sendMessage failed: HTTP ${resp.status} ${detail}\n`, ); + if (resp.status === 401) { + this.tokenCache = undefined; + if (attempt === 0) { + const refreshedToken = await this.getTenantAccessToken(); + if (refreshedToken) { + token = refreshedToken; + continue; + } + } + } + if (throwOnFailure) { + throw new ChannelProactiveDeliveryError( + resp.status === 408 || + resp.status === 429 || + resp.status >= 500 || + (resp.status === 401 && attempt === 0) + ? 'transient' + : 'permanent', + `Feishu sendMessage failed: HTTP ${resp.status}`, + ); + } + break; + } catch (err) { + if ( + throwOnFailure && + err instanceof Error && + (err instanceof ChannelProactiveDeliveryError || + err.message.startsWith('Feishu sendMessage failed:')) + ) { + throw err; + } + process.stderr.write( + `[Feishu:${this.name}] sendMessage error: ${err}\n`, + ); if (throwOnFailure) { - throw new Error(`Feishu sendMessage failed: HTTP ${resp.status}`); + throw err; } - } - } catch (err) { - if ( - throwOnFailure && - err instanceof Error && - err.message.startsWith('Feishu sendMessage failed:') - ) { - throw err; - } - process.stderr.write( - `[Feishu:${this.name}] sendMessage error: ${err}\n`, - ); - if (throwOnFailure) { - throw err; + break; } } } @@ -1963,6 +1999,7 @@ export class FeishuChannel extends ChannelBase { senderId, senderName: senderId, chatId, + ...(!isGroup && senderId ? { deliveryChatId: senderId } : {}), text: cleanText, messageId: msgId, threadId: msg.root_id || undefined, diff --git a/packages/channels/feishu/src/adapter.test.ts b/packages/channels/feishu/src/adapter.test.ts index 468e79f30dc..cef9a31345b 100644 --- a/packages/channels/feishu/src/adapter.test.ts +++ b/packages/channels/feishu/src/adapter.test.ts @@ -21,6 +21,7 @@ import { FeishuChannel } from './FeishuAdapter.js'; import type { ChannelAgentBridge, ChannelConfig, + ChannelProactiveDeliveryError, ChannelTaskLifecycleEvent, SessionTarget, } from '@qwen-code/channel-base'; @@ -101,6 +102,42 @@ describe('FeishuChannel', () => { expect(channel.supportsProactiveSend()).toBe(true); }); + it('uses the sender open ID as the routable direct delivery target', async () => { + const channel = createChannel(); + const handleInbound = vi.fn().mockResolvedValue(undefined); + Object.assign(channel as unknown as Record, { + handleInbound, + }); + const onMessage = getPrivateMethod<(data: unknown) => void>( + channel, + 'onMessage', + ).bind(channel); + + onMessage({ + message: { + message_id: 'direct-m1', + chat_id: 'oc_direct_chat', + chat_type: 'p2p', + message_type: 'text', + content: JSON.stringify({ text: 'hello' }), + }, + sender: { + sender_id: { open_id: 'ou_sender' }, + sender_type: 'user', + }, + }); + + await vi.waitFor(() => + expect(handleInbound).toHaveBeenCalledWith( + expect.objectContaining({ + chatId: 'oc_direct_chat', + deliveryChatId: 'ou_sender', + isGroup: false, + }), + ), + ); + }); + it('logs message debug payloads from the shared handler map', () => { const channel = createChannel(); const logDebugPayload = vi.fn(); @@ -1292,6 +1329,111 @@ describe('FeishuChannel', () => { stderrSpy.mockRestore(); }); + it('classifies a proactive 4xx response as a permanent delivery failure', async () => { + const channel = createTestableChannel(); + (channel as unknown as Record)['tokenCache'] = { + token: 'tenant-token', + expiresAt: Date.now() + 3600_000, + }; + vi.spyOn(global, 'fetch').mockResolvedValue( + new Response('permission denied', { status: 403 }), + ); + vi.spyOn(process.stderr, 'write').mockImplementation(() => true); + + await expect( + channel.pushLoop( + { + channelName: 'test', + senderId: 'ou_user', + chatId: 'oc_chat_id', + }, + 'hello', + ), + ).rejects.toEqual( + expect.objectContaining>({ + disposition: 'permanent', + message: 'Feishu sendMessage failed: HTTP 403', + }), + ); + }); + + it('refreshes a stale token once and retries the proactive send', async () => { + const channel = createTestableChannel(); + (channel as unknown as Record)['tokenCache'] = { + token: 'stale-token', + expiresAt: Date.now() + 3600_000, + }; + const fetchSpy = vi + .spyOn(global, 'fetch') + .mockResolvedValueOnce(new Response('stale', { status: 401 })) + .mockResolvedValueOnce( + new Response( + JSON.stringify({ + tenant_access_token: 'fresh-token', + expire: 3600, + }), + { status: 200 }, + ), + ) + .mockResolvedValueOnce(new Response('{}', { status: 200 })); + vi.spyOn(process.stderr, 'write').mockImplementation(() => true); + + await channel.pushLoop( + { + channelName: 'test', + senderId: 'ou_user', + chatId: 'oc_chat_id', + }, + 'hello', + ); + + expect(fetchSpy).toHaveBeenCalledTimes(3); + expect(fetchSpy.mock.calls[2]?.[1]).toEqual( + expect.objectContaining({ + headers: expect.objectContaining({ + Authorization: 'Bearer fresh-token', + }), + }), + ); + }); + + it('classifies a repeated 401 after token refresh as permanent', async () => { + const channel = createTestableChannel(); + (channel as unknown as Record)['tokenCache'] = { + token: 'stale-token', + expiresAt: Date.now() + 3600_000, + }; + vi.spyOn(global, 'fetch') + .mockResolvedValueOnce(new Response('stale', { status: 401 })) + .mockResolvedValueOnce( + new Response( + JSON.stringify({ + tenant_access_token: 'fresh-token', + expire: 3600, + }), + { status: 200 }, + ), + ) + .mockResolvedValueOnce(new Response('unauthorized', { status: 401 })); + vi.spyOn(process.stderr, 'write').mockImplementation(() => true); + + await expect( + channel.pushLoop( + { + channelName: 'test', + senderId: 'ou_user', + chatId: 'oc_chat_id', + }, + 'hello', + ), + ).rejects.toEqual( + expect.objectContaining>({ + disposition: 'permanent', + message: 'Feishu sendMessage failed: HTTP 401', + }), + ); + }); + it('sends proactive loop output to direct chats', async () => { const channel = createTestableChannel(); (channel as unknown as Record)['tokenCache'] = { @@ -1323,6 +1465,56 @@ describe('FeishuChannel', () => { ); fetchSpy.mockRestore(); }); + + it('maps typed chat and user deliveries to Feishu receive ID types', async () => { + const channel = createTestableChannel(); + (channel as unknown as Record)['tokenCache'] = { + token: 'tenant-token', + expiresAt: Date.now() + 3600_000, + }; + const fetchSpy = vi + .spyOn(global, 'fetch') + .mockResolvedValue(new Response('{}', { status: 200 })); + + await channel.deliverProactive( + { channelName: 'test', type: 'chat', id: 'oc_group' }, + 'group result', + ); + await channel.deliverProactive( + { channelName: 'test', type: 'user', id: 'ou_user' }, + 'direct result', + ); + + expect(fetchSpy.mock.calls[0]![0]).toBe( + 'https://open.feishu.cn/open-apis/im/v1/messages?receive_id_type=chat_id', + ); + expect(fetchSpy.mock.calls[0]![1]).toEqual( + expect.objectContaining({ + body: expect.stringContaining('"receive_id":"oc_group"'), + }), + ); + expect(fetchSpy.mock.calls[1]![0]).toBe( + 'https://open.feishu.cn/open-apis/im/v1/messages?receive_id_type=open_id', + ); + expect(fetchSpy.mock.calls[1]![1]).toEqual( + expect.objectContaining({ + body: expect.stringContaining('"receive_id":"ou_user"'), + }), + ); + + await channel.pushLoop( + { + channelName: 'test', + senderId: 'ou_user', + chatId: 'oc_direct_chat', + isGroup: false, + }, + 'standalone', + ); + expect(fetchSpy.mock.calls.at(-1)?.[0]).toBe( + 'https://open.feishu.cn/open-apis/im/v1/messages?receive_id_type=chat_id', + ); + }); }); describe('onPromptEnd: error recovery branches', () => { diff --git a/packages/channels/telegram/src/TelegramAdapter.test.ts b/packages/channels/telegram/src/TelegramAdapter.test.ts index e1e50b6895d..3cddc1616fd 100644 --- a/packages/channels/telegram/src/TelegramAdapter.test.ts +++ b/packages/channels/telegram/src/TelegramAdapter.test.ts @@ -141,6 +141,33 @@ describe('TelegramChannel', () => { expect(channel.supportsProactiveSend()).toBe(true); }); + it('sends typed chat and user deliveries without a topic option', async () => { + const channel = createChannel(); + const bot = installFakeBot(channel); + + await channel.deliverProactive( + { channelName: 'telegram', type: 'chat', id: '-10042' }, + 'group result', + ); + await channel.deliverProactive( + { channelName: 'telegram', type: 'user', id: '42' }, + 'direct result', + ); + + expect(bot.api.sendMessage).toHaveBeenNthCalledWith( + 1, + '-10042', + expect.any(String), + { parse_mode: 'HTML' }, + ); + expect(bot.api.sendMessage).toHaveBeenNthCalledWith( + 2, + '42', + expect.any(String), + { parse_mode: 'HTML' }, + ); + }); + it('clears active typing intervals on disconnect', () => { const clearIntervalSpy = vi.spyOn(global, 'clearInterval'); const channel = createChannel(); diff --git a/packages/channels/wecom/src/WeComAdapter.test.ts b/packages/channels/wecom/src/WeComAdapter.test.ts index 18a054a3f1d..8d3ae6a15cd 100644 --- a/packages/channels/wecom/src/WeComAdapter.test.ts +++ b/packages/channels/wecom/src/WeComAdapter.test.ts @@ -453,6 +453,30 @@ describe('WeComChannel', () => { expect(channel.supportsProactiveSend()).toBe(true); }); + it('sends typed chat and user deliveries to the selected SDK target', async () => { + const channel = new WeComChannel('bot', makeConfig(), makeBridge()); + await channel.connect(); + const client = lastClient(); + + await channel.deliverProactive( + { channelName: 'bot', type: 'chat', id: 'group-42' }, + 'group result', + ); + await channel.deliverProactive( + { channelName: 'bot', type: 'user', id: 'alice' }, + 'direct result', + ); + + expect(client.sendMessage).toHaveBeenNthCalledWith(1, 'group-42', { + msgtype: 'markdown', + markdown: { content: 'group result' }, + }); + expect(client.sendMessage).toHaveBeenNthCalledWith(2, 'alice', { + msgtype: 'markdown', + markdown: { content: 'direct result' }, + }); + }); + it('connects the official SDK with bot credentials', async () => { const stderr = vi .spyOn(process.stderr, 'write') diff --git a/packages/cli/src/acp-integration/session/Session.test.ts b/packages/cli/src/acp-integration/session/Session.test.ts index e558c8b89c9..aa76124695c 100644 --- a/packages/cli/src/acp-integration/session/Session.test.ts +++ b/packages/cli/src/acp-integration/session/Session.test.ts @@ -52,6 +52,7 @@ const debugLoggerWarnSpy = vi.hoisted(() => vi.fn()); const debugLoggerDebugSpy = vi.hoisted(() => vi.fn()); const runVisionBridgeSpy = vi.hoisted(() => vi.fn()); const refreshMemoryAfterManagedWriteSpy = vi.hoisted(() => vi.fn()); +const enqueueScheduledDeliverySpy = vi.hoisted(() => vi.fn()); const transcribeVoiceAudioSpy = vi.hoisted(() => vi.fn()); // Records every LoopTickResolver construction's deps so a test can assert what // Session computed (e.g. the home confinement root) without a private-field peek. @@ -72,6 +73,7 @@ vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => { logPromptSuggestion: vi.fn(), runVisionBridge: runVisionBridgeSpy, refreshMemoryAfterManagedWrite: refreshMemoryAfterManagedWriteSpy, + enqueueScheduledDelivery: enqueueScheduledDeliverySpy, // Transparent recording wrapper: records the constructor deps, then behaves // exactly like the real resolver (subclass → instanceof + methods preserved). LoopTickResolver: class extends actual.LoopTickResolver { @@ -439,6 +441,8 @@ describe('Session', () => { runVisionBridgeSpy.mockReset(); refreshMemoryAfterManagedWriteSpy.mockReset(); refreshMemoryAfterManagedWriteSpy.mockResolvedValue(false); + enqueueScheduledDeliverySpy.mockReset(); + enqueueScheduledDeliverySpy.mockResolvedValue(undefined); transcribeVoiceAudioSpy.mockReset(); currentModel = 'qwen3-code-plus'; currentAuthType = AuthType.USE_OPENAI; @@ -11554,7 +11558,14 @@ describe('Session', () => { describe('in-session cron MessageDisplay', () => { /** Mock scheduler that delivers exactly one in-session job through `start`. */ - function schedulerFiring(job: { prompt: string }) { + function schedulerFiring(job: { + id?: string; + prompt: string; + cronExpr?: string; + lastFiredAt?: number; + delivery?: core.CronTaskDelivery; + workspaceCwd?: string; + }) { return { size: 1, hasPendingWork: true, @@ -11619,6 +11630,197 @@ describe('Session', () => { }); }); + it('enqueues the final cron answer for admitted Channel delivery', async () => { + const firedAt = 1_718_000_000_000; + const taskWorkspace = '/workspace/task-owner'; + const delivery: core.CronTaskDelivery = { + kind: 'channel', + channelName: 'dingtalk', + target: { type: 'chat', id: 'group-42' }, + }; + const scheduler = schedulerFiring({ + id: 'task-1', + prompt: 'nightly report', + cronExpr: '0 9 * * *', + lastFiredAt: firedAt, + workspaceCwd: taskWorkspace, + delivery, + }); + mockConfig.getWorkingDir = vi + .fn() + .mockReturnValue('/workspace/after-cd'); + mockConfig.isCronEnabled = vi.fn().mockReturnValue(true); + mockConfig.getCronScheduler = vi.fn().mockReturnValue(scheduler); + mockConfig.getDisableAllHooks = vi.fn().mockReturnValue(true); + mockChat.sendMessageStream = vi + .fn() + .mockResolvedValueOnce(createEmptyStream()) + .mockResolvedValueOnce( + createStreamWithChunks([ + { + type: core.StreamEventType.CHUNK, + value: { + candidates: [ + { + content: { + parts: [ + { text: 'internal', thought: true }, + { text: 'daily ' }, + { text: 'result' }, + ], + }, + }, + ], + }, + }, + ]), + ); + + await session.prompt({ + sessionId: 'test-session-id', + prompt: [{ type: 'text', text: 'hello' }], + }); + + await vi.waitFor(() => + expect(enqueueScheduledDeliverySpy).toHaveBeenCalledWith( + taskWorkspace, + { + deliveryId: `task-1:${firedAt}`, + taskId: 'task-1', + firedAt, + channelName: delivery.channelName, + target: delivery.target, + text: 'daily result', + }, + ), + ); + }); + + it.each([ + { + label: 'fresh retry', + resetEvent: { + type: core.StreamEventType.RETRY, + value: {}, + isContinuation: false, + }, + partialText: 'daily ', + resumedText: 'daily result', + expectedText: 'daily result', + }, + { + label: 'model fallback', + resetEvent: { + type: core.StreamEventType.MODEL_FALLBACK, + value: {}, + }, + partialText: 'partial ', + resumedText: 'final answer', + expectedText: 'final answer', + }, + { + label: 'continuation retry', + resetEvent: { + type: core.StreamEventType.RETRY, + value: {}, + isContinuation: true, + }, + partialText: 'daily ', + resumedText: 'result', + expectedText: 'daily result', + }, + ])( + 'keeps only valid cron answer text after a $label', + async ({ resetEvent, partialText, resumedText, expectedText }) => { + const firedAt = 1_718_000_000_000; + const taskWorkspace = '/workspace/task-owner'; + const delivery: core.CronTaskDelivery = { + kind: 'channel', + channelName: 'dingtalk', + target: { type: 'chat', id: 'group-42' }, + }; + const scheduler = schedulerFiring({ + id: 'task-retry', + prompt: 'nightly report', + cronExpr: '0 9 * * *', + lastFiredAt: firedAt, + workspaceCwd: taskWorkspace, + delivery, + }); + mockConfig.isCronEnabled = vi.fn().mockReturnValue(true); + mockConfig.getCronScheduler = vi.fn().mockReturnValue(scheduler); + mockConfig.getDisableAllHooks = vi.fn().mockReturnValue(true); + mockChat.sendMessageStream = vi + .fn() + .mockResolvedValueOnce(createEmptyStream()) + .mockResolvedValueOnce( + createStreamWithChunks([ + { + type: core.StreamEventType.CHUNK, + value: { + candidates: [ + { content: { parts: [{ text: partialText }] } }, + ], + }, + }, + resetEvent, + { + type: core.StreamEventType.CHUNK, + value: { + candidates: [ + { content: { parts: [{ text: resumedText }] } }, + ], + }, + }, + ]), + ); + + await session.prompt({ + sessionId: 'test-session-id', + prompt: [{ type: 'text', text: 'hello' }], + }); + + await vi.waitFor(() => + expect(enqueueScheduledDeliverySpy).toHaveBeenCalledWith( + taskWorkspace, + expect.objectContaining({ text: expectedText }), + ), + ); + }, + ); + + it('does not enqueue Channel delivery when the cron model stream fails', async () => { + const scheduler = schedulerFiring({ + id: 'task-1', + prompt: 'nightly report', + cronExpr: '0 9 * * *', + lastFiredAt: 1_718_000_000_000, + delivery: { + kind: 'channel', + channelName: 'dingtalk', + target: { type: 'chat', id: 'group-42' }, + }, + }); + mockConfig.isCronEnabled = vi.fn().mockReturnValue(true); + mockConfig.getCronScheduler = vi.fn().mockReturnValue(scheduler); + mockConfig.getDisableAllHooks = vi.fn().mockReturnValue(true); + mockChat.sendMessageStream = vi + .fn() + .mockResolvedValueOnce(createEmptyStream()) + .mockRejectedValueOnce(new Error('model unavailable')); + + await session.prompt({ + sessionId: 'test-session-id', + prompt: [{ type: 'text', text: 'hello' }], + }); + await vi.waitFor(() => + expect(agentMessageChunks()).toContain( + '[cron error] model unavailable', + ), + ); + expect(enqueueScheduledDeliverySpy).not.toHaveBeenCalled(); + }); + it('suppresses is_final for MessageDisplay when a cron fire is cancelled mid-stream', async () => { let releaseCron: () => void; const cronGate = new Promise((resolve) => { @@ -19894,6 +20096,246 @@ describe('Session', () => { expect(guardUpdates).toHaveLength(3); }); + it('delivers the terminal cron answer after Todo Stop Guard continuations', async () => { + const firedAt = 1_718_000_000_000; + const delivery: core.CronTaskDelivery = { + kind: 'channel', + channelName: 'dingtalk', + target: { type: 'chat', id: 'group-42' }, + }; + const scheduler = { + hasPendingWork: true, + enableDurable: vi.fn().mockResolvedValue(undefined), + start: vi.fn( + ( + callback: (job: { + id: string; + prompt: string; + cronExpr: string; + lastFiredAt: number; + delivery: core.CronTaskDelivery; + workspaceCwd: string; + }) => void, + ) => + callback({ + id: 'guarded-task', + prompt: 'scheduled work', + cronExpr: '* * * * *', + lastFiredAt: firedAt, + delivery, + workspaceCwd: process.cwd(), + }), + ), + stop: vi.fn(), + list: vi.fn().mockReturnValue([]), + getExitSummary: vi.fn().mockReturnValue(undefined), + }; + mockConfig.isCronEnabled = vi.fn().mockReturnValue(true); + mockConfig.getCronScheduler = vi.fn().mockReturnValue(scheduler); + rebuildSessionWithGuard(); + const executeTodo = installPendingTodoTool(); + const completedTodos = pendingTodos.map((todo) => ({ + ...todo, + status: 'completed' as const, + })); + executeTodo + .mockResolvedValueOnce({ + llmContent: JSON.stringify(pendingTodos), + returnDisplay: { + type: 'todo_list', + todos: pendingTodos, + changes: {}, + }, + }) + .mockResolvedValueOnce({ + llmContent: JSON.stringify(completedTodos), + returnDisplay: { + type: 'todo_list', + todos: completedTodos, + changes: {}, + }, + }); + mockChat.getLastModelMessageText = vi + .fn() + .mockReturnValue('terminal guarded answer'); + const answerStream = (text: string) => + createStreamWithChunks([ + { + type: core.StreamEventType.CHUNK, + value: { + candidates: [{ content: { parts: [{ text }] } }], + }, + }, + ]); + mockChat.sendMessageStream = vi + .fn() + // Initial interactive prompt that starts the cron scheduler. + .mockResolvedValueOnce(createEmptyStream()) + // Cron creates an unfinished todo. + .mockResolvedValueOnce( + createStreamWithChunks([ + { + type: core.StreamEventType.CHUNK, + value: { + functionCalls: [ + { + id: 'cron-todo-delivery', + name: core.ToolNames.TODO_WRITE, + args: { todos: pendingTodos }, + }, + ], + }, + }, + ]), + ) + // Tool follow-up is a draft; Guard continuations replace it. + .mockResolvedValueOnce(answerStream('draft answer')) + // Guard finishes the outstanding Todo. + .mockResolvedValueOnce( + createStreamWithChunks([ + { + type: core.StreamEventType.CHUNK, + value: { + functionCalls: [ + { + id: 'guard-complete-todo', + name: core.ToolNames.TODO_WRITE, + args: { todos: completedTodos }, + }, + ], + }, + }, + ]), + ) + .mockResolvedValue(answerStream('terminal guarded answer')); + + await runGuardPrompt(); + + await vi.waitFor(() => + expect(enqueueScheduledDeliverySpy).toHaveBeenCalledWith( + process.cwd(), + { + deliveryId: `guarded-task:${firedAt}`, + taskId: 'guarded-task', + firedAt, + channelName: delivery.channelName, + target: delivery.target, + text: 'terminal guarded answer', + }, + ), + ); + }); + + it('does not deliver a cron draft when a Guard continuation is permission-cancelled', async () => { + const firedAt = 1_718_000_000_000; + const delivery: core.CronTaskDelivery = { + kind: 'channel', + channelName: 'dingtalk', + target: { type: 'chat', id: 'group-42' }, + }; + const scheduler = { + hasPendingWork: true, + enableDurable: vi.fn().mockResolvedValue(undefined), + start: vi.fn( + ( + callback: (job: { + id: string; + prompt: string; + cronExpr: string; + lastFiredAt: number; + delivery: core.CronTaskDelivery; + workspaceCwd: string; + }) => void, + ) => + callback({ + id: 'cancelled-guard-task', + prompt: 'scheduled work', + cronExpr: '* * * * *', + lastFiredAt: firedAt, + delivery, + workspaceCwd: process.cwd(), + }), + ), + stop: vi.fn(), + list: vi.fn().mockReturnValue([]), + getExitSummary: vi.fn().mockReturnValue(undefined), + }; + mockConfig.isCronEnabled = vi.fn().mockReturnValue(true); + mockConfig.getCronScheduler = vi.fn().mockReturnValue(scheduler); + rebuildSessionWithGuard(); + installPendingTodoTool(); + const todoTool = mockToolRegistry.getTool(core.ToolNames.TODO_WRITE); + const cancelledExecute = vi.fn(); + mockToolRegistry.getTool.mockImplementation((name: string) => + name === core.ToolNames.TODO_WRITE + ? todoTool + : mockConfirmingTool(name, cancelledExecute), + ); + vi.mocked(mockClient.requestPermission).mockResolvedValueOnce({ + outcome: { outcome: 'cancelled' }, + }); + mockChat.getLastModelMessageText = vi + .fn() + .mockReturnValue('draft before cancelled continuation'); + const answerStream = (text: string) => + createStreamWithChunks([ + { + type: core.StreamEventType.CHUNK, + value: { candidates: [{ content: { parts: [{ text }] } }] }, + }, + ]); + mockChat.sendMessageStream = vi + .fn() + .mockResolvedValueOnce(createEmptyStream()) + .mockResolvedValueOnce( + createStreamWithChunks([ + { + type: core.StreamEventType.CHUNK, + value: { + functionCalls: [ + { + id: 'cron-todo-before-cancel', + name: core.ToolNames.TODO_WRITE, + args: { todos: pendingTodos }, + }, + ], + }, + }, + ]), + ) + .mockResolvedValueOnce(answerStream('draft answer')) + .mockResolvedValueOnce( + createStreamWithChunks([ + { + type: core.StreamEventType.CHUNK, + value: { + functionCalls: [ + { + id: 'guard-permission-cancel', + name: core.ToolNames.ASK_USER_QUESTION, + args: { + questions: [ + { header: 'Continue?', question: 'Continue?' }, + ], + }, + }, + ], + }, + }, + ]), + ); + + await runGuardPrompt(); + const internals = session as unknown as { cronProcessing: boolean }; + await vi.waitFor(() => { + expect(mockClient.requestPermission).toHaveBeenCalled(); + expect(internals.cronProcessing).toBe(false); + }); + + expect(cancelledExecute).not.toHaveBeenCalled(); + expect(enqueueScheduledDeliverySpy).not.toHaveBeenCalled(); + }); + it('suspends an armed guard when a cron stream aborts', async () => { const scheduler = { hasPendingWork: true, diff --git a/packages/cli/src/acp-integration/session/Session.ts b/packages/cli/src/acp-integration/session/Session.ts index b19a78114cb..eb1210211dd 100644 --- a/packages/cli/src/acp-integration/session/Session.ts +++ b/packages/cli/src/acp-integration/session/Session.ts @@ -40,6 +40,7 @@ import type { ToolArtifact, VisionBridgeResult, MemoryWriteCandidate, + CronTaskDelivery, } from '@qwen-code/qwen-code-core'; import { AuthType, @@ -149,6 +150,7 @@ import { splitImageParts, approxBase64Bytes, runWithRuntimeContentGenerator, + enqueueScheduledDelivery, } from '@qwen-code/qwen-code-core'; import { NOT_CURRENTLY_GENERATING_CANCEL_MESSAGE } from '@qwen-code/acp-bridge/bridgeErrors'; // Single source of truth shared with the daemon-side answerer (BridgeClient), @@ -376,6 +378,8 @@ type StopContinuationResult = supersededAutomaticContinuation?: boolean; }; +type StopHookCompletionState = { interrupted: boolean }; + type BeforeModelSendDecision = | { kind: 'send'; message: Part[] } | { kind: 'stop'; stopReason: PromptResponse['stopReason'] }; @@ -755,12 +759,17 @@ interface CronFire { * calling `onFire` and writes the run record under the same value, so it * identifies this fire's entry in `runs[]`. */ lastFiredAt?: number; + delivery?: CronTaskDelivery; + workspaceCwd?: string; } interface CronQueueItem { prompt: string; source: 'cron' | 'loop'; taskId?: string; + firedAt?: number; + delivery?: CronTaskDelivery; + workspaceCwd?: string; } const MAX_NOTIFICATION_QUEUE = 20; @@ -2723,7 +2732,11 @@ export class Session implements SessionContext { messageBus: MessageBus | undefined, allowExternalHooks = true, modelOverride?: string, + completionState?: StopHookCompletionState, ): Promise<{ stopReason: PromptResponse['stopReason'] }> { + const markInterrupted = () => { + if (completionState) completionState.interrupted = true; + }; const stopHookBlockingCap = this.config.getStopHookBlockingCap(); let stopHookIterationCount = 0; let stopHookReasons: string[] = []; @@ -2739,6 +2752,7 @@ export class Session implements SessionContext { while (true) { if (pendingSend.signal.aborted) { this.todoStopGuard.suspend(); + markInterrupted(); return { stopReason: 'cancelled' }; } @@ -2747,6 +2761,7 @@ export class Session implements SessionContext { } if (this.todoStopGuardQueuedPromptPriority) { + markInterrupted(); return { stopReason: 'end_turn' }; } @@ -2773,11 +2788,13 @@ export class Session implements SessionContext { }, ); if (continuation.kind === 'terminal') { + markInterrupted(); return { stopReason: continuation.stopReason }; } continue; } if (waitsForQueuedPrompt) { + markInterrupted(); return { stopReason: 'end_turn' }; } } @@ -2825,6 +2842,7 @@ export class Session implements SessionContext { if (pendingSend.signal.aborted) { this.todoStopGuard.suspend(); + markInterrupted(); return { stopReason: 'cancelled' }; } @@ -2851,6 +2869,7 @@ export class Session implements SessionContext { }, ); if (continuation.kind === 'terminal') { + markInterrupted(); return { stopReason: continuation.stopReason }; } // The hook already completed. Process its output below so its @@ -2890,7 +2909,10 @@ export class Session implements SessionContext { if (guardDecision?.kind === 'exhausted') { await this.#emitTodoStopGuardExhausted(guardDecision); - if (!externalReason) return { stopReason: 'end_turn' }; + if (!externalReason) { + markInterrupted(); + return { stopReason: 'end_turn' }; + } } if (externalReason && stopHookIterationCount >= stopHookBlockingCap) { @@ -2906,10 +2928,12 @@ export class Session implements SessionContext { this.todoStopGuard.suspend(); await this.messageEmitter.emitAgentMessage(warning); debugLogger.warn(warning); + markInterrupted(); return { stopReason: 'end_turn' }; } if (queuedPromptArrivedDuringStopHook) { + markInterrupted(); return { stopReason: 'end_turn' }; } @@ -2965,6 +2989,7 @@ export class Session implements SessionContext { stopHookReasons = stopHookReasons.slice(0, -1); } if (continuation.kind === 'terminal') { + markInterrupted(); return { stopReason: continuation.stopReason }; } } @@ -4104,6 +4129,9 @@ export class Session implements SessionContext { prompt: job.prompt, source: job.cronExpr === '@wakeup' ? 'loop' : 'cron', ...(job.id ? { taskId: job.id } : {}), + ...(job.lastFiredAt !== undefined ? { firedAt: job.lastFiredAt } : {}), + ...(job.delivery ? { delivery: job.delivery } : {}), + ...(job.workspaceCwd ? { workspaceCwd: job.workspaceCwd } : {}), }); void this.#drainCronQueue(); }); @@ -4252,6 +4280,8 @@ export class Session implements SessionContext { const promptId = this.config.getSessionId() + '########cron' + Date.now(); let cronHadError = false; + let cronCompleted = false; + let finalAnswer = ''; await withInteractionSpan( this.config, { @@ -4444,6 +4474,7 @@ export class Session implements SessionContext { const messageDisplay = this.#createMessageDisplayDispatcher( ac.signal, ); + let turnAnswer = ''; let streamFailed = false; try { @@ -4467,6 +4498,7 @@ export class Session implements SessionContext { part.thought, ); if (!part.thought) { + turnAnswer += part.text; messageDisplay?.addChunk(part.text); } } @@ -4496,6 +4528,14 @@ export class Session implements SessionContext { `cron/loop tick ${resp.type}`, ); functionCalls.length = 0; + // Fresh retries and model fallbacks replay the answer; + // continuation retries resume the existing stream. + const isContinuation = + resp.type === StreamEventType.RETRY && + resp.isContinuation === true; + if (!isContinuation) { + turnAnswer = ''; + } } } } catch (error) { @@ -4551,20 +4591,43 @@ export class Session implements SessionContext { await this.#preserveStoppedToolRun(toolRun, ac.signal); return; } + } else { + // Tool-planning text from earlier model turns is not the + // deliverable. Keep only the final no-tool turn. + finalAnswer = turnAnswer; } } if (this.todoStopGuard.needsStopInspection) { + const stopCompletion: StopHookCompletionState = { + interrupted: false, + }; const guardStop = await this.#handleStopHookLoop( ac, promptId, false, undefined, false, + undefined, + stopCompletion, ); if (guardStop.stopReason === 'max_tokens') { this.#stopCronAfterTokenLimit(); } + if ( + stopCompletion.interrupted || + guardStop.stopReason !== 'end_turn' + ) { + return; + } + // Stop hooks and Todo Stop Guard may have produced one or more + // continuation turns. The chat's terminal model message is the + // deliverable; the streamed value captured above belongs to the + // pre-continuation draft. + const terminalAnswer = + this.#getCurrentChat().getLastModelMessageText?.(); + if (terminalAnswer?.trim()) finalAnswer = terminalAnswer; } + cronCompleted = !ac.signal.aborted; } catch (error) { if (ac.signal.aborted) { this.todoStopGuard.suspend(); @@ -4597,6 +4660,31 @@ export class Session implements SessionContext { () => ac.signal.aborted ? 'cancelled' : cronHadError ? 'error' : 'ok', ); + if ( + cronCompleted && + !cronHadError && + item.delivery && + item.workspaceCwd && + item.taskId && + item.firedAt !== undefined && + finalAnswer.trim().length > 0 + ) { + try { + await enqueueScheduledDelivery(item.workspaceCwd, { + deliveryId: `${item.taskId}:${item.firedAt}`, + taskId: item.taskId, + firedAt: item.firedAt, + channelName: item.delivery.channelName, + target: item.delivery.target, + text: finalAnswer, + }); + } catch (error) { + debugLogger.error( + `Failed to persist scheduled Channel delivery ${item.taskId}:${item.firedAt}:`, + error, + ); + } + } }, ); } diff --git a/packages/cli/src/commands/channel/daemon-worker.test.ts b/packages/cli/src/commands/channel/daemon-worker.test.ts index 38395db5e7e..a4e05fb54b3 100644 --- a/packages/cli/src/commands/channel/daemon-worker.test.ts +++ b/packages/cli/src/commands/channel/daemon-worker.test.ts @@ -5,6 +5,15 @@ const mockLoadChannelsConfig = vi.hoisted(() => vi.fn()); const mockLoadChannelsFromExtensions = vi.hoisted(() => vi.fn()); const mockParseConfiguredChannels = vi.hoisted(() => vi.fn()); const mockCreateChannel = vi.hoisted(() => vi.fn()); +const mockDurableLoopController = vi.hoisted(() => ({ + create: vi.fn(), + listForTarget: vi.fn(), + disable: vi.fn(), + validateCron: vi.fn(), +})); +const mockCreateDurableChannelLoopController = vi.hoisted(() => + vi.fn(() => mockDurableLoopController), +); const mockReadChannelMemory = vi.hoisted(() => vi.fn()); const mockGetChannelMemoryRevision = vi.hoisted(() => vi.fn()); const mockListChannelMemoryEntries = vi.hoisted(() => vi.fn()); @@ -31,9 +40,19 @@ const mockObservedContactStore = vi.hoisted(() => })), ); const mockLoadSettings = vi.hoisted(() => - vi.fn((_cwd?: string, _opts?: unknown) => ({ - merged: { proxy: 'http://settings-proxy:8080' as string | undefined }, - })), + vi.fn( + ( + _cwd?: string, + _opts?: unknown, + ): { + merged: { + proxy?: string; + experimental?: { cron?: boolean }; + }; + } => ({ + merged: { proxy: 'http://settings-proxy:8080' }, + }), + ), ); const mockResolveProxyUrl = vi.hoisted(() => vi.fn((_cliProxy?: string, settingsProxy?: string) => settingsProxy), @@ -66,6 +85,31 @@ const mockSelectFirstModel = vi.hoisted(() => const mockSanitizeLogText = vi.hoisted(() => vi.fn((value: unknown) => String(value).replace(/[\r\n]/g, ' ')), ); +const MockChannelProactiveDeliveryError = vi.hoisted( + () => + class extends Error { + constructor( + readonly disposition: 'permanent' | 'transient', + message: string, + ) { + super(message); + this.code = 'channel_proactive_delivery_error' as const; + } + + readonly code: 'channel_proactive_delivery_error'; + }, +); +const mockIsChannelProactiveDeliveryError = vi.hoisted(() => + vi.fn( + (error: unknown) => + typeof error === 'object' && + error !== null && + (error as { code?: unknown }).code === + 'channel_proactive_delivery_error' && + ((error as { disposition?: unknown }).disposition === 'permanent' || + (error as { disposition?: unknown }).disposition === 'transient'), + ), +); const mockDefaultDaemonClientCapabilities = vi.hoisted(() => vi.fn().mockResolvedValue({ v: 1, @@ -185,8 +229,14 @@ vi.mock('./observed-contact-store.js', () => ({ ObservedChannelContactStore: mockObservedContactStore, })); +vi.mock('./durable-loop-controller.js', () => ({ + createDurableChannelLoopController: mockCreateDurableChannelLoopController, +})); + vi.mock('@qwen-code/channel-base', () => ({ DaemonChannelBridge: mockDaemonChannelBridge, + ChannelProactiveDeliveryError: MockChannelProactiveDeliveryError, + isChannelProactiveDeliveryError: mockIsChannelProactiveDeliveryError, sanitizeLogText: mockSanitizeLogText, SessionRouter: mockSessionRouter, })); @@ -231,6 +281,16 @@ const webhookTask = { payload: { runId: 123 }, }; +const deliveryRequest = { + deliveryId: 'delivery-1', + channelName: 'telegram', + target: { + type: 'chat' as const, + id: 'group-1', + }, + text: 'inspection result', +}; + function createSdk() { const client = { capabilities: vi.fn().mockResolvedValue({ @@ -624,6 +684,43 @@ describe('createDaemonChannelBridgeFacade', () => { }); describe('runChannelDaemonWorker', () => { + it('injects a workspace durable loop controller when cron is enabled', async () => { + const sdk = createSdk(); + const handle = await runChannelDaemonWorker({ + daemonUrl: 'http://127.0.0.1:4170', + workspace: '/workspace', + selection: { mode: 'names', names: ['telegram'] }, + loadDaemonSdk: async () => sdk, + }); + + expect(mockCreateDurableChannelLoopController).toHaveBeenCalledWith({ + workspaceCwd: '/workspace', + }); + expect(mockCreateChannel.mock.calls[0]?.[3]).toEqual( + expect.objectContaining({ loopController: mockDurableLoopController }), + ); + await handle.close(); + }); + + it('omits the durable loop controller when cron is disabled', async () => { + const sdk = createSdk(); + mockLoadSettings.mockReturnValueOnce({ + merged: { experimental: { cron: false } }, + }); + const handle = await runChannelDaemonWorker({ + daemonUrl: 'http://127.0.0.1:4170', + workspace: '/workspace', + selection: { mode: 'names', names: ['telegram'] }, + loadDaemonSdk: async () => sdk, + }); + + expect(mockCreateDurableChannelLoopController).not.toHaveBeenCalled(); + expect(mockCreateChannel.mock.calls[0]?.[3]).not.toHaveProperty( + 'loopController', + ); + await handle.close(); + }); + it('forwards router discard through the daemon bridge facade', async () => { const sdk = createSdk(); const handle = await runChannelDaemonWorker({ @@ -1553,6 +1650,33 @@ describe('runChannelDaemonWorker', () => { expect(runWebhookTask).toHaveBeenCalledWith(webhookTask); }); + it('delivers an existing result on the matching channel without an agent turn', async () => { + const sdk = createSdk(); + const deliverProactive = vi.fn().mockResolvedValue(undefined); + mockCreateChannel.mockResolvedValueOnce({ + connect: vi.fn().mockResolvedValue(undefined), + disconnect: vi.fn(), + name: 'telegram', + validateWebhookTask: vi.fn(), + deliverProactive, + }); + + const handle = await runChannelDaemonWorker({ + daemonUrl: 'http://127.0.0.1:4170', + workspace: '/workspace', + selection: { mode: 'names', names: ['telegram'] }, + loadDaemonSdk: async () => sdk, + }); + + await handle.deliverChannelMessage(deliveryRequest); + + expect(deliverProactive).toHaveBeenCalledWith( + { channelName: 'telegram', ...deliveryRequest.target }, + deliveryRequest.text, + ); + expect(mockBridgePrompt).not.toHaveBeenCalled(); + }); + it('rejects webhook tasks for channels that are not running', async () => { const sdk = createSdk(); @@ -2158,6 +2282,265 @@ describe('daemonWorkerCommand', () => { } }); + it('reports channel delivery success only after the adapter send completes', async () => { + const exit = mockProcessExitNoThrow(); + const send = vi.fn(); + const restoreSend = stubProcessSend(send as NodeJS.Process['send']); + let resolveDelivery!: () => void; + const deliverProactive = vi.fn( + () => + new Promise((resolve) => { + resolveDelivery = resolve; + }), + ); + mockCreateChannel.mockResolvedValueOnce({ + connect: vi.fn().mockResolvedValue(undefined), + disconnect: vi.fn(), + name: 'telegram', + validateWebhookTask: vi.fn(), + deliverProactive, + }); + vi.stubEnv('QWEN_CHANNEL_DAEMON_WORKER', 'worker-token'); + vi.stubEnv('QWEN_DAEMON_URL', 'http://127.0.0.1:4170'); + vi.stubEnv('QWEN_DAEMON_WORKSPACE', '/workspace'); + const existingMessageListeners = process.listeners('message'); + + try { + const handler = daemonWorkerCommand.handler({ + channel: ['telegram'], + _: [], + $0: 'qwen', + }); + await vi.waitFor(() => { + expect(send).toHaveBeenCalledWith( + expect.objectContaining({ type: 'ready' }), + ); + }); + send.mockClear(); + + const listener = process + .listeners('message') + .find((candidate) => !existingMessageListeners.includes(candidate)); + expect(listener).toBeDefined(); + (listener as ((message: unknown) => void) | undefined)?.({ + type: 'channel_delivery', + id: 'ipc-delivery-1', + expiresAt: Date.now() + 1000, + request: deliveryRequest, + }); + + expect(deliverProactive).toHaveBeenCalledWith( + { channelName: 'telegram', ...deliveryRequest.target }, + deliveryRequest.text, + ); + expect(send).not.toHaveBeenCalledWith( + expect.objectContaining({ type: 'channel_delivery_result' }), + ); + resolveDelivery(); + await vi.waitFor(() => { + expect(send).toHaveBeenCalledWith({ + type: 'channel_delivery_result', + id: 'ipc-delivery-1', + ok: true, + }); + }); + expect(mockBridgePrompt).not.toHaveBeenCalled(); + + process.emit('SIGTERM', 'SIGTERM'); + await handler; + expect(exit).toHaveBeenCalledWith(0); + } finally { + restoreSend(); + } + }); + + it('rejects channel delivery IPC for channels that are not running', async () => { + const exit = mockProcessExitNoThrow(); + const send = vi.fn(); + const restoreSend = stubProcessSend(send as NodeJS.Process['send']); + vi.stubEnv('QWEN_CHANNEL_DAEMON_WORKER', 'worker-token'); + vi.stubEnv('QWEN_DAEMON_URL', 'http://127.0.0.1:4170'); + vi.stubEnv('QWEN_DAEMON_WORKSPACE', '/workspace'); + const existingMessageListeners = process.listeners('message'); + + try { + const handler = daemonWorkerCommand.handler({ + channel: ['telegram'], + _: [], + $0: 'qwen', + }); + await vi.waitFor(() => { + expect(send).toHaveBeenCalledWith( + expect.objectContaining({ type: 'ready' }), + ); + }); + send.mockClear(); + + const listener = process + .listeners('message') + .find((candidate) => !existingMessageListeners.includes(candidate)); + (listener as ((message: unknown) => void) | undefined)?.({ + type: 'channel_delivery', + id: 'ipc-delivery-1', + expiresAt: Date.now() + 1000, + request: { + ...deliveryRequest, + channelName: 'missing', + }, + }); + + await vi.waitFor(() => { + expect(send).toHaveBeenCalledWith({ + type: 'channel_delivery_result', + id: 'ipc-delivery-1', + ok: false, + code: 'channel_worker_unavailable', + error: 'Channel "missing" is not running.', + }); + }); + + process.emit('SIGTERM', 'SIGTERM'); + await handler; + expect(exit).toHaveBeenCalledWith(0); + } finally { + restoreSend(); + } + }); + + it('redacts adapter diagnostics and classifies an invalid recipient as permanent', async () => { + const exit = mockProcessExitNoThrow(); + const send = vi.fn(); + const restoreSend = stubProcessSend(send as NodeJS.Process['send']); + const secret = 'adapter-secret-value'; + mockCreateChannel.mockResolvedValueOnce({ + connect: vi.fn().mockResolvedValue(undefined), + disconnect: vi.fn(), + name: 'telegram', + validateWebhookTask: vi.fn(), + deliverProactive: vi + .fn() + .mockRejectedValue( + new MockChannelProactiveDeliveryError( + 'permanent', + `DingTalk proactive send failed: invalid direct recipient ${secret}`, + ), + ), + }); + vi.stubEnv('QWEN_CHANNEL_DAEMON_WORKER', 'worker-token'); + vi.stubEnv('QWEN_DAEMON_URL', 'http://127.0.0.1:4170'); + vi.stubEnv('QWEN_DAEMON_WORKSPACE', '/workspace'); + vi.stubEnv('QWEN_TEST_API_KEY', secret); + const existingMessageListeners = process.listeners('message'); + + try { + const handler = daemonWorkerCommand.handler({ + channel: ['telegram'], + _: [], + $0: 'qwen', + }); + await vi.waitFor(() => { + expect(send).toHaveBeenCalledWith( + expect.objectContaining({ type: 'ready' }), + ); + }); + send.mockClear(); + + const listener = process + .listeners('message') + .find((candidate) => !existingMessageListeners.includes(candidate)); + (listener as ((message: unknown) => void) | undefined)?.({ + type: 'channel_delivery', + id: 'ipc-delivery-invalid-recipient', + expiresAt: Date.now() + 1000, + request: { + ...deliveryRequest, + channelName: 'telegram', + }, + }); + + await vi.waitFor(() => { + expect(send).toHaveBeenCalledWith({ + type: 'channel_delivery_result', + id: 'ipc-delivery-invalid-recipient', + ok: false, + code: 'channel_delivery_invalid', + error: + 'DingTalk proactive send failed: invalid direct recipient ', + }); + }); + + process.emit('SIGTERM', 'SIGTERM'); + await handler; + expect(exit).toHaveBeenCalledWith(0); + } finally { + restoreSend(); + } + }); + + it('classifies a permanent delivery error from another module instance', async () => { + const foreignError = Object.assign(new Error('recipient is invalid'), { + code: 'channel_proactive_delivery_error', + disposition: 'permanent', + }); + const exit = mockProcessExitNoThrow(); + const send = vi.fn(); + const restoreSend = stubProcessSend(send as NodeJS.Process['send']); + mockCreateChannel.mockResolvedValueOnce({ + connect: vi.fn().mockResolvedValue(undefined), + disconnect: vi.fn(), + name: 'telegram', + validateWebhookTask: vi.fn(), + deliverProactive: vi.fn().mockRejectedValue(foreignError), + }); + vi.stubEnv('QWEN_CHANNEL_DAEMON_WORKER', 'worker-token'); + vi.stubEnv('QWEN_DAEMON_URL', 'http://127.0.0.1:4170'); + vi.stubEnv('QWEN_DAEMON_WORKSPACE', '/workspace'); + const existingMessageListeners = process.listeners('message'); + + try { + const handler = daemonWorkerCommand.handler({ + channel: ['telegram'], + _: [], + $0: 'qwen', + }); + await vi.waitFor(() => { + expect(send).toHaveBeenCalledWith( + expect.objectContaining({ type: 'ready' }), + ); + }); + send.mockClear(); + + const listener = process + .listeners('message') + .find((candidate) => !existingMessageListeners.includes(candidate)); + (listener as ((message: unknown) => void) | undefined)?.({ + type: 'channel_delivery', + id: 'ipc-delivery-foreign', + expiresAt: Date.now() + 1000, + request: { + ...deliveryRequest, + channelName: 'telegram', + }, + }); + + await vi.waitFor(() => { + expect(send).toHaveBeenCalledWith({ + type: 'channel_delivery_result', + id: 'ipc-delivery-foreign', + ok: false, + code: 'channel_delivery_invalid', + error: 'recipient is invalid', + }); + }); + + process.emit('SIGTERM', 'SIGTERM'); + await handler; + expect(exit).toHaveBeenCalledWith(0); + } finally { + restoreSend(); + } + }); + it('rejects webhook IPC messages for channels that are not running', async () => { const exit = mockProcessExitNoThrow(); const send = vi.fn(); diff --git a/packages/cli/src/commands/channel/daemon-worker.ts b/packages/cli/src/commands/channel/daemon-worker.ts index 994862ce2aa..48a3006093a 100644 --- a/packages/cli/src/commands/channel/daemon-worker.ts +++ b/packages/cli/src/commands/channel/daemon-worker.ts @@ -12,6 +12,7 @@ import { import { loadSettings } from '../../config/settings.js'; import { DaemonChannelBridge, + isChannelProactiveDeliveryError, sanitizeLogText, SessionRouter, } from '@qwen-code/channel-base'; @@ -38,6 +39,11 @@ import { isChannelWebhookTaskMessage, type ChannelWebhookEnqueueErrorCode, } from '../../serve/channel-webhook-ipc.js'; +import { + isChannelDeliveryMessage, + type ChannelDeliveryErrorCode, + type ChannelDeliveryRequest, +} from '../../serve/channel-delivery-ipc.js'; import { sanitizeWorkerDiagnostic } from '../../serve/channel-worker-diagnostics.js'; import { isChannelStartupReportAckMessage, @@ -65,9 +71,11 @@ import { } from './runtime.js'; import { BridgeChannelMemoryIntentClassifier } from './memory-intent-classifier.js'; import { ObservedChannelContactStore } from './observed-contact-store.js'; +import { createDurableChannelLoopController } from './durable-loop-controller.js'; const SESSION_SHELL_COMMAND_FEATURE = 'session_shell_command'; const MAX_ACTIVE_WEBHOOK_TASKS = 16; +const MAX_ACTIVE_CHANNEL_DELIVERIES = 16; const WEBHOOK_TASK_SHUTDOWN_DRAIN_MS = 10_000; interface DaemonCapabilitiesLike { @@ -131,6 +139,7 @@ interface ChannelDaemonWorkerReady { export interface ChannelDaemonWorkerHandle { readonly channels: string[]; + deliverChannelMessage(request: ChannelDeliveryRequest): Promise; validateWebhookTask(task: ChannelWebhookTask): void; runWebhookTask( task: ChannelWebhookTask, @@ -408,6 +417,12 @@ export async function runChannelDaemonWorker( const settings = loadSettings(daemonWorkspace, { skipLoadEnvironment: true, }); + const cronEnabled = + process.env['QWEN_CODE_DISABLE_CRON'] !== '1' && + settings.merged.experimental?.cron !== false; + const loopController = cronEnabled + ? createDurableChannelLoopController({ workspaceCwd: daemonWorkspace }) + : undefined; throwIfStartupAborted(startupSignal); const proxy = resolveProxyUrl( undefined, @@ -492,6 +507,7 @@ export async function runChannelDaemonWorker( createChannel(name, config, bridgeFacade, { ...(proxy ? { proxy } : {}), router: createdRouter, + ...(loopController ? { loopController } : {}), channelMemory: { readChannelMemory, getChannelMemoryRevision, @@ -597,6 +613,16 @@ export async function runChannelDaemonWorker( return { channels: connected, + async deliverChannelMessage(request: ChannelDeliveryRequest) { + const channel = channels.get(request.channelName); + if (!channel || !connected.includes(request.channelName)) { + throw new Error(`Channel "${request.channelName}" is not running.`); + } + await channel.deliverProactive( + { channelName: request.channelName, ...request.target }, + request.text, + ); + }, validateWebhookTask(task: ChannelWebhookTask): void { const channel = channels.get(task.channelName); if (!channel || !connected.includes(task.channelName)) { @@ -815,8 +841,72 @@ export const daemonWorkerCommand: CommandModule = { // Supervisor will time out if the IPC channel is already closed. } }; + const sendChannelDeliveryResult = ( + id: string, + result: + | { ok: true } + | { + ok: false; + code: ChannelDeliveryErrorCode; + error: string; + }, + ) => { + try { + process.send?.({ + type: 'channel_delivery_result', + id, + ...result, + }); + } catch { + // Supervisor will time out if the IPC channel is already closed. + } + }; const activeWebhookTasks = new Map>(); + const activeChannelDeliveries = new Map>(); const onMessage = (message: unknown) => { + if (isChannelDeliveryMessage(message)) { + if (message.expiresAt <= Date.now()) { + sendChannelDeliveryResult(message.id, { + ok: false, + code: 'channel_delivery_timeout', + error: 'Channel delivery IPC timed out.', + }); + return; + } + if (activeChannelDeliveries.size >= MAX_ACTIVE_CHANNEL_DELIVERIES) { + sendChannelDeliveryResult(message.id, { + ok: false, + code: 'channel_delivery_queue_full', + error: 'Channel delivery queue is full.', + }); + return; + } + const deliveryId = message.id; + const deliveryPromise = handle + .deliverChannelMessage(message.request) + .then(() => { + sendChannelDeliveryResult(deliveryId, { ok: true }); + }) + .catch((err: unknown) => { + sendChannelDeliveryResult(deliveryId, { + ok: false, + code: classifyChannelDeliveryError(err), + error: sanitizeWorkerDiagnostic( + err instanceof Error ? err.message : String(err), + 512, + { + ...(daemonToken ? { daemonToken } : {}), + workerEnv: process.env, + }, + ), + }); + }) + .finally(() => { + activeChannelDeliveries.delete(deliveryId); + }); + activeChannelDeliveries.set(deliveryId, deliveryPromise); + return; + } if (!isChannelWebhookTaskMessage(message)) return; if (message.expiresAt <= Date.now()) { sendWebhookTaskResult(message.id, { @@ -904,6 +994,21 @@ export const daemonWorkerCommand: CommandModule = { clearHeartbeat(); process.removeListener('message', onMessage); try { + if (activeChannelDeliveries.size > 0) { + writeStderrLine( + `[Channel] shutdown: draining ${activeChannelDeliveries.size} channel delivery task(s)...`, + ); + await Promise.race([ + Promise.allSettled(activeChannelDeliveries.values()), + new Promise((resolve) => { + const timer = setTimeout( + resolve, + WEBHOOK_TASK_SHUTDOWN_DRAIN_MS, + ); + timer.unref(); + }), + ]); + } if (activeWebhookTasks.size > 0) { writeStderrLine( `[Channel] shutdown: draining ${activeWebhookTasks.size} webhook task(s)...`, @@ -990,3 +1095,26 @@ function classifyWebhookTaskValidationError( } return 'channel_webhook_enqueue_failed'; } + +function classifyChannelDeliveryError( + error: unknown, +): ChannelDeliveryErrorCode { + if ( + isChannelProactiveDeliveryError(error) && + error.disposition === 'permanent' + ) { + return 'channel_delivery_invalid'; + } + const message = error instanceof Error ? error.message : String(error); + if (/^Channel ".+" is not running\.$/u.test(message)) { + return 'channel_worker_unavailable'; + } + if ( + message.includes('does not own delivery target') || + message.includes('does not support proactive delivery') || + message.includes('does not support this proactive target') + ) { + return 'channel_delivery_invalid'; + } + return 'channel_delivery_failed'; +} diff --git a/packages/cli/src/commands/channel/durable-loop-controller.test.ts b/packages/cli/src/commands/channel/durable-loop-controller.test.ts new file mode 100644 index 00000000000..2568fa504ef --- /dev/null +++ b/packages/cli/src/commands/channel/durable-loop-controller.test.ts @@ -0,0 +1,168 @@ +import * as fs from 'node:fs/promises'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { + readCronTasks, + Storage, + updateCronTasks, +} from '@qwen-code/qwen-code-core'; +import type { ChannelLoopInput, SessionTarget } from '@qwen-code/channel-base'; +import { createDurableChannelLoopController } from './durable-loop-controller.js'; + +describe('createDurableChannelLoopController', () => { + let scratch: string; + const workspaceCwd = '/workspace/project'; + const target: SessionTarget = { + channelName: 'dingtalk', + senderId: 'user-1', + chatId: 'group-42', + isGroup: true, + }; + const input: ChannelLoopInput = { + channelName: 'dingtalk', + target, + cwd: workspaceCwd, + cron: '0 9 * * *', + prompt: 'post summary', + label: 'Daily summary', + recurring: true, + createdBy: 'Alice', + }; + + beforeEach(async () => { + scratch = await fs.mkdtemp(path.join(os.tmpdir(), 'durable-channel-loop-')); + Storage.setRuntimeBaseDir(scratch); + }); + + afterEach(async () => { + Storage.setRuntimeBaseDir(null); + await fs.rm(scratch, { recursive: true, force: true }); + }); + + it('persists a shared-session durable task with channel delivery', async () => { + const controller = createDurableChannelLoopController({ + workspaceCwd, + now: () => new Date('2026-07-18T01:02:03.000Z'), + idFactory: () => 'loop0001', + }); + + const loop = await controller.createForSession!(input, 10, 'session-1'); + + expect(loop).toMatchObject({ + id: 'loop0001', + target, + createdAt: '2026-07-18T01:02:03.000Z', + enabled: true, + }); + expect(await readCronTasks(workspaceCwd)).toEqual([ + expect.objectContaining({ + id: 'loop0001', + cron: '0 9 * * *', + prompt: 'post summary', + name: 'Daily summary', + sessionId: 'session-1', + sessionOwnership: 'shared', + delivery: { + kind: 'channel', + channelName: 'dingtalk', + target: { type: 'chat', id: 'group-42' }, + }, + channelLoop: { + senderId: 'user-1', + createdBy: 'Alice', + label: 'Daily summary', + }, + }), + ]); + }); + + it('enforces the per-target enabled quota atomically', async () => { + let nextId = 0; + const controller = createDurableChannelLoopController({ + workspaceCwd, + idFactory: () => `loop000${++nextId}`, + }); + + const results = await Promise.all([ + controller.createForSession!(input, 1, 'session-1'), + controller.createForSession!(input, 1, 'session-1'), + ]); + + expect(results.filter(Boolean)).toHaveLength(1); + expect(await readCronTasks(workspaceCwd)).toHaveLength(1); + }); + + it('persists a direct loop as a user target', async () => { + const controller = createDurableChannelLoopController({ + workspaceCwd, + idFactory: () => 'loop0001', + }); + const directTarget: SessionTarget = { + channelName: 'dingtalk', + senderId: 'user-1', + chatId: 'staff-1', + isGroup: false, + }; + + await controller.createForSession!( + { ...input, target: directTarget }, + 10, + 'session-1', + ); + + expect(await readCronTasks(workspaceCwd)).toEqual([ + expect.objectContaining({ + delivery: { + kind: 'channel', + channelName: 'dingtalk', + target: { type: 'user', id: 'staff-1' }, + }, + }), + ]); + }); + + it('rejects threaded daemon loops instead of dropping the topic', async () => { + const controller = createDurableChannelLoopController({ workspaceCwd }); + + await expect( + controller.createForSession!( + { ...input, target: { ...target, threadId: 'thread-7' } }, + 10, + 'session-1', + ), + ).rejects.toThrow(/threaded targets/); + }); + + it('lists and disables only loops owned by the exact channel target', async () => { + const controller = createDurableChannelLoopController({ + workspaceCwd, + idFactory: () => 'loop0001', + }); + await controller.createForSession!(input, 10, 'session-1'); + await updateCronTasks(workspaceCwd, (tasks) => [ + ...tasks, + { + ...tasks[0]!, + id: 'other001', + channelLoop: { ...tasks[0]!.channelLoop!, senderId: 'user-2' }, + }, + ]); + + const loops = await controller.listForTarget('dingtalk', target); + expect(loops.map((loop) => loop.id)).toEqual(['loop0001']); + expect(await controller.disable('loop0001')).toBe(true); + expect( + (await controller.listForTarget('dingtalk', target))[0]?.enabled, + ).toBe(false); + expect(await controller.disable('missing')).toBe(false); + }); + + it('validates cron expressions and rejects unbound creation', async () => { + const controller = createDurableChannelLoopController({ workspaceCwd }); + + expect(() => controller.validateCron('0 9 * * *')).not.toThrow(); + expect(() => controller.validateCron('not cron')).toThrow(); + await expect(controller.create(input)).rejects.toThrow(/session-bound/); + }); +}); diff --git a/packages/cli/src/commands/channel/durable-loop-controller.ts b/packages/cli/src/commands/channel/durable-loop-controller.ts new file mode 100644 index 00000000000..d0fe4a8e2d3 --- /dev/null +++ b/packages/cli/src/commands/channel/durable-loop-controller.ts @@ -0,0 +1,174 @@ +import type { + ChannelLoop, + ChannelLoopController, + SessionTarget, +} from '@qwen-code/channel-base'; +import { + generateCronTaskId, + MAX_JOBS, + nextDurableFireMs, + nextFireTime, + parseCron, + readCronTasks, + updateCronTasks, + type DurableCronTask, +} from '@qwen-code/qwen-code-core'; + +export interface DurableChannelLoopControllerOptions { + workspaceCwd: string; + now?: () => Date; + idFactory?: () => string; +} + +function sameTarget( + task: DurableCronTask, + channelName: string, + target: SessionTarget, +): boolean { + const deliveryTarget = task.delivery?.target; + return ( + task.delivery?.kind === 'channel' && + task.channelLoop !== undefined && + target.threadId === undefined && + task.delivery.channelName === channelName && + deliveryTarget?.type === (target.isGroup === true ? 'chat' : 'user') && + deliveryTarget.id === target.chatId && + task.channelLoop.senderId === target.senderId + ); +} + +function taskToLoop(task: DurableCronTask): ChannelLoop { + const delivery = task.delivery!; + const target = delivery.target; + const lastRun = task.runs?.at(-1); + return { + id: task.id, + channelName: delivery.channelName, + target: { + channelName: delivery.channelName, + senderId: task.channelLoop!.senderId, + chatId: target.id, + isGroup: target.type === 'chat', + }, + cwd: '', + cron: task.cron, + prompt: task.prompt, + ...(task.channelLoop!.label !== undefined + ? { label: task.channelLoop!.label } + : {}), + recurring: task.recurring, + enabled: task.enabled !== false, + createdBy: task.channelLoop!.createdBy, + createdAt: new Date(task.createdAt).toISOString(), + ...(lastRun ? { lastFiredAt: new Date(lastRun.at).toISOString() } : {}), + consecutiveFailures: 0, + runCount: task.runs?.length ?? 0, + }; +} + +export function createDurableChannelLoopController( + options: DurableChannelLoopControllerOptions, +): ChannelLoopController { + const now = options.now ?? (() => new Date()); + const idFactory = options.idFactory ?? generateCronTaskId; + + return { + async create() { + throw new Error('Durable channel loops require session-bound creation.'); + }, + + async createForSession(input, maxEnabledLoops, sessionId) { + if (input.target.threadId !== undefined) { + throw new Error( + 'Durable channel loops do not support threaded targets.', + ); + } + let created: DurableCronTask | undefined; + await updateCronTasks(options.workspaceCwd, (tasks) => { + const enabledForTarget = tasks.filter( + (task) => + task.enabled !== false && + sameTarget(task, input.channelName, input.target), + ).length; + if (enabledForTarget >= maxEnabledLoops) return tasks; + if (tasks.length >= MAX_JOBS) { + throw new Error( + `Maximum number of cron jobs (${MAX_JOBS}) reached. Delete some jobs first.`, + ); + } + + const existingIds = new Set(tasks.map((task) => task.id)); + let id = idFactory(); + while (existingIds.has(id)) id = idFactory(); + const createdAt = now().getTime(); + created = { + id, + cron: input.cron, + prompt: input.prompt, + recurring: input.recurring, + createdAt, + lastFiredAt: createdAt - (createdAt % 60_000), + enabled: true, + ...(input.label !== undefined ? { name: input.label } : {}), + sessionId, + sessionOwnership: 'shared', + delivery: { + kind: 'channel', + channelName: input.channelName, + target: { + type: input.target.isGroup === true ? 'chat' : 'user', + id: input.target.chatId, + }, + }, + channelLoop: { + senderId: input.target.senderId, + createdBy: input.createdBy, + ...(input.label !== undefined ? { label: input.label } : {}), + }, + }; + return [...tasks, created]; + }); + return created ? { ...taskToLoop(created), cwd: input.cwd } : undefined; + }, + + async listForTarget(channelName, target) { + return (await readCronTasks(options.workspaceCwd)) + .filter((task) => sameTarget(task, channelName, target)) + .map((task) => ({ ...taskToLoop(task), cwd: options.workspaceCwd })); + }, + + async disable(id) { + let found = false; + await updateCronTasks(options.workspaceCwd, (tasks) => { + const next = tasks.map((task) => { + if (task.id !== id || !task.channelLoop || !task.delivery) + return task; + found = true; + return { ...task, enabled: false }; + }); + return found ? next : tasks; + }); + return found; + }, + + validateCron(cron) { + parseCron(cron); + nextFireTime(cron, new Date()); + }, + + nextFireTime(job) { + const createdAt = new Date(job.createdAt).getTime(); + const fireAt = nextDurableFireMs({ + id: job.id, + cron: job.cron, + recurring: job.recurring, + createdAt, + lastFiredAt: job.lastFiredAt + ? new Date(job.lastFiredAt).getTime() + : createdAt - (createdAt % 60_000), + }); + if (fireAt === null) throw new Error('Cron expression has no next run.'); + return new Date(fireAt); + }, + }; +} diff --git a/packages/cli/src/serve/capabilities.ts b/packages/cli/src/serve/capabilities.ts index 45ca8d01b44..950b4edbb2f 100644 --- a/packages/cli/src/serve/capabilities.ts +++ b/packages/cli/src/serve/capabilities.ts @@ -288,6 +288,10 @@ export const SERVE_CAPABILITY_REGISTRY = { channel_control: { since: 'v1' }, // Read-only workspace graph of recently observed channel contacts. workspace_channel_observed_contacts: { since: 'v1' }, + // Durable scheduled tasks can deliver their completed result through a + // daemon-managed Channel worker. Conditional: the post-run delivery pipeline + // must be wired. + scheduled_task_channel_delivery: { since: 'v1' }, // Multi-workspace session routing. Advertised only when one daemon hosts // more than one registered workspace runtime. multi_workspace_sessions: { since: 'v1' }, @@ -393,6 +397,7 @@ export interface AdvertiseFeatureToggles { */ channelReloadAvailable?: boolean; channelControlAvailable?: boolean; + scheduledTaskChannelDeliveryAvailable?: boolean; /** * Whether the daemon will accept client-hosted MCP servers over the WS * (`client_mcp_over_ws`, issue #5626). @@ -493,6 +498,10 @@ export const CONDITIONAL_SERVE_FEATURES: ReadonlyMap< ['workspace_reload', (toggles) => toggles.reloadAvailable === true], ['channel_reload', (toggles) => toggles.channelReloadAvailable === true], ['channel_control', (toggles) => toggles.channelControlAvailable === true], + [ + 'scheduled_task_channel_delivery', + (toggles) => toggles.scheduledTaskChannelDeliveryAvailable === true, + ], [ 'multi_workspace_sessions', (toggles) => toggles.multiWorkspaceSessionsEnabled === true, diff --git a/packages/cli/src/serve/channel-delivery-ipc.test.ts b/packages/cli/src/serve/channel-delivery-ipc.test.ts new file mode 100644 index 00000000000..7f24d7047a8 --- /dev/null +++ b/packages/cli/src/serve/channel-delivery-ipc.test.ts @@ -0,0 +1,189 @@ +import { describe, expect, it } from 'vitest'; +import { + CHANNEL_DELIVERY_IPC_TIMEOUT_MS, + ChannelDeliveryError, + createChannelDeliveryMessage, + isChannelDeliveryError, + isChannelDeliveryErrorCode, + isChannelDeliveryMessage, + isChannelDeliveryResultMessage, +} from './channel-delivery-ipc.js'; + +const request = { + deliveryId: 'delivery-1', + channelName: 'dingtalk-main', + target: { + type: 'chat' as const, + id: 'group-1', + }, + text: 'inspection result', +}; + +describe('channel delivery IPC', () => { + it('creates a bounded request message', () => { + const before = Date.now(); + const message = createChannelDeliveryMessage(request); + + expect(message).toMatchObject({ + type: 'channel_delivery', + request, + }); + expect(message.id).toMatch(/^[0-9a-f-]{36}$/u); + expect(message.expiresAt).toBeGreaterThanOrEqual( + before + CHANNEL_DELIVERY_IPC_TIMEOUT_MS, + ); + expect(message.expiresAt).toBeLessThanOrEqual( + Date.now() + CHANNEL_DELIVERY_IPC_TIMEOUT_MS, + ); + expect(isChannelDeliveryMessage(message)).toBe(true); + }); + + it.each([ + null, + {}, + { type: 'other', id: 'ipc-1', expiresAt: 1, request }, + { type: 'channel_delivery', id: '', expiresAt: 1, request }, + { + type: 'channel_delivery', + id: 'ipc-1', + expiresAt: Number.POSITIVE_INFINITY, + request, + }, + { + type: 'channel_delivery', + id: 'ipc-1', + expiresAt: 1, + request: { ...request, deliveryId: '' }, + }, + { + type: 'channel_delivery', + id: 'ipc-1', + expiresAt: 1, + request: { ...request, text: ' ' }, + }, + { + type: 'channel_delivery', + id: 'ipc-1', + expiresAt: 1, + request: { + ...request, + target: { ...request.target, id: '' }, + }, + }, + { + type: 'channel_delivery', + id: 'ipc-1', + expiresAt: 1, + request: { + ...request, + target: { ...request.target, type: 'topic' }, + }, + }, + { + type: 'channel_delivery', + id: 'ipc-1', + expiresAt: 1, + request: { + ...request, + target: { ...request.target, threadId: 'thread-1' }, + }, + }, + { + type: 'channel_delivery', + id: 'ipc-1', + expiresAt: 1, + request: { + ...request, + target: { ...request.target, topicId: 'topic-1' }, + }, + }, + { + type: 'channel_delivery', + id: 'ipc-1', + expiresAt: 1, + request: { + ...request, + target: { ...request.target, chatId: 'group-1' }, + }, + }, + { + type: 'channel_delivery', + id: 'ipc-1', + expiresAt: 1, + request: { + ...request, + target: { ...request.target, isGroup: true }, + }, + }, + ])('rejects malformed request messages %#', (message) => { + expect(isChannelDeliveryMessage(message)).toBe(false); + }); + + it('accepts success and typed failure result messages', () => { + expect( + isChannelDeliveryResultMessage({ + type: 'channel_delivery_result', + id: 'ipc-1', + ok: true, + }), + ).toBe(true); + expect( + isChannelDeliveryResultMessage({ + type: 'channel_delivery_result', + id: 'ipc-1', + ok: false, + code: 'channel_delivery_failed', + error: 'Platform send failed.', + }), + ).toBe(true); + }); + + it.each([ + { + type: 'channel_delivery_result', + id: '', + ok: true, + }, + { + type: 'channel_delivery_result', + id: 'ipc-1', + ok: false, + }, + { + type: 'channel_delivery_result', + id: 'ipc-1', + ok: false, + code: 'unknown', + error: 'nope', + }, + { + type: 'channel_delivery_result', + id: 'ipc-1', + ok: false, + code: 'channel_delivery_failed', + error: 42, + }, + ])('rejects malformed result messages %#', (message) => { + expect(isChannelDeliveryResultMessage(message)).toBe(false); + }); + + it('recognizes only public delivery error codes and errors', () => { + expect(isChannelDeliveryErrorCode('channel_delivery_timeout')).toBe(true); + expect(isChannelDeliveryErrorCode('unknown')).toBe(false); + + const error = new ChannelDeliveryError( + 'channel_delivery_failed', + 'Platform send failed.', + ); + expect(isChannelDeliveryError(error)).toBe(true); + expect( + isChannelDeliveryError({ + code: 'channel_worker_unavailable', + message: 'Worker stopped.', + }), + ).toBe(true); + expect(isChannelDeliveryError({ code: 'unknown', message: 'nope' })).toBe( + false, + ); + }); +}); diff --git a/packages/cli/src/serve/channel-delivery-ipc.ts b/packages/cli/src/serve/channel-delivery-ipc.ts new file mode 100644 index 00000000000..69980079c15 --- /dev/null +++ b/packages/cli/src/serve/channel-delivery-ipc.ts @@ -0,0 +1,151 @@ +import { randomUUID } from 'node:crypto'; + +export type ChannelDeliveryErrorCode = + | 'channel_worker_unavailable' + | 'channel_delivery_timeout' + | 'channel_delivery_invalid' + | 'channel_delivery_queue_full' + | 'channel_delivery_failed'; + +const CHANNEL_DELIVERY_ERROR_CODES: ReadonlySet = new Set([ + 'channel_worker_unavailable', + 'channel_delivery_timeout', + 'channel_delivery_invalid', + 'channel_delivery_queue_full', + 'channel_delivery_failed', +]); + +export class ChannelDeliveryError extends Error { + constructor( + readonly code: ChannelDeliveryErrorCode, + message: string, + ) { + super(message); + this.name = 'ChannelDeliveryError'; + } +} + +export function isChannelDeliveryErrorCode( + value: unknown, +): value is ChannelDeliveryErrorCode { + return typeof value === 'string' && CHANNEL_DELIVERY_ERROR_CODES.has(value); +} + +export function isChannelDeliveryError( + value: unknown, +): value is ChannelDeliveryError { + return ( + value instanceof ChannelDeliveryError || + (typeof value === 'object' && + value !== null && + isChannelDeliveryErrorCode((value as { code?: unknown }).code) && + typeof (value as { message?: unknown }).message === 'string') + ); +} + +export interface ChannelDeliveryRequest { + deliveryId: string; + channelName: string; + target: { type: 'user'; id: string } | { type: 'chat'; id: string }; + text: string; +} + +export interface ChannelDeliveryRequestMessage { + type: 'channel_delivery'; + id: string; + expiresAt: number; + request: ChannelDeliveryRequest; +} + +export type ChannelDeliveryResultMessage = + | { + type: 'channel_delivery_result'; + id: string; + ok: true; + } + | { + type: 'channel_delivery_result'; + id: string; + ok: false; + code: ChannelDeliveryErrorCode; + error: string; + }; + +export interface ChannelDeliveryAccepted { + delivered: true; +} + +export const CHANNEL_DELIVERY_IPC_TIMEOUT_MS = 30_000; + +export function createChannelDeliveryMessage( + request: ChannelDeliveryRequest, +): ChannelDeliveryRequestMessage { + return { + type: 'channel_delivery', + id: randomUUID(), + expiresAt: Date.now() + CHANNEL_DELIVERY_IPC_TIMEOUT_MS, + request, + }; +} + +function isNonEmptyString(value: unknown): value is string { + return typeof value === 'string' && value.trim().length > 0; +} + +function isChannelDeliveryTarget( + value: unknown, +): value is ChannelDeliveryRequest['target'] { + if (typeof value !== 'object' || value === null) return false; + const target = value as Record; + return ( + (target['type'] === 'user' || target['type'] === 'chat') && + isNonEmptyString(target['id']) && + Object.keys(target).every((key) => key === 'type' || key === 'id') + ); +} + +function isChannelDeliveryRequest( + value: unknown, +): value is ChannelDeliveryRequest { + if (typeof value !== 'object' || value === null) return false; + const request = value as Record; + return ( + isNonEmptyString(request['deliveryId']) && + isNonEmptyString(request['channelName']) && + isChannelDeliveryTarget(request['target']) && + isNonEmptyString(request['text']) + ); +} + +export function isChannelDeliveryMessage( + value: unknown, +): value is ChannelDeliveryRequestMessage { + if (typeof value !== 'object' || value === null) return false; + const message = value as Record; + return ( + message['type'] === 'channel_delivery' && + isNonEmptyString(message['id']) && + typeof message['expiresAt'] === 'number' && + Number.isFinite(message['expiresAt']) && + isChannelDeliveryRequest(message['request']) + ); +} + +export function isChannelDeliveryResultMessage( + value: unknown, +): value is ChannelDeliveryResultMessage { + if (typeof value !== 'object' || value === null) return false; + const message = value as Record; + if ( + message['type'] !== 'channel_delivery_result' || + !isNonEmptyString(message['id']) || + typeof message['ok'] !== 'boolean' + ) { + return false; + } + if (message['ok']) return true; + return ( + isChannelDeliveryErrorCode(message['code']) && + typeof message['error'] === 'string' + ); +} diff --git a/packages/cli/src/serve/channel-worker-group.test.ts b/packages/cli/src/serve/channel-worker-group.test.ts index fd60563c743..95ae2eb4355 100644 --- a/packages/cli/src/serve/channel-worker-group.test.ts +++ b/packages/cli/src/serve/channel-worker-group.test.ts @@ -21,6 +21,7 @@ import type { WorkspaceRegistry, WorkspaceRuntime, } from './workspace-registry.js'; +import type { ChannelDeliveryRequest } from './channel-delivery-ipc.js'; const PRIMARY = '/ws/primary'; const SECONDARY = '/ws/secondary'; @@ -88,6 +89,7 @@ interface RecordedSupervisor { stop: ReturnType; restart: ReturnType; killAllSync: ReturnType; + deliverChannelMessage: ReturnType; enqueueWebhookTask: ReturnType; }; } @@ -103,6 +105,7 @@ function makeCreateSupervisor( restart: vi.fn(async () => snapshotFor(opts.workspace)), killAllSync: vi.fn(), snapshot: () => snapshotFor(opts.workspace), + deliverChannelMessage: vi.fn().mockRejectedValue(new Error('unused')), enqueueWebhookTask: vi.fn().mockRejectedValue(new Error('unused')), }; recorded.push({ opts, supervisor }); @@ -126,7 +129,141 @@ const webhookTask: ChannelWebhookTask = { payload: { runId: 123 }, }; +const deliveryRequest: ChannelDeliveryRequest = { + deliveryId: 'delivery-1', + channelName: 'b', + target: { type: 'chat', id: 'group-1' }, + text: 'inspection result', +}; + describe('createChannelWorkerGroup', () => { + it('routes channel delivery to the supervisor that owns the channel', async () => { + const registry = fakeRegistry([ + fakeRuntime(PRIMARY, true), + fakeRuntime(SECONDARY, false), + ]); + const { createSupervisor, recorded } = makeCreateSupervisor(() => + snapshot({}), + ); + const group = createChannelWorkerGroup({ + groups: [ + { workspaceCwd: PRIMARY, selection: { mode: 'names', names: ['a'] } }, + { workspaceCwd: SECONDARY, selection: { mode: 'names', names: ['b'] } }, + ], + registry, + createSupervisor, + shared, + }); + recorded[1]!.supervisor.deliverChannelMessage.mockResolvedValueOnce({ + delivered: true, + }); + + await expect(group.deliverChannelMessage(deliveryRequest)).resolves.toEqual( + { delivered: true }, + ); + expect( + recorded[0]!.supervisor.deliverChannelMessage, + ).not.toHaveBeenCalled(); + expect(recorded[1]!.supervisor.deliverChannelMessage).toHaveBeenCalledWith( + deliveryRequest, + ); + }); + + it('uses the explicit workspace when multiple workers own the same channel', async () => { + const registry = fakeRegistry([ + fakeRuntime(PRIMARY, true), + fakeRuntime(SECONDARY, false), + ]); + const { createSupervisor, recorded } = makeCreateSupervisor(() => + snapshot({}), + ); + const group = createChannelWorkerGroup({ + groups: [ + { + workspaceCwd: PRIMARY, + selection: { mode: 'names', names: ['b'] }, + }, + { + workspaceCwd: SECONDARY, + selection: { mode: 'names', names: ['b'] }, + }, + ], + registry, + createSupervisor, + shared, + }); + recorded[1]!.supervisor.deliverChannelMessage.mockResolvedValueOnce({ + delivered: true, + }); + + await group.deliverChannelMessage(deliveryRequest, SECONDARY); + + expect( + recorded[0]!.supervisor.deliverChannelMessage, + ).not.toHaveBeenCalled(); + expect(recorded[1]!.supervisor.deliverChannelMessage).toHaveBeenCalledWith( + deliveryRequest, + ); + }); + + it('does not fall back to another workspace for scheduled delivery', async () => { + const registry = fakeRegistry([ + fakeRuntime(PRIMARY, true), + fakeRuntime(SECONDARY, false), + ]); + const { createSupervisor, recorded } = makeCreateSupervisor(() => + snapshot({}), + ); + const group = createChannelWorkerGroup({ + groups: [ + { workspaceCwd: PRIMARY, selection: { mode: 'names', names: ['b'] } }, + { workspaceCwd: SECONDARY, selection: { mode: 'names', names: ['a'] } }, + ], + registry, + createSupervisor, + shared, + }); + + await expect( + group.deliverChannelMessage(deliveryRequest, SECONDARY), + ).rejects.toMatchObject({ code: 'channel_worker_unavailable' }); + expect( + recorded[0]!.supervisor.deliverChannelMessage, + ).not.toHaveBeenCalled(); + expect( + recorded[1]!.supervisor.deliverChannelMessage, + ).not.toHaveBeenCalled(); + }); + + it('rejects channel delivery while the owning workspace is draining', async () => { + const registry = fakeRegistry([ + fakeRuntime(PRIMARY, true), + fakeRuntime(SECONDARY, false), + ]); + const { createSupervisor, recorded } = makeCreateSupervisor(() => + snapshot({}), + ); + const group = createChannelWorkerGroup({ + groups: [ + { workspaceCwd: PRIMARY, selection: { mode: 'names', names: ['a'] } }, + { workspaceCwd: SECONDARY, selection: { mode: 'names', names: ['b'] } }, + ], + registry, + createSupervisor, + shared, + }); + group.beginWorkspaceDrain(SECONDARY); + + await expect( + group.deliverChannelMessage(deliveryRequest), + ).rejects.toMatchObject({ + code: 'channel_worker_unavailable', + }); + expect( + recorded[1]!.supervisor.deliverChannelMessage, + ).not.toHaveBeenCalled(); + }); + it('routes webhook tasks to the supervisor that owns the channel', async () => { const registry = fakeRegistry([ fakeRuntime(PRIMARY, true), @@ -800,6 +937,7 @@ describe('createChannelWorkerGroup', () => { restart: vi.fn(async () => snapshot({})), killAllSync: vi.fn(), snapshot: () => snapshot({}), + deliverChannelMessage: vi.fn().mockRejectedValue(new Error('unused')), enqueueWebhookTask: vi.fn().mockRejectedValue(new Error('unused')), }; }; @@ -1040,6 +1178,7 @@ describe('createChannelWorkerGroup', () => { restart: vi.fn(async () => snapshot({})), killAllSync: vi.fn(), snapshot: () => snapshot({}), + deliverChannelMessage: vi.fn().mockRejectedValue(new Error('unused')), enqueueWebhookTask: vi.fn().mockRejectedValue(new Error('unused')), }; recorded.push({ opts, supervisor }); @@ -1294,6 +1433,7 @@ describe('createChannelWorkerGroup', () => { restart: vi.fn(async () => snapshot({})), killAllSync: vi.fn(), snapshot: () => snapshot({}), + deliverChannelMessage: vi.fn().mockRejectedValue(new Error('unused')), enqueueWebhookTask: vi.fn().mockRejectedValue(new Error('unused')), }; }; diff --git a/packages/cli/src/serve/channel-worker-group.ts b/packages/cli/src/serve/channel-worker-group.ts index 3682d7ec1c4..88d159ce518 100644 --- a/packages/cli/src/serve/channel-worker-group.ts +++ b/packages/cli/src/serve/channel-worker-group.ts @@ -13,6 +13,7 @@ import type { CreateChannelWorkerSupervisorOptions, } from './channel-worker-supervisor.js'; import { ChannelWorkerStartupError } from './channel-worker-supervisor.js'; +import { ChannelDeliveryError } from './channel-delivery-ipc.js'; import { ChannelWebhookEnqueueError } from './channel-webhook-ipc.js'; import type { ChannelWorkspaceGroup } from './channel-workspace-grouping.js'; import type { WorkspaceRegistry } from './workspace-registry.js'; @@ -79,6 +80,12 @@ export interface ChannelWorkerGroup { workspaceActivity(workspaceCwd: string): number; removeWorkspace(workspaceCwd: string): Promise; restoreWorkspace(workspaceCwd: string): Promise; + deliverChannelMessage( + request: Parameters< + NonNullable + >[0], + workspaceCwd?: string, + ): ReturnType>; enqueueWebhookTask: ChannelWorkerSupervisor['enqueueWebhookTask']; } @@ -377,7 +384,19 @@ export function createChannelWorkerGroup( const routeEntry = ( channelName: string, + workspaceCwd?: string, ): ChannelWorkerGroupEntry | undefined => { + if (workspaceCwd !== undefined) { + const entry = entries.get(workspaceCwd); + if ( + entry && + (entry.selection.mode === 'all' || + entry.selection.names.includes(channelName)) + ) { + return entry; + } + return undefined; + } for (const entry of entries.values()) { if ( entry.selection.mode === 'all' || @@ -697,6 +716,21 @@ export function createChannelWorkerGroup( throw err; } }, + async deliverChannelMessage(request, workspaceCwd) { + const entry = routeEntry(request.channelName, workspaceCwd); + const deliver = entry?.supervisor.deliverChannelMessage; + if ( + !entry || + drainingWorkspaces.has(entry.workspaceCwd) || + deliver === undefined + ) { + throw new ChannelDeliveryError( + 'channel_worker_unavailable', + `No channel worker owns channel "${request.channelName}".`, + ); + } + return deliver.call(entry.supervisor, request); + }, async enqueueWebhookTask(task) { const entry = routeEntry(task.channelName); if (!entry || drainingWorkspaces.has(entry.workspaceCwd)) { diff --git a/packages/cli/src/serve/channel-worker-manager.test.ts b/packages/cli/src/serve/channel-worker-manager.test.ts index 72271696254..797da0938d1 100644 --- a/packages/cli/src/serve/channel-worker-manager.test.ts +++ b/packages/cli/src/serve/channel-worker-manager.test.ts @@ -60,6 +60,7 @@ function fakeGroup( workspaceActivity: vi.fn(() => 0), removeWorkspace: vi.fn(async () => {}), restoreWorkspace: vi.fn(async () => {}), + deliverChannelMessage: vi.fn(async () => ({ delivered: true as const })), enqueueWebhookTask: vi.fn(async () => ({ accepted: true as const })), ...overrides, }; @@ -745,6 +746,26 @@ describe('createChannelWorkerManager', () => { expect(group.enqueueWebhookTask).not.toHaveBeenCalled(); }); + it('routes scheduled delivery through the committed group and exact workspace', async () => { + const group = fakeGroup(); + const test = setup(group); + await test.manager.setSelection({ + mode: 'names', + names: ['telegram'], + }); + const delivery = { + deliveryId: 'task-1:1000', + channelName: 'telegram', + target: { type: 'chat' as const, id: 'group-42' }, + text: 'daily result', + }; + + await expect( + test.manager.deliverChannelMessage(PRIMARY, delivery), + ).resolves.toEqual({ delivered: true }); + expect(group.deliverChannelMessage).toHaveBeenCalledWith(delivery, PRIMARY); + }); + it('serializes mutations and rejects queued work once shutdown latches', async () => { let releaseStart!: () => void; const group = fakeGroup({ diff --git a/packages/cli/src/serve/channel-worker-manager.ts b/packages/cli/src/serve/channel-worker-manager.ts index dc8de3b277e..e28cdc18ab4 100644 --- a/packages/cli/src/serve/channel-worker-manager.ts +++ b/packages/cli/src/serve/channel-worker-manager.ts @@ -6,6 +6,10 @@ import type { ChannelWebhookTask } from '@qwen-code/channel-base'; import { ChannelWebhookEnqueueError } from './channel-webhook-ipc.js'; +import { + ChannelDeliveryError, + type ChannelDeliveryRequest, +} from './channel-delivery-ipc.js'; import type { ChannelWorkerGroup, ChannelWorkerGroupSnapshot, @@ -113,6 +117,10 @@ export interface ChannelWorkerManager { enqueueWebhookTask( task: ChannelWebhookTask, ): ReturnType; + deliverChannelMessage( + workspaceCwd: string, + request: ChannelDeliveryRequest, + ): ReturnType; beginWorkspaceDrain(workspaceCwd: string): void; cancelWorkspaceDrain(workspaceCwd: string): void; workspaceActivity(workspaceCwd: string): number; @@ -495,6 +503,19 @@ export function createChannelWorkerManager( } return group.enqueueWebhookTask(task); }, + deliverChannelMessage(workspaceCwd, request) { + if (!group || draining) { + return Promise.reject( + new ChannelDeliveryError( + 'channel_worker_unavailable', + draining + ? 'Daemon is shutting down.' + : 'Channel worker is not running.', + ), + ) as ReturnType; + } + return group.deliverChannelMessage(request, workspaceCwd); + }, beginWorkspaceDrain(workspaceCwd) { workspaceDrains.add(workspaceCwd); group?.beginWorkspaceDrain(workspaceCwd); diff --git a/packages/cli/src/serve/channel-worker-supervisor.test.ts b/packages/cli/src/serve/channel-worker-supervisor.test.ts index f7c1e4d6067..92c2b5b9c7a 100644 --- a/packages/cli/src/serve/channel-worker-supervisor.test.ts +++ b/packages/cli/src/serve/channel-worker-supervisor.test.ts @@ -8,6 +8,10 @@ import { } from './channel-worker-supervisor.js'; import { CHANNEL_WORKER_HEARTBEAT_INTERVAL_MS } from './channel-worker-env.js'; import { MAX_CHANNEL_STARTUP_FAILURES } from './channel-worker-startup-ipc.js'; +import { + CHANNEL_DELIVERY_IPC_TIMEOUT_MS, + type ChannelDeliveryRequest, +} from './channel-delivery-ipc.js'; const TEST_HEARTBEAT_TIMEOUT_MS = CHANNEL_WORKER_HEARTBEAT_INTERVAL_MS + 5; @@ -41,6 +45,16 @@ const webhookTask: ChannelWebhookTask = { payload: { runId: 123 }, }; +const deliveryRequest: ChannelDeliveryRequest = { + deliveryId: 'delivery-1', + channelName: 'telegram', + target: { + type: 'chat', + id: 'group-1', + }, + text: 'inspection result', +}; + describe('createChannelWorkerSupervisor', () => { afterEach(() => { vi.useRealTimers(); @@ -2403,6 +2417,159 @@ describe('createChannelWorkerSupervisor', () => { }); }); + it('delivers a channel message through a running worker', async () => { + const child = new FakeChild(false); + const supervisor = createChannelWorkerSupervisor({ + cliEntryPath: '/repo/dist/index.js', + daemonUrl: 'http://127.0.0.1:4170', + workspace: '/workspace', + selection: { mode: 'names', names: ['telegram'] }, + spawnWorker: vi.fn(() => child), + }); + + const started = supervisor.start(); + child.emit('message', { + type: 'ready', + pid: 12345, + channels: ['telegram'], + requestedChannels: ['telegram'], + }); + await started; + + const delivered = supervisor.deliverChannelMessage!(deliveryRequest); + const sent = child.send.mock.calls[0]![0] as { id: string }; + expect(sent).toMatchObject({ + type: 'channel_delivery', + id: expect.any(String), + expiresAt: expect.any(Number), + request: deliveryRequest, + }); + + let settled = false; + void delivered.finally(() => { + settled = true; + }); + await Promise.resolve(); + expect(settled).toBe(false); + + child.emit('message', { + type: 'channel_delivery_result', + id: sent.id, + ok: true, + }); + + await expect(delivered).resolves.toEqual({ delivered: true }); + }); + + it('rejects channel delivery when the worker is not running', async () => { + const supervisor = createChannelWorkerSupervisor({ + cliEntryPath: '/repo/dist/index.js', + daemonUrl: 'http://127.0.0.1:4170', + workspace: '/workspace', + selection: { mode: 'names', names: ['telegram'] }, + spawnWorker: vi.fn(() => new FakeChild()), + }); + + await expect( + supervisor.deliverChannelMessage!(deliveryRequest), + ).rejects.toMatchObject({ + code: 'channel_worker_unavailable', + message: 'Channel worker is not running.', + }); + }); + + it('rejects channel delivery errors reported by the worker', async () => { + const child = new FakeChild(false); + const supervisor = createChannelWorkerSupervisor({ + cliEntryPath: '/repo/dist/index.js', + daemonUrl: 'http://127.0.0.1:4170', + workspace: '/workspace', + selection: { mode: 'names', names: ['telegram'] }, + spawnWorker: vi.fn(() => child), + }); + + const started = supervisor.start(); + child.emit('message', { + type: 'ready', + pid: 12345, + channels: ['telegram'], + }); + await started; + + const delivered = supervisor.deliverChannelMessage!(deliveryRequest); + const sent = child.send.mock.calls[0]![0] as { id: string }; + child.emit('message', { + type: 'channel_delivery_result', + id: sent.id, + ok: false, + code: 'channel_delivery_failed', + error: 'Platform send failed.', + }); + + await expect(delivered).rejects.toMatchObject({ + code: 'channel_delivery_failed', + message: 'Platform send failed.', + }); + }); + + it('times out a channel delivery without a worker result', async () => { + vi.useFakeTimers(); + const child = new FakeChild(false); + const supervisor = createChannelWorkerSupervisor({ + cliEntryPath: '/repo/dist/index.js', + daemonUrl: 'http://127.0.0.1:4170', + workspace: '/workspace', + selection: { mode: 'names', names: ['telegram'] }, + spawnWorker: vi.fn(() => child), + }); + + const started = supervisor.start(); + child.emit('message', { + type: 'ready', + pid: 12345, + channels: ['telegram'], + }); + await started; + + const delivered = supervisor.deliverChannelMessage!(deliveryRequest).then( + () => undefined, + (error: unknown) => error, + ); + await vi.advanceTimersByTimeAsync(CHANNEL_DELIVERY_IPC_TIMEOUT_MS); + + await expect(delivered).resolves.toMatchObject({ + code: 'channel_delivery_timeout', + message: 'Channel delivery IPC timed out.', + }); + }); + + it('rejects pending channel delivery when the worker exits', async () => { + const child = new FakeChild(false); + const supervisor = createChannelWorkerSupervisor({ + cliEntryPath: '/repo/dist/index.js', + daemonUrl: 'http://127.0.0.1:4170', + workspace: '/workspace', + selection: { mode: 'names', names: ['telegram'] }, + spawnWorker: vi.fn(() => child), + }); + + const started = supervisor.start(); + child.emit('message', { + type: 'ready', + pid: 12345, + channels: ['telegram'], + }); + await started; + + const delivered = supervisor.deliverChannelMessage!(deliveryRequest); + child.emit('exit', 1, null); + + await expect(delivered).rejects.toMatchObject({ + code: 'channel_worker_unavailable', + message: 'Channel worker exited.', + }); + }); + it('sends a webhook task to a running worker over IPC', async () => { const child = new FakeChild(false); const supervisor = createChannelWorkerSupervisor({ diff --git a/packages/cli/src/serve/channel-worker-supervisor.ts b/packages/cli/src/serve/channel-worker-supervisor.ts index af9bd8e3a02..6e9c30ebddb 100644 --- a/packages/cli/src/serve/channel-worker-supervisor.ts +++ b/packages/cli/src/serve/channel-worker-supervisor.ts @@ -27,6 +27,16 @@ import { type ChannelWebhookAccepted, type ChannelWebhookEnqueueErrorCode, } from './channel-webhook-ipc.js'; +import { + CHANNEL_DELIVERY_IPC_TIMEOUT_MS, + ChannelDeliveryError, + createChannelDeliveryMessage, + isChannelDeliveryErrorCode, + isChannelDeliveryResultMessage, + type ChannelDeliveryAccepted, + type ChannelDeliveryErrorCode, + type ChannelDeliveryRequest, +} from './channel-delivery-ipc.js'; import { createWorkerDiagnosticRedactor, normalizeWorkerDiagnostic, @@ -132,6 +142,9 @@ export interface ChannelWorkerSupervisor { restart(): Promise; killAllSync(): void; snapshot(): ChannelWorkerSnapshot; + deliverChannelMessage?( + request: ChannelDeliveryRequest, + ): Promise; enqueueWebhookTask(task: ChannelWebhookTask): Promise; } @@ -471,6 +484,14 @@ export function createChannelWorkerSupervisor( timer: NodeJS.Timeout; } >(); + const pendingChannelDeliveries = new Map< + string, + { + resolve: (accepted: ChannelDeliveryAccepted) => void; + reject: (err: Error) => void; + timer: NodeJS.Timeout; + } + >(); let restarting: Promise | undefined; let disposed = false; @@ -549,6 +570,48 @@ export function createChannelWorkerSupervisor( return true; }; + const rejectPendingChannelDeliveries = ( + code: ChannelDeliveryErrorCode, + message: string, + ) => { + for (const pending of pendingChannelDeliveries.values()) { + clearTimeout(pending.timer); + pending.reject(new ChannelDeliveryError(code, message)); + } + pendingChannelDeliveries.clear(); + }; + + const rejectPendingChannelDelivery = (id: string, err: Error) => { + const pending = pendingChannelDeliveries.get(id); + if (!pending) return; + pendingChannelDeliveries.delete(id); + clearTimeout(pending.timer); + pending.reject(err); + }; + + const settleChannelDelivery = (message: unknown): boolean => { + if (!isChannelDeliveryResultMessage(message)) return false; + const pending = pendingChannelDeliveries.get(message.id); + if (!pending) return true; + if (message.ok) { + pendingChannelDeliveries.delete(message.id); + clearTimeout(pending.timer); + pending.resolve({ delivered: true }); + } else { + const code = isChannelDeliveryErrorCode(message.code) + ? message.code + : 'channel_delivery_failed'; + rejectPendingChannelDelivery( + message.id, + new ChannelDeliveryError( + code, + message.error || 'Channel delivery failed.', + ), + ); + } + return true; + }; + const pruneRestartAttempts = (nowMs: number) => { restartAttemptTimes = restartAttemptTimes.filter( (attemptMs) => nowMs - attemptMs < restartPolicy.windowMs, @@ -930,6 +993,9 @@ export function createChannelWorkerSupervisor( }; function handleMessage(message: unknown) { if (child !== startedChild) return; + if (settleChannelDelivery(message)) { + return; + } if (settleWebhookTask(message)) { return; } @@ -958,6 +1024,10 @@ export function createChannelWorkerSupervisor( 'channel_worker_unavailable', 'Channel worker exited.', ); + rejectPendingChannelDeliveries( + 'channel_worker_unavailable', + 'Channel worker exited.', + ); child = undefined; if ((ready || kind === 'restart') && !stopping) { scheduleRestart(); @@ -1036,6 +1106,10 @@ export function createChannelWorkerSupervisor( 'channel_worker_unavailable', 'Channel worker stopped.', ); + rejectPendingChannelDeliveries( + 'channel_worker_unavailable', + 'Channel worker stopped.', + ); if ( !child || snapshot.state === 'exited' || @@ -1093,6 +1167,10 @@ export function createChannelWorkerSupervisor( 'channel_worker_unavailable', 'Channel worker stopped.', ); + rejectPendingChannelDeliveries( + 'channel_worker_unavailable', + 'Channel worker stopped.', + ); if ( !child || snapshot.state === 'exited' || @@ -1121,6 +1199,59 @@ export function createChannelWorkerSupervisor( snapshot() { return snapshotCopy(); }, + async deliverChannelMessage(request) { + const startedChild = child; + if (!startedChild || snapshot.state !== 'running') { + throw new ChannelDeliveryError( + 'channel_worker_unavailable', + 'Channel worker is not running.', + ); + } + const send = startedChild.send; + if (!send) { + throw new ChannelDeliveryError( + 'channel_worker_unavailable', + 'Channel worker IPC send failed.', + ); + } + const message = createChannelDeliveryMessage(request); + return await new Promise((resolve, reject) => { + const timer = setTimeout(() => { + pendingChannelDeliveries.delete(message.id); + reject( + new ChannelDeliveryError( + 'channel_delivery_timeout', + 'Channel delivery IPC timed out.', + ), + ); + }, CHANNEL_DELIVERY_IPC_TIMEOUT_MS); + timer.unref(); + pendingChannelDeliveries.set(message.id, { resolve, reject, timer }); + try { + send.call(startedChild, message, (err) => { + if (err) { + rejectPendingChannelDelivery( + message.id, + new ChannelDeliveryError( + 'channel_worker_unavailable', + `Channel worker IPC send failed: ${err.message}`, + ), + ); + } + }); + } catch (err) { + rejectPendingChannelDelivery( + message.id, + new ChannelDeliveryError( + 'channel_worker_unavailable', + `Channel worker IPC send failed: ${ + err instanceof Error ? err.message : String(err) + }`, + ), + ); + } + }); + }, async enqueueWebhookTask(task) { const startedChild = child; if (!startedChild || snapshot.state !== 'running') { diff --git a/packages/cli/src/serve/routes/scheduled-tasks.test.ts b/packages/cli/src/serve/routes/scheduled-tasks.test.ts index a7f7b0c4dcd..c179422559c 100644 --- a/packages/cli/src/serve/routes/scheduled-tasks.test.ts +++ b/packages/cli/src/serve/routes/scheduled-tasks.test.ts @@ -179,6 +179,76 @@ describe('scheduled-tasks routes', () => { expect(h.bridge.closed).toEqual([]); }); + it('returns an explicit owned session binding for route-created tasks', async () => { + const res = await create({ cron: '0 9 * * *', prompt: 'p' }); + expect(res.status).toBe(201); + expect(res.body.sessionBinding).toEqual({ + sessionId: res.body.sessionId, + ownership: 'owned', + }); + }); + + it('persists an explicit channel delivery target without graph admission', async () => { + const delivery = { + kind: 'channel', + channelName: 'dingtalk', + target: { type: 'chat', id: 'group-42' }, + }; + const res = await create({ cron: '0 9 * * *', prompt: 'digest', delivery }); + + expect(res.status).toBe(201); + expect(res.body.delivery).toEqual(delivery); + const stored = JSON.parse( + await fsp.readFile(getCronFilePath(h.workspace), 'utf-8'), + ); + expect(stored[0].delivery).toEqual(res.body.delivery); + }); + + it.each([ + { + kind: 'channel', + channelName: '', + target: { type: 'chat', id: 'group-42' }, + }, + { + kind: 'channel', + channelName: 'dingtalk', + target: { type: 'chat', id: '' }, + }, + { + kind: 'channel', + channelName: 'dingtalk', + target: { type: 'topic', id: 'topic-1' }, + }, + { + kind: 'channel', + channelName: 'dingtalk', + target: { type: 'chat', id: 'group-42', threadId: 'thread-7' }, + }, + { + kind: 'channel', + target: { channelName: 'dingtalk', chatId: 'group-42', isGroup: true }, + }, + { + kind: 'channel', + channelName: 'x'.repeat(2049), + target: { type: 'chat', id: 'group-42' }, + }, + { + kind: 'channel', + channelName: 'dingtalk', + target: { type: 'chat', id: 'x'.repeat(2049) }, + }, + ])('rejects malformed channel delivery %#', async (delivery) => { + const res = await create({ + cron: '0 9 * * *', + prompt: 'digest', + delivery, + }); + expect(res.status).toBe(400); + expect(res.body.code).toBe('invalid_delivery'); + }); + it('creates an UNBOUND task (no session) when no bridge is provided', async () => { // Mirrors createServeApp passing no bridge when resident task-session // management is off: binding a task to a session nothing keeps resident / @@ -308,6 +378,47 @@ describe('scheduled-tasks routes', () => { expect(list.body.tasks[0].enabled).toBe(false); }); + it('adds, replaces, and clears explicit channel delivery via PATCH', async () => { + const created = await create({ cron: '0 9 * * *', prompt: 'digest' }); + + const added = await request(h.app) + .patch(`/scheduled-tasks/${created.body.id}`) + .send({ + delivery: { + kind: 'channel', + channelName: 'dingtalk', + target: { type: 'chat', id: 'group-42' }, + }, + }); + expect(added.status).toBe(200); + expect(added.body.delivery).toEqual({ + kind: 'channel', + channelName: 'dingtalk', + target: { type: 'chat', id: 'group-42' }, + }); + + const replaced = await request(h.app) + .patch(`/scheduled-tasks/${created.body.id}`) + .send({ + delivery: { + kind: 'channel', + channelName: 'dingtalk', + target: { type: 'user', id: 'staff-42' }, + }, + }); + expect(replaced.status).toBe(200); + expect(replaced.body.delivery.target).toEqual({ + type: 'user', + id: 'staff-42', + }); + + const cleared = await request(h.app) + .patch(`/scheduled-tasks/${created.body.id}`) + .send({ delivery: null }); + expect(cleared.status).toBe(200); + expect(cleared.body.delivery).toBeNull(); + }); + it('clears the name when patched to an empty string', async () => { const created = await create({ name: 'Named', @@ -346,6 +457,41 @@ describe('scheduled-tasks routes', () => { expect(h.bridge.closed).toEqual([created.body.sessionId]); }); + it('does not close a shared IM session when its task is deleted', async () => { + const file = getCronFilePath(h.workspace); + await fsp.mkdir(path.dirname(file), { recursive: true }); + await fsp.writeFile( + file, + JSON.stringify([ + { + id: 'shared01', + cron: '0 9 * * *', + prompt: 'digest', + recurring: true, + createdAt: Date.now(), + lastFiredAt: null, + sessionId: 'im-conversation-1', + sessionOwnership: 'shared', + delivery: { + kind: 'channel', + channelName: 'dingtalk', + target: { type: 'chat', id: 'group-42' }, + }, + }, + ]), + ); + + const list = await request(h.app).get('/scheduled-tasks'); + expect(list.body.tasks[0].sessionBinding).toEqual({ + sessionId: 'im-conversation-1', + ownership: 'shared', + }); + + const del = await request(h.app).delete('/scheduled-tasks/shared01'); + expect(del.status).toBe(200); + expect(h.bridge.closed).toEqual([]); + }); + it('records a manual run: advances lastFiredAt and appends a manual run', async () => { const created = await create({ cron: '0 9 * * *', prompt: 'p' }); const id = created.body.id as string; @@ -1270,6 +1416,24 @@ describe('workspace-qualified scheduled-tasks routes', () => { expect(primaryList.body.tasks).toHaveLength(0); }); + it('creates explicit delivery in the targeted workspace without graph admission', async () => { + const delivery = { + kind: 'channel', + channelName: 'dingtalk', + target: { type: 'chat', id: 'secondary-group' }, + }; + const res = await request(h.app) + .post(qualified(h.secondary.workspaceId)) + .send({ cron: '0 9 * * *', prompt: 'secondary work', delivery }); + + expect(res.status).toBe(201); + expect(res.body.delivery).toEqual(delivery); + const onDisk = JSON.parse( + await fsp.readFile(getCronFilePath(h.secondary.workspaceCwd), 'utf-8'), + ); + expect(onDisk[0].delivery).toEqual(delivery); + }); + it('writes to the targeted workspace’s own cron file on disk', async () => { await request(h.app) .post(qualified(h.secondary.workspaceId)) diff --git a/packages/cli/src/serve/routes/scheduled-tasks.ts b/packages/cli/src/serve/routes/scheduled-tasks.ts index e4c0e386136..0ac91b83ae2 100644 --- a/packages/cli/src/serve/routes/scheduled-tasks.ts +++ b/packages/cli/src/serve/routes/scheduled-tasks.ts @@ -43,8 +43,11 @@ import { SessionService, stripTerminalControlSequences, MAX_JOBS, + MAX_CHANNEL_DELIVERY_NAME_LENGTH, + MAX_CHANNEL_DELIVERY_TARGET_ID_LENGTH, type DurableCronTask, type CronTaskRun, + type CronTaskDelivery, } from '@qwen-code/qwen-code-core'; import { writeStderrLine } from '../../utils/stdioHelpers.js'; import type { WorkspaceRegistry } from '../workspace-registry.js'; @@ -186,6 +189,11 @@ interface ScheduledTaskView { lastFiredAt: number | null; nextRunAt: number | null; sessionId: string | null; + sessionBinding: { + sessionId: string; + ownership: 'owned' | 'shared'; + } | null; + delivery: CronTaskDelivery | null; runs: CronTaskRun[]; } @@ -203,6 +211,10 @@ function computeNextRunAt(task: DurableCronTask): number | null { } function toView(task: DurableCronTask): ScheduledTaskView { + const sessionId = + typeof task.sessionId === 'string' && task.sessionId.length > 0 + ? task.sessionId + : null; return { id: task.id, name: @@ -222,10 +234,17 @@ function toView(task: DurableCronTask): ScheduledTaskView { nextRunAt: taskHasLegacyCondition(task) ? null : computeNextRunAt(task), // The task's bound session (its run-history transcript), or null for an // unbound tool-created/legacy task. - sessionId: - typeof task.sessionId === 'string' && task.sessionId.length > 0 - ? task.sessionId - : null, + sessionId, + sessionBinding: + sessionId === null + ? null + : { + sessionId, + // Legacy route-created tasks predate this field and own their + // dedicated session. IM `/loop` tasks mark shared explicitly. + ownership: task.sessionOwnership ?? 'owned', + }, + delivery: task.delivery ?? null, // Absent runs (tool-created / never-fired) normalizes to [] so the client // never special-cases undefined. runs: Array.isArray(task.runs) ? task.runs : [], @@ -374,6 +393,15 @@ function registerScheduledTaskCrudRoutes( res.status(400).json(removedFieldError(removedField)); return; } + const parsedDelivery = parseDeliveryField(body['delivery']); + if (parsedDelivery.error) { + res.status(400).json({ + error: parsedDelivery.error, + code: 'invalid_delivery', + }); + return; + } + const delivery = parsedDelivery.value; const recurring = body['recurring'] !== false; const enabled = body['enabled'] !== false; const taskId = generateCronTaskId(); @@ -448,6 +476,7 @@ function registerScheduledTaskCrudRoutes( // minute the task was created — same guard cronScheduler.create uses. lastFiredAt: now - (now % 60_000), enabled, + ...(delivery !== undefined ? { delivery } : {}), ...(boundSessionId !== undefined ? { sessionId: boundSessionId } : {}), ...(nameResult.value !== undefined ? { name: nameResult.value } : {}), }; @@ -520,6 +549,7 @@ function registerScheduledTaskCrudRoutes( // would mean holding the lock to reject a bad request. const patch: Partial = {}; let clearName = false; + let clearDelivery = false; const removedPatchField = findRemovedTaskField(body); if (removedPatchField) { @@ -587,7 +617,22 @@ function registerScheduledTaskCrudRoutes( } patch.enabled = body['enabled']; } - if (Object.keys(patch).length === 0 && !clearName) { + if ('delivery' in body) { + if (body['delivery'] === null) { + clearDelivery = true; + } else { + const parsedDelivery = parseDeliveryField(body['delivery']); + if (!parsedDelivery.value) { + res.status(400).json({ + error: parsedDelivery.error ?? '`delivery` is invalid', + code: 'invalid_delivery', + }); + return; + } + patch.delivery = parsedDelivery.value; + } + } + if (Object.keys(patch).length === 0 && !clearName && !clearDelivery) { res.status(400).json({ error: 'No updatable fields provided', code: 'empty_patch', @@ -630,6 +675,7 @@ function registerScheduledTaskCrudRoutes( // `name: null/""` clears the field rather than storing an empty name, // so toView reports it as unnamed and isValidTask never sees a "". if (clearName) delete next.name; + if (clearDelivery) delete next.delivery; // Re-seat the task's schedule anchor to "now" whenever an edit would // otherwise let the scheduler retroactively fire an already-past slot. const justReEnabled = @@ -745,15 +791,21 @@ function registerScheduledTaskCrudRoutes( // Single atomic read-modify-write: capture the task's bound session AND // remove it in one cycle, closing the TOCTOU window a separate // read-then-remove would open (and cutting three file reads to one). The - // dedicated session exists only to run this task, so it's torn down after. + // An owned dedicated session exists only to run this task, so it's torn + // down after. A shared IM conversation session outlives the task. let boundSessionId: string | undefined; let removed = false; try { await updateCronTasks(workspaceCwd, (tasks) => { const idx = tasks.findIndex((t) => t.id === id); if (idx === -1) return tasks; // not found → no write - const match = tasks[idx]!.sessionId; - if (typeof match === 'string' && match.length > 0) { + const task = tasks[idx]!; + const match = task.sessionId; + if ( + task.sessionOwnership !== 'shared' && + typeof match === 'string' && + match.length > 0 + ) { boundSessionId = match; } removed = true; @@ -944,6 +996,61 @@ export function registerWorkspaceQualifiedScheduledTasksRoutes( }); } +function parseDeliveryField(raw: unknown): { + value?: CronTaskDelivery; + error?: string; +} { + if (raw === undefined || raw === null) return {}; + if (typeof raw !== 'object' || raw === null) { + return { error: '`delivery` must be an object or null' }; + } + const delivery = raw as Record; + if (delivery['kind'] !== 'channel') { + return { error: '`delivery.kind` must be `channel`' }; + } + if ( + typeof delivery['channelName'] !== 'string' || + delivery['channelName'].trim().length === 0 || + delivery['channelName'].length > MAX_CHANNEL_DELIVERY_NAME_LENGTH + ) { + return { error: '`delivery.channelName` must be a non-empty string' }; + } + if ( + !Object.keys(delivery).every( + (key) => key === 'kind' || key === 'channelName' || key === 'target', + ) + ) { + return { error: '`delivery` contains unsupported fields' }; + } + const rawTarget = delivery['target']; + if (typeof rawTarget !== 'object' || rawTarget === null) { + return { error: '`delivery.target` must be an object' }; + } + const target = rawTarget as Record; + if ( + (target['type'] !== 'user' && target['type'] !== 'chat') || + typeof target['id'] !== 'string' || + target['id'].trim().length === 0 || + target['id'].length > MAX_CHANNEL_DELIVERY_TARGET_ID_LENGTH || + !Object.keys(target).every((key) => key === 'type' || key === 'id') + ) { + return { + error: + '`delivery.target` requires type `user` or `chat` and a non-empty string id', + }; + } + return { + value: { + kind: 'channel', + channelName: delivery['channelName'].trim(), + target: { + type: target['type'], + id: target['id'].trim(), + }, + }, + }; +} + /** * Fields that a previous version accepted but this one has removed (the * isolated run mode and its precondition). A body that still carries one comes diff --git a/packages/cli/src/serve/run-qwen-serve.test.ts b/packages/cli/src/serve/run-qwen-serve.test.ts index 021863882c5..78bfdf2357e 100644 --- a/packages/cli/src/serve/run-qwen-serve.test.ts +++ b/packages/cli/src/serve/run-qwen-serve.test.ts @@ -6744,10 +6744,11 @@ describe('runQwenServe channel worker supervisor', () => { }, { bridge, - channelWorkerSupervisorFactory: vi.fn(() => worker), + channelWorkerSupervisorFactory: makeReadyWorkerFactory(worker), channelServicePidfile: pidfile, }, ); + await handle.runtimeReady; try { const signalListener = process @@ -6769,10 +6770,12 @@ describe('runQwenServe channel worker supervisor', () => { expect(pidfile.removeServeServiceInfo).toHaveBeenCalledWith(process.pid); expect(exitSpy).toHaveBeenCalledWith(1); + await vi.waitFor(() => expect(bridge.shutdown).toHaveBeenCalled()); finishBridgeShutdown(); await firstSignal; } finally { finishBridgeShutdown?.(); + vi.mocked(bridge.shutdown).mockResolvedValue(undefined); await handle.close(); exitSpy.mockRestore(); } diff --git a/packages/cli/src/serve/run-qwen-serve.ts b/packages/cli/src/serve/run-qwen-serve.ts index bb7bac9e01b..88eba0c4f5b 100644 --- a/packages/cli/src/serve/run-qwen-serve.ts +++ b/packages/cli/src/serve/run-qwen-serve.ts @@ -117,6 +117,8 @@ import type { } from './channel-worker-supervisor.js'; import { QWEN_SERVER_TOKEN_ENV } from './channel-worker-env.js'; import { ChannelWebhookEnqueueError } from './channel-webhook-ipc.js'; +import { ChannelDeliveryError } from './channel-delivery-ipc.js'; +import type { ScheduledDeliveryDispatcher } from './scheduled-delivery-dispatcher.js'; import { channelSelectionNames } from './channel-selection.js'; import { resolveChannelWorkspaceGroups, @@ -2537,6 +2539,7 @@ async function runQwenServeImpl( }; let channelWorkerManager: ChannelWorkerManager | undefined; let channelWorkerManagerStarting: Promise | undefined; + let scheduledDeliveryDispatcher: ScheduledDeliveryDispatcher | undefined; let channelControlDraining = false; let channelWorkspaceGroups: readonly ChannelWorkspaceGroup[] | undefined; const channelWebhookEnvByWorkspace = new Map< @@ -4328,6 +4331,7 @@ async function runQwenServeImpl( // (keepalive) and reloads them on boot (rehydration). Off by default so // direct createServeApp embeds/tests don't spawn sessions. manageScheduledTaskSessions: true, + scheduledTaskChannelDeliveryAvailable: true, fsFactory: routeFsFactory, primaryWorkspaceTrusted: trustedWorkspace, primaryRuntimeEnv, @@ -5059,6 +5063,36 @@ async function runQwenServeImpl( await manager.startInitial(opts.channelSelection); if (runtimeStartupSettled) return; } + const registry = candidateApp.locals?.['workspaceRegistry'] as + | WorkspaceRegistry + | undefined; + const { createScheduledDeliveryDispatcher } = await import( + './scheduled-delivery-dispatcher.js' + ); + if (runtimeStartupSettled) return; + scheduledDeliveryDispatcher ??= createScheduledDeliveryDispatcher({ + listWorkspaces: () => + registry?.list().map((runtime) => runtime.workspaceCwd) ?? [ + boundWorkspace, + ], + deliver: async (workspaceCwd, request) => { + const manager = + channelWorkerManager ?? (await ensureChannelWorkerManager?.()); + if (!manager) { + throw new ChannelDeliveryError( + 'channel_worker_unavailable', + 'Channel worker manager is unavailable.', + ); + } + return manager.deliverChannelMessage(workspaceCwd, request); + }, + onError: (error) => { + daemonLog.warn('scheduled Channel delivery dispatcher error', { + error: error instanceof Error ? error.message : String(error), + }); + }, + }); + scheduledDeliveryDispatcher.start(); if (runtimeStartupSettled) return; runtimeStartupSettled = true; clearRuntimeStartupTimer(); @@ -5169,6 +5203,7 @@ async function runQwenServeImpl( for (const runtimeBridge of getRuntimeBridgesForCleanup()) { runtimeBridge.killAllSync(); } + removeCurrentServePidfile(); } catch (err) { daemonLog.error( 'force-kill error', @@ -5372,6 +5407,9 @@ async function runQwenServeImpl( } disposeRuntimeAppResources(appForCleanup); disposeDaemonEventLoopMonitor(); + // Stop claiming new outbox work and let the current delivery + // settle before its Channel worker is torn down. + await scheduledDeliveryDispatcher?.stop(); // The worker owns daemon-backed sessions; disconnect it before // tearing down the ACP bridge it is attached to. if (channelWorkerManager) { diff --git a/packages/cli/src/serve/scheduled-delivery-dispatcher.test.ts b/packages/cli/src/serve/scheduled-delivery-dispatcher.test.ts new file mode 100644 index 00000000000..1b0a68419b2 --- /dev/null +++ b/packages/cli/src/serve/scheduled-delivery-dispatcher.test.ts @@ -0,0 +1,153 @@ +import * as fs from 'node:fs/promises'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { + enqueueScheduledDelivery, + readScheduledDeliveryOutbox, + Storage, +} from '@qwen-code/qwen-code-core'; +import { ChannelDeliveryError } from './channel-delivery-ipc.js'; +import { createScheduledDeliveryDispatcher } from './scheduled-delivery-dispatcher.js'; + +describe('scheduled delivery dispatcher', () => { + let scratch: string; + let workspace: string; + + beforeEach(async () => { + scratch = await fs.mkdtemp(path.join(os.tmpdir(), 'delivery-dispatch-')); + workspace = path.join(scratch, 'workspace'); + await fs.mkdir(workspace, { recursive: true }); + Storage.setRuntimeBaseDir(scratch); + await enqueueScheduledDelivery(workspace, { + deliveryId: 'task-1:1000', + taskId: 'task-1', + firedAt: 1000, + channelName: 'dingtalk', + target: { type: 'chat', id: 'group-42' }, + text: 'daily result', + createdAt: 1001, + }); + }); + + afterEach(async () => { + Storage.setRuntimeBaseDir(null); + await fs.rm(scratch, { recursive: true, force: true }); + }); + + it('delivers one claimed record through the exact workspace', async () => { + const deliver = vi.fn().mockResolvedValue({ delivered: true }); + const dispatcher = createScheduledDeliveryDispatcher({ + listWorkspaces: () => [workspace], + deliver, + now: () => 2000, + }); + + await dispatcher.runOnce(); + + expect(deliver).toHaveBeenCalledWith(workspace, { + deliveryId: 'task-1:1000', + channelName: 'dingtalk', + target: { type: 'chat', id: 'group-42' }, + text: 'daily result', + }); + expect(await readScheduledDeliveryOutbox(workspace)).toEqual([ + expect.objectContaining({ status: 'delivered', attempts: 1 }), + ]); + }); + + it('backs off a transient transport failure without changing task work', async () => { + let now = 2000; + const deliver = vi + .fn() + .mockRejectedValueOnce( + new ChannelDeliveryError( + 'channel_delivery_timeout', + 'worker timed out', + ), + ) + .mockResolvedValueOnce({ delivered: true }); + const dispatcher = createScheduledDeliveryDispatcher({ + listWorkspaces: () => [workspace], + deliver, + now: () => now, + baseRetryMs: 1000, + }); + + await dispatcher.runOnce(); + expect(deliver).toHaveBeenCalledTimes(1); + expect(await readScheduledDeliveryOutbox(workspace)).toEqual([ + expect.objectContaining({ + status: 'retryable', + attempts: 1, + nextAttemptAt: 3000, + lastError: expect.objectContaining({ + code: 'channel_delivery_timeout', + }), + }), + ]); + + await dispatcher.runOnce(); + expect(deliver).toHaveBeenCalledTimes(1); + now = 3000; + await dispatcher.runOnce(); + expect(deliver).toHaveBeenCalledTimes(2); + expect(await readScheduledDeliveryOutbox(workspace)).toEqual([ + expect.objectContaining({ status: 'delivered', attempts: 2 }), + ]); + }); + + it('marks invalid delivery permanently failed without retrying', async () => { + const deliver = vi + .fn() + .mockRejectedValue( + new ChannelDeliveryError( + 'channel_delivery_invalid', + 'target is invalid', + ), + ); + const dispatcher = createScheduledDeliveryDispatcher({ + listWorkspaces: () => [workspace], + deliver, + now: () => 2000, + }); + + await dispatcher.runOnce(); + await dispatcher.runOnce(); + + expect(deliver).toHaveBeenCalledTimes(1); + expect(await readScheduledDeliveryOutbox(workspace)).toEqual([ + expect.objectContaining({ + status: 'failed', + lastError: expect.objectContaining({ + code: 'channel_delivery_invalid', + }), + }), + ]); + }); + + it('stops admitting polls and waits for the active delivery', async () => { + let release!: () => void; + const gate = new Promise((resolve) => { + release = resolve; + }); + const deliver = vi.fn(async () => { + await gate; + return { delivered: true as const }; + }); + const dispatcher = createScheduledDeliveryDispatcher({ + listWorkspaces: () => [workspace], + deliver, + now: () => 2000, + pollIntervalMs: 10, + }); + dispatcher.start(); + await vi.waitFor(() => expect(deliver).toHaveBeenCalledTimes(1)); + + const stopping = dispatcher.stop(); + await Promise.resolve(); + expect(deliver).toHaveBeenCalledTimes(1); + release(); + await stopping; + }); +}); diff --git a/packages/cli/src/serve/scheduled-delivery-dispatcher.ts b/packages/cli/src/serve/scheduled-delivery-dispatcher.ts new file mode 100644 index 00000000000..c5859a01709 --- /dev/null +++ b/packages/cli/src/serve/scheduled-delivery-dispatcher.ts @@ -0,0 +1,176 @@ +import { + claimScheduledDelivery, + completeScheduledDelivery, + type ScheduledDeliveryRecord, +} from '@qwen-code/qwen-code-core'; +import { + isChannelDeliveryError, + type ChannelDeliveryAccepted, + type ChannelDeliveryErrorCode, + type ChannelDeliveryRequest, +} from './channel-delivery-ipc.js'; + +export interface ScheduledDeliveryDispatcherOptions { + listWorkspaces: () => readonly string[]; + deliver: ( + workspaceCwd: string, + request: ChannelDeliveryRequest, + ) => Promise; + now?: () => number; + pollIntervalMs?: number; + leaseMs?: number; + baseRetryMs?: number; + maxRetryMs?: number; + maxAttempts?: number; + onError?: (error: unknown) => void; +} + +export interface ScheduledDeliveryDispatcher { + start(): void; + runOnce(): Promise; + stop(): Promise; +} + +const DEFAULT_POLL_INTERVAL_MS = 1000; +const DEFAULT_LEASE_MS = 45_000; +const DEFAULT_BASE_RETRY_MS = 1000; +const DEFAULT_MAX_RETRY_MS = 60_000; +const DEFAULT_MAX_ATTEMPTS = 5; + +export function createScheduledDeliveryDispatcher( + options: ScheduledDeliveryDispatcherOptions, +): ScheduledDeliveryDispatcher { + const now = options.now ?? Date.now; + const pollIntervalMs = positiveOrDefault( + options.pollIntervalMs, + DEFAULT_POLL_INTERVAL_MS, + ); + const leaseMs = positiveOrDefault(options.leaseMs, DEFAULT_LEASE_MS); + const baseRetryMs = positiveOrDefault( + options.baseRetryMs, + DEFAULT_BASE_RETRY_MS, + ); + const maxRetryMs = positiveOrDefault( + options.maxRetryMs, + DEFAULT_MAX_RETRY_MS, + ); + const maxAttempts = positiveOrDefault( + options.maxAttempts, + DEFAULT_MAX_ATTEMPTS, + ); + let timer: ReturnType | undefined; + let stopping = false; + let activeRun: Promise | undefined; + + const processWorkspace = async (workspaceCwd: string): Promise => { + const claimed = await claimScheduledDelivery(workspaceCwd, { + now: now(), + leaseMs, + }); + if (!claimed) return; + try { + await options.deliver(workspaceCwd, toDeliveryRequest(claimed)); + await completeScheduledDelivery(workspaceCwd, { + deliveryId: claimed.deliveryId, + outcome: 'delivered', + now: now(), + }); + } catch (error) { + const normalized = normalizeDeliveryError(error); + const completedAt = now(); + if ( + normalized.code === 'channel_delivery_invalid' || + claimed.attempts >= maxAttempts + ) { + await completeScheduledDelivery(workspaceCwd, { + deliveryId: claimed.deliveryId, + outcome: 'failed', + now: completedAt, + error: normalized, + }); + return; + } + const retryMs = Math.min( + maxRetryMs, + baseRetryMs * 2 ** Math.max(0, claimed.attempts - 1), + ); + await completeScheduledDelivery(workspaceCwd, { + deliveryId: claimed.deliveryId, + outcome: 'retryable', + now: completedAt, + nextAttemptAt: completedAt + retryMs, + error: normalized, + }); + } + }; + + const doRun = async (): Promise => { + if (stopping) return; + const workspaces = [...new Set(options.listWorkspaces())]; + await Promise.all(workspaces.map(processWorkspace)); + }; + + const runOnce = (): Promise => { + if (activeRun) return activeRun; + const run = doRun() + .catch((error) => { + options.onError?.(error); + }) + .finally(() => { + if (activeRun === run) activeRun = undefined; + }); + activeRun = run; + return run; + }; + + return { + start() { + if (timer || stopping) return; + void runOnce(); + timer = setInterval(() => void runOnce(), pollIntervalMs); + timer.unref(); + }, + runOnce, + async stop() { + stopping = true; + if (timer) { + clearInterval(timer); + timer = undefined; + } + await activeRun; + }, + }; +} + +function toDeliveryRequest( + record: ScheduledDeliveryRecord, +): ChannelDeliveryRequest { + return { + deliveryId: record.deliveryId, + channelName: record.channelName, + target: record.target, + text: record.text, + }; +} + +function normalizeDeliveryError(error: unknown): { + code: ChannelDeliveryErrorCode; + message: string; +} { + if (isChannelDeliveryError(error)) { + return { code: error.code, message: error.message || error.code }; + } + return { + code: 'channel_delivery_failed', + message: error instanceof Error && error.message ? error.message : 'failed', + }; +} + +function positiveOrDefault( + value: number | undefined, + fallback: number, +): number { + return typeof value === 'number' && Number.isFinite(value) && value > 0 + ? value + : fallback; +} diff --git a/packages/cli/src/serve/server.test.ts b/packages/cli/src/serve/server.test.ts index 819565ded47..d4e11d2a8c3 100644 --- a/packages/cli/src/serve/server.test.ts +++ b/packages/cli/src/serve/server.test.ts @@ -453,6 +453,7 @@ const EXPECTED_REGISTERED_FEATURES = [ 'channel_reload', 'channel_control', 'workspace_channel_observed_contacts', + 'scheduled_task_channel_delivery', 'multi_workspace_sessions', 'multi_workspace_session_rewind', 'multi_workspace_session_shell', @@ -2390,6 +2391,24 @@ describe('createServeApp', () => { ); continue; } + if (feature === 'scheduled_task_channel_delivery') { + expect( + predicate({ scheduledTaskChannelDeliveryAvailable: true }), + ).toBe(true); + expect( + predicate({ scheduledTaskChannelDeliveryAvailable: false }), + ).toBe(false); + expect(predicate({})).toBe(false); + expect( + getAdvertisedServeFeatures(undefined, { + scheduledTaskChannelDeliveryAvailable: true, + }), + ).toContain(feature); + expect(getAdvertisedServeFeatures(undefined, {})).not.toContain( + feature, + ); + continue; + } if (feature === 'multi_workspace_sessions') { expect(predicate({ multiWorkspaceSessionsEnabled: true })).toBe(true); expect(predicate({ multiWorkspaceSessionsEnabled: false })).toBe( diff --git a/packages/cli/src/serve/server.ts b/packages/cli/src/serve/server.ts index 41fa453b234..6f0bdc5bacd 100644 --- a/packages/cli/src/serve/server.ts +++ b/packages/cli/src/serve/server.ts @@ -341,6 +341,10 @@ export interface ServeAppDeps { * a heartbeat timer. */ manageScheduledTaskSessions?: boolean; + /** + * True only when post-run Channel delivery is fully wired. + */ + scheduledTaskChannelDeliveryAvailable?: boolean; /** * Directory of the built Web Shell SPA (`index.html` + `assets/`). When * set (and `opts.serveWebShell !== false`), `createServeApp` mounts the @@ -703,6 +707,8 @@ export function createServeApp( deps.getChannelWorkerControl !== undefined && deps.setChannelWorkerSelection !== undefined && deps.stopChannelWorker !== undefined, + scheduledTaskChannelDeliveryAvailable: + deps.scheduledTaskChannelDeliveryAvailable === true, channelReloadAvailable: () => { if (deps.reloadChannelWorker === undefined) return false; const control = deps.getChannelWorkerControl?.(); diff --git a/packages/cli/src/serve/server/serve-features.ts b/packages/cli/src/serve/server/serve-features.ts index 143c8a5a57c..1f3b0339337 100644 --- a/packages/cli/src/serve/server/serve-features.ts +++ b/packages/cli/src/serve/server/serve-features.ts @@ -47,6 +47,7 @@ interface CreateServeFeaturesDeps { reloadAvailable: boolean; channelReloadAvailable: () => boolean; channelControlAvailable: boolean; + scheduledTaskChannelDeliveryAvailable: boolean; sessionShellCommandEnabled: boolean; multiWorkspaceSessionsEnabled: () => boolean; persistentWorkspaceRegistrationAvailable: boolean; @@ -72,6 +73,7 @@ export function createServeFeatures( reloadAvailable, channelReloadAvailable, channelControlAvailable, + scheduledTaskChannelDeliveryAvailable, sessionShellCommandEnabled, multiWorkspaceSessionsEnabled, persistentWorkspaceRegistrationAvailable, @@ -115,6 +117,7 @@ export function createServeFeatures( reloadAvailable, channelReloadAvailable: channelReloadAvailable(), channelControlAvailable, + scheduledTaskChannelDeliveryAvailable, multiWorkspaceSessionsEnabled: multiWorkspaceSessionsEnabled(), persistentWorkspaceRegistrationAvailable, workspaceRuntimeRemovalAvailable, diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 2d83c9f59a6..cf51569a68a 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -228,7 +228,28 @@ export { } from './services/chatCompressionService.js'; export * from './services/chatRecordingService.js'; export * from './services/cronScheduler.js'; -export type { DurableCronTask, CronTaskRun } from './services/cronTasksFile.js'; +export type { + DurableCronTask, + CronTaskRun, + CronTaskChannelTarget, + CronTaskDelivery, + CronTaskSessionOwnership, + CronTaskChannelLoopMetadata, +} from './services/cronTasksFile.js'; +export type { + ScheduledDeliveryStatus, + ScheduledDeliveryError, + ScheduledDeliveryRecord, + EnqueueScheduledDeliveryInput, + CompleteScheduledDeliveryInput, +} from './services/scheduled-delivery-outbox.js'; +export { + getScheduledDeliveryOutboxPath, + readScheduledDeliveryOutbox, + enqueueScheduledDelivery, + claimScheduledDelivery, + completeScheduledDelivery, +} from './services/scheduled-delivery-outbox.js'; export { readCronTasks, updateCronTasks, @@ -238,6 +259,8 @@ export { appendCronRun, taskHasLegacyCondition, MAX_TASK_RUNS, + MAX_CHANNEL_DELIVERY_NAME_LENGTH, + MAX_CHANNEL_DELIVERY_TARGET_ID_LENGTH, } from './services/cronTasksFile.js'; export * from './services/fileDiscoveryService.js'; export * from './services/fileHistoryService.js'; diff --git a/packages/core/src/services/cronScheduler.test.ts b/packages/core/src/services/cronScheduler.test.ts index 474b75c3708..b021e68c234 100644 --- a/packages/core/src/services/cronScheduler.test.ts +++ b/packages/core/src/services/cronScheduler.test.ts @@ -832,6 +832,20 @@ describe('CronScheduler', () => { expect(scheduler.durableActive).toBe(false); }); + it('keeps the immutable owning workspace on durable jobs', async () => { + await scheduler.enableDurable('session-1'); + const job = await scheduler.createDurable( + '0 9 * * *', + 'workspace-owned task', + true, + ); + + expect(job.workspaceCwd).toBe(tmpDir); + expect( + scheduler.list().find((item) => item.id === job.id)?.workspaceCwd, + ).toBe(tmpDir); + }); + it('releases the lock on stop so another session can take over', async () => { await scheduler.enableDurable('session-1'); const lockPath = getLockFilePath(tmpDir); @@ -1268,6 +1282,23 @@ describe('CronScheduler', () => { }); }); + it('carries a durable task channel delivery snapshot into the fire', async () => { + const delivery = { + kind: 'channel' as const, + channelName: 'dingtalk', + target: { type: 'chat' as const, id: 'group-42' }, + }; + await writeCronTasks(tmpDir, [{ ...diskTask('deliver1'), delivery }]); + await scheduler.enableDurable('session-1'); + + const fired: CronJob[] = []; + scheduler.start((job) => fired.push(job)); + scheduler.tick(new Date(2025, 0, 15, 10, 30, 59)); + + expect(fired).toHaveLength(1); + expect(fired[0]!.delivery).toEqual(delivery); + }); + it('appends a scheduled run record on each recurring fire (newest last)', async () => { await writeCronTasks(tmpDir, [diskTask('rec1')]); await scheduler.enableDurable('session-1'); @@ -1352,6 +1383,66 @@ describe('CronScheduler', () => { await settle(schedB); }); + it('keeps a loaded bound one-shot raw when another session reloads the shared file during its due minute', async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date(2025, 0, 15, 10, 14, 0)); + const createdAt = Date.now(); + await writeCronTasks(tmpDir, [ + { + ...diskTask('taskA'), + cron: '15 * * * *', + recurring: false, + createdAt, + sessionId: 'sess-A', + }, + { + ...diskTask('taskB'), + cron: '15 * * * *', + recurring: false, + createdAt, + sessionId: 'sess-B', + }, + ]); + const firedA: CronJob[] = []; + await scheduler.enableDurable('sess-A'); + scheduler.start((job) => firedA.push(job)); + const schedB = new CronScheduler(tmpDir); + const firedB: CronJob[] = []; + await schedB.enableDurable('sess-B'); + schedB.start((job) => firedB.push(job)); + + try { + const due = new Date(2025, 0, 15, 10, 15, 5); + vi.setSystemTime(due); + scheduler.tick(due); + expect(firedA.map((job) => job.id)).toEqual(['taskA']); + await vi.waitFor(async () => { + expect((await readCronTasks(tmpDir)).map((task) => task.id)).toEqual([ + 'taskB', + ]); + }); + + await ( + schedB as unknown as { + loadFileTasks(handleMissed: boolean): Promise; + } + ).loadFileTasks(false); + expect(firedB).toHaveLength(0); + + schedB.tick(due); + expect(firedB).toHaveLength(1); + expect(firedB[0]).toMatchObject({ + id: 'taskB', + prompt: 'task taskB', + recurring: false, + }); + expect(firedB[0]!.missed).toBeUndefined(); + } finally { + await settle(schedB); + vi.useRealTimers(); + } + }); + it('a non-owner session catches up its own overdue bound task', async () => { const createdAt = Date.now() - 3 * 60 * 60_000; // 3h overdue await writeCronTasks(tmpDir, [ @@ -2074,6 +2165,40 @@ describe('CronScheduler', () => { }); }); + it.each(['never-fired', 'previously-fired'] as const)( + 'stamps a fresh delivery identity on an aged final fire (%s)', + async (history) => { + const createdAt = Date.now() - 8 * 24 * 60 * 60_000; + const priorFireAt = Date.now() - 2 * 60 * 60_000; + await writeCronTasks(tmpDir, [ + { + id: `aged-${history}`, + cron: '0 * * * *', + prompt: 'aged delivery', + recurring: true, + createdAt, + lastFiredAt: history === 'never-fired' ? null : priorFireAt, + delivery: { + kind: 'channel', + channelName: 'dingtalk', + target: { type: 'chat', id: 'group-1' }, + }, + }, + ]); + + const fired: CronJob[] = []; + scheduler.start((job) => fired.push(job)); + const beforeMinute = Date.now() - (Date.now() % 60_000); + await scheduler.enableDurable('session-1'); + const afterMinute = Date.now() - (Date.now() % 60_000); + + expect(fired).toHaveLength(1); + expect(fired[0]!.lastFiredAt).toBeGreaterThanOrEqual(beforeMinute); + expect(fired[0]!.lastFiredAt).toBeLessThanOrEqual(afterMinute); + expect(fired[0]!.lastFiredAt).not.toBe(priorFireAt); + }, + ); + it('re-detects a dropped catch-up fire on re-enable', async () => { const createdAt = Date.now() - 3 * 60 * 60_000; await writeCronTasks(tmpDir, [ diff --git a/packages/core/src/services/cronScheduler.ts b/packages/core/src/services/cronScheduler.ts index 02ece6495a7..3ccb6328904 100644 --- a/packages/core/src/services/cronScheduler.ts +++ b/packages/core/src/services/cronScheduler.ts @@ -12,7 +12,7 @@ import { matches, nextFireTime, parseCron } from '../utils/cronParser.js'; import { humanReadableCron } from '../utils/cronDisplay.js'; import { createDebugLogger } from '../utils/debugLogger.js'; import { ToolNames } from '../tools/tool-names.js'; -import type { DurableCronTask } from './cronTasksFile.js'; +import type { CronTaskDelivery, DurableCronTask } from './cronTasksFile.js'; import { addCronTask, CRON_TASKS_DISPLAY_PATH, @@ -89,6 +89,10 @@ export interface CronJob { * absent, the task uses the shared model: only the lock owner fires it. */ boundSessionId?: string; + /** Optional daemon Channel destination copied from the durable task. */ + delivery?: CronTaskDelivery; + /** Immutable workspace that owns this durable task and its delivery outbox. */ + workspaceCwd?: string; /** One-shot that was due while no owning session ran — fired late. */ missed?: boolean; } @@ -499,6 +503,7 @@ export class CronScheduler { } const job = this.create(cronExpr, prompt, recurring); job.durable = true; + job.workspaceCwd = this.projectRoot; this.pendingAdd.add(job.id); try { await addCronTask(this.projectRoot, jobToDurableTask(job)); @@ -819,6 +824,20 @@ export class CronScheduler { const nextFire = computeNextFireMs(t.cron, anchor, jitter); if (nextFire === null || nextFire >= now) continue; if (!t.recurring) { + // A watcher reload can race another bound session removing its own + // one-shot from the shared file during this task's due minute. If + // this session already loaded the task, leave it live for the normal + // tick path so the raw prompt fires; it was not missed while Qwen was + // offline. Once the scheduled minute has elapsed, the ordinary + // confirm-first missed behavior still applies. + const existing = this.jobs.get(t.id); + if ( + existing?.durable && + existing.boundSessionId === this.sessionId && + now - nextFire < 60_000 + ) { + continue; + } // Missed one-shots are delivered as one batched confirm-first // notification: the task file is project-controlled, and // executing a prompt read from it would bypass the approval @@ -901,7 +920,12 @@ export class CronScheduler { ); continue; } - const job = durableTaskToJob(task, this.recurringMaxAgeMs, existing); + const job = durableTaskToJob( + task, + this.recurringMaxAgeMs, + this.projectRoot, + existing, + ); if (existing?.lastFiredAt !== undefined) { job.lastFiredAt = Math.max(existing.lastFiredAt, job.lastFiredAt ?? 0); } @@ -929,10 +953,19 @@ export class CronScheduler { this.fireOrBuffer({ kind: 'catch-up', ids: catchUpIds }); } if (finalTasks.length > 0) { + // A final age-out fire is a new execution, not a replay of the task's + // previous scheduled slot. Give it a fresh stable stamp so downstream + // consumers can correlate and deduplicate this final result even when the + // task has never fired before or already has an older lastFiredAt. + const finalFireAt = now - (now % 60_000); this.fireOrBuffer({ kind: 'final', jobs: finalTasks.map((t) => - durableTaskToJob(t, this.recurringMaxAgeMs), + durableTaskToJob( + { ...t, lastFiredAt: finalFireAt }, + this.recurringMaxAgeMs, + this.projectRoot, + ), ), }); } @@ -970,7 +1003,11 @@ export class CronScheduler { // surfaces and runs them instead of losing the task permanently. const skipped: string[] = []; const runnable = pending.tasks.filter((t) => { - const job = durableTaskToJob(t, this.recurringMaxAgeMs); + const job = durableTaskToJob( + t, + this.recurringMaxAgeMs, + this.projectRoot, + ); // `job.durable &&` mirrors catch-up/final/tick — durableTaskToJob always // sets durable, so it's a no-op today, but keeps the four skip sites // identical so a future non-durable carrier can't be silently dropped. @@ -995,6 +1032,7 @@ export class CronScheduler { const carrier = durableTaskToJob( runnable[0]!, this.recurringMaxAgeMs, + this.projectRoot, ); onFire({ ...carrier, @@ -1579,6 +1617,7 @@ function hasParseableCron(task: DurableCronTask): boolean { function durableTaskToJob( task: DurableCronTask, recurringMaxAgeMs: number, + projectRoot: string | null, existing?: CronJob, ): CronJob { // Jitter is deterministic per (id, cron, recurring) but costly to @@ -1599,7 +1638,9 @@ function durableTaskToJob( lastFiredAt: task.lastFiredAt ?? undefined, jitterMs, durable: true, + ...(projectRoot ? { workspaceCwd: projectRoot } : {}), ...(task.sessionId ? { boundSessionId: task.sessionId } : {}), + ...(task.delivery ? { delivery: task.delivery } : {}), }; } @@ -1612,6 +1653,7 @@ function jobToDurableTask(job: CronJob): DurableCronTask { createdAt: job.createdAt, lastFiredAt: job.lastFiredAt ?? null, ...(job.boundSessionId ? { sessionId: job.boundSessionId } : {}), + ...(job.delivery ? { delivery: job.delivery } : {}), }; } diff --git a/packages/core/src/services/cronTasksFile.test.ts b/packages/core/src/services/cronTasksFile.test.ts index af7762b698e..26b974536fc 100644 --- a/packages/core/src/services/cronTasksFile.test.ts +++ b/packages/core/src/services/cronTasksFile.test.ts @@ -145,6 +145,110 @@ describe('cronTasksFile', () => { expect(result).toEqual([task]); }); + it.each([ + { type: 'user' as const, id: 'staff-42' }, + { type: 'chat' as const, id: 'group-42' }, + ])( + 'round-trips $type channel delivery and explicit shared session ownership', + async (target) => { + const task = makeTask({ + sessionId: 'im-session-1', + sessionOwnership: 'shared', + delivery: { + kind: 'channel', + channelName: 'dingtalk', + target, + }, + }); + await writeCronTasks(tmpDir, [task]); + expect(await readCronTasks(tmpDir)).toEqual([task]); + }, + ); + + it('round-trips Channel /loop ownership metadata', async () => { + const task = makeTask({ + sessionId: 'im-session-1', + sessionOwnership: 'shared', + channelLoop: { + senderId: 'user-1', + createdBy: 'Alice', + label: 'Daily digest', + }, + }); + await writeCronTasks(tmpDir, [task]); + expect(await readCronTasks(tmpDir)).toEqual([task]); + }); + + it('rejects malformed Channel /loop metadata', async () => { + await seedTasksFile( + tmpDir, + JSON.stringify([ + { + ...makeTask(), + channelLoop: { senderId: '', createdBy: 'Alice' }, + }, + ]), + ); + await expect(readCronTasks(tmpDir)).rejects.toThrow(/Invalid task entry/); + }); + + it.each([ + { + kind: 'webhook', + channelName: 'dingtalk', + target: { type: 'chat', id: 'g1' }, + }, + { + kind: 'channel', + channelName: '', + target: { type: 'chat', id: 'g1' }, + }, + { + kind: 'channel', + channelName: 'dingtalk', + target: { type: 'chat', id: '' }, + }, + { + kind: 'channel', + channelName: 'dingtalk', + target: { type: 'topic', id: 'topic-1' }, + }, + { + kind: 'channel', + channelName: 'dingtalk', + target: { type: 'chat', id: 'g1', threadId: 'thread-7' }, + }, + { + kind: 'channel', + channelName: 'dingtalk', + target: { type: 'user', id: 'u1', isGroup: false }, + }, + { + kind: 'channel', + target: { channelName: 'dingtalk', chatId: 'g1', isGroup: true }, + }, + ])('rejects malformed channel delivery %#', async (delivery) => { + await seedTasksFile( + tmpDir, + JSON.stringify([{ ...makeTask(), delivery }]), + ); + await expect(readCronTasks(tmpDir)).rejects.toThrow(/Invalid task entry/); + }); + + it('rejects invalid or unbound session ownership', async () => { + await seedTasksFile( + tmpDir, + JSON.stringify([{ ...makeTask(), sessionOwnership: 'borrowed' }]), + ); + await expect(readCronTasks(tmpDir)).rejects.toThrow(/Invalid task entry/); + + await seedTasksFile( + tmpDir, + JSON.stringify([{ ...makeTask(), sessionOwnership: 'shared' }]), + ); + await expect(readCronTasks(tmpDir)).rejects.toThrow(/Invalid task entry/); + }); + it('accepts legacy tasks with no name/enabled fields', async () => { // A task written before the fields existed must still read back. const legacy = makeTask(); diff --git a/packages/core/src/services/cronTasksFile.ts b/packages/core/src/services/cronTasksFile.ts index 93f3f249913..6ae19eebe46 100644 --- a/packages/core/src/services/cronTasksFile.ts +++ b/packages/core/src/services/cronTasksFile.ts @@ -61,6 +61,34 @@ export interface CronTaskRun { * `lastFiredAt`, so appending a capped run adds no extra write, only bytes). */ export const MAX_TASK_RUNS = 20; +/** A daemon-managed Channel destination for a scheduled task result. */ +export type CronTaskChannelTarget = + | { type: 'user'; id: string } + | { type: 'chat'; id: string }; + +export const MAX_CHANNEL_DELIVERY_NAME_LENGTH = 2048; +export const MAX_CHANNEL_DELIVERY_TARGET_ID_LENGTH = 2048; + +/** + * Optional post-run delivery. Kept separate from the prompt/session binding: + * the scheduler still executes the prompt in its normal session, then hands + * the final result to the daemon's Channel transport. + */ +export interface CronTaskDelivery { + kind: 'channel'; + channelName: string; + target: CronTaskChannelTarget; +} + +export type CronTaskSessionOwnership = 'owned' | 'shared'; + +/** Authorization/display snapshot for a Channel-originated `/loop` task. */ +export interface CronTaskChannelLoopMetadata { + senderId: string; + createdBy: string; + label?: string; +} + export interface DurableCronTask { id: string; cron: string; @@ -97,6 +125,16 @@ export interface DurableCronTask { * (`cron_create`) and legacy tasks, which keep the shared-owner firing model. */ sessionId?: string; + /** + * Lifecycle of a bound session. Absent preserves the legacy meaning: + * route-created sessions are owned by the task. IM-created `/loop` tasks set + * `shared`, so deleting the task never closes the conversation session. + * Only valid when `sessionId` is present. + */ + sessionOwnership?: CronTaskSessionOwnership; + /** Optional delivery of the completed run to a daemon-managed Channel. */ + delivery?: CronTaskDelivery; + channelLoop?: CronTaskChannelLoopMetadata; /** * Bounded, newest-last history of recent fires (capped at MAX_TASK_RUNS). * Absent on tool-created tasks and on any task that has not fired yet. @@ -417,6 +455,46 @@ function isValidRuns(value: unknown): value is CronTaskRun[] { }); } +function isValidDelivery(value: unknown): value is CronTaskDelivery { + if (typeof value !== 'object' || value === null) return false; + const delivery = value as Record; + if ( + delivery['kind'] !== 'channel' || + typeof delivery['channelName'] !== 'string' || + delivery['channelName'].trim().length === 0 || + delivery['channelName'].length > MAX_CHANNEL_DELIVERY_NAME_LENGTH || + !Object.keys(delivery).every( + (key) => key === 'kind' || key === 'channelName' || key === 'target', + ) + ) { + return false; + } + const rawTarget = delivery['target']; + if (typeof rawTarget !== 'object' || rawTarget === null) return false; + const target = rawTarget as Record; + return ( + (target['type'] === 'user' || target['type'] === 'chat') && + typeof target['id'] === 'string' && + target['id'].trim().length > 0 && + target['id'].length <= MAX_CHANNEL_DELIVERY_TARGET_ID_LENGTH && + Object.keys(target).every((key) => key === 'type' || key === 'id') + ); +} + +function isValidChannelLoopMetadata( + value: unknown, +): value is CronTaskChannelLoopMetadata { + if (typeof value !== 'object' || value === null) return false; + const metadata = value as Record; + return ( + typeof metadata['senderId'] === 'string' && + metadata['senderId'].length > 0 && + typeof metadata['createdBy'] === 'string' && + metadata['createdBy'].length > 0 && + (metadata['label'] === undefined || typeof metadata['label'] === 'string') + ); +} + function isValidTask(value: unknown): value is DurableCronTask { if (typeof value !== 'object' || value === null) return false; const obj = value as Record; @@ -440,6 +518,14 @@ function isValidTask(value: unknown): value is DurableCronTask { // would treat it as unbound, so a "bound" task would silently run unbound. (obj['sessionId'] === undefined || (typeof obj['sessionId'] === 'string' && obj['sessionId'].length > 0)) && + (obj['sessionOwnership'] === undefined || + ((obj['sessionOwnership'] === 'owned' || + obj['sessionOwnership'] === 'shared') && + typeof obj['sessionId'] === 'string' && + obj['sessionId'].length > 0)) && + (obj['delivery'] === undefined || isValidDelivery(obj['delivery'])) && + (obj['channelLoop'] === undefined || + isValidChannelLoopMetadata(obj['channelLoop'])) && (obj['runs'] === undefined || isValidRuns(obj['runs'])) ); } diff --git a/packages/core/src/services/scheduled-delivery-outbox.test.ts b/packages/core/src/services/scheduled-delivery-outbox.test.ts new file mode 100644 index 00000000000..6e24306ea76 --- /dev/null +++ b/packages/core/src/services/scheduled-delivery-outbox.test.ts @@ -0,0 +1,286 @@ +import * as fs from 'node:fs/promises'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { Storage } from '../config/storage.js'; +import { + claimScheduledDelivery, + completeScheduledDelivery, + enqueueScheduledDelivery, + getScheduledDeliveryOutboxPath, + readScheduledDeliveryOutbox, +} from './scheduled-delivery-outbox.js'; + +describe('scheduledDeliveryOutbox', () => { + let scratch: string; + let workspace: string; + + beforeEach(async () => { + scratch = await fs.mkdtemp(path.join(os.tmpdir(), 'delivery-outbox-')); + workspace = path.join(scratch, 'workspace'); + await fs.mkdir(workspace, { recursive: true }); + Storage.setRuntimeBaseDir(scratch); + }); + + afterEach(async () => { + Storage.setRuntimeBaseDir(null); + await fs.rm(scratch, { recursive: true, force: true }); + }); + + const enqueue = (overrides: Record = {}) => + enqueueScheduledDelivery(workspace, { + deliveryId: 'task-1:1718000000000', + taskId: 'task-1', + firedAt: 1718000000000, + channelName: 'dingtalk', + target: { type: 'chat', id: 'group-42' }, + text: 'daily result', + createdAt: 1718000001000, + ...overrides, + }); + + const truncationMarker = + '\n\n[Channel delivery truncated because the result exceeded the outbox size limit.]'; + + it('enqueues an idempotent pending record', async () => { + const first = await enqueue(); + const second = await enqueue(); + + expect(second).toEqual(first); + expect(await readScheduledDeliveryOutbox(workspace)).toEqual([first]); + expect(first).toMatchObject({ + channelName: 'dingtalk', + target: { type: 'chat', id: 'group-42' }, + status: 'pending', + attempts: 0, + updatedAt: 1718000001000, + }); + }); + + it('preserves delivery text exactly at the outbox limit', async () => { + const text = 'x'.repeat(100_000); + + const record = await enqueue({ text }); + + expect(record.text).toBe(text); + }); + + it('truncates oversized delivery text without splitting a surrogate pair', async () => { + const prefixLimit = 100_000 - truncationMarker.length; + const text = `${'x'.repeat(prefixLimit - 1)}😀${'y'.repeat( + truncationMarker.length + 1, + )}`; + + const record = await enqueue({ text }); + + expect(record.text.length).toBeLessThanOrEqual(100_000); + expect(record.text).toBe( + `${'x'.repeat(prefixLimit - 1)}${truncationMarker}`, + ); + }); + + it('keeps repeated oversized enqueue idempotent', async () => { + const text = 'x'.repeat(100_001); + + const first = await enqueue({ text }); + const second = await enqueue({ text }); + + expect(second).toEqual(first); + expect(await readScheduledDeliveryOutbox(workspace)).toEqual([first]); + }); + + it.skipIf(process.platform === 'win32')( + 'stores recipient data in owner-only files and directories', + async () => { + await enqueue(); + const file = getScheduledDeliveryOutboxPath(workspace); + const directory = path.dirname(file); + const guard = path.join(directory, 'scheduled_deliveries.guard'); + + expect((await fs.stat(directory)).mode & 0o777).toBe(0o700); + expect((await fs.stat(guard)).mode & 0o777).toBe(0o600); + expect((await fs.stat(file)).mode & 0o777).toBe(0o600); + }, + ); + + it.skipIf(process.platform === 'win32')( + 'heals permissive permissions when a claim does not change the outbox', + async () => { + await enqueue(); + await claimScheduledDelivery(workspace, { + now: 1718000002000, + leaseMs: 30_000, + }); + await completeScheduledDelivery(workspace, { + deliveryId: 'task-1:1718000000000', + outcome: 'delivered', + now: 1718000003000, + }); + + const file = getScheduledDeliveryOutboxPath(workspace); + await fs.chmod(file, 0o644); + expect( + await claimScheduledDelivery(workspace, { + now: 1719000000000, + leaseMs: 30_000, + }), + ).toBeNull(); + expect((await fs.stat(file)).mode & 0o777).toBe(0o600); + }, + ); + + it('rejects a conflicting reuse of a delivery id', async () => { + await enqueue(); + await expect(enqueue({ text: 'different result' })).rejects.toThrow( + /conflicting delivery id/, + ); + await expect(enqueue({ channelName: 'feishu' })).rejects.toThrow( + /conflicting delivery id/, + ); + }); + + it.each([ + { channelName: '' }, + { target: { type: 'chat', id: '' } }, + { target: { type: 'topic', id: 'topic-1' } }, + { target: { type: 'chat', id: 'group-42', threadId: 'thread-7' } }, + { target: { channelName: 'dingtalk', chatId: 'group-42', isGroup: true } }, + ])('rejects malformed enqueue input %#', async (overrides) => { + await expect(enqueue(overrides)).rejects.toThrow( + /Invalid scheduled delivery enqueue input/, + ); + }); + + it('serializes concurrent enqueues without losing records', async () => { + await Promise.all([ + enqueue(), + enqueue({ + deliveryId: 'task-2:1718000000000', + taskId: 'task-2', + text: 'second', + }), + ]); + expect(await readScheduledDeliveryOutbox(workspace)).toHaveLength(2); + }); + + it('claims the oldest due record with a recoverable lease', async () => { + await enqueue(); + const claimed = await claimScheduledDelivery(workspace, { + now: 1718000002000, + leaseMs: 30_000, + }); + expect(claimed).toMatchObject({ + deliveryId: 'task-1:1718000000000', + status: 'sending', + attempts: 1, + leaseExpiresAt: 1718000032000, + }); + expect( + await claimScheduledDelivery(workspace, { + now: 1718000003000, + leaseMs: 30_000, + }), + ).toBeNull(); + + const recovered = await claimScheduledDelivery(workspace, { + now: 1718000032001, + leaseMs: 30_000, + }); + expect(recovered).toMatchObject({ status: 'sending', attempts: 2 }); + }); + + it('retries at nextAttemptAt without rerunning the task', async () => { + await enqueue(); + await claimScheduledDelivery(workspace, { + now: 1718000002000, + leaseMs: 30_000, + }); + await completeScheduledDelivery(workspace, { + deliveryId: 'task-1:1718000000000', + outcome: 'retryable', + now: 1718000003000, + nextAttemptAt: 1718000063000, + error: { code: 'channel_delivery_timeout', message: 'timed out' }, + }); + + expect( + await claimScheduledDelivery(workspace, { + now: 1718000062999, + leaseMs: 30_000, + }), + ).toBeNull(); + expect( + await claimScheduledDelivery(workspace, { + now: 1718000063000, + leaseMs: 30_000, + }), + ).toMatchObject({ status: 'sending', attempts: 2 }); + }); + + it('redacts credential-shaped values before persisting an error', async () => { + await enqueue(); + await claimScheduledDelivery(workspace, { + now: 1718000002000, + leaseMs: 30_000, + }); + await completeScheduledDelivery(workspace, { + deliveryId: 'task-1:1718000000000', + outcome: 'failed', + now: 1718000003000, + error: { + code: 'channel_delivery_failed', + message: + 'request failed: Authorization: Bearer secret-token api_key=another-secret', + }, + }); + + const [record] = await readScheduledDeliveryOutbox(workspace); + expect(record?.lastError?.message).toBe( + 'request failed: Authorization: Bearer api_key=', + ); + }); + + it.each(['delivered', 'failed'] as const)( + 'stores terminal %s state and never reclaims it', + async (outcome) => { + await enqueue(); + await claimScheduledDelivery(workspace, { + now: 1718000002000, + leaseMs: 30_000, + }); + if (outcome === 'failed') { + await completeScheduledDelivery(workspace, { + deliveryId: 'task-1:1718000000000', + outcome, + now: 1718000003000, + error: { code: 'invalid_target', message: 'not deliverable' }, + }); + } else { + await completeScheduledDelivery(workspace, { + deliveryId: 'task-1:1718000000000', + outcome, + now: 1718000003000, + }); + } + expect(await readScheduledDeliveryOutbox(workspace)).toEqual([ + expect.objectContaining({ status: outcome }), + ]); + expect( + await claimScheduledDelivery(workspace, { + now: 1719000000000, + leaseMs: 30_000, + }), + ).toBeNull(); + }, + ); + + it('fails closed on a malformed outbox file', async () => { + const file = getScheduledDeliveryOutboxPath(workspace); + await fs.mkdir(path.dirname(file), { recursive: true }); + await fs.writeFile(file, '{not json'); + await expect(readScheduledDeliveryOutbox(workspace)).rejects.toThrow( + /Malformed JSON/, + ); + await expect(enqueue()).rejects.toThrow(/Malformed JSON/); + }); +}); diff --git a/packages/core/src/services/scheduled-delivery-outbox.ts b/packages/core/src/services/scheduled-delivery-outbox.ts new file mode 100644 index 00000000000..dd28696841c --- /dev/null +++ b/packages/core/src/services/scheduled-delivery-outbox.ts @@ -0,0 +1,437 @@ +/** + * Cross-process handoff between a scheduled-task session and the daemon's + * Channel delivery dispatcher. Records are workspace-private runtime state, + * never project files. + */ + +import * as fs from 'node:fs/promises'; +import * as path from 'node:path'; +import { Mutex } from 'async-mutex'; +import lockfile from 'proper-lockfile'; +import { Storage } from '../config/storage.js'; +import { getProjectHash } from '../utils/paths.js'; +import { atomicWriteJSON } from '../utils/atomicFileWrite.js'; +import { + MAX_CHANNEL_DELIVERY_NAME_LENGTH, + MAX_CHANNEL_DELIVERY_TARGET_ID_LENGTH, + type CronTaskChannelTarget, +} from './cronTasksFile.js'; + +export type ScheduledDeliveryStatus = + | 'pending' + | 'sending' + | 'retryable' + | 'delivered' + | 'failed'; + +export interface ScheduledDeliveryError { + code: string; + message: string; +} + +export interface ScheduledDeliveryRecord { + deliveryId: string; + taskId: string; + firedAt: number; + channelName: string; + target: CronTaskChannelTarget; + text: string; + status: ScheduledDeliveryStatus; + attempts: number; + createdAt: number; + updatedAt: number; + nextAttemptAt?: number; + leaseExpiresAt?: number; + lastError?: ScheduledDeliveryError; +} + +export interface EnqueueScheduledDeliveryInput { + deliveryId: string; + taskId: string; + firedAt: number; + channelName: string; + target: CronTaskChannelTarget; + text: string; + createdAt?: number; +} + +export type CompleteScheduledDeliveryInput = + | { + deliveryId: string; + outcome: 'delivered'; + now?: number; + } + | { + deliveryId: string; + outcome: 'retryable'; + now?: number; + nextAttemptAt: number; + error: ScheduledDeliveryError; + } + | { + deliveryId: string; + outcome: 'failed'; + now?: number; + error: ScheduledDeliveryError; + }; + +const OUTBOX_FILENAME = 'scheduled_deliveries.json'; +const OUTBOX_GUARD_FILENAME = 'scheduled_deliveries.guard'; +const MAX_RECORDS = 200; +const MAX_TEXT_LENGTH = 100_000; +const TRUNCATED_TEXT_SUFFIX = + '\n\n[Channel delivery truncated because the result exceeded the outbox size limit.]'; +const MAX_ID_LENGTH = 256; +const MAX_ERROR_CODE_LENGTH = 128; +const MAX_ERROR_MESSAGE_LENGTH = 1000; + +const LOCK_OPTIONS: lockfile.LockOptions = { + realpath: false, + stale: 10_000, + retries: { + retries: 20, + minTimeout: 10, + maxTimeout: 250, + factor: 2, + randomize: true, + }, +}; + +const outboxMutexes = new Map(); + +function getOutboxMutex(file: string): Mutex { + let mutex = outboxMutexes.get(file); + if (!mutex) { + mutex = new Mutex(); + outboxMutexes.set(file, mutex); + } + return mutex; +} + +function getOutboxDirectory(projectRoot: string): string { + return path.join(Storage.getGlobalTempDir(), getProjectHash(projectRoot)); +} + +export function getScheduledDeliveryOutboxPath(projectRoot: string): string { + return path.join(getOutboxDirectory(projectRoot), OUTBOX_FILENAME); +} + +function isFiniteNumber(value: unknown): value is number { + return typeof value === 'number' && Number.isFinite(value); +} + +function isBoundedString(value: unknown, maxLength: number): value is string { + return ( + typeof value === 'string' && value.length > 0 && value.length <= maxLength + ); +} + +function isValidTarget(value: unknown): value is CronTaskChannelTarget { + if (typeof value !== 'object' || value === null) return false; + const target = value as Record; + return ( + (target['type'] === 'user' || target['type'] === 'chat') && + isBoundedString(target['id'], MAX_CHANNEL_DELIVERY_TARGET_ID_LENGTH) && + target['id'].trim().length > 0 && + Object.keys(target).every((key) => key === 'type' || key === 'id') + ); +} + +function isValidError(value: unknown): value is ScheduledDeliveryError { + if (typeof value !== 'object' || value === null) return false; + const error = value as Record; + return ( + isBoundedString(error['code'], MAX_ERROR_CODE_LENGTH) && + isBoundedString(error['message'], MAX_ERROR_MESSAGE_LENGTH) + ); +} + +function isValidRecord(value: unknown): value is ScheduledDeliveryRecord { + if (typeof value !== 'object' || value === null) return false; + const record = value as Record; + return ( + isBoundedString(record['deliveryId'], MAX_ID_LENGTH) && + isBoundedString(record['taskId'], MAX_ID_LENGTH) && + isFiniteNumber(record['firedAt']) && + isBoundedString(record['channelName'], MAX_CHANNEL_DELIVERY_NAME_LENGTH) && + (record['channelName'] as string).trim().length > 0 && + isValidTarget(record['target']) && + isBoundedString(record['text'], MAX_TEXT_LENGTH) && + (record['status'] === 'pending' || + record['status'] === 'sending' || + record['status'] === 'retryable' || + record['status'] === 'delivered' || + record['status'] === 'failed') && + Number.isInteger(record['attempts']) && + (record['attempts'] as number) >= 0 && + isFiniteNumber(record['createdAt']) && + isFiniteNumber(record['updatedAt']) && + (record['nextAttemptAt'] === undefined || + isFiniteNumber(record['nextAttemptAt'])) && + (record['leaseExpiresAt'] === undefined || + isFiniteNumber(record['leaseExpiresAt'])) && + (record['lastError'] === undefined || isValidError(record['lastError'])) + ); +} + +async function readOutboxFile( + file: string, +): Promise { + let raw: string; + try { + raw = await fs.readFile(file, 'utf8'); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return []; + throw error; + } + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + throw new Error( + `Malformed JSON in ${file}; refusing to replace the outbox.`, + ); + } + if (!Array.isArray(parsed) || parsed.length > MAX_RECORDS) { + throw new Error(`Invalid scheduled delivery outbox in ${file}.`); + } + for (const record of parsed) { + if (!isValidRecord(record)) { + throw new Error(`Invalid scheduled delivery record in ${file}.`); + } + } + return parsed; +} + +async function hardenExistingOutboxPermissions(file: string): Promise { + let stat: Awaited>; + try { + stat = await fs.lstat(file); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return; + throw error; + } + if (!stat.isFile() || stat.isSymbolicLink()) { + throw new Error(`Invalid scheduled delivery outbox in ${file}.`); + } + await fs.chmod(file, 0o600); +} + +export async function readScheduledDeliveryOutbox( + projectRoot: string, +): Promise { + return readOutboxFile(getScheduledDeliveryOutboxPath(projectRoot)); +} + +async function mutateOutbox( + projectRoot: string, + mutate: (records: ScheduledDeliveryRecord[]) => { + records: ScheduledDeliveryRecord[]; + result: T; + }, +): Promise { + const directory = getOutboxDirectory(projectRoot); + const file = getScheduledDeliveryOutboxPath(projectRoot); + const guard = path.join(directory, OUTBOX_GUARD_FILENAME); + return getOutboxMutex(file).runExclusive(async () => { + await fs.mkdir(directory, { recursive: true, mode: 0o700 }); + await fs.chmod(directory, 0o700); + await fs.writeFile(guard, '', { flag: 'a', mode: 0o600 }); + await fs.chmod(guard, 0o600); + const release = await lockfile.lock(guard, LOCK_OPTIONS); + try { + await hardenExistingOutboxPermissions(file); + const current = await readOutboxFile(file); + const next = mutate(current); + if (next.records !== current) { + await atomicWriteJSON(file, next.records, { + noFollow: true, + mode: 0o600, + forceMode: true, + }); + } + return next.result; + } finally { + await release().catch(() => undefined); + } + }); +} + +function normalizeDeliveryText(text: string): string { + if (text.length <= MAX_TEXT_LENGTH) return text; + const prefixLimit = MAX_TEXT_LENGTH - TRUNCATED_TEXT_SUFFIX.length; + let prefix = text.slice(0, prefixLimit); + const lastCodeUnit = prefix.charCodeAt(prefix.length - 1); + if (lastCodeUnit >= 0xd800 && lastCodeUnit <= 0xdbff) { + prefix = prefix.slice(0, -1); + } + return `${prefix}${TRUNCATED_TEXT_SUFFIX}`; +} + +function sameEnqueue( + record: ScheduledDeliveryRecord, + input: EnqueueScheduledDeliveryInput, +): boolean { + return ( + record.taskId === input.taskId && + record.firedAt === input.firedAt && + record.channelName === input.channelName && + record.text === input.text && + JSON.stringify(record.target) === JSON.stringify(input.target) + ); +} + +export async function enqueueScheduledDelivery( + projectRoot: string, + input: EnqueueScheduledDeliveryInput, +): Promise { + const normalizedInput: EnqueueScheduledDeliveryInput = { + ...input, + text: normalizeDeliveryText(input.text), + }; + const createdAt = normalizedInput.createdAt ?? Date.now(); + const candidate: ScheduledDeliveryRecord = { + deliveryId: normalizedInput.deliveryId, + taskId: normalizedInput.taskId, + firedAt: normalizedInput.firedAt, + channelName: normalizedInput.channelName, + target: { ...normalizedInput.target }, + text: normalizedInput.text, + status: 'pending', + attempts: 0, + createdAt, + updatedAt: createdAt, + }; + if (!isValidRecord(candidate)) { + throw new Error('Invalid scheduled delivery enqueue input.'); + } + return mutateOutbox(projectRoot, (records) => { + const existing = records.find( + (record) => record.deliveryId === normalizedInput.deliveryId, + ); + if (existing) { + if (!sameEnqueue(existing, normalizedInput)) { + throw new Error( + `Refusing conflicting delivery id ${JSON.stringify(normalizedInput.deliveryId)}.`, + ); + } + return { records, result: existing }; + } + let retained = records; + if (retained.length >= MAX_RECORDS) { + const terminalIndex = retained.findIndex( + (record) => record.status === 'delivered' || record.status === 'failed', + ); + if (terminalIndex < 0) { + throw new Error('Scheduled delivery outbox is full.'); + } + retained = retained.filter((_, index) => index !== terminalIndex); + } + return { records: [...retained, candidate], result: candidate }; + }); +} + +export async function claimScheduledDelivery( + projectRoot: string, + options: { now?: number; leaseMs: number }, +): Promise { + const now = options.now ?? Date.now(); + if ( + !isFiniteNumber(now) || + !isFiniteNumber(options.leaseMs) || + options.leaseMs <= 0 + ) { + throw new Error('Invalid scheduled delivery claim options.'); + } + return mutateOutbox(projectRoot, (records) => { + const candidate = records + .filter( + (record) => + record.status === 'pending' || + (record.status === 'retryable' && + (record.nextAttemptAt ?? 0) <= now) || + (record.status === 'sending' && (record.leaseExpiresAt ?? 0) <= now), + ) + .sort( + (left, right) => + left.createdAt - right.createdAt || + left.deliveryId.localeCompare(right.deliveryId), + )[0]; + if (!candidate) return { records, result: null }; + const claimed: ScheduledDeliveryRecord = { + ...candidate, + status: 'sending', + attempts: candidate.attempts + 1, + updatedAt: now, + leaseExpiresAt: now + options.leaseMs, + }; + delete claimed.nextAttemptAt; + delete claimed.lastError; + return { + records: records.map((record) => + record.deliveryId === claimed.deliveryId ? claimed : record, + ), + result: claimed, + }; + }); +} + +export async function completeScheduledDelivery( + projectRoot: string, + input: CompleteScheduledDeliveryInput, +): Promise { + const now = input.now ?? Date.now(); + if (!isFiniteNumber(now)) + throw new Error('Invalid delivery completion time.'); + return mutateOutbox(projectRoot, (records) => { + const current = records.find( + (record) => record.deliveryId === input.deliveryId, + ); + if (!current) { + throw new Error( + `Scheduled delivery ${JSON.stringify(input.deliveryId)} not found.`, + ); + } + const completed: ScheduledDeliveryRecord = { + ...current, + status: input.outcome, + updatedAt: now, + }; + delete completed.leaseExpiresAt; + delete completed.nextAttemptAt; + delete completed.lastError; + if (input.outcome === 'retryable') { + if (!isFiniteNumber(input.nextAttemptAt) || input.nextAttemptAt < now) { + throw new Error('Invalid scheduled delivery retry time.'); + } + completed.nextAttemptAt = input.nextAttemptAt; + completed.lastError = sanitizeError(input.error); + } else if (input.outcome === 'failed') { + completed.lastError = sanitizeError(input.error); + } + return { + records: records.map((record) => + record.deliveryId === completed.deliveryId ? completed : record, + ), + result: completed, + }; + }); +} + +function sanitizeError(error: ScheduledDeliveryError): ScheduledDeliveryError { + const code = error.code.trim().slice(0, MAX_ERROR_CODE_LENGTH); + const message = redactPersistedCredentials(error.message) + .trim() + .slice(0, MAX_ERROR_MESSAGE_LENGTH); + if (!code || !message) throw new Error('Invalid scheduled delivery error.'); + return { code, message }; +} + +function redactPersistedCredentials(message: string): string { + return message + .replace(/(Authorization\s*:\s*Bearer\s+)[^\s,;]+/giu, '$1') + .replace( + /\b(api[_-]?key|access[_-]?token|auth[_-]?token|password|secret)\s*([=:])\s*[^\s,;]+/giu, + '$1$2', + ); +}