Skip to content
Closed
Show file tree
Hide file tree
Changes from 12 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 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.
Comment thread
wenshao marked this conversation as resolved.
Resolved

### 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 @@ -111,6 +111,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 @@ -799,6 +800,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 @@ -94,6 +94,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 @@ -137,6 +140,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 thread
wenshao marked this conversation as resolved.
Resolved
Comment thread
wenshao marked this conversation as resolved.
Resolved
this.knownSessionIds.clear();
this.sessionBindingTokens.clear();
Expand Down Expand Up @@ -217,11 +221,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 @@ -231,11 +238,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 @@ -339,6 +348,7 @@ export class AcpBridge extends EventEmitter implements ChannelAgentBridge {
}

stop(): void {
this.rejectPendingSessionRequests();
Comment thread
wenshao marked this conversation as resolved.
Resolved
this.resolvePendingPermissions();
this.knownSessionIds.clear();
this.sessionBindingTokens.clear();
Expand Down Expand Up @@ -440,6 +450,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 thread
wenshao marked this conversation as resolved.
Resolved
(error: unknown) => {
this.pendingSessionRequests.delete(pending);
reject(error);
},
Comment thread
wenshao marked this conversation as resolved.
Resolved
);
});
}

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 thread
wenshao marked this conversation as resolved.
Resolved

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