Skip to content
Closed
Show file tree
Hide file tree
Changes from 4 commits
Commits
Show all changes
38 commits
Select commit Hold shift + click to select a range
710a121
feat(channels): bound session lifetime with sessionRotation
qwen-code-dev-bot Aug 11, 2026
fa2f082
Merge branch 'main' into feat/channel-session-rotation
qwen-code-dev-bot Aug 11, 2026
53a6777
fix(channels): register sessionRotation bounds in every launch mode (…
qwen-code-dev-bot Aug 11, 2026
48758ff
Merge branch 'main' into feat/channel-session-rotation
qwen-code-dev-bot Aug 11, 2026
6dbca59
fix(channels): retire rotated sessions safely and harden rotation con…
qwen-code-dev-bot Aug 11, 2026
e4f78f3
fix(channels): persist stamped rotation clock for pre-rotation routes…
qwen-code-dev-bot Aug 11, 2026
3d6fedf
fix(channels): defer rotation until routed messages settle (#8927)
qwen-code-dev-bot Aug 11, 2026
1759b9e
fix(channels): harden session rotation edge cases from review (#8927)
qwen-code-dev-bot Aug 12, 2026
0452293
fix(channels): harden rotation restore races and pin rotation edge te…
qwen-code-dev-bot Aug 12, 2026
3f7ffe2
fix(channels): keep route store whole across overlapping restores (#8…
qwen-code-dev-bot Aug 12, 2026
0a86d72
fix(channels): settle abandoned session requests, keep overlap restor…
qwen-code-dev-bot Aug 12, 2026
e570597
fix(channels): net mid-restore rotation writes and keep cleared route…
qwen-code-dev-bot Aug 12, 2026
0646611
Merge branch 'main' into feat/channel-session-rotation
qwen-code-dev-bot Aug 13, 2026
1714662
Merge remote-tracking branch 'origin/main' into feat/channel-session-…
wenshao Aug 30, 2026
f36b676
chore(channels): merge origin/main into feat/channel-session-rotation…
qwen-code-dev-bot Sep 4, 2026
1951930
chore(channels): merge origin/main into feat/channel-session-rotation…
wenshao Sep 6, 2026
7151a80
fix(channels): close the restore/rotation race cluster and retire con…
wenshao Sep 6, 2026
8edc709
test(channels): pin the rotation gate's in-flight guard and waiter ac…
wenshao Sep 6, 2026
be94b87
fix(channels): tombstone every mid-restore removal and re-check at se…
wenshao Sep 6, 2026
6082d7c
test(cli): dedupe the sessionRotation not-an-object test title (#8927)
wenshao Sep 6, 2026
9823737
chore(channels): merge origin/main into feat/channel-session-rotation…
qwen-code-dev-bot Sep 6, 2026
6c86e44
fix(channels): close restore-window resurrection paths and the /btw r…
qwen-code-dev-bot Sep 7, 2026
27e864a
Merge branch 'main' into feat/channel-session-rotation
qwen-code-dev-bot Sep 7, 2026
20ace44
Merge remote-tracking branch 'origin/main' into feat/channel-session-…
qwen-code-dev-bot Sep 11, 2026
6d2ffd3
fix(channels): close superseded-restore, lazy-death, abort-waiter and…
qwen-code-dev-bot Sep 11, 2026
ad1dfea
fix(channels): close restore-settle discard, supersession, and abort-…
qwen-code-dev-bot Sep 11, 2026
4901d8f
Merge branch 'main' into feat/channel-session-rotation
qwen-code-dev-bot Sep 11, 2026
51de497
Merge branch 'main' into feat/channel-session-rotation
qwen-code-dev-bot Sep 12, 2026
8812b10
Merge branch 'main' into feat/channel-session-rotation
qwen-code-dev-bot Sep 13, 2026
f70341e
fix(channels): close the review rounds 15-19 cluster in restore/rotation
wenshao Sep 14, 2026
490a941
Merge remote-tracking branch 'origin/main' into takeover/channel-sess…
wenshao Sep 14, 2026
f3a030a
Merge branch 'main' into feat/channel-session-rotation
qwen-code-dev-bot Sep 14, 2026
d27a61d
Merge branch 'main' into feat/channel-session-rotation
qwen-code-dev-bot Sep 14, 2026
35d5644
Merge branch 'main' into feat/channel-session-rotation
qwen-code-dev-bot Sep 14, 2026
382d459
chore(channels): merge main into the sessionRotation branch
qwen-code-dev-bot Sep 23, 2026
7330f7d
chore(channels): merge main into the sessionRotation branch
qwen-code-dev-bot Sep 23, 2026
a5ad737
fix(cli): merge duplicate channel-base imports in config-utils
yiliang114 Sep 23, 2026
0999b63
style(channels): apply prettier to the sessionRotation merge resolution
yiliang114 Sep 23, 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
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": {
Comment thread
wenshao marked this conversation as resolved.
Resolved
"type": "dingtalk",
"sessionScope": "thread",
"sessionRotation": {
"maxTurns": 200,
"maxAgeHours": 24
}
}
}
}
```

- **`maxTurns`** — Rotate once this many messages have used the current session.
Comment thread
wenshao marked this conversation as resolved.
Resolved
- **`maxAgeHours`** — Rotate once the current session is older than this.

Set either, both, or neither; whichever bound is hit first rotates. Both must be positive numbers. Omitting `sessionRotation` keeps the previous behavior of never rotating.

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. 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.

### Channel Memory

Channel memory stores durable context for one chat or thread. Entries have stable
Expand Down
5 changes: 5 additions & 0 deletions packages/channels/base/src/ChannelBase.ts
Original file line number Diff line number Diff line change
Expand Up @@ -847,6 +847,11 @@ export abstract class ChannelBase {
this.router =
options?.router ||
new SessionRouter(bridge, config.cwd, config.sessionScope);
// In gateway mode the caller registers rotation for every channel it owns;
// a standalone channel owns its router, so it registers its own.
Comment thread
wenshao marked this conversation as resolved.
Resolved
if (!options?.router) {
this.router.setChannelRotation(this.name, config.sessionRotation);
}
Comment thread
wenshao marked this conversation as resolved.
Resolved

this.registerSharedCommands();
if (this.loopController) {
Expand Down
109 changes: 109 additions & 0 deletions packages/channels/base/src/SessionRouter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1270,6 +1270,115 @@ describe('SessionRouter', () => {
});
});

describe('session rotation', () => {
Comment thread
wenshao marked this conversation as resolved.
Resolved
it('keeps reusing the session when no rotation is configured', async () => {
const router = new SessionRouter(bridge, '/tmp');
const first = await router.resolve('ch', 'alice', 'chat1');
for (let index = 0; index < 20; index++) {
expect(await router.resolve('ch', 'alice', 'chat1')).toBe(first);
}
});

it('starts a new session once maxTurns is reached', async () => {
const router = new SessionRouter(bridge, '/tmp');
router.setChannelRotation('ch', { maxTurns: 3 });

const first = await router.resolve('ch', 'alice', 'chat1');
expect(await router.resolve('ch', 'alice', 'chat1')).toBe(first);
expect(await router.resolve('ch', 'alice', 'chat1')).toBe(first);

const rotated = await router.resolve('ch', 'alice', 'chat1');
expect(rotated).not.toBe(first);
expect(await router.resolve('ch', 'alice', 'chat1')).toBe(rotated);
});

it('rotates only the route that hit the bound', async () => {
const router = new SessionRouter(bridge, '/tmp');
router.setChannelRotation('ch', { maxTurns: 2 });

const busy = await router.resolve('ch', 'alice', 'chat1');
const quiet = await router.resolve('ch', 'bob', 'chat2');
await router.resolve('ch', 'alice', 'chat1');

expect(await router.resolve('ch', 'alice', 'chat1')).not.toBe(busy);
expect(await router.resolve('ch', 'bob', 'chat2')).toBe(quiet);
});

it('does not rotate a channel that has no bound configured', async () => {
const router = new SessionRouter(bridge, '/tmp');
router.setChannelRotation('bounded', { maxTurns: 2 });

const unbounded = await router.resolve('other', 'alice', 'chat1');
for (let index = 0; index < 5; index++) {
expect(await router.resolve('other', 'alice', 'chat1')).toBe(unbounded);
}
});

it('starts a new session once maxAgeHours has elapsed', async () => {
vi.useFakeTimers();
try {
const router = new SessionRouter(bridge, '/tmp');
router.setChannelRotation('ch', { maxAgeHours: 2 });

const first = await router.resolve('ch', 'alice', 'chat1');
vi.advanceTimersByTime(90 * 60 * 1000);
expect(await router.resolve('ch', 'alice', 'chat1')).toBe(first);

vi.advanceTimersByTime(31 * 60 * 1000);
expect(await router.resolve('ch', 'alice', 'chat1')).not.toBe(first);
} finally {
vi.useRealTimers();
}
});

it('ignores non-positive bounds instead of rotating every message', async () => {
const router = new SessionRouter(bridge, '/tmp');
router.setChannelRotation('ch', { maxTurns: 0, maxAgeHours: -1 });
Comment thread
wenshao marked this conversation as resolved.
Resolved

const first = await router.resolve('ch', 'alice', 'chat1');
expect(await router.resolve('ch', 'alice', 'chat1')).toBe(first);
});

it('persists turn counts so a restart cannot reset the bound', async () => {
const dir = mkdtempSync(join(tmpdir(), 'qwen-router-'));
tempDirs.push(dir);
const persistPath = join(dir, 'routes.json');

const router = new SessionRouter(bridge, '/tmp', 'user', persistPath);
router.setChannelRotation('ch', { maxTurns: 3 });
const first = await router.resolve('ch', 'alice', 'chat1');
await router.resolve('ch', 'alice', 'chat1');

const persisted = JSON.parse(readFileSync(persistPath, 'utf-8'));
expect(persisted['ch:alice:chat1'].turns).toBe(2);

const revived = new SessionRouter(bridge, '/tmp', 'user', persistPath, {
recoveryMode: 'lazy',
});
Comment thread
wenshao marked this conversation as resolved.
Resolved
revived.setChannelRotation('ch', { maxTurns: 3 });
revived.restoreRoutes();

expect(await revived.resolve('ch', 'alice', 'chat1')).toBe(first);
expect(await revived.resolve('ch', 'alice', 'chat1')).not.toBe(first);
});

it('accepts route stores written before rotation existed', async () => {
const dir = mkdtempSync(join(tmpdir(), 'qwen-router-'));
tempDirs.push(dir);
const persistPath = join(dir, 'routes.json');
writePersistedSession(persistPath, 'ch:alice:chat1');

const router = new SessionRouter(bridge, '/tmp', 'user', persistPath, {
recoveryMode: 'lazy',
});
router.setChannelRotation('ch', { maxAgeHours: 1 });

expect(router.restoreRoutes()).toEqual({ restored: 1, dropped: 0 });
// No recorded start: the clock starts now rather than rotating on sight.
expect(await router.resolve('ch', 'alice', 'chat1')).toBe('old-session');
});
});

describe('clearAll', () => {
it('clears all in-memory state', async () => {
const router = new SessionRouter(bridge, '/tmp');
Expand Down
Loading
Loading