Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
aa874e6
feat(channels): add lazy session route recovery
qqqys Jul 10, 2026
21dfa30
fix(channels): harden session route persistence
qqqys Jul 10, 2026
437dffb
fix(channels): drop malformed eager routes
qqqys Jul 10, 2026
997640d
fix(channels): preserve durable routes on session death
qqqys Jul 10, 2026
8d4d0a8
feat(cli): restore daemon channel routes lazily
qqqys Jul 10, 2026
281ecfe
fix(channels): invalidate stale route operations
qqqys Jul 10, 2026
4abe61c
fix(channels): close route lifecycle microtask gaps
qqqys Jul 10, 2026
4d3dbc1
fix(channels): release route invalidation metadata
qqqys Jul 10, 2026
7677528
fix(channels): discard invalidated daemon sessions
qqqys Jul 10, 2026
e1de956
fix(channels): defer orphan session cleanup
qqqys Jul 10, 2026
ebfa37a
fix(channels): scope orphan cleanup to bindings
qqqys Jul 10, 2026
d9b8a67
fix(cli): forward daemon session discard
qqqys Jul 10, 2026
33778de
fix(channels): detach stale daemon clients
qqqys Jul 10, 2026
250a368
fix(channels): reject stale sessions promptly
qqqys Jul 10, 2026
64b54ff
test(channels): cover merged session arguments
qqqys Jul 10, 2026
24216cb
Merge branch 'main' into feat/daemon-channel-session-recovery
wenshao Jul 10, 2026
880f3ec
fix(channels): update session cleanup test mock
qqqys Jul 10, 2026
10483b6
test(channels): update Telegram session cleanup mock
qqqys Jul 10, 2026
764772a
Merge branch 'main' into feat/daemon-channel-session-recovery
wenshao Jul 11, 2026
6da5f3d
fix(channels): release mismatched daemon sessions
qqqys Jul 11, 2026
544052c
fix(channels): release replaced daemon sessions
qqqys Jul 11, 2026
1eb7412
Merge branch 'main' into feat/daemon-channel-session-recovery
wenshao Jul 11, 2026
db2d803
fix(channels): release dropped daemon sessions
qqqys Jul 11, 2026
4f99a60
fix(channels): guard lazy route recovery
qqqys Jul 11, 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
7 changes: 7 additions & 0 deletions packages/channels/base/src/ChannelAgentBridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,18 +96,25 @@ export interface ChannelAgentBridge {
newSession(
cwd: string,
options?: ChannelAgentBridgeSessionOptions,
bindingToken?: object,
): Promise<string>;
loadSession(
sessionId: string,
cwd: string,
options?: ChannelAgentBridgeSessionOptions,
bindingToken?: object,
): Promise<string>;
prompt(
sessionId: string,
text: string,
options?: { imageBase64?: string; imageMimeType?: string },
): Promise<string>;
cancelSession(sessionId: string): Promise<void>;
/** Release a bridge-owned session that will not be routed to a caller. */
discardSession?(
sessionId: string,
expectedBindingToken?: object,
): Promise<void>;
respondToPermission?(
requestId: string,
response: RequestPermissionResponse,
Expand Down
58 changes: 50 additions & 8 deletions packages/channels/base/src/ChannelBase.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import type {
ChannelWebhookConfig,
ChannelWebhookTask,
} from './ChannelWebhookTask.js';
import { SessionRouter } from './SessionRouter.js';

// Concrete test implementation
class TestChannel extends ChannelBase {
Expand Down Expand Up @@ -4337,10 +4338,51 @@ describe('ChannelBase', () => {
expect(secondPrompt).toContain('Be concise.');
});

it('forgets instructions when policy-aware session death preserves a route', async () => {
const router = new SessionRouter(bridge, '/tmp', 'user', undefined, {
recoveryMode: 'lazy',
});
const ch = createChannel(
{ instructions: 'Be concise.' },
{ router, registerBridgeEvents: true },
);
await ch.handleInbound(envelope({ text: 'first' }));
const sessionId = router.getSession('test-chan', 'user1', 'chat1');
expect(sessionId).toBeDefined();

(bridge as unknown as EventEmitter).emit('sessionDied', { sessionId });

expect(router.hasSession('test-chan', 'user1', 'chat1')).toBe(true);
(bridge.loadSession as ReturnType<typeof vi.fn>).mockResolvedValueOnce(
sessionId,
);
await ch.handleInbound(envelope({ text: 'second' }));

const secondPrompt = (bridge.prompt as ReturnType<typeof vi.fn>).mock
.calls[1]![1] as string;
expect(secondPrompt).toContain('Be concise.');
});

it('/status reports a dormant durable route as active', async () => {
const router = new SessionRouter(bridge, '/tmp', 'user', undefined, {
recoveryMode: 'lazy',
});
const ch = createChannel({}, { router, registerBridgeEvents: true });
await ch.handleInbound(envelope({ text: 'first' }));
(bridge as unknown as EventEmitter).emit('sessionDied', {
sessionId: 's-1',
});
ch.sent = [];

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

expect(ch.sent[0]!.text).toContain('Session: active');
});

it('can register bridge events when a supplied router is channel-owned', () => {
const router = {
getTarget: vi.fn().mockReturnValue({ chatId: 'chat1' }),
removeSessionId: vi.fn(),
handleSessionDied: vi.fn(),
setBridge: vi.fn(),
};
const ch = createChannel({}, {
Expand All @@ -4361,13 +4403,13 @@ describe('ChannelBase', () => {
});

expect(ch.toolCalls).toEqual([{ chatId: 'chat1', event: toolCall }]);
expect(router.removeSessionId).toHaveBeenCalledWith('s-1');
expect(router.handleSessionDied).toHaveBeenCalledWith('s-1');
});

it('leaves supplied router bridge events to the gateway by default', () => {
const router = {
getTarget: vi.fn(),
removeSessionId: vi.fn(),
handleSessionDied: vi.fn(),
setBridge: vi.fn(),
};
const ch = createChannel({}, { router } as unknown as ChannelBaseOptions);
Expand All @@ -4384,13 +4426,13 @@ describe('ChannelBase', () => {
});

expect(ch.toolCalls).toEqual([]);
expect(router.removeSessionId).not.toHaveBeenCalled();
expect(router.handleSessionDied).not.toHaveBeenCalled();
});

it('updates a supplied router bridge even when events are gateway-owned', () => {
const router = {
getTarget: vi.fn(),
removeSessionId: vi.fn(),
handleSessionDied: vi.fn(),
setBridge: vi.fn(),
};
const ch = createChannel({}, { router } as unknown as ChannelBaseOptions);
Expand Down Expand Up @@ -4431,7 +4473,7 @@ describe('ChannelBase', () => {
const newBridge = createBridge();
const router = {
getTarget: vi.fn().mockReturnValue({ chatId: 'chat1' }),
removeSessionId: vi.fn(),
handleSessionDied: vi.fn(),
setBridge: vi.fn(),
};
const ch = createChannel({}, {
Expand Down Expand Up @@ -4461,8 +4503,8 @@ describe('ChannelBase', () => {
(newBridge as unknown as EventEmitter).emit('toolCall', toolCall);

expect(router.setBridge).toHaveBeenCalledWith(newBridge);
expect(router.removeSessionId).toHaveBeenCalledTimes(1);
expect(router.removeSessionId).toHaveBeenCalledWith('new-session');
expect(router.handleSessionDied).toHaveBeenCalledTimes(1);
expect(router.handleSessionDied).toHaveBeenCalledWith('new-session');
expect(ch.toolCalls).toEqual([{ chatId: 'chat1', event: toolCall }]);
});

Expand Down
2 changes: 1 addition & 1 deletion packages/channels/base/src/ChannelBase.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1430,7 +1430,7 @@ export abstract class ChannelBase {
onToolCall(_chatId: string, _event: ToolCallEvent): void {}

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

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] handleSessionDied in lazy mode preserves the route mapping (unlike the old removeSessionId), so hasSession() still returns true after a session dies. This causes /status at line 2203 (Session: ${hasSession ? 'active' : 'none'}) to report a dead-but-route-preserved session as "active", which is misleading — the user cannot distinguish a healthy session from one that is dormant and awaiting lazy recovery.

Consider either adding a isSessionLive() method to SessionRouter that checks liveSessionIds, or updating the /status display to differentiate (e.g., "active (dormant)" vs "active").

— qwen3.7-max via Qwen Code /review

this.instructedSessions.delete(sessionId);
this.removePendingPermissionsForSession(sessionId);
}
Expand Down
Loading
Loading