Skip to content

feat(base): persist channel sessions across restarts - #3865

Closed
Mr-Maidong wants to merge 11 commits into
QwenLM:mainfrom
Mr-Maidong:fix/session-restore
Closed

feat(base): persist channel sessions across restarts#3865
Mr-Maidong wants to merge 11 commits into
QwenLM:mainfrom
Mr-Maidong:fix/session-restore

Conversation

@Mr-Maidong

Copy link
Copy Markdown
Contributor

Motivation

Channel sessions are lost every time the channel process restarts (e.g., Ctrl+C then qwen channel start). Users lose their conversation context and a new session is created each time.

Changes

Root cause: AcpBridge.loadSession() always returned undefined

AcpBridge.loadSession() read response.sessionId from the ACP response, but LoadSessionResponse has no sessionId field — so the session router always stored undefined as the session ID, meaning restored sessions were never matched on incoming messages.

Fix: Return the passed-in sessionId directly, since conn.loadSession() confirms the session exists but does not return its ID.

SessionRouter.clearAll() no longer deletes sessions.json

Previously, clearAll() called unlinkSync on the persist file during shutdown, erasing all session routing data. Now it only clears in-memory state, keeping the file for the next startup.

startSingle() and startAll() call restoreSessions() at startup

Both entry points now call router.restoreSessions() before channel.connect(), restoring the routing map from the persisted sessions.json.

How to verify

  1. Start a channel: qwen channel start
  2. Send a message to create a session
  3. Ctrl+C to stop
  4. Start again: qwen channel start
  5. Send another message — should resume the same session with full history

Session routing was lost on channel restart because clearAll() deleted
sessions.json and restoreSessions() was never called at startup.
Additionally, AcpBridge.loadSession() always returned undefined due to
reading a non-existent field on LoadSessionResponse.

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
@wenshao

wenshao commented May 6, 2026

Copy link
Copy Markdown
Collaborator

Overview

The PR fixes a real bug that prevented persisted channel sessions from being restored after a clean restart of qwen channel start. Three changes:

  1. AcpBridge.loadSession returns the passed-in sessionId instead of the (non-existent) response.sessionId.
  2. SessionRouter.clearAll no longer deletes the persist file on shutdown.
  3. startSingle / startAll call router.restoreSessions() at startup, after the bridge starts but before channels connect.

Bug analysis — confirmed correct

I verified against @agentclientprotocol/sdk types: LoadSessionResponse is { _meta?, configOptions?, models?, modes? } — no sessionId field. The original return response.sessionId was indeed always undefined, so persisted entries were silently re-keyed under undefined in toSession and never matched on incoming messages. Returning the input sessionId is the right fix; the qwen agent's own loadSession (packages/cli/src/acp-integration/acpAgent.ts:286) takes params.sessionId as the session identity and doesn't issue a new one.

Issues

1. No test for the bug — a test-mock false-positive masked it

SessionRouter.test.ts:10 already uses loadSession: vi.fn().mockImplementation((id: string) => id) — a mock that returned the input ID, hiding the bug from tests. There is no AcpBridge.test.ts, and there is no test exercising restoreSessions() end-to-end. The PR adds zero test coverage for either the bug fix or the new startup-time restore path. Minimum suggestion: add a restoreSessions test in SessionRouter.test.ts that:

  • writes a persist file,
  • constructs a router pointing at it,
  • calls restoreSessions(),
  • asserts toSession, getTarget, and the persist file content.

2. Inconsistent log output between startup and crash recovery

Crash recovery (start.ts:265-268, unchanged) always logs restored: X, failed: Y. The new startup-time block only logs when restored > 0:

if (restoreResult.restored > 0) {
  writeStdoutLine(
    `[Channel] Sessions restored: ${restoreResult.restored}` +
      (restoreResult.failed > 0 ? `, failed: ${restoreResult.failed}` : ''),
  );
}

If restored === 0 && failed > 0 (e.g., all persisted sessions are stale), the user gets no signal. Either log when failed > 0 too, or always log when restored + failed > 0. The duplicated 9-line block in startSingle and startAll would also benefit from extraction into a small helper alongside attachDisconnectHandler.

3. No filtering of persisted entries by configured channels

restoreSessions() iterates all entries in ~/.qwen/channels/sessions.json and calls bridge.loadSession for each. If a user previously ran qwen channel start dingtalk and now runs qwen channel start telegram, the dingtalk sessions still get loaded into the telegram bridge — unnecessary IPC + memory, and the resulting routing entries are unreachable (no channel will ever resolve their keys). The persist key already prefixes channel name, so a filter is straightforward. Pre-existing in the crash-recovery path, but this PR makes it happen on every startup. Worth at least a TODO.

4. Stale-session accumulation has no escape hatch

With clearAll no longer deleting the file, the only self-cleanup is when loadSession throws (handled at SessionRouter.ts:185-188). Looking at acpAgent.ts:286-316, loadSession appears to create a fresh session when exists=false rather than throwing — meaning failed restores might never be pruned, and the persist file grows unboundedly. This deserves verification: what does loadSession return/throw when params.sessionId doesn't exist on disk? If it silently succeeds, options are:

  • have the agent throw a well-defined error for unknown sessionId, or
  • cross-check sessionExists client-side before calling loadSession, or
  • add a manual qwen channel reset-sessions subcommand.

5. Minor: redundant set after fix

SessionRouter.ts:181 is now this.toSession.set(key, sessionId) where sessionId === entry.sessionId always. Functional, but the contract is now "loadSession returns the input." Fine to leave for forward-compat.

6. Minor: comment wording

SessionRouter.ts:199-200: "Used on clean shutdown" is no longer fully accurate — clearAll is also used after the crash-restart-giveup branches (start.ts:247, start.ts:414). Could simplify to "Clear in-memory state. Persist file is left intact for the next start."

Risk assessment

  • Low risk for the core fix. AcpBridge.loadSession was previously dead code (always returned undefined and the caller silently corrupted state); fixing it can't break any working flow.
  • Low-medium risk for the persist-file-preservation change. Worst case a user has a stale sessions.json and gets confusing behavior on next start; cleanup story (issue Are you interested in AI Terminal? #4) is the main gap.
  • Backward compatibility: Persist file format unchanged. No new dependencies. No breaking API changes.

Recommendation

Approve with requested changes. The bug fix is correct and the feature is reasonable. Before merging, I'd ask for:

Issues #3 and #5/#6 are nice-to-haves; not blockers.

Bojun-Vvibe added a commit to Bojun-Vvibe/oss-contributions that referenced this pull request May 6, 2026
gemini-cli/qwen-code/crush/goose coverage:
- google-gemini/gemini-cli#26551 externalize https-proxy-agent (man)
- QwenLM/qwen-code#3865 persist channel sessions across restarts (man)
- charmbracelet/crush#2807 oauth refresh in summarize path (man)
- aaif-goose/goose#9047 settings moved into app shell with URL state (man)
- Add restoreSessions() tests: persist file restoration, stale entry
  skipping, missing file, and empty file cases
- Fix log consistency: log restore results when failed > 0 even if
  restored is 0 (both startSingle and startAll)
- Throw in acpAgent.loadSession when session does not exist on disk,
  preventing stale session accumulation in the persist file
- Fix clearAll() JSDoc wording

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
@Mr-Maidong

Copy link
Copy Markdown
Contributor Author

Thanks for the thorough review! I've addressed all requested changes:

  1. Tests (Issue pre-release: fix ci #1): Added 4 restoreSessions() tests in SessionRouter.test.ts — successful restoration, stale entry skipping (loadSession throws), missing persist file, and empty persist file.

  2. Log consistency (Issue Where is the config saved? #2): Changed the log condition in both startSingle and startAll from restored > 0 to restored > 0 || failed > 0, so failed restores are always visible to the user.

  3. Stale session accumulation (Issue 如何自定义密钥文件 .env可能与其他文件冲突 #3/4): acpAgent.loadSession now throws when sessionExists returns false, so restoreSessions catch block naturally skips stale entries. The persist file is also cleaned up on the next persist() call.

  4. Minor: JSDoc wording (Issue OpenAI API Error: 401 Incorecct API Key provided #6): Updated clearAll() comment.

}

/** Clear in-memory state and delete persist file. Used on clean shutdown. */
/** Clear in-memory state. Persist file is left intact for the next start

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] clearAll() now intentionally preserves the persist file, but the test coverage only verifies that the in-memory maps are cleared. This leaves the new shutdown persistence contract unprotected: a future change could re-delete or truncate sessions.json while the current tests would still pass.

Please add a regression test that writes a session through a router with a real persistPath, calls clearAll(), and then asserts that the file still exists and can be restored by a fresh router.

— gpt-5.5 via Qwen Code /review

},
);

if (!exists) {

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 new sessionExists === false branch is the behavior that lets SessionRouter.restoreSessions() prune stale persisted sessions, but it is not directly covered by ACP agent tests. The router tests mock loadSession, so they would not catch a regression where the real agent accidentally resumes or creates a session for a nonexistent ID again.

Please add an ACP agent test that mocks SessionService.sessionExists(params.sessionId) to return false, expects agent.loadSession(...) to reject with this error, and verifies that session creation/config loading is not attempted.

— gpt-5.5 via Qwen Code /review

…tion

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
@Mr-Maidong

Copy link
Copy Markdown
Contributor Author

Addressed both suggestions:

Suggestion 1 — clearAll() regression test in SessionRouter.test.ts:

  • Writes a session through a router with a real persistPath
  • Calls clearAll() — verifies in-memory state is cleared but persist file survives
  • Creates a fresh router with the same persistPath
  • Calls restoreSessions() — verifies the session is restored with correct target

Suggestion 2 — loadSession rejection test in acpAgent.test.ts:

  • Mocks SessionService.sessionExists() to return false
  • Calls agent.loadSession({ sessionId: 'nonexistent' })
  • Verifies the call rejects with 'Session nonexistent does not exist at /tmp'
  • Asserts sessionExists was queried for the correct session ID

channels.set(name, channel);
registerToolCallDispatch(bridge, router, channels);

// Restore sessions from previous run

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] startSinglestartAll 中插入了完全相同的 restoreSessions + 日志代码块(6 行)。建议提取为私有辅助函数以避免未来维护时两处产生分歧:

Suggested change
// Restore sessions from previous run
async function restoreAndLogSessions(router: SessionRouter): Promise<void> {
const result = await router.restoreSessions();
if (result.restored > 0 || result.failed > 0) {
writeStdoutLine(
`[Channel] Sessions restored: ${result.restored}` +
(result.failed > 0 ? `, failed: ${result.failed}` : ''),
);
}
}

— deepseek-v4-pro via Qwen Code /review

expect(bridge.newSession).not.toHaveBeenCalled();
});
});

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] describe('restoreSessions') 下的 3 个新测试直接调用 rmSync 清理临时目录,没有 try/finally 包裹。若测试中途失败(断言异常等),/tmp/test-restore-* 目录会残留。建议与同文件中 preserves persist file 测试保持一致,使用 try { ... } finally { rmSync(tmpDir, { recursive: true }) } 包裹测试体。

— deepseek-v4-pro via Qwen Code /review

mockConnectionState.resolve();
await agentPromise;
});

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] loadSession rejects when sessionExists returns false 测试通过 vi.mocked(SessionService).mockImplementation(...) 全局覆盖 mock,但测试结束时未恢复默认实现。虽然当前恰好是该 describe 块的最后一个测试,但若未来有人在该块末尾新增测试,残留的 mock 状态可能造成污染。建议在测试中手动保存/恢复原始 mock,或使用 beforeEach 钩子统一重置。

— deepseek-v4-pro via Qwen Code /review

@Mr-Maidong

Copy link
Copy Markdown
Contributor Author

Addressed all three suggestions:

@wenshao wenshao left a comment

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.

⚠️ CI failing (Lint).

PR direction is correct — the loadSession return value fix is the right root cause. However, this PR activates a pre-existing data-loss path in restoreSessions() that was dormant before (because loadSession always returned undefined).

Critical issues that cannot be mapped to diff lines (in existing SessionRouter.restoreSessions):

  1. All-restore-failure erases persist file: when restored=0, failed>0, persist() writes empty {} to sessions.json, irreversibly deleting all session routing data. Any transient failure (ACP not ready, NFS delay) triggers permanent data loss. Fix: change guard to if (failed > 0 && restored > 0).

  2. Restore failures are silent: catch { failed++ } swallows all error details. At 3 AM the only signal is failed: 5 with no indication of why. Fix: log err.message to stderr in the catch block.

  3. Corrupted persist file silently ignored: JSON.parse failure returns {restored:0, failed:0} — indistinguishable from "no sessions to restore". The broken file is never cleaned up, so every subsequent restart silently fails. Fix: emit a warning to stderr.

Suggestion: persist() uses non-atomic writeFileSync — a crash mid-write can corrupt the file. Consider write-to-temp + renameSync.

— deepseek-v4-pro via Qwen Code /review

} finally {
// Restore the default mock to avoid polluting subsequent tests
vi.mocked(SessionService).mockImplementation(
savedImpl as typeof vi.mocked<typeof SessionService>,

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] Type error TS2345/TS2352 — savedImpl as typeof vi.mocked<typeof SessionService> cast is incompatible. getMockImplementation() returns NormalizedProcedure<...> | undefined which cannot be cast to vitest's overloaded MockedObject type. This breaks npm run build.

Suggested change
savedImpl as typeof vi.mocked<typeof SessionService>,
vi.mocked(SessionService).mockImplementation(
(savedImpl ?? vi.fn()) as (cwd: string) => SessionService,
);

— deepseek-v4-pro via Qwen Code /review

@wenshao wenshao left a comment

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.

Additional findings from deeper analysis:

@wenshao wenshao left a comment

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.

Additional Critical findings (cannot be mapped to diff lines):

1. SessionRouter.restoreSessions ~L168 — JSON.parse(null) causes uncaught TypeError crash. JSON.parse can return null (e.g., file content is literally null), but the code only handles parse-throw via catch. If JSON.parse returns null, Object.entries(null) throws an uncaught TypeError, crashing channel start instead of gracefully returning {restored:0, failed:0}.

Fix: add a null/type guard after JSON.parse:

const parsed = JSON.parse(readFileSync(this.persistPath, "utf-8"));
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
  return { restored: 0, failed: 0 };
}
entries = parsed as Record<string, PersistedEntry>;

2. start.ts crash recovery ~L274 — race condition between channel.setBridge and restoreSessions. In both startSingle and startAll crash handlers, channel.setBridge(bridge) runs before await router.restoreSessions(). The channel is already connected, so messages arriving in this gap hit the router which finds pre-crash session IDs in memory (now populated by initial restoreAndLogSessions) — but the new ACP process hasn't loaded those sessions yet.

Fix: move restoreSessions() between router.setBridge and channel.setBridge:

router.setBridge(bridge);
await router.restoreSessions();
channel.setBridge(bridge);

— deepseek-v4-pro via Qwen Code /review

@Mr-Maidong

Copy link
Copy Markdown
Contributor Author

Addressed all 5 critical issues:

@wenshao wenshao left a comment

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.

补充 6 条新发现(不重复已有的 6 条 inline 评论)。最关键的是 #1 / #2 / #4,其余为 nit。

bridge = new AcpBridge(bridgeOpts);
await bridge.start();
router.setBridge(bridge);
await router.restoreSessions();

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] 桥接重启路径丢失了 [Channel] Bridge restarted. Sessions restored: X, failed: Y 日志。本 PR 把 startup 路径用 restoreAndLogSessions(router) 封装并加了日志,但 restart 路径(这里以及 startAll 中 line 440)只裸调用了 router.restoreSessions(),结果不再被读取。运维在 bridge crash 后只能从 SessionRouter 内部逐条 stderr 失败日志反推总数,看不到「成功 N 条、失败 M 条」的概览,是一次明显的可观测性退步。

建议恢复日志:

router.setBridge(bridge);
const result = await router.restoreSessions();
writeStdoutLine(
  `[Channel] Bridge restarted. Sessions restored: ${result.restored}` +
    (result.failed > 0 ? `, failed: ${result.failed}` : ''),
);

或者直接复用 restoreAndLogSessions(restart 时也输出 [Channel] Sessions restored: X 是可以接受的简化)。startAll 的对应位置同改。


return { restored, failed };
}

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] 新加的 && restored > 0 守护合理(避免一次性失败清空文件),但反向意味着:当 failed > 0 && restored === 0 时,持久文件永远不会被修剪。如果底层 qwen-code session 存储被清掉、或所有 sessionId 都真的失效,每次启动都会重试同一批死 ID 并写 stderr 失败日志;同时文件还会随后续 resolve() 单调增长。

可考虑:

  • 在「至少加载到 1 条」之外,再加一个「连续 N 次全失败则强制修剪」的逃生口;或
  • 区分「桥未就绪/瞬时错误」与「agent 明确拒绝(如 sessionExists=false)」,只对后者修剪。

若不想现在改,建议至少留 TODO 标注,避免后续遗忘。


// Update persist file to only include successfully restored sessions
if (failed > 0) {
// Update persist file to remove failed entries.

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.

[Nit] 函数顶部的 doc 注释(line 155 附近)仍写着「Failed loads are silently dropped (new session on next message)」,但本 PR 已在此处加了 process.stderr.write(...),行为不再 silent。建议同步更新 doc:

/**
 * ...
 * Failed loads are logged to stderr and dropped from the persist file
 * (a new session will be created on the next incoming message).
 */

);
return { restored: 0, failed: 0 };
}

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] 这里把 parsed 直接 as Record<string, PersistedEntry>,但没有验证每个 entry 的 shape。如果 sessions.json 被外部改坏,或将来被一个不兼容版本写入,entry.sessionId / entry.cwd / entry.target 可能 undefined,下面的 await this.bridge.loadSession(undefined, undefined) 会被当成普通失败计入 failed,stderr 出现 Failed to restore session undefined: ...,且因为 line 225 的 restored > 0 守护,这种错误条目不一定能被修剪(见我另一条 comment)。

便宜的修法:

if (
  typeof entry?.sessionId !== 'string' ||
  typeof entry?.cwd !== 'string' ||
  typeof entry?.target?.channelName !== 'string'
) {
  process.stderr.write(`[SessionRouter] Skipping malformed entry for key ${key}\n`);
  failed++;
  continue;
}

const { mkdirSync, writeFileSync, rmSync, readFileSync, existsSync } =
await import('node:fs');
const { join } = await import('node:path');
const tmpDir = join('/tmp', `test-cleara11-persist-${Date.now()}`);

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.

[Nit] 4 个新测试都用 join('/tmp', `test-...-${Date.now()}`) 拼临时目录名。Vitest 文件级并行下,两个测试在同一毫秒启动会撞同名目录;/tmp 也不是 Windows 友好。改用 fs.mkdtempSync 既保证唯一性又跨平台:

import { mkdtempSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
const tmpDir = mkdtempSync(join(tmpdir(), 'test-restore-'));

顺便:本行的目录前缀写成了 test-cleara11-persist-,应是 test-clearAll-persist- 的笔误。

sessionId,
cwd,
mcpServers: [],
});

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.

[Nit] 行为契约从「返回 agent 报告的 session ID」改成了「直接回显调用者传入的 sessionId,信任 ACP agent 用同样的 ID 加载」。这是正确的——LoadSessionResponse 规范里就没这个字段——但建议加一行注释,避免未来 reader 觉得「这看起来像 bug」又把它改回去:

// LoadSessionResponse has no sessionId field — the ACP agent loads the
// session under the caller-supplied ID, so we just echo it back.
return sessionId;

- Add restoreAndLogSessions() in bridge crash-recovery paths (startSingle/startAll)
- Add TODO for all-failure accumulation when restored === 0
- Update JSDoc to reflect stderr logging of failed loads
- Validate entry shape before calling loadSession to guard corrupted persist files
- Fix test temp dir: typo 'cleara11' and use mkdtempSync+tmpdir for cross-platform
- Add comment explaining AcpBridge.loadSession returns caller-supplied sessionId
- Fix TS cast in acpAgent.test.ts: savedImpl type error
@Mr-Maidong

Copy link
Copy Markdown
Contributor Author

Addressed all 6 issues from the latest review round:

  1. Bridge restart log (Issue pre-release: fix ci #1): startSingle/startAll crash-recovery paths now use restoreAndLogSessions() instead of bare router.restoreSessions().

  2. All-failure accumulation (Issue Where is the config saved? #2): Added TODO on the failed > 0 && restored > 0 guard describing the risk and potential escape hatches.

  3. JSDoc update (Issue 如何自定义密钥文件 .env可能与其他文件冲突 #3): Updated restoreSessions doc from "silently dropped" to "logged to stderr and dropped from the persist file".

  4. Entry shape validation (Issue Are you interested in AI Terminal? #4): Added guard before loadSession to validate sessionId, cwd, and target.channelName on each persisted entry — malformed entries are skipped and logged.

  5. Test temp dir (Issue TypeError in Authentication Selection Interface #5): Fixed typo test-cleara11test-clearAll, and switched all 4 tests from join('/tmp', ...) + mkdirSync to mkdtempSync(join(tmpdir(), ...)) for cross-platform uniqueness.

  6. AcpBridge comment (Issue OpenAI API Error: 401 Incorecct API Key provided #6): Added comment on loadSession explaining why it returns the caller-supplied sessionId.

Also fixed the TS type error in acpAgent.test.ts (savedImpl as typeof vi.mocked<...>(savedImpl ?? vi.fn()) as (cwd: string) => SessionService).

All tests pass: SessionRouter 28/28 ✓, acpAgent 24/24 ✓.

@wenshao wenshao left a comment

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.

⚠️ CI failing (Test windows-latest + ubuntu-latest, Node 22.x — likely pre-existing, not PR-caused). tsc + eslint pass on changed files.

This review found 4 Critical and 5 Suggestion issues beyond what was already discussed. Key concerns: (1) restoreSessions catch block doesn't clean in-memory maps during bridge restart, causing dead session IDs to be re-persisted; (2) bridge restart ordering puts restoreAndLogSessions before channel.setBridge/attachDisconnectHandler, risking zombie processes and silent message loss.

this.toCwd.set(sessionId, entry.cwd);
restored++;
} catch {
} catch (err: unknown) {

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] restoreSessions() 的 catch 块在 loadSession 失败时只记录 failed++不清除 this.toSessionthis.toTargetthis.toCwd 中已有的映射。

在桥接重启路径(startSingle / startAll crash recovery)中,router 的内存态残留崩溃前的条目。失败条目的 key 留在 toSession 中 → 后续 persist() 将其重新写回文件 → resolve() 返回死 sessionId → 静默消息丢失。

Suggested change
} catch (err: unknown) {
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : String(err);
process.stderr.write(
`[SessionRouter] Failed to restore session ${entry.sessionId}: ${msg}\n`,
);
this.toSession.delete(key);
this.toTarget.delete(entry.sessionId);
this.toCwd.delete(entry.sessionId);
failed++;
}

— DeepSeek/deepseek-v4-pro via Qwen Code /review

bridge = new AcpBridge(bridgeOpts);
await bridge.start();
router.setBridge(bridge);
await restoreAndLogSessions(router);

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] startSingle 桥接重启路径中 restoreAndLogSessions(router) 被调用于 channel.setBridge(bridge)registerToolCallDispatch()attachDisconnectHandler() 之前

后果:(a) restore 期间 channel 仍持有已死 bridge,到达的消息无法处理;(b) disconnect handler 未注册——bridge 在 restore 中途死亡则进程变为僵尸。

Suggested change
await restoreAndLogSessions(router);
bridge = new AcpBridge(bridgeOpts);
await bridge.start();
router.setBridge(bridge);
channel.setBridge(bridge);
registerToolCallDispatch(bridge, router, channels);
attachDisconnectHandler(bridge);
await restoreAndLogSessions(router);

— DeepSeek/deepseek-v4-pro via Qwen Code /review

bridge = new AcpBridge(bridgeOpts);
await bridge.start();
router.setBridge(bridge);
await restoreAndLogSessions(router);

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] startAll 桥接重启路径存在与 startSingle 相同的顺序问题。

Suggested change
await restoreAndLogSessions(router);
bridge = new AcpBridge(bridgeOpts);
await bridge.start();
router.setBridge(bridge);
for (const channel of channels.values()) {
channel.setBridge(bridge);
}
registerToolCallDispatch(bridge, router, channels);
attachDisconnectHandler(bridge);
await restoreAndLogSessions(router);

— DeepSeek/deepseek-v4-pro via Qwen Code /review

}

entries = parsed as Record<string, PersistedEntry>;
} catch {

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] restoreSessions() 中 corrupt JSON(JSON.parse 抛异常)的 catch 分支未被测试覆盖。同时,损坏的 persist 文件永不被清理——每次重启都重复打印 stderr 警告,无法自愈。

建议:(1) 新增测试写入非法 JSON 如 {bad,验证返回 {restored: 0, failed: 0};(2) 在 catch 分支中重命名损坏文件为 sessions.json.corrupted 或删除,避免永久日志噪音。

— DeepSeek/deepseek-v4-pro via Qwen Code /review

const raw = readFileSync(this.persistPath, 'utf-8');
const parsed: unknown = JSON.parse(raw);

// Guard against JSON.parse(null) and other non-object results

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] JSON.parse 返回 null / [] / "string" 等非普通对象的 3 条 guard 分支均未被测试。这是本 PR 专门添加的防御代码——评审中明确指出 JSON.parse(null) 会导致 TypeError 崩溃。

建议:新增测试写入 null[] 到 persist 文件,验证返回 {restored: 0, failed: 0} 且不调用 bridge.loadSession

— DeepSeek/deepseek-v4-pro via Qwen Code /review

// an escape hatch: after N consecutive all-failure cycles, force-prune
// entries that the ACP agent explicitly rejects (sessionExists=false).
if (failed > 0 && restored > 0) {
this.persist();

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] loadSession 可能因暂时性错误ensureAuthenticated 网络故障、磁盘 I/O)失败,但 restoreSessions 将所有失败同等对待 → persist() 从文件中永久删除条目。

acpAgent.loadSession() 流程:sessionExistsnewSessionConfigensureAuthenticatedcreateAndStoreSession。只有第一步是确定性的。后续步骤的临时错误不应导致路由数据永久丢失。

建议:区分「session 确定不存在」和「加载过程出错」,只有前者才删除条目。

— DeepSeek/deepseek-v4-pro via Qwen Code /review

let restored = 0;
let failed = 0;

for (const [key, entry] of Object.entries(entries)) {

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] restoreSessionsfor...of 顺序 await 每个 loadSession。每个调用是一次 ACP 往返 + agent 端处理(ensureAuthenticatedcreateAndStoreSession 等)。启动延迟随持久化条目数线性增长。

多 session 场景下,channel 在 restore 完成前无法接收消息。建议确认 agent 的 loadSession 是否可重入,然后考虑有限并发(如 3-5 个)。

— DeepSeek/deepseek-v4-pro via Qwen Code /review

//
// TODO: When failed > 0 && restored === 0, the persist file is never
// trimmed — every restart retries the same dead session IDs. Consider
// an escape hatch: after N consecutive all-failure cycles, force-prune

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] if (failed > 0 && restored > 0) { this.persist(); } 中全部失败(restored === 0)时跳过 persist 的分支未被测试。这是一个关键的数据安全决策——防止一次性全失败清空所有路由数据。

建议:新增测试,所有条目的 loadSession 都抛异常,验证 restored=0, failed=N,且 persist 文件内容未被修改。

— DeepSeek/deepseek-v4-pro via Qwen Code /review

@tanzhenxin tanzhenxin left a comment

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.

Review

The startup-path fix is correct and well-targeted — the root cause for loadSession returning undefined is verified against the ACP schema, the clearAll no-op-on-shutdown change is right, and calling restoreSessions before connect() closes the obvious race. Two issues in the bridge crash-recovery handler that this PR also modifies — both narrow, both fixable in a few lines. Happy to approve once they're addressed.

1. Failed restore in crash recovery leaves stale router entries pointing at the dead bridge (severity: medium · confidence: very high)

On bridge crash recovery the router is not cleared before restoreSessions runs, so the pre-crash key → sessionId maps are still in memory. The catch on a failed restore only increments the failure counter; it never drops the key. So if a session that worked before the crash fails to reload on the new bridge (e.g. its underlying file was GC'd), the router keeps resolving that key to a session ID the new bridge has never heard of, and every subsequent envelope for that key fails until the process restarts. The post-loop persist() then writes the stale entry back to disk.

2. Crash-recovery race: channel bridge swapped after the restore await (severity: medium · confidence: high)

In the disconnect handler, restoreAndLogSessions is awaited before channel.setBridge(bridge). The channel transport stays connected across the bridge restart, so during that await the router already points at the live bridge while the channel still holds the dead one. Any inbound envelope landing in this window routes through the new-bridge router but calls prompt/cancel on the dead old bridge held by the channel. Same ordering issue in the startAll disconnect handler. Reordering so channel.setBridge(bridge) (and the per-channel loop in startAll) runs immediately after router.setBridge(bridge), before the restore await, closes both windows.

Verdict

COMMENT — solid main-path fix; happy to approve once the two crash-recovery issues above are addressed.

…rdering

Two crash-recovery issues:

1. restoreSessions() now deletes in-memory toSession/toTarget/toCwd entries
   when loadSession fails, preventing stale session IDs from being re-persisted
   and silently dropping messages after bridge restart.

2. Reorder crash-recovery in startSingle/startAll: channel.setBridge(),
   registerToolCallDispatch(), and attachDisconnectHandler() now run before
   restoreAndLogSessions(), closing a race where the channel held a dead
   bridge while the router already pointed at the new one.
@Mr-Maidong

Copy link
Copy Markdown
Contributor Author

Addressed the two crash-recovery issues raised in the latest review:

Issue 1 — Stale router entries after failed restore

restoreSessions() now cleans up toSession/toTarget/toCwd in the catch block when loadSession fails. Previously a failed restore left dead session IDs in memory, which would be re-persisted by the next persist() call and silently drop messages. A new test (removes stale in-memory mappings on failed restore (crash recovery)) pre-populates in-memory maps then verifies they are fully cleaned up after a failed restore.

Issue 2 — Crash-recovery setBridge ordering

Reordered the disconnect handler in both startSingle and startAll: channel.setBridge(), registerToolCallDispatch(), and attachDisconnectHandler() now execute before restoreAndLogSessions(). This closes the window where the channel transport still held the dead bridge while the router already pointed at the new one.

// Before
router.setBridge → restoreAndLogSessions → channel.setBridge

// After
router.setBridge → channel.setBridge → registerToolCallDispatch → attachDisconnectHandler → restoreAndLogSessions

All 29 SessionRouter tests pass.


try {
const sessionId = await this.bridge.loadSession(
entry.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.

[Critical] restoreSessions() 中对 this.bridge.loadSession() 的调用没有超时机制。如果 ACP agent 挂起(网络分区、agent 死锁),该循环将永久阻塞——后续 session 无法恢复,channel 永远无法就绪。

Suggested change
entry.sessionId,
const LOAD_SESSION_TIMEOUT_MS = 30_000;
try {
const sessionId = await Promise.race([
this.bridge.loadSession(entry.sessionId, entry.cwd),
new Promise<string>((_, reject) =>
setTimeout(() => reject(new Error('loadSession timed out')), LOAD_SESSION_TIMEOUT_MS)
),
]);
this.toSession.set(key, sessionId);
this.toTarget.set(sessionId, entry.target);
this.toCwd.set(sessionId, entry.cwd);
restored++;
} catch (err: unknown) {
// ... existing cleanup ...
failed++;
}

— DeepSeek/deepseek-v4-pro via Qwen Code /review

@wenshao wenshao left a comment

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 (non-diff-line findings):

  1. packages/channels/base/src/SessionRouter.ts:276persist() 非原子写入writeFileSync 采用 truncate-then-write。若进程崩溃或磁盘满,sessions.json 损坏为部分 JSON。下次 restoreSessions 的 catch 返回 {restored:0, failed:0},下一次 resolve() 调用的 persist() 仅写入单个新条目——其他所有已持久化的 session 路由永久丢失。修复:atomic write via temp file + rename。

  2. SessionRouter.ts persist() — 静默失败使 restoreSessions 清理不可靠persist() 的 catch 块完全为空。若 restoreSessions 依赖 persist() 移除失败条目时写入失败(磁盘满等),清理逻辑静默失效,无任何日志。在 catch 中添加 process.stderr.write


— deepseek-v4-pro via Qwen Code /review

continue;
}

try {

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] Promise.racesetTimeout 未在成功时清理。loadSession 在 30 秒内返回时,timeoutId 被丢弃,定时器继续运行完整的 30 秒。N 个 session = N 个孤儿定时器。

Suggested change
try {
try {
let timeoutId: ReturnType<typeof setTimeout>;
const sessionId = await Promise.race([
this.bridge.loadSession(entry.sessionId, entry.cwd),
new Promise<string>((_, reject) => {
timeoutId = setTimeout(
() => reject(new Error('loadSession timed out')),
LOAD_SESSION_TIMEOUT_MS,
);
}),
]);
clearTimeout(timeoutId!);

— deepseek-v4-pro via Qwen Code /review

new Promise<string>((_, reject) =>
setTimeout(
() => reject(new Error('loadSession timed out')),
LOAD_SESSION_TIMEOUT_MS,

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] Promise.race 超时逻辑(LOAD_SESSION_TIMEOUT_MS = 30_000)完全无测试覆盖。超时 reject 路径、stderr 日志、catch 块中的 stale mapping 清理均未被触发。这是本轮 diff 新增的关键防御代码,缺乏覆盖意味着超时处理的任何 bug 只能在生产环境暴露。

建议使用 vi.useFakeTimers() 编写超时测试。

— deepseek-v4-pro via Qwen Code /review

new Promise<string>((_, reject) =>
setTimeout(
() => reject(new Error('loadSession timed out')),
LOAD_SESSION_TIMEOUT_MS,

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] Promise.race 超时不取消底层 ACP loadSession 调用。超时触发后 reject 进入 catch 块,标记失败并移除路由,但 ACP agent 的 loadSession(内含 ensureAuthenticatedcreateAndStoreSession)仍在后台运行。若它最终完成,agent 端残留 router 不知情的孤儿 session。下次同一 key 的 resolve() 通过 bridge.newSession() 创建第二个重复 session——本 PR 恢复历史会话的核心目标在超时场景下被静默打破。

修复方向:(1) 超时后对 loadSession Promise 追加 .catch(() => {}) 防止 unhandled rejection;(2) 中期在 AcpBridge 层支持 AbortSignal 真正取消底层请求。

— deepseek-v4-pro via Qwen Code /review

@@ -365,6 +374,9 @@ async function startAll(proxy?: string): Promise<void> {
}
registerToolCallDispatch(bridge, router, channels);

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] startAll 初始启动路径(line 376)中 await restoreAndLogSessions(router)try/catch 保护,而崩溃恢复路径(line 442)有。虽然 restoreSessions 内部有错误处理,但同步异常(如 EACCES)仍可能传播为未处理 rejection,导致 channel 服务崩溃。

Suggested change
try {
await restoreAndLogSessions(router);
} catch (err) {
writeStderrLine(
`[Channel] Failed to restore sessions: ${err instanceof Error ? err.message : String(err)}`,
);
}

— deepseek-v4-pro via Qwen Code /review

- Atomic write: use temp file + rename for persist() to prevent data
  corruption on crash or disk full
- Add error logging in persist() catch block instead of silent failure
- Fix timeout handling: clear setTimeout on success and attach no-op
  catch to loadSession promise to prevent unhandled rejection
- Add try/catch around restoreAndLogSessions in startSingle/startAll
  initial startup paths for consistent error handling
- Add tests for loadSession timeout and timer cleanup scenarios
@Mr-Maidong

Copy link
Copy Markdown
Contributor Author

Addressed all Critical and Suggestion issues

Critical fixes:

  1. persist() non-atomic write — Now uses temp file + renameSync for atomic writes, preventing data corruption on crash or disk full
  2. persist() silent failure — Added error logging in catch block instead of silent failure
  3. Timeout not cancelling underlying call — Now attaches a no-op .catch() to the loadSession promise after timeout to prevent unhandled rejection
  4. setTimeout not cleared — Now clears the timeout timer when loadSession completes before timeout
  5. Timeout logic untested — Added 2 new tests using vi.useFakeTimers() for timeout scenarios

Suggestion fixes:

  1. startSingle/startAll initial startup without try/catch — Wrapped restoreAndLogSessions() calls in try/catch blocks for consistent error handling with crash recovery paths

Changes:

  • packages/channels/base/src/SessionRouter.ts — Atomic write, error logging, timeout cleanup, unhandled rejection prevention
  • packages/channels/base/src/SessionRouter.test.ts — Added timeout and timer cleanup tests
  • packages/cli/src/commands/channel/start.ts — Added try/catch around restoreAndLogSessions() in initial startup paths

All tests pass: 111 tests ✓

@wenshao wenshao left a comment

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.

补充发现(无法映射到 diff 行):

  1. [Suggestion] 崩溃恢复中 setBridgerestoreSessions 之前执行startSingle (L279-283) 和 startAll (L451-457) 的 attachDisconnectHandler 内,Bridge 设置后 channel 立即可以接收消息触发 resolve()。本 PR 引入的 30 秒超时大幅拉长了 restore 阻塞窗口,resolve() 创建的新 session 会被 restore 的陈旧数据覆盖。建议将 restoreSessions 移到 setBridge 之前。

  2. [Suggestion] 崩溃恢复中误导性错误消息 — L284-286 / L458-460 的 catch 块始终打印 "Failed to restart bridge",即使 bridge 重启成功但 restoreAndLogSessions 失败。建议区分两种失败场景。

— DeepSeek/deepseek-v4-pro via Qwen Code /review

// is on the same filesystem as the target for atomic rename.
// rename is atomic on POSIX and works reliably on Windows when
// source and target are on the same filesystem.
const tmpDir = mkdtempSync(join(tmpdir(), 'session-persist-'));

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] Cross-device rename failure (EXDEV) — persist() uses mkdtempSync(join(tmpdir(), ...)) to create a temp dir in the OS temp directory (e.g., /tmp on Linux), then renameSync to ~/.qwen/channels/sessions.json. When /tmp is a tmpfs or separate partition (common in Docker / systemd), renameSync throws EXDEV: cross-device link not permitted. The code comment says the temp dir ensures same-filesystem, but tmpdir() defeats that guarantee.

Impact: ALL persist() calls silently fail on affected systems. Session routing data is never persisted — every restart loses all sessions.

Additionally: If writeFileSync or renameSync throws after mkdtempSync succeeds, the temp directory is never cleaned up (catch block only logs stderr).

Suggested change
const tmpDir = mkdtempSync(join(tmpdir(), 'session-persist-'));
// Create temp file in the same directory as the target to guarantee
// same-filesystem for atomic rename. No need for a temp directory.
import { dirname } from 'node:path';
const persistDir = dirname(this.persistPath);
const tmpFile = join(persistDir, '.sessions.json.tmp.' + process.pid);
writeFileSync(tmpFile, JSON.stringify(data, null, 2), 'utf-8');
renameSync(tmpFile, this.persistPath);

— DeepSeek/deepseek-v4-pro via Qwen Code /review

);
}),
]);
clearTimeout(timeoutId!);

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] clearTimeout(timeoutId!) is never reached when loadSession rejects before the timeout (non-timeout failure). The Promise.race jumps to the catch block, leaving the setTimeout timer active for 30s — a resource leak on fast-fail paths.

Suggested change
clearTimeout(timeoutId!);
let timeoutId: ReturnType<typeof setTimeout> | undefined;
try {
loadPromise = this.bridge.loadSession(entry.sessionId, entry.cwd);
const sessionId = await Promise.race([
loadPromise,
new Promise<string>((_, reject) => {
timeoutId = setTimeout(
() => reject(new Error('loadSession timed out')),
LOAD_SESSION_TIMEOUT_MS,
);
}),
]);
if (timeoutId !== undefined) clearTimeout(timeoutId);
this.toSession.set(key, sessionId);
// ...
} catch (err: unknown) {
if (timeoutId !== undefined) clearTimeout(timeoutId);
loadPromise?.catch(() => {});
// ...
}

— DeepSeek/deepseek-v4-pro via Qwen Code /review


// Restore sessions from previous run
try {
await restoreAndLogSessions(router);

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 try/catch wrapping restoreAndLogSessions() is duplicated identically in startSingle (L228-234) and startAll (L383-389). Meanwhile, the crash recovery paths (L283, L457 after attachDisconnectHandler) call restoreAndLogSessions() without try/catch, so a restore failure there crashes the process rather than logging and continuing.

Suggested change
await restoreAndLogSessions(router);
// Extract a shared helper to cover all 4 call sites:
async function safeRestoreAndLogSessions(router: SessionRouter): Promise<void> {
try {
await restoreAndLogSessions(router);
} catch (err) {
writeStderrLine(
`[Channel] Failed to restore sessions: ${err instanceof Error ? err.message : String(err)}`,
);
}
}

Replace all 4 await restoreAndLogSessions(router) calls with await safeRestoreAndLogSessions(router).

— DeepSeek/deepseek-v4-pro via Qwen Code /review

- Move restoreAndLogSessions before channel.setBridge to prevent
  new sessions from being overwritten by stale restore data
- Split error handling to distinguish bridge startup failures from
  session restore failures with appropriate error messages
@Mr-Maidong

Copy link
Copy Markdown
Contributor Author

已处理最新两条建议

Issue #1: 崩溃恢复中 setBridge 在 restoreSessions 之前执行

修复: 将 restoreAndLogSessions 移到 channel.setBridge 之前执行

// 修改前
router.setBridge → channel.setBridge → restoreAndLogSessions

// 修改后  
router.setBridge → restoreAndLogSessions → channel.setBridge

这样 restore 完成后才让 channel 开始接收消息,避免了新 session 被 restore 的陈旧数据覆盖的问题。

Issue #2: 崩溃恢复中误导性错误消息

修复: 将单一的 try-catch 拆分为两个独立的错误处理

  • Bridge 启动失败: "Failed to restart bridge: ..." + return(放弃重启)
  • Restore 失败: "Bridge restarted but session restore failed: ..."(继续执行,bridge 已成功重启)

用户现在能清楚区分两种失败场景。

@wenshao wenshao left a comment

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.

Additional findings (cannot be mapped to specific diff lines):

  1. [Suggestion] Stale READMEpackages/channels/base/README.md still describes clearAll() as "Clear all sessions and delete persist file". Update to reflect that the persist file is now preserved.

  2. [Suggestion] start.test.ts missing assertions — The PR adds restoreAndLogSessions(router) calls to both startup paths, but no test verifies the call happens or tests the error-handling branch.

  3. [Suggestion] Test coverage gaps — No test for non-object JSON guard (null/[]/42) or malformed entries with missing senderId/chatId fields in SessionRouter.test.ts.

— glm-5.1 via Qwen Code /review

// is on the same filesystem as the target for atomic rename.
// rename is atomic on POSIX and works reliably on Windows when
// source and target are on the same filesystem.
const tmpDir = mkdtempSync(join(tmpdir(), 'session-persist-'));

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] EXDEV cross-device rename — persist() broken on Linux

mkdtempSync(join(tmpdir(), 'session-persist-')) creates the temp directory in /tmp (typically a tmpfs mount), but the target is ~/.qwen/channels/sessions.json. On Linux where /tmp and /home are separate filesystems, renameSync fails with EXDEV: cross-device link not permitted.

The comment "ensures the temp file is on the same filesystem as the target" is incorrect — tmpdir() provides no such guarantee.

Additionally, when renameSync throws, rmSync(tmpDir) is never reached (no finally), leaking one temp directory per persist() call.

Impact: Session persistence completely broken on standard Linux deployments. Every resolve() and removeSession() leaks a temp dir.

Suggested change
const tmpDir = mkdtempSync(join(tmpdir(), 'session-persist-'));
// Atomic write: temp dir adjacent to target for same-filesystem guarantee.
let tmpDir: string | undefined;
try {
tmpDir = mkdtempSync(join(dirname(this.persistPath), '.session-persist-'));
const tmpFile = join(tmpDir, 'sessions.json');
writeFileSync(tmpFile, JSON.stringify(data, null, 2), 'utf-8');
renameSync(tmpFile, this.persistPath);
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : String(err);
process.stderr.write(
`[SessionRouter] Failed to persist sessions to ${this.persistPath}: ${msg}\n`,
);
} finally {
if (tmpDir) {
try { rmSync(tmpDir, { recursive: true, force: true }); } catch { /* best-effort */ }
}
}

— glm-5.1 via Qwen Code /review

if (
typeof entry?.sessionId !== 'string' ||
typeof entry?.cwd !== 'string' ||
typeof entry?.target?.channelName !== 'string'

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] Incomplete entry validation — missing senderId/chatId checks

The guard checks entry?.target?.channelName but not senderId or chatId, which are also required fields of SessionTarget. A corrupted persist file entry with a valid channelName but missing senderId/chatId would pass validation, storing an incomplete target in toTarget.

Suggested change
typeof entry?.target?.channelName !== 'string'
typeof entry?.target?.channelName !== 'string' ||
typeof entry?.target?.senderId !== 'string' ||
typeof entry?.target?.chatId !== 'string'

— glm-5.1 via Qwen Code /review

let restored = 0;
let failed = 0;

for (const [key, entry] of Object.entries(entries)) {

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] Sequential restore blocks startup with N×30s for dead sessions

The for...of + await loop processes sessions sequentially. When loadSession hangs (network partition, agent deadlock), each dead session incurs the full 30s timeout. With N dead sessions, startup is blocked for N×30s with no stdout progress — only individual stderr lines per failure. restoreAndLogSessions writes the summary to stdout only after all sessions are processed.

Consider at minimum logging per-session progress, or adding an overall timeout budget, or running restores in parallel with Promise.allSettled.

— glm-5.1 via Qwen Code /review

@@ -261,11 +280,7 @@ async function startSingle(name: string, proxy?: string): Promise<void> {
channel.setBridge(bridge);

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] Crash recovery: channel holds dead bridge during restoreAndLogSessions

In both startSingle and startAll crash-recovery paths, channel.setBridge(bridge) (this line) is called after restoreAndLogSessions(router) (line 283). During the restoration window (potentially N×30s for dead sessions), the channel still holds the old dead bridge. Any incoming messages call this.bridge.prompt()ensureConnection() throws "Not connected to ACP agent" — messages are silently dropped.

Previously, channel.setBridge was called before restoration, so the channel had the new bridge during this window (though it risked duplicate sessions if a message triggered resolve() before restoration completed).

Consider calling channel.setBridge(bridge) before restoreAndLogSessions, or queuing incoming messages during the restoration window.

— glm-5.1 via Qwen Code /review

// Using a temp directory (not just a temp file) ensures the temp file
// is on the same filesystem as the target for atomic rename.
// rename is atomic on POSIX and works reliably on Windows when
// source and target are on the same filesystem.

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] persist() temp dir leaks session data on write/rename failure

tmpDir is declared inside the try block (line 303), so the catch block cannot access it for cleanup. When writeFileSync or renameSync fails (ENOSPC, EACCES, EXDEV), the temp directory and its fully-written sessions.json file are abandoned in the OS temp area — leaking both disk space and session routing data (sender IDs, chat IDs, session IDs, cwd paths) into a world-readable /tmp.

Additionally, using mkdtempSync + writeFileSync + renameSync + rmSync requires 4 syscalls when a temp file written next to the target (.sessions.json.tmp in dirname(this.persistPath)) achieves the same atomic guarantee with only 2 syscalls and automatically avoids cross-filesystem EXDEV issues (the current comment on lines 298-301 incorrectly claims tmpdir() ensures same-filesystem).

Suggested change
// source and target are on the same filesystem.
// Atomic write: write to temp file in target's directory then rename.
// This avoids cross-filesystem EXDEV that can occur with os.tmpdir()
// when /tmp is a separate filesystem from the target.
const targetDir = dirname(this.persistPath);
const tmpFile = join(targetDir, '.sessions.json.tmp');
writeFileSync(tmpFile, JSON.stringify(data, null, 2), 'utf-8');
renameSync(tmpFile, this.persistPath);

This also eliminates the need for mkdtempSync, rmSync, and tmpdir imports from the persist() path.

— deepseek-v4-pro via Qwen Code /review

`[SessionRouter] Corrupted persist file (expected object, got ${parsed === null ? 'null' : Array.isArray(parsed) ? 'array' : typeof parsed}).\n`,
);
return { restored: 0, failed: 0 };
}

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] persistPath logged to stderr exposes home directory

[SessionRouter] Failed to parse persist file: ${this.persistPath} writes the full path (e.g., /Users/<username>/.qwen/channels/sessions.json) to stderr, exposing the OS username on multi-tenant systems where stderr is aggregated.

Suggested change
}
process.stderr.write(
`[SessionRouter] Failed to parse persist file: ${this.persistPath.replace(os.homedir(), '~')}\n`,
);

The same exposure occurs in persist() at line 315.

— deepseek-v4-pro via Qwen Code /review

entry.cwd,
// Guard against malformed entries from a corrupted persist file.
if (
typeof entry?.sessionId !== 'string' ||

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] Routing key logged to stderr may expose sender/chat identifiers

The routing key format channelName:senderId:chatId is written directly to stderr when skipping malformed entries. senderId and chatId are externally-visible messaging platform identifiers that may constitute PII or reveal communication patterns.

Suggested change
typeof entry?.sessionId !== 'string' ||
const safeKey = key.slice(0, key.indexOf(':')) + ':***';
process.stderr.write(
`[SessionRouter] Skipping malformed entry for key ${safeKey}\n`,
);

— deepseek-v4-pro via Qwen Code /review

const tmpFile = join(tmpDir, 'sessions.json');
writeFileSync(tmpFile, JSON.stringify(data, null, 2), 'utf-8');
renameSync(tmpFile, this.persistPath);
// Clean up the now-empty temp directory (file was moved by rename)

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] persist() catch block has no test coverage

The new atomic-write error-handling path (stderr log on writeFileSync/renameSync failure) is completely untested. No test triggers this path via filesystem error injection or spies on process.stderr.write.

When persist() fails (ENOSPC, EACCES, EXDEV), this catch block is the only defense against an unhandled crash. If the message format or error extraction breaks in production, tests won't catch it.

Suggested test: spy process.stderr.write, mock renameSync to throw, call persist() indirectly via resolve(), and assert: (a) the stderr message contains the persist path and error info, (b) the catch block returns gracefully without propagating the exception.

— deepseek-v4-pro via Qwen Code /review

@tanzhenxin tanzhenxin left a comment

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.

Review

C1 (stale router entries on failed restore) is solidly closed with a dedicated test — thanks. The 30s loadSession timeout, atomic-write attempt, and try/catch around the initial restoreAndLogSessions paths are good additions. Two blockers remain before merge — both bugs that break behavior in common deployments. A few one-line follow-ups noted at the end.

1. The channel.setBridge ordering fix from 06036bd6b was reverted in the wrong direction (severity: high · confidence: very high)

The original PR put restoreAndLogSessions before channel.setBridge in the disconnect handler. The prior review pointed out that the channel transport stays connected across the bridge restart, so during the restore await ChannelBase.bridge still holds the dead bridge; inbound messages route through the new-bridge router but call prompt()/cancel() on the dead bridge, throw Not connected to ACP agent, and the adapter replies "Sorry, something went wrong" to the user.

You correctly fixed this in 06036bd6b (moved channel.setBridge and registerToolCallDispatch to run before the restore await), then reverted the fix in 7e40926e3 ("Move restoreAndLogSessions before channel.setBridge to prevent new sessions from being overwritten by stale restore data"). The reverted commit message describes a concern that doesn't materialize — restoreSessions doesn't overwrite a fresher session ID, it sets a freshly-loaded one from disk. The actual catastrophe is the one the revert reintroduced: prompts on a dead bridge.

This is materially worse now because the new 30s × N loadSession timeout from ac56d92b9 extends the dead-bridge window to potentially minutes for N dead sessions, and the user sees error replies for the entire window. @wenshao independently flagged the same problem on start.ts:280. Fix shape: revert to the 06036bd6b ordering — channel.setBridge(bridge) (and the per-channel loop in startAll) runs immediately after router.setBridge(bridge), before the restore await.

2. persist() atomic-write crosses filesystems on Docker, systemd-default Linux, and most CI runners (severity: high · confidence: very high)

The atomic-write rewrite uses mkdtempSync(join(tmpdir(), 'session-persist-')) then renameSyncs sessions.json into ~/.qwen/channels/. On the common case where /tmp is a tmpfs and /home lives on the root or a separate data filesystem (default Docker base images, systemd-default Linux, GitHub/GitLab CI), renameSync across filesystems throws EXDEV: cross-device link not permitted. The catch swallows it to stderr and persist() silently no-ops. Every subsequent resolve() and removeSession() then writes nothing. The core feature this PR exists to add (session persistence across restarts) is broken in those environments. The comment claiming "temp directory on the same filesystem as the target" is incorrect.

There's a privacy compound: when the rename fails, rmSync(tmpDir, ...) is unreached (it sits inside the try block after renameSync), and tmpDir is out of scope in the catch. The temp directory persists with sessions.json containing senderIds, chatIds, sessionIds, and cwds sitting in world-readable /tmp — one PII-leak per failed write.

Fix shape: write the temp file in the same directory as the persist target — either <persistPath>.tmp or mkdtempSync(dirname(persistPath)) — and put rmSync in a finally block. This also covers the pre-existing concern that ~/.qwen/channels/ may not exist on a fresh install (the same mkdirSync(dirname(persistPath), {recursive: true}) once before write closes both).

Verdict

REQUEST_CHANGES — both items above need fixing before merge. While you're in there, three one-line follow-ups worth folding into the same round: clearTimeout(timeoutId!) isn't reached on non-timeout rejection so each fast-fail restore leaks a 30s timer (@wenshao's W7); the persist-entry type guard validates only target.channelName and would let a missing senderId/chatId through (@wenshao's W10); the all-failure-restore case still skips trimming the persist file (the TODO at SessionRouter.ts:268 from the prior review).

// is on the same filesystem as the target for atomic rename.
// rename is atomic on POSIX and works reliably on Windows when
// source and target are on the same filesystem.
const tmpDir = mkdtempSync(join(tmpdir(), 'session-persist-'));

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] EXDEV cross-device rename — persist() atomic write broken on Linux

mkdtempSync(join(tmpdir(), 'session-persist-')) creates the temp directory in the OS temp directory (e.g. /tmp, typically tmpfs), then renameSync to ~/.qwen/channels/sessions.json. When /tmp and /home are on different filesystems (common on Linux, Docker default), renameSync throws EXDEV: cross-device link not permitted. The comment claims "ensures the temp file is on the same filesystem" — this is incorrect; tmpdir() is system temp, NOT the target directory.

Suggested change
const tmpDir = mkdtempSync(join(tmpdir(), 'session-persist-'));
// Atomic write: write to a sibling temp file in the target directory,
// then rename. This guarantees same-filesystem atomic rename on all
// platforms without the overhead of mkdtemp/rmSync.
const tmpFile = this.persistPath + '.tmp';
writeFileSync(tmpFile, JSON.stringify(data, null, 2), 'utf-8');
renameSync(tmpFile, this.persistPath);

This also eliminates the temp directory leak risk (no rmSync needed).

— DeepSeek/deepseek-v4-pro via Qwen Code /review

writeStderrLine(
`[Channel] Failed to restart bridge: ${err instanceof Error ? err.message : String(err)}`,
);
return;

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] Bridge restart failure leaves zombie process — return skips channel reconnect

When bridge.start() fails in the disconnect handler, the return statement exits early, skipping channel.setBridge(bridge), registerToolCallDispatch(), and attachDisconnectHandler(). The channel still references the dead bridge and can never process messages again. The process hangs forever on await new Promise<void>(() => {}) — requiring kill -9 to exit.

Suggested change
return;
} catch (err) {
writeStderrLine(
`[Channel] Failed to restart bridge: ${err instanceof Error ? err.message : String(err)}`,
);
writeStderrLine(
`[Channel] Bridge restart failed. Channel will remain connected but unable to process messages. Exiting for supervisor restart.`,
);
channel.disconnect();
router.clearAll();
removeServiceInfo();
process.exit(1);
}

— DeepSeek/deepseek-v4-pro via Qwen Code /review

await restoreAndLogSessions(router);
} catch (err) {
writeStderrLine(
`[Channel] Bridge restarted but session restore failed: ${err instanceof Error ? err.message : String(err)}`,

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] Crash recovery ordering: restoreSessions blocks before channel.setBridge

restoreAndLogSessions(router) (up to 30s per persisted session) runs BEFORE channel.setBridge(bridge) and attachDisconnectHandler(bridge). During the restore window: (a) the channel still holds the old dead bridge, so all incoming messages are silently dropped; (b) no disconnect handler is attached to the new bridge, so if it crashes during restore, no recovery triggers. The old code correctly ordered channel.setBridge + attachDisconnectHandler before restore.

Suggested change
`[Channel] Bridge restarted but session restore failed: ${err instanceof Error ? err.message : String(err)}`,
channel.setBridge(bridge);
registerToolCallDispatch(bridge, router, channels);
attachDisconnectHandler(bridge);
try {
await restoreAndLogSessions(router);
} catch (err) {
writeStderrLine(
`[Channel] Bridge restarted but session restore failed: ${err instanceof Error ? err.message : String(err)}`,
);
}

— DeepSeek/deepseek-v4-pro via Qwen Code /review

failed++;
continue;
}

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] Sequential restore loop — N×30s blocking startup

for...of + await restores sessions one at a time. With N persisted sessions, cold-start and crash-recovery time grows to N × 30s (timeout per session). Each loadSession is an independent ACP IPC call — there is no ordering constraint requiring sequential execution.

Suggested change
// Parallel restore with per-session timeout
const results = await Promise.allSettled(
Object.entries(entries).map(async ([key, entry]) => {
if (
typeof entry?.sessionId !== 'string' ||
typeof entry?.cwd !== 'string' ||
typeof entry?.target?.channelName !== 'string'
) {
process.stderr.write(
`[SessionRouter] Skipping malformed entry for key ${key}\n`,
);
return { key, status: 'failed' as const };
}
try {
const sessionId = await Promise.race([
this.bridge.loadSession(entry.sessionId, entry.cwd),
new Promise<string>((_, reject) =>
setTimeout(() => reject(new Error('loadSession timed out')), LOAD_SESSION_TIMEOUT_MS),
),
]);
this.toSession.set(key, sessionId);
this.toTarget.set(sessionId, entry.target);
this.toCwd.set(sessionId, entry.cwd);
return { key, status: 'restored' as const };
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : String(err);
process.stderr.write(
`[SessionRouter] Failed to restore session ${entry.sessionId}: ${msg}\n`,
);
return { key, status: 'failed' as const };
}
}),
);
restored = results.filter(r => r.status === 'restored').length;
failed = results.filter(r => r.status === 'failed').length;

— DeepSeek/deepseek-v4-pro via Qwen Code /review

const tmpDir = mkdtempSync(join(tmpdir(), 'session-persist-'));
const tmpFile = join(tmpDir, 'sessions.json');
writeFileSync(tmpFile, JSON.stringify(data, null, 2), 'utf-8');
renameSync(tmpFile, this.persistPath);

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] persist() temp directory leaks on write/rename failure

rmSync(tmpDir, { recursive: true }) is inside the try block. If writeFileSync or renameSync throws, the catch block logs the error but the temp directory created by mkdtempSync is never cleaned up. Over time this accumulates orphaned /tmp/session-persist-* directories.

The same-directory temp file approach suggested in the EXDEV fix above eliminates this issue entirely. If keeping the temp-directory approach, move rmSync to a finally block:

Suggested change
renameSync(tmpFile, this.persistPath);
const tmpDir = mkdtempSync(join(tmpdir(), 'session-persist-'));
try {
const tmpFile = join(tmpDir, 'sessions.json');
writeFileSync(tmpFile, JSON.stringify(data, null, 2), 'utf-8');
renameSync(tmpFile, this.persistPath);
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : String(err);
process.stderr.write(
`[SessionRouter] Failed to persist sessions to ${this.persistPath}: ${msg}\n`,
);
} finally {
try { rmSync(tmpDir, { recursive: true }); } catch {}
}

— DeepSeek/deepseek-v4-pro via Qwen Code /review

// Atomic write: write to temp file in a temp directory then rename.
// Using a temp directory (not just a temp file) ensures the temp file
// is on the same filesystem as the target for atomic rename.
// rename is atomic on POSIX and works reliably on Windows when

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] persist() silently fails when parent directory does not exist

renameSync(tmpFile, this.persistPath) requires the target parent directory (~/.qwen/channels/) to exist. Nothing in the startup path guarantees this directory is created before the first resolve() triggers persist(). On first install, this causes ENOENT — session routing data is silently lost with only a stderr log line.

Suggested change
// rename is atomic on POSIX and works reliably on Windows when
private persist(): void {
if (!this.persistPath) return;
// Ensure the parent directory exists
const dir = path.dirname(this.persistPath);
try { mkdirSync(dir, { recursive: true }); } catch { /* best-effort */ }
const data: Record<string, PersistedEntry> = {};

Requires importing mkdirSync from node:fs and dirname from node:path.

— DeepSeek/deepseek-v4-pro via Qwen Code /review

// Update persist file to remove failed entries.
// Only persist if at least one session was successfully restored —
// an all-failure persist() would write empty {} and permanently delete
// routing data that might be recoverable on a subsequent attempt.

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] All-failure restore never trims persist file — stale entries retried every startup

The guard if (failed > 0 && restored > 0) skips persist() when all sessions fail to restore. The TODO comment acknowledges this: dead session IDs are retried on every restart. If the ACP agent's session store is cleared while the persist file remains, every startup wastes N×30s retrying irrecoverable sessions with no escape hatch.

The TODO suggests an escape hatch after N consecutive all-failure cycles. A simpler approach: when loadSession rejects with a clear "session does not exist" error (i.e., sessionExists returned false, not a network timeout), the entry is provably dead and should be pruned. Timeout errors should still be retried.

— DeepSeek/deepseek-v4-pro via Qwen Code /review

let restored = 0;
let failed = 0;

for (const [key, entry] of Object.entries(entries)) {

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] No progress logging during restore — silent until completion

restoreSessions logs individual failures but emits no "starting restore" message. If the process hangs during restore (e.g., loadSession timeout), the operator sees no indication that restore is in progress — only a silent wait followed by error logs 30s later. If the bridge crashes before any session completes, the summary line never appears.

Suggested change
for (const [key, entry] of Object.entries(entries)) {
const entryCount = Object.keys(entries).length;
if (entryCount > 0) {
process.stderr.write(
`[SessionRouter] Restoring ${entryCount} sessions from persist file...\n`,
);
}

— DeepSeek/deepseek-v4-pro via Qwen Code /review

return path.join(os.homedir(), '.qwen', 'channels', 'sessions.json');
}

async function restoreAndLogSessions(router: SessionRouter): Promise<void> {

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] restoreAndLogSessions swallows the result — callers can't distinguish partial vs total failure

The helper returns void, discarding { restored, failed }. In the crash-recovery path, the error message says "Bridge restarted but session restore failed" regardless of whether 1/50 or 50/50 sessions failed. Returning the result object would let callers provide better diagnostics.

Suggested change
async function restoreAndLogSessions(router: SessionRouter): Promise<void> {
async function restoreAndLogSessions(router: SessionRouter): Promise<{ restored: number; failed: number }> {
const result = await router.restoreSessions();
if (result.restored > 0 || result.failed > 0) {
writeStdoutLine(
`[Channel] Sessions restored: ${result.restored}` +
(result.failed > 0 ? `, failed: ${result.failed}` : ''),
);
}
return result;
}

— DeepSeek/deepseek-v4-pro via Qwen Code /review

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

type/bug Something isn't working as expected

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants