Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
54 commits
Select commit Hold shift + click to select a range
49cca3f
feat(daemon): add a2a settings and capabilities
qqqys Jul 7, 2026
86727fb
docs(channels): design webhook-triggered tasks
qqqys Jul 7, 2026
76af57e
feat(channels): add webhook task helpers
qqqys Jul 7, 2026
feb18d7
fix(channels): bound webhook prompt metadata
qqqys Jul 7, 2026
f3d3b31
feat(channels): run webhook-triggered tasks
qqqys Jul 7, 2026
2bcf6e3
fix(channels): harden webhook task lifecycle
qqqys Jul 7, 2026
c4fdf6e
fix(channels): require yolo for webhook tasks
qqqys Jul 7, 2026
2f3c7c6
feat(channels): parse webhook configuration
qqqys Jul 7, 2026
43e6e9b
fix(channels): validate webhook secrets
qqqys Jul 7, 2026
8751251
feat(channels): forward webhook tasks to channel worker
qqqys Jul 7, 2026
ca8fabd
fix(channels): require webhook enqueue on supervisors
qqqys Jul 7, 2026
6bf8292
fix(channels): handle webhook IPC send failures safely
qqqys Jul 7, 2026
8c0fffc
feat(serve): accept channel webhook tasks
qqqys Jul 7, 2026
98bd26e
fix(serve): stop webhook validation after first error
qqqys Jul 7, 2026
fef00b6
fix(webhooks): reject inherited target refs
qqqys Jul 7, 2026
0a04776
docs(channels): document webhook-triggered tasks
qqqys Jul 7, 2026
8d90032
docs(channels): fix webhook task example
qqqys Jul 7, 2026
963c94e
docs(channels): refine webhook task docs
qqqys Jul 7, 2026
b1868e8
docs(channels): add webhook task implementation plan
qqqys Jul 7, 2026
8ce2274
fix(channels): restore webhook task context and chunks
qqqys Jul 7, 2026
8f16b3c
fix(serve): classify worker webhook enqueue failures
qqqys Jul 7, 2026
1ae3ff0
fix(channels): address webhook review feedback
qqqys Jul 8, 2026
1e5b01e
Merge upstream/main into channel webhook tasks
qqqys Jul 8, 2026
8b9f457
fix(serve): address channel webhook review blockers
qqqys Jul 8, 2026
16bcc1d
fix(serve): satisfy channel webhook lint
qqqys Jul 8, 2026
cd54581
fix(serve): harden channel webhook admission
qqqys Jul 8, 2026
593114a
fix(serve): narrow channel webhook source config
qqqys Jul 8, 2026
37cd166
fix(serve): classify webhook session scope failures
qqqys Jul 8, 2026
cf18f72
fix(serve): harden webhook payload handling
qqqys Jul 8, 2026
49b451b
fix(serve): authenticate webhook startup cheaply
qqqys Jul 8, 2026
459e30b
fix(serve): keep deferred serve fast path lean
qqqys Jul 8, 2026
72cc2ca
fix(serve): address deferred webhook review blockers
qqqys Jul 8, 2026
ea6170a
Merge upstream/main into channel webhook tasks
qqqys Jul 8, 2026
3618123
fix(channels): propagate webhook approval mode
qqqys Jul 8, 2026
dcd1c99
fix(channels): harden webhook task admission
qqqys Jul 8, 2026
dd4a429
fix(acp): harden approval mode initialization
qqqys Jul 8, 2026
c91c47c
fix(channels): harden webhook shutdown and secrets
qqqys Jul 8, 2026
01c74c1
fix(channels): harden webhook review blockers
qqqys Jul 8, 2026
b8ec2f5
fix(serve): harden deferred webhook auth
qqqys Jul 8, 2026
07d0aa4
test(channels): cover webhook target rejection
qqqys Jul 8, 2026
6630d54
Merge remote-tracking branch 'refs/remotes/upstream/main' into automa…
qqqys Jul 8, 2026
9d4860f
fix(channels): harden webhook auth review blockers
qqqys Jul 9, 2026
05af728
fix(channels): preserve webhook thread targets
qqqys Jul 9, 2026
649c513
fix(channels): address webhook review blockers
qqqys Jul 9, 2026
67ae5b3
fix(channels): harden webhook review blockers
qqqys Jul 9, 2026
cec7d94
fix(channel): address webhook review blockers
qqqys Jul 9, 2026
46bba9e
test(serve): align deferred webhook secret log assertion
qqqys Jul 9, 2026
eecb60c
fix(channels): isolate webhook thread sessions
qqqys Jul 9, 2026
570e85a
Merge remote-tracking branch 'refs/remotes/upstream/main' into automa…
qqqys Jul 9, 2026
29912d4
Merge remote-tracking branch 'refs/remotes/upstream/main' into automa…
qqqys Jul 9, 2026
60a1b7b
Merge upstream/main into channel webhook tasks
qqqys Jul 9, 2026
43cd2b8
fix(channels): harden webhook enqueue failures
qqqys Jul 9, 2026
76afe92
Merge upstream/main into agent/channel-webhook-tasks
qqqys Jul 10, 2026
6e99def
fix(serve): classify disabled channel workers
qqqys Jul 10, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions docs/developers/daemon/15-channel-adapters.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,12 @@ There are two current host modes:

In daemon-managed mode, each channel maps inbound chat traffic to daemon sessions under a configurable `SessionScope` (`user`, `thread`, or `single`). The adapter delegates to `DaemonChannelBridge`, which delegates to the SDK's `DaemonSessionClient` (see [`13-sdk-daemon-client.md`](./13-sdk-daemon-client.md)). Channel workers remain primary-workspace only in Phase 2a, so every selected channel's `cwd` must resolve to the daemon primary workspace.

### Webhook-triggered channel tasks

Webhook-triggered tasks are hosted by `qwen serve` and executed inside the daemon-managed channel worker. The HTTP route validates the source and forwards a `ChannelWebhookTask` to the worker over IPC. The worker calls `ChannelBase.runWebhookTask()`, so adapters do not implement webhook parsing.

Adapters still participate through proactive send support: `supportsProactiveSend()` tells the host whether a channel can send without an inbound message, `supportsProactiveTarget()` handles delivery limits for specific target shapes, and `pushProactive()` carries the outbound content.

## Responsibilities

- Receive inbound messages from the channel's native transport (DingTalk WebSocket stream, WeChat HTTP long-poll, Telegram Bot long-poll, Feishu WebSocket or HTTP webhook).
Expand Down
66 changes: 66 additions & 0 deletions docs/users/features/channels/overview.md
Original file line number Diff line number Diff line change
Expand Up @@ -389,6 +389,72 @@ This mode starts one channel worker process owned by `qwen serve`. The worker co

When channels are serve-managed, `qwen channel status` shows the owner as `qwen serve`, and `qwen channel stop` tells you to stop the daemon instead of signaling the worker directly. If a ready worker exits unexpectedly, the daemon continues running and reports a channel-worker warning in `/daemon/status`.

## Webhook-triggered tasks

Daemon-managed channels can also accept authenticated webhook events. Qwen receives the event as context, summarizes and decides what matters, and then delivers the final response to the configured chat target. This is not a raw notification relay.
Webhook tasks require `approvalMode: "yolo"` because they run without interactive approval. That setting applies to the whole channel, not only webhook turns, so use a dedicated webhook channel or tightly restrict normal chat senders for that channel.

Example channel config:

```json
{
"channels": {
"dingtalk-main": {
"type": "dingtalk",
"clientId": "$DINGTALK_CLIENT_ID",
"clientSecret": "$DINGTALK_CLIENT_SECRET",
"cwd": "/repo",
"senderPolicy": "allowlist",
"allowedUsers": ["12345"],
"approvalMode": "yolo",
"sessionScope": "user",
"webhooks": {
"sources": {
"github-ci": {
"secretEnv": "QWEN_CHANNEL_GITHUB_CI_SECRET",
"targets": {
"default": {
"chatId": "OPEN_CONVERSATION_ID",
"senderId": "webhook:github-ci",
"isGroup": true
}
}
}
}
}
}
}
}
```

For DingTalk, `chatId` must be the group `openConversationId`; other adapters may require their own proactive target shape.

Start `qwen serve` with the channel worker enabled:

```bash
QWEN_SERVER_TOKEN="$QWEN_SERVER_TOKEN" qwen serve --require-auth --channel dingtalk-main
```

Example request:

```bash
curl -X POST "http://127.0.0.1:4170/channels/dingtalk-main/webhooks/github-ci" \
-H "x-qwen-webhook-secret: $QWEN_CHANNEL_GITHUB_CI_SECRET" \
-H "Content-Type: application/json" \
-d '{
"eventType": "push",
"targetRef": "default",
"title": "CI pipeline finished",
"payload": {
"targetRef": "refs/heads/main",
"repository": "qwen-code",
"status": "success"
}
}'
```

Webhook routes authenticate with the webhook secret header, even when `qwen serve` is running with bearer auth enabled. Do not share the daemon bearer token with webhook providers. Webhook config and `secretEnv` values are loaded when the daemon starts; restart `qwen serve` after changing webhook sources or rotating secrets. A `202 {"accepted": true}` response means the channel worker accepted ownership of the task, not that the final response has already been delivered to chat. Check daemon and channel worker logs, plus `/daemon/status`, when troubleshooting delivery failures.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Low] 建议补充错误状态码文档

用户文档中仅描述了 202 成功响应语义,但路由实现了完整的错误体系(401/404/409/503/504/400/429),这些在 channel-webhooks.ts 和对应测试中都有明确定义。

建议在文档中补充「响应与错误语义」小节,概括各状态码含义和重试策略,帮助用户理解失败场景。例如:

  • 401 – 密钥错误或未知 source
  • 404 – 未知 targetRef
  • 409 – 目标会话不满足 webhook 条件(non-yolo / single scope / 不支持 proactive send)
  • 503 – worker 不可用或队列已满
  • 504 – IPC 超时
  • 400 – 请求体无效
  • 429 – rate limit


### Multi-Channel Mode

When you run `qwen channel start` without a name, all channels defined in `settings.json` start together sharing a single agent process. Each channel maintains its own sessions — a Telegram user and a WeChat user get separate conversations, even though they share the same agent.
Expand Down
220 changes: 220 additions & 0 deletions packages/acp-bridge/src/bridge.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7978,6 +7978,226 @@ describe('createAcpSessionBridge', () => {
return { factory, getCalls: () => calls };
}

function rejectingApprovalModeFactory(): ChannelFactory {
return async () => {
const { clientStream, agentStream } = createInMemoryChannel();
const agent = new FakeAgent({
extMethodImpl: (method) => {
if (method === 'qwen/control/session/approval_mode') {
return Promise.reject(
Object.assign(new Error('trust gate rejected'), {
data: { errorKind: 'trust_gate' },
}),
);
}
return Promise.resolve({});
},
});
new AgentSideConnection(() => agent as Agent, agentStream);
return {
stream: clientStream,
exited: new Promise<
| { exitCode: number | null; signalCode: NodeJS.Signals | null }
| undefined
>(() => {}),
kill: async () => {},
killSync: () => {},
};
};
}

function deferredApprovalModeFactory(): {
factory: ChannelFactory;
waitForApprovalMode: () => Promise<void>;
rejectApprovalMode: (error?: Error) => void;
} {
let started!: () => void;
let rejectApprovalMode: ((error: Error) => void) | undefined;
const startedPromise = new Promise<void>((resolve) => {
started = resolve;
});
return {
factory: async () => {
const { clientStream, agentStream } = createInMemoryChannel();
const agent = new FakeAgent({
extMethodImpl: (method) => {
if (method !== 'qwen/control/session/approval_mode') {
return Promise.resolve({});
}
return new Promise((_resolve, reject) => {
rejectApprovalMode = reject;
started();
});
},
});
new AgentSideConnection(() => agent as Agent, agentStream);
return {
stream: clientStream,
exited: new Promise<
| { exitCode: number | null; signalCode: NodeJS.Signals | null }
| undefined
>(() => {}),
kill: async () => {},
killSync: () => {},
};
},
waitForApprovalMode: () => startedPromise,
rejectApprovalMode: (error = new Error('trust gate rejected')) => {
if (!rejectApprovalMode) {
throw new Error('approval mode was not requested');
}
rejectApprovalMode(
Object.assign(error, { data: { errorKind: 'trust_gate' } }),
);
},
};
}

it('reaps a fresh session when approval-mode initialization fails', async () => {
const bridge = makeBridge({
channelFactory: rejectingApprovalModeFactory(),
maxSessions: 1,
});

await expect(
bridge.spawnOrAttach({
workspaceCwd: WS_A,
sessionScope: 'thread',
approvalMode: ApprovalMode.YOLO,
}),
).rejects.toThrow();

await expect(
bridge.spawnOrAttach({
workspaceCwd: WS_A,
sessionScope: 'thread',
}),
).resolves.toMatchObject({ attached: false });
await bridge.shutdown();
});

it('does not publish a failing approval-mode spawn as the default session', async () => {
const { factory, waitForApprovalMode, rejectApprovalMode } =
deferredApprovalModeFactory();
const bridge = makeBridge({
channelFactory: factory,
sessionScope: 'single',
});

const first = bridge.spawnOrAttach({
workspaceCwd: WS_A,
sessionScope: 'single',
approvalMode: ApprovalMode.YOLO,
});
await waitForApprovalMode();

const second = bridge.spawnOrAttach({
workspaceCwd: WS_A,
sessionScope: 'single',
});
let secondSettled = false;
void second.then(
() => {
secondSettled = true;
},
() => {
secondSettled = true;
},
);
await Promise.resolve();
expect(secondSettled).toBe(false);

rejectApprovalMode();
await expect(first).rejects.toThrow();
await expect(second).rejects.toThrow();
await expect(
bridge.spawnOrAttach({
workspaceCwd: WS_A,
sessionScope: 'thread',
}),
).resolves.toMatchObject({ attached: false });
await bridge.shutdown();
});

it('rolls back attach bookkeeping when approval-mode initialization fails', async () => {
const bridge = makeBridge({
channelFactory: rejectingApprovalModeFactory(),
maxSessions: 1,
});
const first = await bridge.spawnOrAttach({
workspaceCwd: WS_A,
sessionScope: 'single',
});

await expect(
bridge.spawnOrAttach({
workspaceCwd: WS_A,
sessionScope: 'single',
approvalMode: ApprovalMode.YOLO,
}),
).rejects.toThrow();

await bridge.detachClient(first.sessionId, first.clientId);
await expect(
bridge.spawnOrAttach({
workspaceCwd: WS_A,
sessionScope: 'thread',
}),
).resolves.toMatchObject({ attached: false });
await bridge.shutdown();
});

it('rolls back restored sessions when approval-mode initialization fails', async () => {
const bridge = makeBridge({
channelFactory: rejectingApprovalModeFactory(),
maxSessions: 1,
});

await expect(
bridge.loadSession({
sessionId: 'restore-with-mode',
workspaceCwd: WS_A,
approvalMode: ApprovalMode.YOLO,
}),
).rejects.toThrow();

expect(bridge.sessionCount).toBe(0);
await expect(
bridge.spawnOrAttach({
workspaceCwd: WS_A,
sessionScope: 'thread',
}),
).resolves.toMatchObject({ attached: false });
await bridge.shutdown();
});

it('reaps a tombstoned session when approval-mode attach rollback removes the last attach', async () => {
const { factory, waitForApprovalMode, rejectApprovalMode } =
deferredApprovalModeFactory();
const bridge = makeBridge({
channelFactory: factory,
sessionScope: 'single',
});
const first = await bridge.spawnOrAttach({
workspaceCwd: WS_A,
sessionScope: 'single',
});

const attach = bridge.spawnOrAttach({
workspaceCwd: WS_A,
sessionScope: 'single',
approvalMode: ApprovalMode.YOLO,
});
await waitForApprovalMode();
await bridge.killSession(first.sessionId, { requireZeroAttaches: true });
expect(bridge.sessionCount).toBe(1);

rejectApprovalMode();
await expect(attach).rejects.toThrow();
expect(bridge.sessionCount).toBe(0);
await bridge.shutdown();
});

it('throws BEFORE the ACP roundtrip when persist:true but no callback wired', async () => {
// The previous post-ACP placement of the persist guard meant a
// missing callback produced a 500 *after* the ACP child had
Expand Down
Loading
Loading