Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
13 changes: 7 additions & 6 deletions docs/INDEX.md

Large diffs are not rendered by default.

14 changes: 14 additions & 0 deletions docs/PITFALLS.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,9 @@
| hover 才现的控件刚截完图就点不到、点击静默落空 | 四 · 鼠标事件 + `:hover` → 补「截图会清掉 hover」(2026-09-19) |
| 改完自定义 hook 后整页报「Rendered fewer hooks than expected」 | 四 · HMR 改 hook 数量会假报错(2026-09-19) |
| 清理 dev 进程后端口还占着、CDP 连上但页面全空 | 五 · `pkill -f` 杀 Electron 会留下孤儿 main(2026-09-19) |
| 跨会话频道订阅后,会话被卸载/关闭期间的消息永久丢失(或反过来重复提醒) | 二 · 长生命周期订阅不能挂在可被自动 GC 的会话上(2026-09-20) |
| 有频道订阅的会话仍被自动卸载(或被用户关)→ 从此收不到唤醒 | 二 · 同章节「订阅 = 明确驻留语义」 |
| 恢复后把对端历史消息当新消息提醒、或每次重开都重提醒一次 | 二 · 同章节「游标缺 key ≠ null」 |
| 组里最后一个展开的项目折不掉、切会话又自己展开(空数组身兼两义) | 四 · 空数组不能同时当「未初始化」与「有效空值」(2026-09-20) |
| 打开模型选择器后整页向左偷跑、左栏与顶栏左侧按钮被挤/裁切 | 四 · absolute 弹层越界 + autoFocus = 整页横向偷跑(2026-09-20) |
| CDP 量测得出「弹层在视口内、也没滚动」但界面明明错位(量错元素) | 四 · 同章节「量测三纪律」(2026-09-20) |
Expand Down Expand Up @@ -135,6 +138,17 @@ LAN 页重连/中途进入时,快照种子经 `messagesToUIMessages` 重建—

**教训**:想让另一个会话及时收到消息,先看它的 turn 什么时候结束——`followUp` 的送达时机由**对方**的 turn 边界决定,不由发送方决定;要「立即送达」只有 `steer`(GUI 里用户自己发的消息目前也走 followUp 排队,`pi-backend.ts:572`)。所以「让双方停在同一节奏上」比引入锁/快照沙箱便宜得多。

### 长生命周期订阅不能挂在「可被自动 GC 的会话实例」上(2026-09-20,channel-watch retention + 持久补投)

背景:channel-watch 的 watcher 与订阅集都是**会话实例内的内存态**,而 renderer 的内存策略会把「空闲且不受保护」的会话 `dispose()` 掉。结果就是:会话一被卸载(或用户关掉),订阅无声消失——对端以为「已经通知过」,消息永久丢失;更糟的是用户看不出来(左栏那个会话还在,只是它不再收唤醒)。修复落在两处(`PiBackend.closeSession(id, intent)` + renderer 内存策略 + 落盘内容游标),四条不易自己想到的事实:

1. **订阅 = 明确驻留语义**:有有效运行态订阅的会话必须排除在自动回收之外(含「晾过 idleTimeout」的兜底路径),且**不占** 保留名额(K=3 是「可回收空闲」的名额,不是总会话数)。判定要用 backend 的**运行态快照**(扩展上报),不要用 UI 列表/设置偏好:`disabled`/`untrusted` 的订阅根本收不到唤醒,保护它只会白占内存。自动卸载与用户主动关/删要走**两个意图**(`intent:"gc"` 才被订阅挡,用户意图优先),否则用户会发现「这会话关不掉」。
2. **恢复只能靠会话文件条目回放**:游标(`MESSAGES.md` 内容 hash)必须和订阅集写在**同一条**快照里(分开写会出现「新订阅配旧游标」的中间态,恢复时要么补历史要么吞消息);`cursors` 里**缺 key(旧载荷,未知基线)= 只记当前版本不补历史**,与值为 `null`(确认时文件不存在)语义不同,别用 `Map.get()` 把两者混淆。
3. **「自己写的」不能无条件推进游标**:必须先确认**写前磁盘版本 == 游标**,否则会把「对端已写、我们还没提醒」的内容吞掉(`paused` 期间的写入、或落在 watcher 防抖窗口里的外部写入同样算未确认)。要拿到写前版本,得在 `tool_call` 钩子里读(SDK 会在工具真正执行前 `await` 这些钩子);拿到之前先想清楚「这条推进会不会跨过一个还没提醒的版本」。
4. **所有跨 `await` 的续体都要复查生命周期**(`active/trusted/订阅`):shutdown/退订完全可能落在「读盘 ↔ 起 watcher」之间,不复查就会出现幽灵投递、幽灵游标、孤儿 watcher。watcher 的 single-flight 推荐用 `p.then(settle, settle)` 而不是 `void p.finally(settle)`——`finally` 会派生新 promise,`p` 意外 reject 时变成未处理拒绝。

**本期未覆盖的已知限制(缺口)**:轮询降级模式的删除检测(`readdir` 快照里不含被删文件)与 watcher root 目录被删后重建——这两种异常场景下 watcher 会**漏事件**(订阅会话仍在,但那一次变化不会投递),不影响本期主故障(会话被卸载导致订阅整个消失)的验收,但它们是真实缺口、本期明确不修,不要当成「不是 bug」。

## 三、构建 · 打包 · 环境

### electron-vite dev 主进程 watcher 不可依赖:改 `src/main/` 后必须验产物(2026-09-17 实测)
Expand Down
50 changes: 46 additions & 4 deletions packages/backend/src/pi-backend.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ import type {
PermissionRequest,
PermissionResolved,
QuotaInfo,
SessionCloseIntent,
SessionEvent,
SessionMessage,
SessionMeta,
Expand Down Expand Up @@ -173,6 +174,12 @@ export interface PiBackendOptions {
*/
export class PiBackend {
private readonly registry = new SessionRegistry();
/**
* 已加载会话的频道订阅快照(spec §6.1/§6.2):sessionId → 有效运行态 topic 集,
* 由 channel-watch 扩展经 `reportChannelSubscriptions` 维护(只存 topic 名)。
* 阶段 0 只建管道,GC 守卫/查询 IPC 见 plan 阶段 1。
*/
private readonly channelSubscriptionIds = new Map<string, Set<string>>();
/** 用户级模型可见性与子代理模型偏好(独立于 CLI 共用 settings.json)。readonly 直暴露 */
readonly modelPrefs = new ModelPrefsService(join(getAgentDir(), "model-prefs.json"));
/** 社区包管理(安装/卸载 + 会话热重载)。readonly 直暴露 */
Expand Down Expand Up @@ -306,7 +313,14 @@ export class PiBackend {
);
// channel-watch 跨会话协作(开关默认开;session_start 检查开关 + trusted 门,
// 无订阅时零 fs 监听;钩子与蒸发/todo 无语义交互,位置不敏感,放 todo 前)
factories.push(makeChannelWatchExtension({ agentDir: getAgentDir(), cwd }));
// 订阅快照回报给 backend:有有效订阅的会话不被 renderer 自动 GC 卸载(retention)
factories.push(
makeChannelWatchExtension({
agentDir: getAgentDir(),
cwd,
onSubscriptionsChanged: (sessionId, topics) => this.reportChannelSubscriptions(sessionId, topics),
}),
);
// todo-reminder 最后:compaction 后恢复注入的任务列表不被上游折叠
factories.push(makeTodoReminderExtension());
return factories;
Expand Down Expand Up @@ -530,11 +544,31 @@ export class PiBackend {
}

/**
* 关会话(**用户主动关**):agent 还在跑(含等审批)时拒绝——run 中途把后端会话 dispose 会直接掐断本轮,
* channel-watch 扩展回调入口(spec §6.1):上报该会话当前的「有效运行态订阅」快照。
* 只存 topic 名(不存消息内容);空集 = 移出快照(退订最后一个 topic 即恢复普通 GC 资格)。
* 这里只存取快照,不做保护策略(策略在 closeSession 的 intent 守卫与 renderer 纯策略层)。
*/
reportChannelSubscriptions(sessionId: string, topics: ReadonlySet<string>): void {
if (topics.size === 0) this.channelSubscriptionIds.delete(sessionId);
else this.channelSubscriptionIds.set(sessionId, new Set(topics));
}

/**
* renderer GC 查询用(spec §6.2):**仍在 registry 中**且有效频道订阅非空的会话 ID
* (顺序不构成契约)。按 registry 过滤是兜底:回调/构造异常路径可能留下孤儿键,
* 不能让一个已不存在的会话 ID 永久影响 GC 判定。
*/
getChannelSubscriptionSessionIds(): string[] {
return [...this.channelSubscriptionIds.keys()].filter((id) => this.registry.has(id));
}

/**
* 关会话(默认**用户主动关**):agent 还在跑(含等审批)时拒绝——run 中途把后端会话 dispose 会直接掐断本轮,
* 内存策略(自动卸载)靠这道兵底兼固(策略层已保护,这里是最后一道门)。
* 自动卸载走同一个方法:行为与主动关完全一致,只是调用点分开(renderer 侧 `unloadSession`)。
* 自动卸载走同一个方法:行为与主动关基本一致,只是调用点分开(renderer 侧 `unloadSession`,intent="gc"),
* 且 intent="gc" 时多一道频道订阅守卫(订阅场景下卸了就收不到唤醒——这是显式订阅语义,见 spec §6.2)。
*/
async closeSession(sessionId: string): Promise<{ closed: boolean }> {
async closeSession(sessionId: string, intent: SessionCloseIntent = "user"): Promise<{ closed: boolean }> {
const entry = this.registry.get(sessionId);
// 没有该会话 = 它本来就没在跑(幂等成功):调用方该照常清掉自己的会话条目
if (!entry) return { closed: true };
Expand All @@ -543,6 +577,12 @@ export class PiBackend {
log.warn("closeSession ignored: streaming", sessionId);
return { closed: false };
}
// 自动 GC 不得卸掉有频道订阅的会话(快照按 sessionId 精确查,退订后自然恢复资格)。
// 显式关(intent="user"/缺省,含 delete 路径)不受此限:用户意图优先,订阅也不能绑架用户。
if (intent === "gc" && this.channelSubscriptionIds.has(sessionId)) {
log.info("closeSession ignored: channel subscriptions", sessionId);
return { closed: false };
}
await this.disposeSession(entry);
return { closed: true };
}
Expand All @@ -554,6 +594,8 @@ export class PiBackend {
// entry 级清理:unsubscribe + gate/dialogs dispose(pending 对话框按 sessionClosed 结算,
// 广播 resolved 让 renderer 撤卡;扩展 Promise 落取消值)
this.registry.delete(sessionId);
// 订阅快照兜底清理(正常路径 shutdown 会先上报空集;这里接住 session_start 前/异常路径)
this.channelSubscriptionIds.delete(sessionId);
// 全局键控子系统(本就独立于 registry)
this.streamGuard.cleanup(sessionId);
this.eventRates.delete(sessionId);
Expand Down
Loading
Loading