Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
27 changes: 27 additions & 0 deletions docs/users/features/channels/overview.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ Channels are configured under the `channels` key in `settings.json`. Each channe
| `senderPolicy` | No | Who can talk to the bot: `allowlist` (default), `open`, or `pairing` |
| `allowedUsers` | No | List of user IDs allowed to use the bot (used by `allowlist` and `pairing` policies) |
| `sessionScope` | No | How sessions are scoped: `user` (default), `thread`, or `single` |
| `sessionRotation` | No | Bounds after which a route starts a fresh session: `{ "maxTurns": N, "maxAgeHours": N }`. Unset means a session is reused forever. See [Session rotation](#session-rotation) |
| `cwd` | No | Working directory for the agent. Defaults to the current directory |
| `approvalMode` | No | Tool approval mode for channel sessions. Unattended webhook tasks require `yolo`; the setting applies to every session on the channel |
| `instructions` | No | Custom instructions prepended to the first message of each session |
Expand Down Expand Up @@ -91,6 +92,32 @@ Controls how conversation sessions are managed:
- **`thread`** — One session per thread/topic. Useful for group chats with threads.
- **`single`** — One shared session for all users. Everyone shares the same conversation.

### Session Rotation

By default a route keeps the same session forever, so a long-lived route — a busy group thread, a `single`-scope channel — accumulates context without bound. Once it grows past the model's context window every later message on that route fails, while the rest of the channel keeps working. `sessionRotation` puts a ceiling on that: when the current session is past its bound, the next message starts a fresh one instead.

```json
{
"channels": {
"my-bot": {
"type": "dingtalk",
"sessionScope": "thread",
"sessionRotation": {
"maxTurns": 200,
"maxAgeHours": 24
}
}
}
}
```

- **`maxTurns`** — Rotate once this many messages have started a turn on the current session. Messages that settle without one (a `!` shell command, a dropped loop firing) do not count.
- **`maxAgeHours`** — Rotate once the current session is older than this.

Set either, both, or neither; whichever bound is hit first rotates. `maxTurns` must be a positive integer and `maxAgeHours` a positive number. Omitting `sessionRotation` keeps the previous behavior of never rotating. In `collect` dispatch mode, messages buffered while a turn runs are coalesced into one turn and count once against `maxTurns`.

Rotation is a context reset, not a cleanup: the new session starts empty, so the bot no longer remembers the earlier conversation on that route. The channel posts a short notice in the chat or thread whose message triggered the rotation, and the daemon logs the rotated route. With `sessionScope: single`, only the chat whose message triggered the rotation is notified; other chats sharing the session see the reset without a notice. Counters are stored alongside the routes and survive a daemon restart. Sessions that were already routed before you enabled rotation start their clock at the first message after the upgrade. A route that still has a turn running or queued rotates on the next message after it settles instead of mid-turn; under sustained traffic, where every message arrives while a turn is still running or queued, the bound waits for the first pause in traffic. Each message routed during that window extends it, so a continuously saturated route — a webhook receiving events faster than turns complete, or an overrunning loop — rotates only once traffic stops.

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.

[Suggestion] The documented sessionScope: single rotation semantics in this sentence (only the triggering chat is notified; other chats sharing the session see a silent reset) have zero test coverage — every rotation test in ChannelBase.test.ts uses the default user scope or threads, and no test anywhere in packages/ combines single scope with rotation. This sentence was added as the R5-8 fix, and the announce target comes from the triggering message's input (the R2-8 fix) — nothing pins either in-tree. — Failure scenario: a future refactor that fetches the announce target from the stored route target (reverting R2-8), or that broadcasts the notice to every chat on a single-scope route, would silently change this user-visible documented behaviour with the entire suite green. — Suggested fix: add a ChannelBase rotation test with sessionScope: 'single': two chats share the route, hit maxTurns from chat A, assert chat A receives the rotation notice and chat B receives none while its next prompt lands on the fresh session.

中文说明

建议:本句所记载的 sessionScope: single 轮换语义(只通知触发轮换的那个聊天;共享该会话的其他聊天静默重置)没有任何测试覆盖——ChannelBase.test.ts 中的全部轮换测试都使用默认 user 作用域或 thread,packages/ 下也没有任何测试把 single 作用域与轮换组合起来。这句话是作为 R5-8 的修复加入的,通知目标取自触发消息的输入(R2-8 的修复)——两者在仓库中都没有测试钉住。失败场景:未来若有重构把通知目标改为取自存储的路由 target(回退 R2-8),或把通知广播给 single 作用域路由上的所有聊天,都会静默改变这一用户可见的已记载行为,而整个测试套件全绿。建议修复:新增一个 sessionScope: 'single' 的 ChannelBase 轮换测试:两个聊天共享同一路由,从聊天 A 触达 maxTurns,断言聊天 A 收到轮换通知、聊天 B 收不到通知且其下一条消息落在新会话上。

— qwen3.8-max via Qwen Code /review (v0.21.10)


### Channel Memory

Channel memory stores durable context for one chat or thread. Entries have stable
Expand Down
82 changes: 82 additions & 0 deletions packages/channels/base/src/AcpBridge.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,7 @@ type TestableAcpBridge = AcpBridge & {
};
knownSessionIds: Set<string>;
sessionBindingTokens: Map<string, object | undefined>;
pendingSessionRequests: Set<{ reject: (error: Error) => void }>;
channelLoopMcpServer: unknown;
channelLoopToolHandlers: ChannelLoopToolHandler[];
channelLoopMcpRegistered: boolean;
Expand Down Expand Up @@ -845,6 +846,87 @@ describe('AcpBridge', () => {
expect(bridge.resolveChannelLoopToolHandler('s-1')).toBe(handler);
});

it('rejects in-flight session requests when the ACP child exits', async () => {
const bridge = new AcpBridge({
cliEntryPath: '/tmp/qwen',
cwd: '/tmp',
});

await bridge.start();
const proc = child.instances[0]!;
const connection = child.connections[0] as unknown as {
newSession: ReturnType<typeof vi.fn>;
loadSession: ReturnType<typeof vi.fn>;
};
// The SDK never settles requests still awaiting a response once the
// stream ends, so neither call can finish on its own.
connection.newSession = vi.fn(() => new Promise(() => {}));
connection.loadSession = vi.fn(() => new Promise(() => {}));

const pendingNew = bridge.newSession('/tmp');
const pendingLoad = bridge.loadSession('s-1', '/tmp');

proc.emit('exit', 1, null);

const reason =
'ACP agent process exited while a session request was in flight';
await expect(pendingNew).rejects.toThrow(reason);
await expect(pendingLoad).rejects.toThrow(reason);
expect(
(bridge as unknown as TestableAcpBridge).pendingSessionRequests.size,
).toBe(0);
});

it('rejects in-flight session requests when the bridge stops', async () => {
const bridge = new AcpBridge({
cliEntryPath: '/tmp/qwen',
cwd: '/tmp',
});

await bridge.start();
const connection = child.connections[0] as unknown as {
newSession: ReturnType<typeof vi.fn>;
};
connection.newSession = vi.fn(() => new Promise(() => {}));

const pending = bridge.newSession('/tmp');
bridge.stop();

await expect(pending).rejects.toThrow(
'ACP agent process exited while a session request was in flight',
);
});

it('drops a settled session request from the pending set', async () => {
const bridge = new AcpBridge({
cliEntryPath: '/tmp/qwen',
cwd: '/tmp',
}) as unknown as TestableAcpBridge;
bridge.child = { killed: false, exitCode: null };
bridge.connection = {
extMethod: vi.fn(),
newSession: vi.fn().mockResolvedValue({ sessionId: 's-1' }),
};

await expect(bridge.newSession('/tmp')).resolves.toBe('s-1');
expect(bridge.pendingSessionRequests.size).toBe(0);
});

it('drops a failed session request from the pending set', async () => {
const bridge = new AcpBridge({
cliEntryPath: '/tmp/qwen',
cwd: '/tmp',
}) as unknown as TestableAcpBridge;
bridge.child = { killed: false, exitCode: null };
bridge.connection = {
extMethod: vi.fn(),
newSession: vi.fn().mockRejectedValue(new Error('spawn failed')),
};

await expect(bridge.newSession('/tmp')).rejects.toThrow('spawn failed');
expect(bridge.pendingSessionRequests.size).toBe(0);
});

it('kills the ACP child when it reports a large event loop stall', async () => {
const bridge = new AcpBridge({
cliEntryPath: '/tmp/qwen',
Expand Down
64 changes: 54 additions & 10 deletions packages/channels/base/src/AcpBridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,9 @@ export class AcpBridge extends EventEmitter implements ChannelAgentBridge {
timeout: ReturnType<typeof setTimeout>;
}
>();
private readonly pendingSessionRequests = new Set<{
reject: (error: Error) => void;
}>();

constructor(options: AcpBridgeOptions) {
super();
Expand Down Expand Up @@ -149,6 +152,7 @@ export class AcpBridge extends EventEmitter implements ChannelAgentBridge {
);
// Do not emit sessionDied here: a full ACP process exit is handled by
// channel start crash recovery, which reloads the persisted sessions.
this.rejectPendingSessionRequests();
this.resolvePendingPermissions();
Comment on lines 154 to 156

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.

[Critical] Rejecting in-flight loadSession calls on child exit turns an interrupted restoreSessions() into a fast-completed one whose end-of-restore flush permanently prunes every route the restore never reached from the persisted store — the crash-recovery restore that runs seconds later has nothing to reload for those routes. — Failure scenario: an eager restore of N routes is loading when the ACP child dies (crash, or the stall watchdog's SIGKILL): the in-flight request rejects, every later key fast-fails in ensureConnection() ("Not connected to ACP agent"), each catch sets changed/persistRequestedWhileSuspended without re-adding the key (the reservation pass already deleteByKey'd it), and the finally-flush persists only the restored prefix. Probe-reproduced at this commit: a 3-route store ends with 1 key on disk and crash recovery restores 1 of 3; removing the reject flips it (the restore hangs pre-flush, store intact — the pre-PR behavior). — Suggested fix: in restoreSessions(), treat bridge-death load failures differently from genuine load failures — re-seed the persisted entry instead of leaving the key dropped (and don't set changed/persistRequestedWhileSuspended for those keys), or skip the end-flush when the restore was interrupted by a bridge exit.

中文说明

严重:子进程退出时拒绝在途 loadSession 调用,会把被打断的 restoreSessions() 变成一次快速完成的恢复,其结束 flush 会把恢复未触及的所有路由从持久化存储中永久剪掉——几秒后 crash recovery 读到的就是这份被裁剪的文件,那些路由无从重载。失败场景:eager 恢复 N 条路由途中 ACP 子进程死亡(崩溃或事件循环卡死看门狗 SIGKILL):在途请求被拒绝,其余每个 key 在 ensureConnection() 中快速失败("Not connected to ACP agent"),每个 catch 设置 changed/persistRequestedWhileSuspended 但不重新放回该 key(预留阶段已 deleteByKey),最终 flush 只持久化已恢复的前缀。已在本 commit 用探针复现:3 路由存储结束后磁盘只剩 1 个 key,crash recovery 只恢复 1/3;去掉该 reject 后翻转(恢复在 flush 前挂起、存储保持完整——即改动前行为)。建议修复:在 restoreSessions() 中把「bridge 死亡导致的加载失败」与真正的加载失败区别对待——重新种入持久化条目而不是丢弃该 key(且不为这些 key 设置 changed/persistRequestedWhileSuspended),或在恢复被 bridge 退出打断时跳过结束 flush。

— qwen3.8-max via Qwen Code /review (v0.21.10)

this.knownSessionIds.clear();
this.sessionBindingTokens.clear();
Expand Down Expand Up @@ -232,11 +236,14 @@ export class AcpBridge extends EventEmitter implements ChannelAgentBridge {
bindingToken?: object,
): Promise<string> {
const conn = this.ensureConnection();
await this.registerChannelLoopMcpServer();
const response = await conn.newSession({ cwd, mcpServers: [] });
this.knownSessionIds.add(response.sessionId);
this.sessionBindingTokens.set(response.sessionId, bindingToken);
return response.sessionId;
const sessionId = await this.settleOnChildExit(async () => {
await this.registerChannelLoopMcpServer();
const response = await conn.newSession({ cwd, mcpServers: [] });
return response.sessionId;
});
this.knownSessionIds.add(sessionId);
this.sessionBindingTokens.set(sessionId, bindingToken);
return sessionId;
}

async loadSession(
Expand All @@ -246,11 +253,13 @@ export class AcpBridge extends EventEmitter implements ChannelAgentBridge {
bindingToken?: object,
): Promise<string> {
const conn = this.ensureConnection();
await this.registerChannelLoopMcpServer();
await conn.loadSession({
sessionId,
cwd,
mcpServers: [],
await this.settleOnChildExit(async () => {
await this.registerChannelLoopMcpServer();
await conn.loadSession({
sessionId,
cwd,
mcpServers: [],
});
});
this.knownSessionIds.add(sessionId);
this.sessionBindingTokens.set(sessionId, bindingToken);
Expand Down Expand Up @@ -361,6 +370,7 @@ export class AcpBridge extends EventEmitter implements ChannelAgentBridge {
}

stop(): void {
this.rejectPendingSessionRequests();
Comment on lines 372 to +373

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.

[Suggestion] R7-5: The stop() rejection path for in-flight session requests added in this diff has no test; the only new test exercises the child-exit path. — Failure scenario: mutation-verified at this commit — deleting this rejectPendingSessionRequests() call leaves all 37 AcpBridge tests green, so if a later change drops it, a newSession/loadSession racing channel shutdown hangs its caller forever (the exact hang this PR fixes) with no red test. — Suggested fix: extend the new test (or add a sibling): hold a never-resolving newSession, call bridge.stop(), and assert rejection with the same reason string.

中文说明

建议:本 diff 新增的 stop() 对在途会话请求的拒绝路径没有测试;唯一的新测试只覆盖了子进程 exit 路径。——失败场景:已在本 commit 做变异验证——删除这行 rejectPendingSessionRequests() 后全部 37 个 AcpBridge 测试仍为绿,因此后续改动若删掉它,与频道关闭竞争的 newSession/loadSession 会永远挂起调用方(正是本 PR 修复的挂起),且没有任何测试变红。——建议修复:扩展新测试(或补一个姊妹测试):持有一个永不落定的 newSession,调用 bridge.stop(),断言以相同原因串被拒绝。

— qwen3.8-max via Qwen Code /review (v0.21.10)

this.resolvePendingPermissions();
this.knownSessionIds.clear();
this.sessionBindingTokens.clear();
Expand Down Expand Up @@ -462,6 +472,40 @@ export class AcpBridge extends EventEmitter implements ChannelAgentBridge {
return this.connection;
}

/**
* The ACP SDK never settles requests still awaiting a response once the
* stream ends, so a child death mid-request would hang the caller forever;
* a restore's persist suspension, in particular, would never lift. Reject
* those requests when the child exits instead.
*/
private settleOnChildExit<T>(run: () => Promise<T>): Promise<T> {
return new Promise<T>((resolve, reject) => {
const pending = { reject };
this.pendingSessionRequests.add(pending);
run().then(
(result) => {
this.pendingSessionRequests.delete(pending);
resolve(result);
},
Comment on lines +486 to +489

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.

[Suggestion] R7-6: Test-efficacy probe (harness validated): no test fails when this success-path pendingSessionRequests.delete(pending) is deleted. — Failure scenario: if a future change drops this delete, every successfully settled session request leaves its { reject } record in pendingSessionRequests for the connection's lifetime; the Set grows with traffic and the next child exit iterates all stale records and re-rejects already-settled promises. — Suggested fix: add a test that settles a session request successfully and then asserts the pending set is empty (e.g. a subsequent child exit rejects nothing).

中文说明

建议:测试有效性探针(harness 已验证):删除成功路径上的这个 pendingSessionRequests.delete(pending) 时没有任何测试失败。——失败场景:若后续改动删除该 delete,每个成功落定的会话请求都会在连接生命周期内留下 { reject } 记录;Set 随流量增长,下一次子进程退出会遍历所有过期记录并重复拒绝已落定的 promise。——建议修复:补一个测试——让一个会话请求成功落定,随后断言 pending 集合为空(例如此后的子进程退出不再拒绝任何东西)。

— qwen3.8-max via Qwen Code /review (v0.21.10)

(error: unknown) => {
this.pendingSessionRequests.delete(pending);
reject(error);
},
Comment on lines +490 to +493

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.

[Suggestion] R7-7: Test-efficacy probe (harness validated): no test fails when this error-path pendingSessionRequests.delete(pending) is deleted. — Failure scenario: if this delete is removed, every failed session request keeps its record in pendingSessionRequests; the next child exit calls reject again on already-rejected promises and retains the records indefinitely — the same unbounded-growth mechanism as the success path, exercised by request failures. — Suggested fix: add a test that fails a session request and then asserts a subsequent child-exit settlement finds no pending records.

中文说明

建议:测试有效性探针(harness 已验证):删除错误路径上的这个 pendingSessionRequests.delete(pending) 时没有任何测试失败。——失败场景:若删除该 delete,每个失败的会话请求都会保留其记录;下一次子进程退出会对已拒绝的 promise 再次调用 reject 并无限期保留记录——与成功路径相同的无界增长机制,由请求失败触发。——建议修复:补一个测试——让一个会话请求失败,随后断言子进程退出结算时不再有 pending 记录。

— qwen3.8-max via Qwen Code /review (v0.21.10)

);
});
}

private rejectPendingSessionRequests(): void {
for (const pending of this.pendingSessionRequests) {
pending.reject(
new Error(
'ACP agent process exited while a session request was in flight',
),
);
}
this.pendingSessionRequests.clear();
}
Comment on lines +504 to +507

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.

[Suggestion] R7-8: Test-efficacy probe (harness validated): no test fails when pendingSessionRequests.clear() in rejectPendingSessionRequests is deleted. — Failure scenario: this method exists because a child death mid-request means the underlying promise may never settle — the per-request delete callbacks may never run, so clear() is the only removal path for those records. If it is deleted, every record in flight at child-exit time stays in the Set forever (re-rejected on every subsequent exit) — a permanent leak per crash, and no test in this diff would catch it. — Suggested fix: add a test: put a request in flight, trigger child exit, then assert pendingSessionRequests is empty afterwards (or that a second exit rejects nothing).

中文说明

建议:测试有效性探针(harness 已验证):删除 rejectPendingSessionRequests 中的 pendingSessionRequests.clear() 时没有任何测试失败。——失败场景:该方法存在的原因是子进程在请求中途死亡时底层 promise 可能永不落定——按请求的 delete 回调可能永远不会执行,clear() 是这些记录唯一的移除路径。若删除它,子进程退出时在途的每条记录都会永远留在 Set 中(之后每次退出都被重复拒绝)——每次崩溃一个永久泄漏,且本 diff 中没有任何测试能捕获。——建议修复:补一个测试——让一个请求在途、触发子进程退出,断言之后 pendingSessionRequests 为空(或第二次退出不再拒绝任何东西)。

— qwen3.8-max via Qwen Code /review (v0.21.10)


private requestPermission(
request: RequestPermissionRequest,
): Promise<RequestPermissionResponse> {
Expand Down
Loading
Loading