Skip to content
Closed
Show file tree
Hide file tree
Changes from 5 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. The channel posts a short notice in the affected chat or thread when a rotation happens, and the daemon logs the rotated 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. A route that still has a turn running or queued rotates on the next message after it settles instead of mid-turn.

### Channel Memory

Channel memory stores durable context for one chat or thread. Entries have stable
Expand Down
102 changes: 102 additions & 0 deletions packages/channels/base/src/ChannelBase.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2089,6 +2089,9 @@ describe('ChannelBase', () => {
chatId: 'chat1',
})),
setBridge: vi.fn(),
setChannelRotation: vi.fn(),
setSessionActivityChecker: vi.fn(),
onSessionRotated: vi.fn().mockReturnValue(() => {}),
};
const ch = createChannel({}, { router } as unknown as ChannelBaseOptions);

Expand Down Expand Up @@ -8613,6 +8616,9 @@ describe('ChannelBase', () => {
getTarget: vi.fn().mockReturnValue({ chatId: 'chat1' }),
handleSessionDied: vi.fn(),
setBridge: vi.fn(),
setChannelRotation: vi.fn(),
setSessionActivityChecker: vi.fn(),
onSessionRotated: vi.fn().mockReturnValue(() => {}),
};
const ch = createChannel({}, {
router,
Expand Down Expand Up @@ -8646,6 +8652,9 @@ describe('ChannelBase', () => {
getTarget: vi.fn().mockReturnValue(target),
handleSessionDied: vi.fn(),
setBridge: vi.fn(),
setChannelRotation: vi.fn(),
setSessionActivityChecker: vi.fn(),
onSessionRotated: vi.fn().mockReturnValue(() => {}),
};
const ch = createChannel({}, {
router,
Expand Down Expand Up @@ -8679,6 +8688,9 @@ describe('ChannelBase', () => {
getTarget: vi.fn().mockReturnValue(target),
handleSessionDied: vi.fn(),
setBridge: vi.fn(),
setChannelRotation: vi.fn(),
setSessionActivityChecker: vi.fn(),
onSessionRotated: vi.fn().mockReturnValue(() => {}),
};
const ch = createChannel({}, {
router,
Expand Down Expand Up @@ -8711,6 +8723,9 @@ describe('ChannelBase', () => {
getTarget: vi.fn().mockReturnValue(target),
handleSessionDied: vi.fn(),
setBridge: vi.fn(),
setChannelRotation: vi.fn(),
setSessionActivityChecker: vi.fn(),
onSessionRotated: vi.fn().mockReturnValue(() => {}),
};
const ch = createChannel({}, {
router,
Expand Down Expand Up @@ -8755,6 +8770,9 @@ describe('ChannelBase', () => {
getTarget: vi.fn().mockReturnValue(target),
handleSessionDied: vi.fn(),
setBridge: vi.fn(),
setChannelRotation: vi.fn(),
setSessionActivityChecker: vi.fn(),
onSessionRotated: vi.fn().mockReturnValue(() => {}),
};
const ch = createChannel({}, {
router,
Expand Down Expand Up @@ -8799,6 +8817,9 @@ describe('ChannelBase', () => {
getTarget: vi.fn(),
handleSessionDied: vi.fn(),
setBridge: vi.fn(),
setChannelRotation: vi.fn(),
setSessionActivityChecker: vi.fn(),
onSessionRotated: vi.fn().mockReturnValue(() => {}),
};
const ch = createChannel({}, { router } as unknown as ChannelBaseOptions);

Expand All @@ -8822,6 +8843,9 @@ describe('ChannelBase', () => {
getTarget: vi.fn(),
handleSessionDied: vi.fn(),
setBridge: vi.fn(),
setChannelRotation: vi.fn(),
setSessionActivityChecker: vi.fn(),
onSessionRotated: vi.fn().mockReturnValue(() => {}),
};
const ch = createChannel({}, { router } as unknown as ChannelBaseOptions);
const newBridge = createBridge();
Expand Down Expand Up @@ -8863,6 +8887,9 @@ describe('ChannelBase', () => {
getTarget: vi.fn().mockReturnValue({ chatId: 'chat1' }),
handleSessionDied: vi.fn(),
setBridge: vi.fn(),
setChannelRotation: vi.fn(),
setSessionActivityChecker: vi.fn(),
onSessionRotated: vi.fn().mockReturnValue(() => {}),
};
const ch = createChannel({}, {
router,
Expand Down Expand Up @@ -12280,6 +12307,81 @@ describe('ChannelBase', () => {
});
});

describe('session rotation', () => {
it('registers rotation bounds on a supplied router', () => {
const router = new SessionRouter(bridge, '/tmp');
const spy = vi.spyOn(router, 'setChannelRotation');

createChannel({ sessionRotation: { maxTurns: 100 } }, { router });

expect(spy).toHaveBeenCalledWith('test-chan', { maxTurns: 100 });
});

it('registers rotation bounds on a self-created router', () => {
const spy = vi.spyOn(SessionRouter.prototype, 'setChannelRotation');

createChannel({ sessionRotation: { maxAgeHours: 24 } });

expect(spy).toHaveBeenCalledWith('test-chan', { maxAgeHours: 24 });
spy.mockRestore();
});

it('announces rotation and discards the retired session', async () => {
const ch = createChannel({ sessionRotation: { maxTurns: 1 } });
await ch.handleInbound(envelope({ text: 'first' }));
const retiredId = (bridge.prompt as ReturnType<typeof vi.fn>).mock
.calls[0]![0] as string;
ch.sent = [];

await ch.handleInbound(envelope({ text: 'second' }));

const secondPrompt = (bridge.prompt as ReturnType<typeof vi.fn>).mock
.calls[1]!;
expect(secondPrompt[0]).not.toBe(retiredId);
expect(ch.sent.some((m) => m.text.includes('rotated'))).toBe(true);
expect(bridge.discardSession).toHaveBeenCalledWith(retiredId);
Comment thread
wenshao marked this conversation as resolved.
Resolved
});

it('defers rotation while the outgoing turn is still running', async () => {
const ch = createChannel({ sessionRotation: { maxTurns: 1 } });
let settleFirst!: (value: string) => void;
(bridge.prompt as ReturnType<typeof vi.fn>).mockReturnValueOnce(
new Promise<string>((resolve) => {
settleFirst = resolve;
}),
);

const firstTurn = ch.handleInbound(envelope({ text: 'first' }));
await vi.waitFor(() => expect(bridge.prompt).toHaveBeenCalledTimes(1));
const sessionId = (bridge.prompt as ReturnType<typeof vi.fn>).mock
.calls[0]![0] as string;

// Rotation is due, but the session is mid-turn: the second message
// queues behind it instead of retiring it.
const secondTurn = ch.handleInbound(envelope({ text: 'second' }));
// Let the second message resolve and queue behind the running turn;
// a rotation would have created a second session by now.
await new Promise((resolve) => setTimeout(resolve, 20));
expect(bridge.newSession).toHaveBeenCalledTimes(1);
expect(bridge.discardSession).not.toHaveBeenCalled();

settleFirst('done');
await firstTurn;
await secondTurn;

// The deferred message reused the outgoing session ...
expect(
(bridge.prompt as ReturnType<typeof vi.fn>).mock.calls[1]![0],
).toBe(sessionId);
// ... and the next message rotates it.
await ch.handleInbound(envelope({ text: 'third' }));
expect(bridge.discardSession).toHaveBeenCalledWith(sessionId);
expect(
(bridge.prompt as ReturnType<typeof vi.fn>).mock.calls[2]![0],
).not.toBe(sessionId);
});
});

describe('session routing', () => {
it('creates new session on first message', async () => {
const ch = createChannel();
Expand Down
61 changes: 61 additions & 0 deletions packages/channels/base/src/ChannelBase.ts
Original file line number Diff line number Diff line change
Expand Up @@ -397,6 +397,8 @@ export abstract class ChannelBase {
private commands: Map<string, CommandHandler> = new Map();
/** Per-session promise chain to serialize prompt + send (followup mode). */
private sessionQueues: Map<string, Promise<void>> = new Map();
/** Sessions with a turn running or queued; rotation defers while non-zero. */
private sessionPendingTurns = new Map<string, number>();
private readonly registerBridgeEvents: boolean;
private readonly bridgeRecovery?: () => Promise<void> | undefined;
/**
Expand Down Expand Up @@ -847,6 +849,16 @@ export abstract class ChannelBase {
this.router =
options?.router ||
new SessionRouter(bridge, config.cwd, config.sessionScope);
// Registration is idempotent and name-keyed, so the channel owning the
// config is the single owner of this invariant — gateway callers pass the
// same parsed config and need not mirror it.
this.router.setChannelRotation(this.name, config.sessionRotation);
this.router.setSessionActivityChecker(this.name, (sessionId) =>
this.hasPendingTurns(sessionId),
);
this.router.onSessionRotated((sessionId, target) => {
this.handleSessionRotated(sessionId, target);
});

this.registerSharedCommands();
if (this.loopController) {
Expand Down Expand Up @@ -1758,6 +1770,7 @@ export abstract class ChannelBase {
sessionId,
current.then(() => undefined).catch(() => {}),
);
this.trackSessionTurn(sessionId, current);
return current;
Comment thread
wenshao marked this conversation as resolved.
Resolved
}

Expand Down Expand Up @@ -2034,6 +2047,7 @@ export abstract class ChannelBase {
sessionId,
current.then(() => undefined).catch(() => undefined),
);
this.trackSessionTurn(sessionId, current);
return await current;
Comment thread
wenshao marked this conversation as resolved.
Resolved
}

Expand Down Expand Up @@ -2275,11 +2289,57 @@ export abstract class ChannelBase {

onSessionDied(sessionId: string): void {
this.router.handleSessionDied(sessionId);
this.purgeSessionState(sessionId);
}

private purgeSessionState(sessionId: string): void {
this.instructedSessions.delete(sessionId);
this.unattendedMemorySessions.delete(sessionId);
Comment thread
wenshao marked this conversation as resolved.
Resolved
Comment thread
wenshao marked this conversation as resolved.
Resolved
this.removePendingPermissionsForSession(sessionId);
}

/**
* The router retired a session by rotation: purge the per-session state
* a death would clean up, and tell the chat its context is starting fresh —
* rotation is automatic, so participants get no other signal.
*/
private handleSessionRotated(
sessionId: string,
target: SessionTarget | undefined,
): void {
if (target?.channelName !== this.name) return;
Comment thread
wenshao marked this conversation as resolved.
Resolved
this.purgeSessionState(sessionId);
Comment thread
wenshao marked this conversation as resolved.
Resolved
void this.sendThreadMessage(
target.chatId,
target.threadId,
'This conversation reached its configured limit and was rotated; starting a fresh session.',
Comment thread
wenshao marked this conversation as resolved.
Resolved
).catch((err: unknown) => {
process.stderr.write(
`[${this.name}] failed to announce session rotation in chat ${sanitizeLogText(target.chatId, 64)}: ${this.lifecycleError(err)}\n`,
);
});
}

private hasPendingTurns(sessionId: string): boolean {
return (this.sessionPendingTurns.get(sessionId) ?? 0) > 0;
}

private trackSessionTurn(sessionId: string, turn: Promise<unknown>): void {
this.sessionPendingTurns.set(
sessionId,
(this.sessionPendingTurns.get(sessionId) ?? 0) + 1,
);
const finish = (): void => {
const remaining = (this.sessionPendingTurns.get(sessionId) ?? 1) - 1;
if (remaining <= 0) {
this.sessionPendingTurns.delete(sessionId);
} else {
this.sessionPendingTurns.set(sessionId, remaining);
}
};
void turn.then(finish, finish);
}

private attachBridgeEvents(bridge: ChannelAgentBridge): void {
bridge.on('toolCall', this.bridgeToolCallListener);
bridge.on('backgroundResponse', this.bridgeBackgroundResponseListener);
Expand Down Expand Up @@ -5813,6 +5873,7 @@ export abstract class ChannelBase {
sessionId,
current.catch(() => {}),
);
this.trackSessionTurn(sessionId, current);
await current;
Comment thread
wenshao marked this conversation as resolved.
Resolved
}

Expand Down
Loading
Loading