From 247a621ddfdc0238c5a6477096eb17ecc4a810bd Mon Sep 17 00:00:00 2001 From: Jaxton07 Date: Sun, 20 Sep 2026 20:00:13 +0800 Subject: [PATCH 01/17] =?UTF-8?q?test(sidebar-switch):=20=E9=98=B6?= =?UTF-8?q?=E6=AE=B5=200=20=E2=80=94=20=E7=BA=A2=E6=B5=8B=E5=9B=BA?= =?UTF-8?q?=E5=AE=9A=E5=9B=9B=E6=9D=A1=E6=95=85=E9=9A=9C=E9=93=BE=EF=BC=88?= =?UTF-8?q?=E6=8E=92=E5=BA=8F=E5=85=83=E6=95=B0=E6=8D=AE/=E5=AF=BC?= =?UTF-8?q?=E8=88=AA=20latest-wins/open=20single-flight/GC=20=E7=AB=9E?= =?UTF-8?q?=E6=80=81/projects=20load=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/backend/src/session/single-flight.ts | 24 ++ .../test/open-session-idempotency.test.ts | 109 +++++++ packages/backend/test/registry.test.ts | 29 ++ packages/backend/test/session-meta.test.ts | 211 +++++++++++++ .../test/session-single-flight.test.ts | 75 +++++ .../renderer/src/lib/sidebar-groups.test.ts | 67 +++++ .../src/renderer/src/stores/projects.test.ts | 97 +++++- .../src/renderer/src/stores/sessions.test.ts | 279 ++++++++++++++++++ 8 files changed, 889 insertions(+), 2 deletions(-) create mode 100644 packages/backend/src/session/single-flight.ts create mode 100644 packages/backend/test/open-session-idempotency.test.ts create mode 100644 packages/backend/test/session-meta.test.ts create mode 100644 packages/backend/test/session-single-flight.test.ts diff --git a/packages/backend/src/session/single-flight.ts b/packages/backend/src/session/single-flight.ts new file mode 100644 index 00000000..cd115fc8 --- /dev/null +++ b/packages/backend/src/session/single-flight.ts @@ -0,0 +1,24 @@ +/** + * 按 key 的并发 single-flight 原语(spec sidebar-session-switch-stability D4): + * 同一 key 在途时复用同一个 Promise(多个调用者拿到同一结果),settle 后**无论成败**都清 key, + * 故失败可以重试;不同 key 互不影响。 + * + * 为什么单独成文件:PiBackend 的构造路径依赖真实 SDK/资源加载,无法在单测里无侵入地多次构造, + * 把「按 key 去重」这层纯逻辑抽出来才能确定性单测(见 test/session-single-flight.test.ts)。 + * + * **阶段 0 占位**:签名与调用形状先固定,当前是直通实现(不去重、不缓存), + * 接入 PiBackend.openSession 见 plan 阶段 3.1。 + */ +export class KeyedSingleFlight { + private readonly inFlight = new Map>(); + + /** 在途任务数(单测断言「settle 后必然清空」用) */ + get size(): number { + return this.inFlight.size; + } + + run(key: string, task: () => Promise): Promise { + void key; // 占位阶段不按 key 去重(去重实现见 plan 阶段 3.1) + return task(); + } +} diff --git a/packages/backend/test/open-session-idempotency.test.ts b/packages/backend/test/open-session-idempotency.test.ts new file mode 100644 index 00000000..41206b4e --- /dev/null +++ b/packages/backend/test/open-session-idempotency.test.ts @@ -0,0 +1,109 @@ +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import type { AgentSession, ModelRuntime } from "@earendil-works/pi-coding-agent"; +import { SessionManager } from "@earendil-works/pi-coding-agent"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { PiBackend } from "../src/pi-backend"; +import type { RegisteredSession, SessionRegistry } from "../src/session/registry"; + +/** + * 阶段 0 红测(spec D4 / §5 Backend 1-2):已加载的会话文件再次 open 必须**命中 registry 短路**, + * 返回现有 entry 的最新 meta,绝不再构造第二个 AgentSession(构造会重复 subscribe/trace, + * 并在 registry.add 时静默覆盖旧 entry,泄漏第一份实例)。 + * + * 为什么把 createAgentSession 与资源加载都挡住:真走到构造就会读真实 `~/.pi/agent` 资源、 + * 拉起用户扩展 —— 红测要「不该走到构造」这条路**可判定**(走进去就抛),而不是碰运气。 + * 并发路径的去重契约由 KeyedSingleFlight 单测固定(见 session-single-flight.test.ts)。 + */ + +vi.mock("@earendil-works/pi-coding-agent", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + createAgentSession: () => { + throw new Error("不该构造第二个 AgentSession(应命中 registry 短路)"); + }, + }; +}); + +const PROJECT = "/tmp/project"; + +function writeSessionFile(dir: string, id: string): string { + const file = join(dir, `${id}.jsonl`); + const lines = [ + JSON.stringify({ + type: "session", + version: 3, + id, + timestamp: "2020-01-01T00:00:00.000Z", + cwd: PROJECT, + }), + JSON.stringify({ + type: "message", + id: "m1", + parentId: null, + timestamp: "2021-03-04T05:06:07.000Z", + message: { role: "user", content: "hi", timestamp: Date.parse("2021-03-04T05:06:07.000Z") }, + }), + ]; + writeFileSync(file, `${lines.join("\n")}\n`); + return file; +} + +function entryFor(file: string, sessionId: string): RegisteredSession { + return { + session: { + sessionId, + sessionFile: file, + sessionName: "已加载会话", + model: null, + thinkingLevel: "medium", + messages: [], + sessionManager: SessionManager.open(file), + } as unknown as AgentSession, + unsubscribe: () => {}, + cwd: PROJECT, + gate: { dispose: () => {} }, + dialogs: { dispose: () => {} }, + modeRef: { current: "default" }, + } as unknown as RegisteredSession; +} + +let dir: string; + +beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), "open-idempotency-")); +}); + +afterEach(() => { + rmSync(dir, { recursive: true, force: true }); +}); + +describe("PiBackend.openSession 幂等(spec D4)", () => { + it("已在 registry 的会话再 open:返回现有 entry 的 meta,不替换 entry、不重复构造", async () => { + const file = writeSessionFile(dir, "sess-open"); + const backend = new PiBackend({ projectTrust: false, permissionGates: false }); + const registry = (backend as unknown as { registry: SessionRegistry }).registry; + vi.spyOn( + backend as unknown as { getModelRuntime: () => Promise }, + "getModelRuntime", + ).mockResolvedValue({} as ModelRuntime); + // 任何构造尝试都要先过资源加载:这里直接抛,命中短路时不会被调用 + const load = vi.fn(() => { + throw new Error("不该构造第二个 AgentSession(应命中 registry 短路)"); + }); + Object.defineProperty(backend, "projectLoader", { value: { load } }); + + const entry = entryFor(file, "sess-open"); + registry.add(entry); + + const meta = await backend.openSession(file); + + expect(meta.sessionId).toBe("sess-open"); + expect(meta).toEqual(registry.toMeta(entry)); + expect(registry.list()).toHaveLength(1); + expect(registry.get("sess-open")).toBe(entry); + expect(load).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/backend/test/registry.test.ts b/packages/backend/test/registry.test.ts index 6582bb55..27e42326 100644 --- a/packages/backend/test/registry.test.ts +++ b/packages/backend/test/registry.test.ts @@ -113,3 +113,32 @@ describe("SessionRegistry toMeta createdAt(D7:会话文件 birthtime)", () expect(meta.createdAt).toBeLessThanOrEqual(Date.now()); }); }); + +// --------------------------------------------------------------------------- +// 阶段 0 红测(spec sidebar-session-switch-stability D4):add 不得静默覆盖同 sessionId 的旧 entry —— +// 覆盖 = 第一份实例的订阅/gate/dialogs 全部泄漏(且没人再能 dispose 它)。 +// 防住这条才能让「open 幂等」有意义(构造前短路 + 注册处兜底)。实现见 plan 阶段 3.1。 +// --------------------------------------------------------------------------- + +describe("SessionRegistry.add 幂等兜底", () => { + it("同一 sessionId 再 add 不同 entry:抛错,不静默替换旧 entry", () => { + const registry = new SessionRegistry(); + const first = makeEntry("dup", join(dir, "dup.jsonl")); + const second = makeEntry("dup", join(dir, "dup.jsonl")); + registry.add(first.entry); + + expect(() => registry.add(second.entry)).toThrow(); + + expect(registry.get("dup")).toBe(first.entry); + expect(registry.list()).toHaveLength(1); + }); + + it("同 sessionId 重复 add 同一个 entry:幂等(不抛错)", () => { + const registry = new SessionRegistry(); + const first = makeEntry("dup", join(dir, "dup.jsonl")); + registry.add(first.entry); + + expect(() => registry.add(first.entry)).not.toThrow(); + expect(registry.list()).toHaveLength(1); + }); +}); diff --git a/packages/backend/test/session-meta.test.ts b/packages/backend/test/session-meta.test.ts new file mode 100644 index 00000000..1586e0b2 --- /dev/null +++ b/packages/backend/test/session-meta.test.ts @@ -0,0 +1,211 @@ +import { copyFileSync, mkdtempSync, rmSync, statSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import type { AgentSession } from "@earendil-works/pi-coding-agent"; +import { SessionManager } from "@earendil-works/pi-coding-agent"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { type RegisteredSession, SessionRegistry } from "../src/session/registry"; + +/** + * 阶段 0 红测(spec sidebar-session-switch-stability D1 / §5 排序 3-4): + * 活跃会话 meta 的时间字段必须与磁盘枚举同语义 —— createdAt = session header 时间(**不是**文件 birthtime, + * 复制/恢复文件会改 birthtime),modifiedAt = 所有 user/assistant 消息的最大活动时间 + * (message.timestamp 数值优先,退回 entry timestamp;custom/tool 类 entry 不影响)。 + * + * 现状(红):toMeta 用 statSync(file).birthtimeMs 当 createdAt,且完全没有 modifiedAt → + * 左栏排序键从 modifiedAt 掉到 createdAt,点击行下移。实现见 plan 阶段 1.1。 + */ + +const PROJECT = "/tmp/project"; +const CREATED_ISO = "2020-01-01T00:00:00.000Z"; +const CREATED_MS = Date.parse(CREATED_ISO); +const USER_TS = Date.parse("2021-03-04T05:06:07.000Z"); +const ASSISTANT_TS = Date.parse("2022-07-08T09:10:11.000Z"); +/** 比所有消息都晚的 custom entry(channel cursor 同类):绝不能当成会话活动时间 */ +const CUSTOM_ISO = "2023-05-06T07:08:09.000Z"; + +interface MessageSpec { + id: string; + role: string; + /** message 自带的数值时间戳(缺省则退回 entry timestamp) */ + timestamp?: number; + /** entry 的 ISO 时间戳 */ + entryTimestamp: string; +} + +function messageEntry(spec: MessageSpec): unknown { + return { + type: "message", + id: spec.id, + parentId: null, + timestamp: spec.entryTimestamp, + message: { + role: spec.role, + content: "x", + ...(spec.timestamp === undefined ? {} : { timestamp: spec.timestamp }), + }, + }; +} + +function customEntry(id: string, entryTimestamp: string): unknown { + return { type: "custom", id, parentId: null, timestamp: entryTimestamp, customType: "channel-cursor" }; +} + +/** 按真实 pi 会话文件格式手写一份会话(header + 逐行 entry),零 SDK 写入 */ +function writeSessionFile(dir: string, id: string, entries: unknown[]): string { + const file = join(dir, `${id}.jsonl`); + const lines = [ + JSON.stringify({ type: "session", version: 3, id, timestamp: CREATED_ISO, cwd: PROJECT }), + ...entries.map((entry) => JSON.stringify(entry)), + ]; + writeFileSync(file, `${lines.join("\n")}\n`); + return file; +} + +/** 最小 RegisteredSession 桩:sessionManager 是真的(读 header/entries),不构造 AgentSession */ +function entryFor(file: string, sessionId: string): RegisteredSession { + return { + session: { + sessionId, + sessionFile: file, + sessionName: undefined, + model: null, + thinkingLevel: "medium", + messages: [], + sessionManager: SessionManager.open(file), + } as unknown as AgentSession, + unsubscribe: () => {}, + cwd: PROJECT, + gate: { dispose: () => {} }, + dialogs: { dispose: () => {} }, + modeRef: { current: "default" }, + } as unknown as RegisteredSession; +} + +function metaOf(file: string, sessionId: string) { + return new SessionRegistry().toMeta(entryFor(file, sessionId)); +} + +let dir: string; + +beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), "session-meta-")); +}); + +afterEach(() => { + rmSync(dir, { recursive: true, force: true }); +}); + +describe("SessionRegistry.toMeta · 时间语义(spec D1)", () => { + it("createdAt 取 header 时间(不是文件 birthtime);modifiedAt 取 user/assistant 最大活动时间", () => { + const file = writeSessionFile(dir, "sess-a", [ + messageEntry({ + id: "m1", + role: "user", + timestamp: USER_TS, + entryTimestamp: "2021-03-04T05:06:07.000Z", + }), + customEntry("c1", CUSTOM_ISO), + messageEntry({ + id: "m2", + role: "assistant", + timestamp: ASSISTANT_TS, + entryTimestamp: "2022-07-08T09:10:11.000Z", + }), + ]); + + const meta = metaOf(file, "sess-a"); + + expect(meta.createdAt).toBe(CREATED_MS); + expect(meta.modifiedAt).toBe(ASSISTANT_TS); + // 反证:文件刚写出来,birthtime 是「现在」,与 header 时间(2020)必然不同 + expect(statSync(file).birthtimeMs).not.toBe(CREATED_MS); + }); + + it("custom / toolResult 类 entry 不影响 modifiedAt(channel cursor 不参与排序)", () => { + const file = writeSessionFile(dir, "sess-b", [ + messageEntry({ + id: "m1", + role: "user", + timestamp: USER_TS, + entryTimestamp: "2021-03-04T05:06:07.000Z", + }), + messageEntry({ + id: "m2", + role: "toolResult", + timestamp: ASSISTANT_TS, + entryTimestamp: "2022-07-08T09:10:11.000Z", + }), + customEntry("c1", CUSTOM_ISO), + ]); + + expect(metaOf(file, "sess-b").modifiedAt).toBe(USER_TS); + }); + + it("消息没有数值 timestamp 时退回 entry timestamp", () => { + const file = writeSessionFile(dir, "sess-c", [ + messageEntry({ id: "m1", role: "user", entryTimestamp: "2021-03-04T05:06:07.000Z" }), + ]); + + expect(metaOf(file, "sess-c").modifiedAt).toBe(USER_TS); + }); + + it("没有任何 user/assistant 消息:modifiedAt = createdAt", () => { + const file = writeSessionFile(dir, "sess-d", [customEntry("c1", CUSTOM_ISO)]); + + const meta = metaOf(file, "sess-d"); + + expect(meta.createdAt).toBe(CREATED_MS); + expect(meta.modifiedAt).toBe(CREATED_MS); + }); + + it("与 SDK 磁盘枚举同语义:SessionManager.list 的 created/modified 与活跃 meta 一致", async () => { + const file = writeSessionFile(dir, "sess-e", [ + messageEntry({ + id: "m1", + role: "user", + timestamp: USER_TS, + entryTimestamp: "2021-03-04T05:06:07.000Z", + }), + customEntry("c1", CUSTOM_ISO), + messageEntry({ + id: "m2", + role: "assistant", + timestamp: ASSISTANT_TS, + entryTimestamp: "2022-07-08T09:10:11.000Z", + }), + ]); + const infos = await SessionManager.list(PROJECT, dir); + const info = infos.find((item) => item.id === "sess-e"); + if (!info) throw new Error("session file not enumerated"); + + const meta = metaOf(file, "sess-e"); + + expect(meta.createdAt).toBe(info.created.getTime()); + expect(meta.modifiedAt).toBe(info.modified.getTime()); + }); + + it("复制会话文件(birthtime 变新)不改变 createdAt", () => { + const file = writeSessionFile(dir, "sess-f", [ + messageEntry({ + id: "m1", + role: "user", + timestamp: USER_TS, + entryTimestamp: "2021-03-04T05:06:07.000Z", + }), + ]); + const copyDir = mkdtempSync(join(tmpdir(), "session-meta-copy-")); + const copy = join(copyDir, "sess-f-copy.jsonl"); + copyFileSync(file, copy); + + try { + const meta = metaOf(copy, "sess-f"); + expect(meta.createdAt).toBe(CREATED_MS); + expect(meta.modifiedAt).toBe(USER_TS); + // 副本的 birthtime 是复制时刻(远比 2020 晚):证明没在用 birthtime + expect(statSync(copy).birthtimeMs).toBeGreaterThan(CREATED_MS + 86_400_000); + } finally { + rmSync(copyDir, { recursive: true, force: true }); + } + }); +}); diff --git a/packages/backend/test/session-single-flight.test.ts b/packages/backend/test/session-single-flight.test.ts new file mode 100644 index 00000000..e6f3f691 --- /dev/null +++ b/packages/backend/test/session-single-flight.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, it } from "vitest"; +import { KeyedSingleFlight } from "../src/session/single-flight"; + +/** 手写 deferred:single-flight 的契约全在「settle 前 / settle 后」两个时刻,必须能精确控制 */ +function deferred() { + let resolve!: (value: T) => void; + let reject!: (error: unknown) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, resolve, reject }; +} + +/** + * 阶段 0 红测(spec D4 / §5 Backend 1):同一路径并发 open 只构造一次, + * 靠这层原语保证;失败可重试靠「settle 必清 key」。 + */ +describe("KeyedSingleFlight", () => { + it("同一 key 在途时只执行一次,多个调用者拿到同一结果", async () => { + const flight = new KeyedSingleFlight(); + const pending = deferred(); + let calls = 0; + const task = () => { + calls += 1; + return pending.promise; + }; + + const first = flight.run("k", task); + const second = flight.run("k", task); + + expect(calls).toBe(1); + expect(flight.size).toBe(1); + pending.resolve(7); + expect(await Promise.all([first, second])).toEqual([7, 7]); + }); + + it("settle 后清 key:同一个 key 再次调用会重新执行", async () => { + const flight = new KeyedSingleFlight(); + let calls = 0; + const task = () => { + calls += 1; + return Promise.resolve(calls); + }; + + await flight.run("k", task); + expect(flight.size).toBe(0); + expect(await flight.run("k", task)).toBe(2); + expect(calls).toBe(2); + }); + + it("失败也清 key:可重试(失败 Promise 不留在表里)", async () => { + const flight = new KeyedSingleFlight(); + const failing = deferred(); + const first = flight.run("k", () => failing.promise); + failing.reject(new Error("boom")); + await expect(first).rejects.toThrow("boom"); + + expect(flight.size).toBe(0); + expect(await flight.run("k", () => Promise.resolve(1))).toBe(1); + }); + + it("不同 key 并发互不影响(各自独立执行)", async () => { + const flight = new KeyedSingleFlight(); + const a = deferred(); + const b = deferred(); + + const first = flight.run("a", () => a.promise); + const second = flight.run("b", () => b.promise); + expect(flight.size).toBe(2); + a.resolve("A"); + b.resolve("B"); + expect(await Promise.all([first, second])).toEqual(["A", "B"]); + }); +}); diff --git a/packages/desktop/src/renderer/src/lib/sidebar-groups.test.ts b/packages/desktop/src/renderer/src/lib/sidebar-groups.test.ts index 0a861e26..7002f972 100644 --- a/packages/desktop/src/renderer/src/lib/sidebar-groups.test.ts +++ b/packages/desktop/src/renderer/src/lib/sidebar-groups.test.ts @@ -267,6 +267,73 @@ describe("mergeSidebarSessions(磁盘历史 + 当前内存会话)", () => { }); }); +// --------------------------------------------------------------------------- +// 阶段 0 红测(spec sidebar-session-switch-stability D1 / §5 排序): +// 内存 meta 覆盖历史 meta 时不得丢掉稳定时间字段,否则左栏排序键从 modifiedAt 掉到 createdAt、行跳动。 +// 实现见 plan 阶段 1.2。 +// --------------------------------------------------------------------------- + +describe("mergeSidebarSessions · 稳定时间字段(打开/卸载不改变行位置)", () => { + it("内存同 ID 缺 modifiedAt:保留历史活动时间;createdAt 用历史权威值(不信内存的运行态时间)", () => { + const history: SessionMeta = { ...session("h1", P1, 900), createdAt: 100, messageCount: 3 }; + // 活跃会话 meta:registry 目前只给 createdAt(且是文件 birthtime),没有 modifiedAt + const memory: SessionMeta = { + sessionId: "h1", + cwd: P1, + active: true, + messageCount: 5, + createdAt: 5_000, + }; + const merged = mergeSidebarSessions([history], [memory]); + + expect(merged).toHaveLength(1); + expect(merged[0]?.modifiedAt).toBe(900); + expect(merged[0]?.createdAt).toBe(100); + // 运行态字段仍以内存为准 + expect(merged[0]?.messageCount).toBe(5); + expect(merged[0]?.active).toBe(true); + }); + + it("内存给了 modifiedAt 就以内存的为准(会话真的产生了新活动)", () => { + const merged = mergeSidebarSessions( + [{ ...session("h1", P1, 900), createdAt: 100 }], + [{ sessionId: "h1", cwd: P1, active: true, messageCount: 1, createdAt: 100, modifiedAt: 1_500 }], + ); + expect(merged[0]?.modifiedAt).toBe(1_500); + }); + + it("打开前后派生顺序完全一致(点击历史行不再下移)", () => { + const hist = [session("a", P1, 900), session("b", P1, 800), session("c", P1, 700)]; + const before = derive({ sessions: hist, projects: [project(P1)] }); + const orderBefore = before.projects[0]?.sessions.map((s) => s.session.sessionId); + + // 用户点了 b:b 变成「内存活跃会话」,内存 meta 无 modifiedAt(registry 现状) + const merged = mergeSidebarSessions(hist, [ + { sessionId: "b", cwd: P1, active: true, messageCount: 2, createdAt: 5_000 }, + ]); + const after = derive({ sessions: merged, projects: [project(P1)] }); + + expect(orderBefore).toEqual(["a", "b", "c"]); + expect(after.projects[0]?.sessions.map((s) => s.session.sessionId)).toEqual(orderBefore); + }); + + it("置顶分区顺序不受合并影响(置顶仍在最前,按 pinnedSessions 顺序)", () => { + const hist = [session("a", P1, 900), session("b", P1, 800), session("c", P1, 700)]; + const merged = mergeSidebarSessions(hist, [ + { sessionId: "c", cwd: P1, active: true, messageCount: 2, createdAt: 5_000 }, + ]); + const result = derive({ sessions: merged, projects: [project(P1)], pinnedSessions: ["c"] }); + const alpha = result.projects.find((p) => p.cwd === P1); + expect(alpha?.sessions.map((s) => s.session.sessionId)).toEqual(["c", "a", "b"]); + expect(alpha?.sessions.map((s) => s.pinned)).toEqual([true, false, false]); + }); + + it("无历史对应(同 ID 只出现在内存:draft / 刚创建)时不做字段兜底,保持原样", () => { + const draft = session("draft:x", P1, 300); + expect(mergeSidebarSessions([session("h1", P1, 100)], [draft])[1]).toBe(draft); + }); +}); + describe("toggleInList / toggleExpandedGroup", () => { it("置顶切换:新置顶排最前,取消则移除", () => { expect(toggleInList([], "a")).toEqual(["a"]); diff --git a/packages/desktop/src/renderer/src/stores/projects.test.ts b/packages/desktop/src/renderer/src/stores/projects.test.ts index 7a2093fe..a394152c 100644 --- a/packages/desktop/src/renderer/src/stores/projects.test.ts +++ b/packages/desktop/src/renderer/src/stores/projects.test.ts @@ -1,7 +1,15 @@ import type { SessionMeta } from "@percho/shared"; -import { afterEach, describe, expect, it } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +/** window.pi 的 mock:store 的 load 经 getPi() 访问(测试环境无 preload 注入) */ +const piMock = vi.hoisted(() => ({ + listAllSessions: vi.fn(), + getDailyDir: vi.fn(() => Promise.resolve(null)), +})); +vi.mock("../api", () => ({ getPi: () => piMock })); + import { setDailyDirForTest } from "../lib/daily"; -import { deriveProjects } from "./projects"; +import { deriveProjects, useProjectsStore } from "./projects"; function session(cwd: string, modifiedAt: number): SessionMeta { return { @@ -61,3 +69,88 @@ describe("deriveProjects · 日常空间隔离", () => { expect(out).toHaveLength(2); }); }); + +// --------------------------------------------------------------------------- +// 阶段 0 红测(spec sidebar-session-switch-stability D6、§5 项目 load): +// 列表刷新 latest-wins —— 旧 load 响应(成功或失败)不得覆盖更新响应,也不得提前把 loading 置 false。 +// 实现见 plan 阶段 3.3。 +// --------------------------------------------------------------------------- + +/** 手写 deferred:要精确控制两次 load 的返回顺序(旧后到 / 旧失败后到) */ +function deferred() { + let resolve!: (value: T) => void; + let reject!: (error: unknown) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, resolve, reject }; +} + +function ids(): string[] { + return useProjectsStore.getState().allSessions.map((s) => s.sessionId); +} + +describe("projects.load latest-wins(spec D6)", () => { + beforeEach(() => { + vi.clearAllMocks(); + piMock.listAllSessions.mockReset(); + useProjectsStore.setState({ + allSessions: [], + selectedCwd: null, + search: "", + loading: false, + loaded: false, + }); + }); + + it("load A 后 load B,B 先返回、A 后返回:最终 store 是 B,loading=false", async () => { + const a = deferred(); + const b = deferred(); + piMock.listAllSessions.mockImplementationOnce(() => a.promise).mockImplementationOnce(() => b.promise); + + const loadA = useProjectsStore.getState().load(); + const loadB = useProjectsStore.getState().load(); + b.resolve([session("/p", 200)]); + await loadB; + a.resolve([session("/p", 100)]); + await loadA; + + expect(ids()).toEqual(["/p-200"]); + expect(useProjectsStore.getState().loading).toBe(false); + expect(useProjectsStore.getState().loaded).toBe(true); + }); + + it("旧请求失败:不得覆盖新请求成功结果", async () => { + const a = deferred(); + const b = deferred(); + piMock.listAllSessions.mockImplementationOnce(() => a.promise).mockImplementationOnce(() => b.promise); + + const loadA = useProjectsStore.getState().load(); + const loadB = useProjectsStore.getState().load(); + b.resolve([session("/p", 300)]); + await loadB; + a.reject(new Error("boom")); + await loadA; + + expect(ids()).toEqual(["/p-300"]); + expect(useProjectsStore.getState().loading).toBe(false); + }); + + it("旧请求先失败:不得提前把 loading 置 false(新请求仍在途)", async () => { + const a = deferred(); + const b = deferred(); + piMock.listAllSessions.mockImplementationOnce(() => a.promise).mockImplementationOnce(() => b.promise); + + const loadA = useProjectsStore.getState().load(); + const loadB = useProjectsStore.getState().load(); + a.reject(new Error("boom")); + await loadA; + expect(useProjectsStore.getState().loading).toBe(true); + + b.resolve([session("/p", 400)]); + await loadB; + expect(ids()).toEqual(["/p-400"]); + expect(useProjectsStore.getState().loading).toBe(false); + }); +}); diff --git a/packages/desktop/src/renderer/src/stores/sessions.test.ts b/packages/desktop/src/renderer/src/stores/sessions.test.ts index 443ffea7..6a74afad 100644 --- a/packages/desktop/src/renderer/src/stores/sessions.test.ts +++ b/packages/desktop/src/renderer/src/stores/sessions.test.ts @@ -58,6 +58,8 @@ function resetStore() { beforeEach(() => { vi.clearAllMocks(); + // openSession 的 mockImplementation 是持久实现(clearAllMocks 不清),用例之间必须显式复位 + piMock.openSession.mockReset(); resetStore(); useTranscriptStore.setState({ bySession: {} }); }); @@ -567,3 +569,280 @@ describe("unloadSession(自动 GC)的 intent 标记", () => { expect(piMock.closeSession).toHaveBeenCalledWith({ sessionId: "r2" }); }); }); + +// --------------------------------------------------------------------------- +// 阶段 0 红测(spec sidebar-session-switch-stability D2/D3/D5、§5 导航 / GC): +// ① 异步导航 latest-wins(旧 open/create/fork 迟到不得抢回 active) +// ② 同 sessionFile 的 open single-flight(同一文件同时只发一次 IPC、bundle 不并发重复) +// ③ GC close 在途时用户选中被卸载会话 → close 返回后 reopen 恢复,而不是把它抹掉 +// 实现见 plan 阶段 2 / 3。 +// --------------------------------------------------------------------------- + +/** 手写 deferred:要精确控制「异步动作什么时候返回」,才能复现点击与响应的交错 */ +function deferred() { + let resolve!: (value: T) => void; + let reject!: (error: unknown) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, resolve, reject }; +} + +function toastKeys(): string[] { + return useToastsStore + .getState() + .toasts.filter((t) => t.titleKey !== undefined) + .map((t) => `${t.titleKey}`); +} + +describe("导航 latest-wins:最后一次点击获胜(spec D2)", () => { + beforeEach(() => useToastsStore.setState({ toasts: [] })); + + it("旧 open 迟到不得抢回 active:点击未加载 A → 切到已加载 B → A 才返回", async () => { + const opened = deferred(); + piMock.openSession.mockImplementationOnce(() => opened.promise); + useSessionsStore.setState({ sessions: [realMeta("b", "/p")], activeSessionId: "b", cwd: "/p" }); + + const opening = useSessionsStore.getState().openFromHistory("/p/a.jsonl"); + useSessionsStore.getState().switchSession("b"); + opened.resolve(realMeta("a", "/p")); + await opening; + + const state = useSessionsStore.getState(); + expect(state.activeSessionId).toBe("b"); + expect(state.cwd).toBe("/p"); + // 旧 open 成功仍可成为「已加载的后台会话」:latest-wins 只限制谁能激活 + expect(state.sessions.map((s) => s.sessionId).sort()).toEqual(["a", "b"]); + }); + + it("连续 open A / C:响应正序或逆序返回,最终都停在最后一次点击的 C", async () => { + for (const order of ["a-then-c", "c-then-a"] as const) { + resetStore(); + const a = deferred(); + const c = deferred(); + piMock.openSession.mockImplementation((args: { filePath: string }) => + args.filePath.includes("/a.jsonl") ? a.promise : c.promise, + ); + + const openingA = useSessionsStore.getState().openFromHistory("/p/a.jsonl"); + const openingC = useSessionsStore.getState().openFromHistory("/p/c.jsonl"); + if (order === "a-then-c") { + a.resolve(realMeta("a", "/p")); + await openingA; + c.resolve(realMeta("c", "/p")); + await openingC; + } else { + c.resolve(realMeta("c", "/p")); + await openingC; + a.resolve(realMeta("a", "/p")); + await openingA; + } + + expect(useSessionsStore.getState().activeSessionId).toBe("c"); + } + }); + + it("draft 转正(createSession)迟到不得覆盖后续 switch(新会话仍进 tabs)", async () => { + const created = deferred(); + piMock.createSession.mockImplementationOnce(() => created.promise); + useSessionsStore.getState().createDraftSession("/p"); + const draftId = useSessionsStore.getState().activeSessionId; + if (!draftId) throw new Error("no draft"); + useSessionsStore.setState((state) => ({ sessions: [...state.sessions, realMeta("b", "/p")] })); + + const creating = useSessionsStore.getState().createSession("/p", draftId); + useSessionsStore.getState().switchSession("b"); + created.resolve(realMeta("new-1", "/p")); + await creating; + + const state = useSessionsStore.getState(); + expect(state.activeSessionId).toBe("b"); + expect(state.cwd).toBe("/p"); + expect(state.sessions.map((s) => s.sessionId)).toContain("new-1"); + }); + + it("fork 迟到不得覆盖后续 switch(新会话进 tabs,但不抢 active)", async () => { + useSessionsStore.setState({ + sessions: [realMeta("r1", "/p"), realMeta("r2", "/p")], + activeSessionId: "r1", + cwd: "/p", + }); + const forked = deferred(); + piMock.forkSession.mockImplementationOnce(() => forked.promise); + + const forking = useSessionsStore.getState().forkSession({ entryId: "e1" }); + useSessionsStore.getState().switchSession("r2"); + forked.resolve(realMeta("f1", "/p")); + + // fork 事实上发生了:仍要返回新 id 供调用方使用 + expect(await forking).toBe("f1"); + const state = useSessionsStore.getState(); + expect(state.activeSessionId).toBe("r2"); + expect(state.sessions.map((s) => s.sessionId)).toContain("f1"); + }); + + it("旧 open 失败不得回滚用户的新选择(新选择仍在,且只提示一次)", async () => { + const opened = deferred(); + piMock.openSession.mockImplementationOnce(() => opened.promise); + useSessionsStore.setState({ sessions: [realMeta("b", "/p")], activeSessionId: "b", cwd: "/p" }); + + const opening = useSessionsStore.getState().openFromHistory("/p/a.jsonl"); + useSessionsStore.getState().switchSession("b"); + opened.reject(new Error("open boom")); + await opening; + + expect(useSessionsStore.getState().activeSessionId).toBe("b"); + expect(useSessionsStore.getState().cwd).toBe("/p"); + }); +}); + +describe("同 sessionFile 的 open single-flight(spec D3)", () => { + beforeEach(() => useToastsStore.setState({ toasts: [] })); + + it("同一文件双击:只发一次 IPC,bundle 不并发重复装载,最终 active = 该会话", async () => { + const opened = deferred(); + piMock.openSession.mockImplementation(() => opened.promise); + + const first = useSessionsStore.getState().openFromHistory("/p/a.jsonl"); + const second = useSessionsStore.getState().openFromHistory("/p/a.jsonl"); + expect(piMock.openSession).toHaveBeenCalledTimes(1); + opened.resolve(realMeta("a", "/p")); + await Promise.all([first, second]); + + expect(useSessionsStore.getState().activeSessionId).toBe("a"); + expect(piMock.getSessionMessages).toHaveBeenCalledTimes(1); + expect(useSessionsStore.getState().sessions.filter((s) => s.sessionId === "a")).toHaveLength(1); + }); + + it("共享请求失败只 toast 一次(不因调用者数量重复刷屏),且 in-flight 清空可重试", async () => { + const opened = deferred(); + piMock.openSession + .mockImplementationOnce(() => opened.promise) + .mockImplementationOnce(() => Promise.resolve(realMeta("a", "/p"))); + + const failing = useSessionsStore.getState().openFromHistory("/p/a.jsonl"); + const failingToo = useSessionsStore.getState().openFromHistory("/p/a.jsonl"); + opened.reject(new Error("boom")); + await Promise.all([failing, failingToo]); + + expect(toastKeys().filter((key) => key === "toast.sessionOpenFailed")).toHaveLength(1); + expect(useSessionsStore.getState().activeSessionId).toBeNull(); + + // settle 后 key 已清:再次点击会重新 open + await useSessionsStore.getState().openFromHistory("/p/a.jsonl"); + expect(piMock.openSession).toHaveBeenCalledTimes(2); + expect(useSessionsStore.getState().activeSessionId).toBe("a"); + }); + + it("不同文件并发 open 各自独立(single-flight 只按文件去重)", async () => { + piMock.openSession.mockImplementation((args: { filePath: string }) => + Promise.resolve(realMeta(args.filePath.includes("/a.jsonl") ? "a" : "c", "/p")), + ); + + await Promise.all([ + useSessionsStore.getState().openFromHistory("/p/a.jsonl"), + useSessionsStore.getState().openFromHistory("/p/c.jsonl"), + ]); + + expect(piMock.openSession).toHaveBeenCalledTimes(2); + expect( + useSessionsStore + .getState() + .sessions.map((s) => s.sessionId) + .sort(), + ).toEqual(["a", "c"]); + }); +}); + +describe("GC close 在途的选择竞态(spec D5)", () => { + beforeEach(() => useToastsStore.setState({ toasts: [] })); + + it("close 在途期间用户切到该会话:关闭成功后自动 reopen,保留 transcript/active,返回 closed:false", async () => { + useSessionsStore.setState({ + sessions: [realMeta("a", "/p"), realMeta("b", "/p")], + activeSessionId: "a", + cwd: "/p", + }); + // transcript 侧标记:reopen 恢复不能 reset 已有数据 + useTranscriptStore.getState().setFollowUpQueue("b", ["kept"]); + const closing = deferred<{ closed: boolean }>(); + piMock.closeSession.mockImplementationOnce(() => closing.promise); + piMock.openSession.mockResolvedValueOnce(realMeta("b", "/p")); + + const unloading = useSessionsStore.getState().unloadSession("b"); + useSessionsStore.getState().switchSession("b"); + closing.resolve({ closed: true }); + const result = await unloading; + + // 后端已关又重建 → GC 这一轮视为「没卸成」,而不是让 renderer 删掉用户刚选的会话 + expect(result).toEqual({ closed: false }); + expect(piMock.openSession).toHaveBeenCalledWith({ filePath: "/tmp/b.jsonl" }); + const state = useSessionsStore.getState(); + expect(state.activeSessionId).toBe("b"); + expect(state.sessions.map((s) => s.sessionId)).toEqual(["a", "b"]); + expect(useTranscriptStore.getState().bySession.b?.followUpQueue).toEqual(["kept"]); + }); + + it("close 在途期间用户切到别处:正常卸载(closed:true、条目移除,不 reopen)", async () => { + useSessionsStore.setState({ + sessions: [realMeta("a", "/p"), realMeta("b", "/p")], + activeSessionId: "a", + cwd: "/p", + }); + const closing = deferred<{ closed: boolean }>(); + piMock.closeSession.mockImplementationOnce(() => closing.promise); + + const unloading = useSessionsStore.getState().unloadSession("b"); + useSessionsStore.getState().switchSession("a"); + closing.resolve({ closed: true }); + + expect(await unloading).toEqual({ closed: true }); + expect(piMock.openSession).not.toHaveBeenCalled(); + expect(useSessionsStore.getState().sessions.map((s) => s.sessionId)).toEqual(["a"]); + }); + + it("reopen 失败:不留幽灵 active(按正常关闭清理并显形提示)", async () => { + useSessionsStore.setState({ + sessions: [realMeta("a", "/p"), realMeta("b", "/p")], + activeSessionId: "a", + cwd: "/p", + }); + const closing = deferred<{ closed: boolean }>(); + piMock.closeSession.mockImplementationOnce(() => closing.promise); + piMock.openSession.mockRejectedValueOnce(new Error("reopen boom")); + + const unloading = useSessionsStore.getState().unloadSession("b"); + useSessionsStore.getState().switchSession("b"); + closing.resolve({ closed: true }); + const result = await unloading; + + expect(result).toEqual({ closed: true }); + const state = useSessionsStore.getState(); + expect(state.sessions.map((s) => s.sessionId)).toEqual(["a"]); + expect(state.activeSessionId).toBe("a"); + expect(toastKeys()).toContain("toast.sessionOpenFailed"); + }); + + it("reopen 在途期间用户又切走:不得抢回 active(恢复不是新的用户导航)", async () => { + useSessionsStore.setState({ + sessions: [realMeta("a", "/p"), realMeta("b", "/p"), realMeta("c", "/p")], + activeSessionId: "a", + cwd: "/p", + }); + const closing = deferred<{ closed: boolean }>(); + const reopening = deferred(); + piMock.closeSession.mockImplementationOnce(() => closing.promise); + piMock.openSession.mockImplementationOnce(() => reopening.promise); + + const unloading = useSessionsStore.getState().unloadSession("b"); + useSessionsStore.getState().switchSession("b"); + closing.resolve({ closed: true }); + await vi.waitFor(() => expect(piMock.openSession).toHaveBeenCalledTimes(1)); + useSessionsStore.getState().switchSession("c"); + reopening.resolve(realMeta("b", "/p")); + + expect(await unloading).toEqual({ closed: false }); + expect(useSessionsStore.getState().activeSessionId).toBe("c"); + }); +}); From 90023e06d5843a80a8c7dd14d9aedf1881dce95a Mon Sep 17 00:00:00 2001 From: Jaxton07 Date: Sun, 20 Sep 2026 20:28:18 +0800 Subject: [PATCH 02/17] =?UTF-8?q?fix(sidebar-switch):=20=E9=98=B6=E6=AE=B5?= =?UTF-8?q?=201=20=E2=80=94=20=E6=B4=BB=E8=B7=83=20meta=20=E6=97=B6?= =?UTF-8?q?=E9=97=B4=E8=AF=AD=E4=B9=89=E5=AF=B9=E9=BD=90=E7=A3=81=E7=9B=98?= =?UTF-8?q?=E6=9E=9A=E4=B8=BE=20+=20=E5=B7=A6=E6=A0=8F=20merge=20=E4=BF=9D?= =?UTF-8?q?=E7=95=99=E7=A8=B3=E5=AE=9A=E6=97=B6=E9=97=B4=E5=AD=97=E6=AE=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/backend/src/session/meta.ts | 69 +++++++++++++++++++ packages/backend/src/session/registry.ts | 20 +++--- packages/backend/test/registry.test.ts | 64 +++++++++++++++-- .../src/renderer/src/lib/sidebar-groups.ts | 26 ++++++- 4 files changed, 158 insertions(+), 21 deletions(-) create mode 100644 packages/backend/src/session/meta.ts diff --git a/packages/backend/src/session/meta.ts b/packages/backend/src/session/meta.ts new file mode 100644 index 00000000..f49a6742 --- /dev/null +++ b/packages/backend/src/session/meta.ts @@ -0,0 +1,69 @@ +import { statSync } from "node:fs"; +import type { SessionEntry, SessionHeader } from "@earendil-works/pi-coding-agent"; + +/** + * 会话时间字段的**权威口径**(spec sidebar-session-switch-stability D1), + * 与 SDK `buildSessionInfo()`(`SessionManager.list` 的磁盘枚举)逐条对齐: + * + * - `createdAt` = session header 的 timestamp —— **不是**文件时间:复制/恢复/迁移会话文件会改 + * birthtime(旧实现就是踩在这上面),header 时间才是「会话诞生时刻」; + * - `modifiedAt` = 所有 `message` entry 里 **role 为 user/assistant** 的最大活动时间; + * message 自带数值 `timestamp` 优先,退回 entry 的 ISO `timestamp`; + * 其它 entry(custom / label / compaction / session_info…)与非 user/assistant 消息(toolResult 等) + * 一律不算活动 —— 否则 channel cursor 之类的扩展写入会把会话顶到列表最前; + * - 没有任何这类消息时 `modifiedAt = createdAt`。 + * + * header 读不出来(空文件/非会话文件/异常)时返回 `null`,由调用方走 `fileTimeFallback` 兜底。 + */ +export interface SessionTimes { + createdAt: number; + modifiedAt: number; +} + +export function deriveSessionTimes( + header: SessionHeader | null | undefined, + entries: readonly SessionEntry[], +): SessionTimes | null { + const createdAt = parseTimestamp(header?.timestamp); + if (createdAt === undefined) return null; + let latest: number | undefined; + for (const entry of entries) { + const activity = messageActivityTime(entry); + if (activity === undefined || activity <= 0) continue; + latest = latest === undefined ? activity : Math.max(latest, activity); + } + return { createdAt, modifiedAt: latest ?? createdAt }; +} + +/** 单条 entry 的会话活动时间:只认 user/assistant 消息,其余一律 undefined */ +function messageActivityTime(entry: SessionEntry): number | undefined { + if (entry.type !== "message") return undefined; + const message = entry.message as { role?: unknown; timestamp?: unknown } | undefined; + if (message?.role !== "user" && message?.role !== "assistant") return undefined; + if (typeof message.timestamp === "number") return message.timestamp; + return parseTimestamp(entry.timestamp); +} + +function parseTimestamp(value: unknown): number | undefined { + if (typeof value !== "string") return undefined; + const parsed = Date.parse(value); + return Number.isNaN(parsed) ? undefined : parsed; +} + +/** + * 异常路径兜底(header 读不出来):文件 mtime → 当前时刻,两个字段用同一基准保证自洽。 + * 刻意不用 birthtime:它随复制/恢复而变,语义上不是「会话创建时间」。 + */ +export function fallbackSessionTimes(sessionFile: string | undefined): SessionTimes { + const time = fileTimeFallback(sessionFile); + return { createdAt: time, modifiedAt: time }; +} + +function fileTimeFallback(sessionFile: string | undefined): number { + if (!sessionFile) return Date.now(); + try { + return statSync(sessionFile).mtimeMs; + } catch { + return Date.now(); + } +} diff --git a/packages/backend/src/session/registry.ts b/packages/backend/src/session/registry.ts index 5270511a..73f52e14 100644 --- a/packages/backend/src/session/registry.ts +++ b/packages/backend/src/session/registry.ts @@ -1,9 +1,9 @@ -import { statSync } from "node:fs"; import type { AgentSession, AgentSessionEvent } from "@earendil-works/pi-coding-agent"; import type { SessionMeta } from "@percho/shared"; import type { PermissionModeRef } from "../permissions/extension"; import type { PermissionGate } from "../permissions/gate"; import type { ExtensionDialogHost } from "./extension-dialog-host"; +import { deriveSessionTimes, fallbackSessionTimes } from "./meta"; export interface RegisteredSession { session: AgentSession; @@ -51,15 +51,12 @@ export class SessionRegistry { toMeta(entry: RegisteredSession): SessionMeta { const { session, cwd } = entry; - // createdAt 用会话文件创建时刻(fork = fork 时刻,语义真实);SDK 无活跃会话 created 访问器 - let createdAt = Date.now(); - if (session.sessionFile) { - try { - createdAt = statSync(session.sessionFile).birthtimeMs; - } catch { - // 文件不存在/异常回退当前时刻 - } - } + // 时间口径唯一出处见 meta.ts(spec D1):header + entries 权威(与 SDK 磁盘枚举同语义, + // 不能用文件 birthtime/mtime 替代——复制/恢复文件会改 birthtime,channel cursor 等 + // custom entry 也不该影响排序);只有 header 读不出来时才退化到文件时间。 + const times = + deriveSessionTimes(session.sessionManager.getHeader(), session.sessionManager.getEntries()) ?? + fallbackSessionTimes(session.sessionFile); return { sessionId: session.sessionId, sessionFile: session.sessionFile, @@ -70,7 +67,8 @@ export class SessionRegistry { thinkingLevel: session.thinkingLevel, active: true, messageCount: session.messages.length, - createdAt, + createdAt: times.createdAt, + modifiedAt: times.modifiedAt, readOnly: entry.readOnly || undefined, }; } diff --git a/packages/backend/test/registry.test.ts b/packages/backend/test/registry.test.ts index 27e42326..34a992a7 100644 --- a/packages/backend/test/registry.test.ts +++ b/packages/backend/test/registry.test.ts @@ -1,6 +1,8 @@ import { mkdtempSync, rmSync, statSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; +import type { SessionEntry, SessionHeader, SessionManager } from "@earendil-works/pi-coding-agent"; +import { SessionManager as RealSessionManager } from "@earendil-works/pi-coding-agent"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { type RegisteredSession, SessionRegistry } from "../src/session/registry"; @@ -91,24 +93,72 @@ describe("SessionRegistry disposeAll(B8:与 closeSession 对称)", () => { }); }); -describe("SessionRegistry toMeta createdAt(D7:会话文件 birthtime)", () => { - it("createdAt 取会话文件 birthtimeMs", () => { - const file = join(dir, "session.jsonl"); +describe("SessionRegistry toMeta 时间字段(spec D1 取代 D7 的 birthtime 语义)", () => { + const CREATED_ISO = "2020-01-01T00:00:00.000Z"; + const USER_TS = Date.parse("2021-03-04T05:06:07.000Z"); + + /** 把 makeEntry 的桩 session 换上一个真的/替身的 sessionManager(时间字段的唯一来源) */ + function entryWithManager(sessionId: string, file: string, manager: unknown): RegisteredSession { + const { entry } = makeEntry(sessionId, file); + return { + ...entry, + session: { ...(entry.session as unknown as object), sessionManager: manager }, + } as unknown as RegisteredSession; + } + + /** 按真实 pi 会话文件格式写一份会话(header + 逐行 entry) */ + function writeSession(id: string, entries: unknown[]): string { + const file = join(dir, `${id}.jsonl`); + const header = JSON.stringify({ type: "session", version: 3, id, timestamp: CREATED_ISO, cwd: "/tmp" }); + const body = entries.map((entry) => JSON.stringify(entry)); + writeFileSync(file, `${[header, ...body].join("\n")}\n`, "utf8"); + return file; + } + + it("createdAt 取 session header 时间(刻意不是文件 birthtime)", () => { + // 契约迁移:D7 旧口径用 birthtimeMs,复制/恢复文件就会改「创建时间」;D1 改为 header 权威 + const file = writeSession("header-session", [ + { + type: "message", + id: "m1", + parentId: null, + timestamp: "2021-03-04T05:06:07.000Z", + message: { role: "user", content: "hi", timestamp: USER_TS }, + }, + ]); + const registry = new SessionRegistry(); + const entry = entryWithManager("s1", file, RealSessionManager.open(file)); + registry.add(entry); + + const meta = registry.toMeta(entry); + + expect(meta.createdAt).toBe(Date.parse(CREATED_ISO)); + expect(meta.createdAt).not.toBe(statSync(file).birthtimeMs); + expect(meta.modifiedAt).toBe(USER_TS); + }); + + it("header 读不出来(异常路径):回退文件 mtime(不再用 birthtime)", () => { + const file = join(dir, "no-header.jsonl"); writeFileSync(file, "{}\n", "utf8"); const registry = new SessionRegistry(); - const { entry } = makeEntry("s1", file); + const manager = { getHeader: (): SessionHeader | null => null, getEntries: (): SessionEntry[] => [] }; + const entry = entryWithManager("s1", file, manager as unknown as SessionManager); registry.add(entry); const meta = registry.toMeta(entry); - expect(meta.createdAt).toBe(statSync(file).birthtimeMs); + + expect(meta.createdAt).toBe(statSync(file).mtimeMs); + expect(meta.modifiedAt).toBe(statSync(file).mtimeMs); }); - it("会话文件不存在时回退当前时刻(不抛错)", () => { + it("header 与文件都不可用时回退当前时刻(不抛错)", () => { const registry = new SessionRegistry(); - const { entry } = makeEntry("s1", join(dir, "missing.jsonl")); + const manager = { getHeader: (): SessionHeader | null => null, getEntries: (): SessionEntry[] => [] }; + const entry = entryWithManager("s1", join(dir, "missing.jsonl"), manager as unknown as SessionManager); const before = Date.now(); const meta = registry.toMeta(entry); + expect(meta.createdAt).toBeGreaterThanOrEqual(before); expect(meta.createdAt).toBeLessThanOrEqual(Date.now()); }); diff --git a/packages/desktop/src/renderer/src/lib/sidebar-groups.ts b/packages/desktop/src/renderer/src/lib/sidebar-groups.ts index ee86596b..227022ad 100644 --- a/packages/desktop/src/renderer/src/lib/sidebar-groups.ts +++ b/packages/desktop/src/renderer/src/lib/sidebar-groups.ts @@ -44,10 +44,27 @@ export type SidebarGroupsResult = { defaultExpandedKeys: string[]; }; +/** + * 同 ID 合并(spec D1):运行态字段(name/model/active/messageCount/readOnly…)以**内存**为准, + * 但**稳定时间字段**不能被不完整的活跃 meta 抹掉: + * + * - `createdAt`:历史(磁盘 header 时间)是权威值。活跃 meta 曾经用文件 birthtime,复制/恢复会话文件就会变; + * - `modifiedAt`:内存 meta 缺活动时间(老 registry meta)时**保留历史值**。丢了它排序键会从 + * `modifiedAt` 掉到 `createdAt`(`groupSessions` 的 `modifiedAt ?? createdAt`), + * 于是「点一下历史行,它就跳到别处」——同一个会话并未真的产生新活动。 + */ +function mergeSessionMeta(history: SessionMeta, memory: SessionMeta): SessionMeta { + return { + ...memory, + createdAt: history.createdAt, + modifiedAt: memory.modifiedAt ?? history.modifiedAt, + }; +} + /** * 左栏数据源合并:**磁盘历史(`projects.allSessions`)+ 当前内存会话(`sessions.sessions`)**。 - * 内存项按 `sessionId` 覆盖历史项(名称/模型/状态以当前实例为准),历史项保持原顺序, - * 内存独有项(draft、刚创建还没落盘的真实会话)按内存顺序补在后面。**不负责排序**: + * 内存项按 `sessionId` 覆盖历史项(名称/模型/状态以当前实例为准,时间字段走 `mergeSessionMeta` 合并), + * 历史项保持原顺序,内存独有项(draft、刚创建还没落盘的真实会话)按内存顺序补在后面。**不负责排序**: * 组内排序统一由 `groupSessions` 做(最后活动倒序 + 置顶分区)。 * * 为什么不只拼 draft:draft 发首条消息时会在 `sessions` 里**原地替换**成真实会话, @@ -61,7 +78,10 @@ export function mergeSidebarSessions( // Map 保序:覆盖同 id 不会改变它原有的插入位置(历史顺序不被改写) const byId = new Map(); for (const session of history) byId.set(session.sessionId, session); - for (const session of memory) byId.set(session.sessionId, session); + for (const session of memory) { + const previous = byId.get(session.sessionId); + byId.set(session.sessionId, previous ? mergeSessionMeta(previous, session) : session); + } return [...byId.values()]; } From d934c05e21de0ee879b9965acd7d36049cde18b4 Mon Sep 17 00:00:00 2001 From: Jaxton07 Date: Sun, 20 Sep 2026 20:34:47 +0800 Subject: [PATCH 03/17] =?UTF-8?q?fix(sidebar-switch):=20=E9=98=B6=E6=AE=B5?= =?UTF-8?q?=202=20=E2=80=94=20=E5=AF=BC=E8=88=AA=20latest-wins=20=E4=BB=A4?= =?UTF-8?q?=E7=89=8C=20+=20open/bundle=20single-flight=EF=BC=88=E5=90=AB?= =?UTF-8?q?=20composer=20=E8=B0=83=E7=94=A8=E7=82=B9=E4=BF=AE=E6=AD=A3?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../components/composer/use-composer-send.ts | 17 +- .../src/renderer/src/stores/sessions.test.ts | 15 +- .../src/renderer/src/stores/sessions.ts | 171 ++++++++++++++---- 3 files changed, 157 insertions(+), 46 deletions(-) diff --git a/packages/desktop/src/renderer/src/components/composer/use-composer-send.ts b/packages/desktop/src/renderer/src/components/composer/use-composer-send.ts index 54f77ad8..5a26c114 100644 --- a/packages/desktop/src/renderer/src/components/composer/use-composer-send.ts +++ b/packages/desktop/src/renderer/src/components/composer/use-composer-send.ts @@ -60,16 +60,15 @@ export function useComposerSend(options: UseComposerSendOptions) { const pendingMode = current ? state.permissionModes[current] : undefined; const targetCwd = draftCwd ?? state.cwd; if (!targetCwd) return null; - await useSessionsStore.getState().createSession(targetCwd, current ?? undefined); - const created = useSessionsStore.getState().activeSessionId; - if (created && !isDraftSessionId(created)) { - // 失败仅 toast(store 内已提示+回滚),不阻塞发送 - if (pendingMode && pendingMode !== "default") { - await useSessionsStore.getState().setSessionPermissionMode(created, pendingMode); - } - return created; + // 用返回值定位新会话,不读 activeSessionId:创建期间用户可能已切走, + // 此时新会话仍在后台存在(latest-wins 不让它抢焦点),读 active 会把消息与权限模式发到别人身上 + const created = await useSessionsStore.getState().createSession(targetCwd, current ?? undefined); + if (!created) return null; + // 失败仅 toast(store 内已提示+回滚),不阻塞发送 + if (pendingMode && pendingMode !== "default") { + await useSessionsStore.getState().setSessionPermissionMode(created, pendingMode); } - return null; + return created; }; /** 执行内置命令(发送以 / 开头文本时的分发;未匹配则透传给 SDK 原生处理模板/skill/扩展命令) */ diff --git a/packages/desktop/src/renderer/src/stores/sessions.test.ts b/packages/desktop/src/renderer/src/stores/sessions.test.ts index 6a74afad..0ea955c1 100644 --- a/packages/desktop/src/renderer/src/stores/sessions.test.ts +++ b/packages/desktop/src/renderer/src/stores/sessions.test.ts @@ -654,14 +654,27 @@ describe("导航 latest-wins:最后一次点击获胜(spec D2)", () => { const creating = useSessionsStore.getState().createSession("/p", draftId); useSessionsStore.getState().switchSession("b"); created.resolve(realMeta("new-1", "/p")); - await creating; + const createdId = await creating; + // 迟到的创建仍要把新会话 id 交给调用方(发送方按返回值定位,不读 active) + expect(createdId).toBe("new-1"); const state = useSessionsStore.getState(); expect(state.activeSessionId).toBe("b"); expect(state.cwd).toBe("/p"); expect(state.sessions.map((s) => s.sessionId)).toContain("new-1"); }); + it("createSession 失败返回 null(调用方据此中止发送),且不动 active/cwd", async () => { + piMock.createSession.mockRejectedValueOnce(new Error("create boom")); + useSessionsStore.setState({ sessions: [realMeta("b", "/p")], activeSessionId: "b", cwd: "/p" }); + + expect(await useSessionsStore.getState().createSession("/p")).toBeNull(); + + expect(useSessionsStore.getState().activeSessionId).toBe("b"); + expect(useSessionsStore.getState().cwd).toBe("/p"); + expect(toastKeys()).toContain("toast.sessionCreateFailed"); + }); + it("fork 迟到不得覆盖后续 switch(新会话进 tabs,但不抢 active)", async () => { useSessionsStore.setState({ sessions: [realMeta("r1", "/p"), realMeta("r2", "/p")], diff --git a/packages/desktop/src/renderer/src/stores/sessions.ts b/packages/desktop/src/renderer/src/stores/sessions.ts index 95fe9d1b..a7cb9ab5 100644 --- a/packages/desktop/src/renderer/src/stores/sessions.ts +++ b/packages/desktop/src/renderer/src/stores/sessions.ts @@ -53,12 +53,50 @@ export function selectBarSessions( return pinnedSessions.map((id) => byId.get(id)).filter((s): s is SessionMeta => s !== undefined); } +/** + * 进程内单调激活序号(spec D2):**用户导航动作一开始**就领号(不能等 IPC 返回才领,否则表达不了点击顺序)。 + * 异步动作返回时只有「自己领的号仍是最新」才允许写 `activeSessionId/cwd/rememberCwd`; + * 过期的仍可落数据(sessions 条目、transcript 装载),但不得抄回焦点。 + * + * 不落盘、不进 store:重启后一个在途动作都没有,序号归零无意义;测试间保留递增即可。 + */ +let activationSeq = 0; + +function claimActivation(): number { + activationSeq += 1; + return activationSeq; +} + +/** 领号后所有更早的在途导航立即失效(同步 `switchSession` 因此天然淘汰在飞的 open/create/fork) */ +function isLatestActivation(token: number): boolean { + return token === activationSeq; +} + +/** 同会话在途的 bundle 装载(key = sessionId) */ +const bundleInFlight = new Map>(); + /** * 打开会话时同步四件套:消息历史(可选跳过 live 态)、排队队列、todo 面板、权限模式。 * 取数并行(各写 store 不同字段,无交叉读),应用顺序保持 history → queue → todos。 * 权限模式对齐后端真值:关 tab 重开后端已归零 default,拉回防 stale(spec permission-mode D1)。 + * + * 同会话同时在途时复用同一个 Promise(spec D3,见 `ensureOpenSession`):open 完成时 meta 会先落进 + * `sessions`,用户这时再点那一行就走 `switchSession` 的懒加载,两条路会撞车重复拉四件套。 */ -async function loadSessionBundle(sessionId: string, opts?: { skipHistoryIfLive?: boolean }): Promise { +function loadSessionBundle(sessionId: string, opts?: { skipHistoryIfLive?: boolean }): Promise { + const existing = bundleInFlight.get(sessionId); + if (existing) return existing; + const promise = loadSessionBundleInner(sessionId, opts).finally(() => { + if (bundleInFlight.get(sessionId) === promise) bundleInFlight.delete(sessionId); + }); + bundleInFlight.set(sessionId, promise); + return promise; +} + +async function loadSessionBundleInner( + sessionId: string, + opts?: { skipHistoryIfLive?: boolean }, +): Promise { // skip 判定在入口一次性取值:并行发起 IPC 前先确定是否跳过历史(agent 运行中保流式态,见 openFromHistory 注释) const skipHistory = opts?.skipHistoryIfLive === true && @@ -175,6 +213,55 @@ async function optimisticSessionSetting( } } +/** + * 共享 open pipeline(spec D3):key = 规范化 sessionFile(trim)。同一文件在途时复用同一个 Promise, + * 因此「同一文件双击只发一次 IPC + 只装载一次 bundle + 失败只 toast 一次」; + * settle(无论成败)后立即清 key,失败可以重试;不同文件互不影响。 + * 返回 null = 失败(提示已在共享链里报过,调用者不再重复报)。 + * + * 模块级 Map 只存正在执行的 Promise,不落盘、不进 store:没有任何「仅测试用的 reset 入口」。 + */ +function ensureOpenSession(filePath: string): Promise { + const key = filePath.trim(); + const existing = openInFlight.get(key); + if (existing) return existing; + const promise = ensureOpenSessionInner(key).finally(() => { + if (openInFlight.get(key) === promise) openInFlight.delete(key); + }); + openInFlight.set(key, promise); + return promise; +} + +const openInFlight = new Map>(); + +async function ensureOpenSessionInner(filePath: string): Promise { + let meta: SessionMeta; + try { + meta = await getPi().openSession({ filePath }); + } catch (error) { + console.error("打开会话失败", error); + pushToast("warning", "toast.sessionOpenFailed", errText(error)); + return null; + } + // 数据先落:meta 进 tabs + LRU 打点 —— 这些与「谁抢到焦点」无关(由调用者的 token 决定)。 + // lastUsedAt 必须在数据路径打:用户确实点了这一行,不然 GC 会把它当成「从没用过」。 + useSessionsStore.setState((state) => ({ + sessions: [...state.sessions.filter((s) => s.sessionId !== meta.sessionId), meta], + lastUsedAt: { ...state.lastUsedAt, [meta.sessionId]: Date.now() }, + })); + try { + // 运行中子会话的事件已按其 sessionId 实时转发;保留已有流式态, + // 否则会在点击卡片时把 agent_start 建立的进度视图重置为静态历史。 + await loadSessionBundle(meta.sessionId, { skipHistoryIfLive: true }); + } catch (error) { + // 会话已经打开了(后端在内存里、meta 在 tabs):数据装载失败只能提示, + // 不能当成「打开失败」——否则用户点了行却什么都没发生。 + console.error("装载会话数据失败", error); + pushToast("warning", "toast.sessionOpenFailed", errText(error)); + } + return meta; +} + /** * 记住「上次项目目录」(重启后启动页预填,用户不用重选项目)。 * 只写 cwd、**不恢复任何会话**(v10 启动纯空不变);同值短路,避免切会话时频繁写 ui-state。 @@ -204,7 +291,12 @@ interface SessionsStore { * 只活在本次进程(不落盘):重启后一个会话都没打开,本来也没有 LRU 可言。 */ lastUsedAt: Record; - createSession: (cwd?: string, replaceDraftId?: string) => Promise; + /** + * 真正创建后端会话(draft 转正走这里)。返回新会话 id;失败返回 null。 + * **调用方必须用返回值定位新会话**,不能读 `activeSessionId`:创建是异步的, + * 期间用户可能已切走(latest-wins 下新会话可能不抢焦点),读 active 会拿到别人的 id。 + */ + createSession: (cwd?: string, replaceDraftId?: string) => Promise; /** 新建草稿会话 tab:不触后端、不落盘(空 tab 重启后自动消失),发送首条消息时才用其 cwd 真正创建 */ createDraftSession: (cwd?: string) => void; /** 设置新会话的目标项目目录;活跃 tab 是 draft 时同步更新其条目(切 tab 往返不丢选择) */ @@ -241,7 +333,9 @@ export const useSessionsStore = create((set, get) => ({ createSession: async (cwd, replaceDraftId) => { const targetCwd = cwd ?? get().cwd; - if (!targetCwd) return; + if (!targetCwd) return null; + // 新建会话(含 draft 转正)会切到新会话 = 用户导航:先领号 + const token = claimActivation(); try { const meta = await getPi().createSession({ options: { @@ -250,25 +344,31 @@ export const useSessionsStore = create((set, get) => ({ thinkingLevel: get().lastUsedThinkingLevel, }, }); - set((state) => ({ + set((state) => { // draft 转正式会话:原地替换保持 tab 位置;普通新建则追加 - sessions: replaceDraftId + const sessions = replaceDraftId ? state.sessions.map((s) => (s.sessionId === replaceDraftId ? meta : s)) - : [...state.sessions, meta], - activeSessionId: meta.sessionId, - cwd: targetCwd, - // draft 键下的权限模式由 ensureSession 在创建后应用到新 id,这里顺手满档防泄漏 - permissionModes: replaceDraftId - ? withPermissionMode(state.permissionModes, replaceDraftId, "default") - : state.permissionModes, - lastUsedAt: { ...state.lastUsedAt, [meta.sessionId]: Date.now() }, - })); + : [...state.sessions, meta]; + const rest = { + // draft 键下的权限模式由 ensureSession 在创建后应用到新 id,这里顺手满档防泄漏 + permissionModes: replaceDraftId + ? withPermissionMode(state.permissionModes, replaceDraftId, "default") + : state.permissionModes, + lastUsedAt: { ...state.lastUsedAt, [meta.sessionId]: Date.now() }, + }; + // 迟到(用户已切到别的会话):新会话仍进 tabs,但不抢焦点/cwd + return isLatestActivation(token) + ? { ...rest, sessions, activeSessionId: meta.sessionId, cwd: targetCwd } + : { ...rest, sessions }; + }); useTranscriptStore.getState().resetSession(meta.sessionId); - rememberCwd(targetCwd); + if (isLatestActivation(token)) rememberCwd(targetCwd); + return meta.sessionId; } catch (error) { // 失败时 draft tab 保留,用户重试即可;toast 提示(非会话内容,不残留) console.error("创建会话失败", error); pushToast("warning", "toast.sessionCreateFailed", errText(error)); + return null; } }, @@ -294,6 +394,8 @@ export const useSessionsStore = create((set, get) => ({ createdAt: now, modifiedAt: now, }; + // 同步导航:领号后一切在途 open/create/fork 立即过期(点新会话后又碰旧行不会反弹) + claimActivation(); set((state) => ({ sessions: [...state.sessions, draft], activeSessionId: draft.sessionId, @@ -326,6 +428,8 @@ export const useSessionsStore = create((set, get) => ({ }, switchSession: (sessionId) => { + // 同步导航:先领号,在途的 open/create/fork 全部作废(latest-wins 的另一半) + claimActivation(); set((state) => { const session = state.sessions.find((s) => s.sessionId === sessionId); return { @@ -395,36 +499,31 @@ export const useSessionsStore = create((set, get) => ({ unloadSession: (sessionId) => get().closeSession(sessionId, "gc"), openFromHistory: async (filePath) => { - try { - const meta = await getPi().openSession({ filePath }); - set((state) => ({ - sessions: [...state.sessions.filter((s) => s.sessionId !== meta.sessionId), meta], - activeSessionId: meta.sessionId, - cwd: meta.cwd, - lastUsedAt: { ...state.lastUsedAt, [meta.sessionId]: Date.now() }, - })); - // 先记 cwd 再拉数据:即便随后装载失败(下面的 catch),用户「在用哪个项目」的事实也已经成立 - rememberCwd(meta.cwd); - // 运行中子会话的事件已按其 sessionId 实时转发;保留已有流式态, - // 否则会在点击卡片时把 agent_start 建立的进度视图重置为静态历史。 - await loadSessionBundle(meta.sessionId, { skipHistoryIfLive: true }); - } catch (error) { - console.error("打开会话失败", error); - pushToast("warning", "toast.sessionOpenFailed", errText(error)); - } + // 先领号再发请求:这次点击就是当前最新意图(同文件双击则第二次领号,自然胜出) + const token = claimActivation(); + const meta = await ensureOpenSession(filePath); + // 失败已由共享链报过一次;这里的提前返回同时保证不对用户的新选择做任何回滚 + if (!meta || !isLatestActivation(token)) return; + set({ activeSessionId: meta.sessionId, cwd: meta.cwd }); + // 先记 cwd 再拉数据:即便随后装载失败,用户「在用哪个项目」的事实也已经成立 + rememberCwd(meta.cwd); }, forkSession: async (ref) => { const { activeSessionId } = get(); // draft 还没有消息,无可分叉(UI 上也到不了这里,防御性拦截) if (!activeSessionId || isDraftSessionId(activeSessionId)) return undefined; + // 分叉成功会切到新会话 = 用户导航:先领号 + const token = claimActivation(); try { const meta = await getPi().forkSession({ sessionId: activeSessionId, ref }); - set((state) => ({ - sessions: [...state.sessions.filter((s) => s.sessionId !== meta.sessionId), meta], - activeSessionId: meta.sessionId, - })); + set((state) => { + const sessions = [...state.sessions.filter((s) => s.sessionId !== meta.sessionId), meta]; + // 迟到:新会话仍进 tabs,但不抢焦点 + return isLatestActivation(token) ? { sessions, activeSessionId: meta.sessionId } : { sessions }; + }); await loadSessionBundle(meta.sessionId); + // fork 事实已发生:即便已不是最新意图也要把新 id 交给调用方 return meta.sessionId; } catch (error) { console.error("分叉会话失败", error); From 7a1d96ba3437c687fd3c7e9efc4b814be9bebb29 Mon Sep 17 00:00:00 2001 From: Jaxton07 Date: Sun, 20 Sep 2026 20:45:56 +0800 Subject: [PATCH 04/17] =?UTF-8?q?fix(sidebar-switch):=20=E9=98=B6=E6=AE=B5?= =?UTF-8?q?=203=20=E2=80=94=20backend=20open=20=E5=B9=82=E7=AD=89/registry?= =?UTF-8?q?=20=E5=85=9C=E5=BA=95=E3=80=81GC=20close=20=E7=AB=9E=E6=80=81?= =?UTF-8?q?=E6=81=A2=E5=A4=8D=E3=80=81projects=20load=20latest-wins?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/backend/src/pi-backend.ts | 60 +++++++++++++++---- packages/backend/src/session/registry.ts | 10 ++++ packages/backend/src/session/single-flight.ts | 14 +++-- .../test/open-session-idempotency.test.ts | 59 ++++++++++++++++++ .../src/renderer/src/stores/projects.ts | 11 ++++ .../src/renderer/src/stores/sessions.test.ts | 23 +++++++ .../src/renderer/src/stores/sessions.ts | 38 +++++++++++- 7 files changed, 195 insertions(+), 20 deletions(-) diff --git a/packages/backend/src/pi-backend.ts b/packages/backend/src/pi-backend.ts index ec082bb5..20974188 100644 --- a/packages/backend/src/pi-backend.ts +++ b/packages/backend/src/pi-backend.ts @@ -1,6 +1,6 @@ import { existsSync } from "node:fs"; import { readFile, unlink } from "node:fs/promises"; -import { join } from "node:path"; +import { join, resolve } from "node:path"; import type { Model, ThinkingLevel } from "@earendil-works/pi-ai"; import { getSupportedThinkingLevels } from "@earendil-works/pi-ai"; import { @@ -84,6 +84,7 @@ import { autoNameSession } from "./session/naming"; import { EventRateTracker } from "./session/rates"; import { type EventForwarder, type RegisteredSession, SessionRegistry } from "./session/registry"; import { renameSessionFile } from "./session/rename"; +import { KeyedSingleFlight } from "./session/single-flight"; import { StreamGuard } from "./session/stream-guard"; import { TraceRecorder } from "./session/trace"; import { SessionTraces } from "./session/traces"; @@ -174,6 +175,8 @@ export interface PiBackendOptions { */ export class PiBackend { private readonly registry = new SessionRegistry(); + /** open 的 single-flight(key = 规范化绝对路径):同文件并发 open 共享一次构造(spec D4) */ + private readonly openFlights = new KeyedSingleFlight(); /** * 已加载会话的频道订阅快照(spec §6.1/§6.2):sessionId → 有效运行态 topic 集, * 由 channel-watch 扩展经 `reportChannelSubscriptions` 维护(只存 topic 名)。 @@ -433,12 +436,30 @@ export class PiBackend { return this.toMetaOrThrow(session.sessionId); } + /** + * 打开会话(幂等 + single-flight,spec D4): + * - key = 规范化绝对路径 → 同一文件并发 open 共享一次构造; + * - 已在 registry 的 sessionId 直接返回现有 entry 的最新 meta,**不构造第二实例** + * (短路在资源加载/扩展构造之前,重复 open 不再重复 subscribe/trace); + * - 失败(含构造失败)必清 in-flight key → 可重试。 + */ async openSession(filePath: string): Promise { - const runtime = await this.getModelRuntime(); - const sessionManager = SessionManager.open(filePath); - const cwd = sessionManager.getCwd() || process.cwd(); + const key = resolve(filePath); + return this.openFlights.run(key, () => this.openSessionOnce(key)); + } + + private async openSessionOnce(filePath: string): Promise { + // 读 header 拿到 sessionId(只读文件头,不构造会话)后先查 registry: // 子代理产物目录下的会话文件 = 只读检视(spec §8.1:防能力静默漂移/递归绕过)。 // 其运行 trace 由 runner 管理,检视页不可写,故不另建 recorder(避免覆盖运行中的 recorder)。 + const sessionManager = SessionManager.open(filePath); + const existing = this.registry.get(sessionManager.getSessionId()); + if (existing) { + log.info("session open short-circuited", sessionManager.getSessionId(), { file: filePath }); + return this.registry.toMeta(existing); + } + const runtime = await this.getModelRuntime(); + const cwd = sessionManager.getCwd() || process.cwd(); const readOnly = isSubagentSessionPath(filePath); const session = await this.wireSession(cwd, readOnly, async (deps) => { const { settingsManager, resourceLoader } = await deps.load(); @@ -503,15 +524,28 @@ export class PiBackend { autoNameSession(session, event); this.emitEvent(session.sessionId, event); }); - this.registry.add({ - session, - unsubscribe, - cwd, - gate, - dialogs, - modeRef, - readOnly: readOnly || undefined, - }); + try { + this.registry.add({ + session, + unsubscribe, + cwd, + gate, + dialogs, + modeRef, + readOnly: readOnly || undefined, + }); + } catch (error) { + // registry 的最后防线(同 sessionId 已被别的 entry 占住,如并发/别名路径): + // 刚构造的这份必须当场拆干净(订阅/gate/dialogs/session),不能只抛错把资源泄漏出去 + unsubscribe(); + gate.dispose(); + dialogs.dispose(); + session.dispose(); + log.error("session register conflicted; constructed instance disposed", { + sessionId: session.sessionId, + }); + throw error; + } if (!readOnly) await this.traces.start(session.sessionId, session.sessionManager.getSessionDir()); return session; } diff --git a/packages/backend/src/session/registry.ts b/packages/backend/src/session/registry.ts index 73f52e14..fe6c84c0 100644 --- a/packages/backend/src/session/registry.ts +++ b/packages/backend/src/session/registry.ts @@ -23,7 +23,17 @@ export interface RegisteredSession { export class SessionRegistry { private readonly sessions = new Map(); + /** + * 注册会话。同 sessionId 重复 add **同一个 entry** = 幂等; + * 不同 entry = 抛错,**绝不静默覆盖**:覆盖会把第一份实例的订阅/gate/dialogs 全泄漏, + * 而且再没人能 dispose 它。正常主路径走不到这里(`PiBackend.openSession` 在构造前就按 + * sessionId 短路),这是并发/别名路径的最后防线;调用方负责清理刚构造的实例(见 wireSession)。 + */ add(entry: RegisteredSession): void { + const existing = this.sessions.get(entry.session.sessionId); + if (existing && existing !== entry) { + throw new Error(`Session already registered: ${entry.session.sessionId}`); + } this.sessions.set(entry.session.sessionId, entry); } diff --git a/packages/backend/src/session/single-flight.ts b/packages/backend/src/session/single-flight.ts index cd115fc8..aaec6d74 100644 --- a/packages/backend/src/session/single-flight.ts +++ b/packages/backend/src/session/single-flight.ts @@ -6,8 +6,9 @@ * 为什么单独成文件:PiBackend 的构造路径依赖真实 SDK/资源加载,无法在单测里无侵入地多次构造, * 把「按 key 去重」这层纯逻辑抽出来才能确定性单测(见 test/session-single-flight.test.ts)。 * - * **阶段 0 占位**:签名与调用形状先固定,当前是直通实现(不去重、不缓存), - * 接入 PiBackend.openSession 见 plan 阶段 3.1。 + * 契约:`task` 必须返回 Promise(调用方均为 async 函数);同 key 在途期间复用同一个 Promise; + * settle(无论成败)后清 key,且只清「自己那一条」(settle 期间可能有新请求占用同一个 key, + * 旧 Promise 不得把后继请求删掉);不同 key 互不影响。 */ export class KeyedSingleFlight { private readonly inFlight = new Map>(); @@ -18,7 +19,12 @@ export class KeyedSingleFlight { } run(key: string, task: () => Promise): Promise { - void key; // 占位阶段不按 key 去重(去重实现见 plan 阶段 3.1) - return task(); + const existing = this.inFlight.get(key); + if (existing) return existing; + const promise = task().finally(() => { + if (this.inFlight.get(key) === promise) this.inFlight.delete(key); + }); + this.inFlight.set(key, promise); + return promise; } } diff --git a/packages/backend/test/open-session-idempotency.test.ts b/packages/backend/test/open-session-idempotency.test.ts index 41206b4e..cfdb8ba8 100644 --- a/packages/backend/test/open-session-idempotency.test.ts +++ b/packages/backend/test/open-session-idempotency.test.ts @@ -4,7 +4,9 @@ import { join } from "node:path"; import type { AgentSession, ModelRuntime } from "@earendil-works/pi-coding-agent"; import { SessionManager } from "@earendil-works/pi-coding-agent"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { PermissionGate } from "../src/permissions/gate"; import { PiBackend } from "../src/pi-backend"; +import { ExtensionDialogHost } from "../src/session/extension-dialog-host"; import type { RegisteredSession, SessionRegistry } from "../src/session/registry"; /** @@ -107,3 +109,60 @@ describe("PiBackend.openSession 幂等(spec D4)", () => { expect(load).not.toHaveBeenCalled(); }); }); + +describe("PiBackend.wireSession 注册冲突(D4 最后防线)", () => { + it("同 sessionId 已被占住:抛错,并把刚构造的实例拆干净(订阅/gate/dialogs/session)", async () => { + const file = writeSessionFile(dir, "sess-dup"); + const backend = new PiBackend({ + projectTrust: false, + permissionGates: false, + subagentPreferBuiltin: false, + }); + const registry = (backend as unknown as { registry: SessionRegistry }).registry; + // 先占住 sessionId(模拟并发/别名路径:另一个 entry 已经在册) + const holder = entryFor(file, "sess-dup"); + registry.add(holder); + + const unsubscribe = vi.fn(); + const dispose = vi.fn(); + const gateDispose = vi.spyOn(PermissionGate.prototype, "dispose"); + const dialogsDispose = vi.spyOn(ExtensionDialogHost.prototype, "dispose"); + const session = { + sessionId: "sess-dup", + sessionFile: file, + sessionName: undefined, + model: null, + thinkingLevel: "medium", + messages: [], + sessionManager: SessionManager.open(file), + subscribe: () => unsubscribe, + bindExtensions: () => Promise.resolve(), + dispose, + }; + const wire = ( + backend as unknown as { + wireSession: (cwd: string, readOnly: undefined, make: () => Promise) => Promise; + } + ).wireSession.bind(backend); + + await expect( + wire("/tmp/project", undefined, async () => ({ + session, + extensionsResult: { extensions: [], errors: [] }, + })), + ).rejects.toThrow(/already registered/); + + try { + expect(unsubscribe).toHaveBeenCalledTimes(1); + expect(gateDispose).toHaveBeenCalledTimes(1); + expect(dialogsDispose).toHaveBeenCalledTimes(1); + expect(dispose).toHaveBeenCalledTimes(1); + // 占位 entry 原样保留(没有被静默替换),registry 里也不会多出第二条 + expect(registry.get("sess-dup")).toBe(holder); + expect(registry.list()).toHaveLength(1); + } finally { + gateDispose.mockRestore(); + dialogsDispose.mockRestore(); + } + }); +}); diff --git a/packages/desktop/src/renderer/src/stores/projects.ts b/packages/desktop/src/renderer/src/stores/projects.ts index 673e9cc5..d6a9fddc 100644 --- a/packages/desktop/src/renderer/src/stores/projects.ts +++ b/packages/desktop/src/renderer/src/stores/projects.ts @@ -51,6 +51,12 @@ interface ProjectsStore { openSession: (session: SessionMeta) => Promise; } +/** + * 列表刷新的单调序号(spec D6):旧请求(含比新请求早发却晚回、或晚失败的那次)不得覆盖新请求的 + * `allSessions/loading/loaded`。模块级:不落盘、不进 store,重载归零也无意义。 + */ +let loadSeq = 0; + export const useProjectsStore = create((set, get) => ({ allSessions: [], addedProjects: loadAddedProjects(), @@ -60,10 +66,13 @@ export const useProjectsStore = create((set, get) => ({ loaded: false, load: async () => { + const seq = ++loadSeq; set({ loading: true }); try { // 日常目录与全量会话并行取;目录进 lib/daily 模块缓存(isDailyCwd 同步判定供各组件用) const [allSessions] = await Promise.all([getPi().listAllSessions(), initDailyDir()]); + // latest-wins:更晚的 load 已经发起 → 这份旧快照直接丢弃(loading 由最新那次收尾) + if (seq !== loadSeq) return; set({ allSessions, loading: false, loaded: true }); const { selectedCwd } = get(); if (!selectedCwd) { @@ -71,6 +80,8 @@ export const useProjectsStore = create((set, get) => ({ if (projects[0]) set({ selectedCwd: projects[0].cwd }); } } catch { + // 旧请求失败也不得插手:既不能覆盖新结果,也不能提前把新请求的 loading 置 false + if (seq !== loadSeq) return; set({ loading: false, loaded: true }); } }, diff --git a/packages/desktop/src/renderer/src/stores/sessions.test.ts b/packages/desktop/src/renderer/src/stores/sessions.test.ts index 0ea955c1..202125da 100644 --- a/packages/desktop/src/renderer/src/stores/sessions.test.ts +++ b/packages/desktop/src/renderer/src/stores/sessions.test.ts @@ -815,6 +815,29 @@ describe("GC close 在途的选择竞态(spec D5)", () => { expect(useSessionsStore.getState().sessions.map((s) => s.sessionId)).toEqual(["a"]); }); + it("reopen 成功后把 renderer 持有的权限档位拉回新 backend 会话(重开一律 default 起步)", async () => { + useSessionsStore.setState({ + sessions: [realMeta("a", "/p"), realMeta("b", "/p")], + activeSessionId: "a", + cwd: "/p", + permissionModes: { b: "fullAccess" }, + }); + const closing = deferred<{ closed: boolean }>(); + piMock.closeSession.mockImplementationOnce(() => closing.promise); + piMock.openSession.mockResolvedValueOnce(realMeta("b", "/p")); + // b 已有 transcript(真实场景:它是被 GC 判定可卸的已打开会话)→ 切换不触发懒加载 + useTranscriptStore.getState().setFollowUpQueue("b", ["kept"]); + + const unloading = useSessionsStore.getState().unloadSession("b"); + useSessionsStore.getState().switchSession("b"); + closing.resolve({ closed: true }); + const result = await unloading; + + expect(result).toEqual({ closed: false }); + expect(piMock.setPermissionMode).toHaveBeenCalledWith({ sessionId: "b", mode: "fullAccess" }); + expect(useSessionsStore.getState().permissionModes.b).toBe("fullAccess"); + }); + it("reopen 失败:不留幽灵 active(按正常关闭清理并显形提示)", async () => { useSessionsStore.setState({ sessions: [realMeta("a", "/p"), realMeta("b", "/p")], diff --git a/packages/desktop/src/renderer/src/stores/sessions.ts b/packages/desktop/src/renderer/src/stores/sessions.ts index a7cb9ab5..55e437eb 100644 --- a/packages/desktop/src/renderer/src/stores/sessions.ts +++ b/packages/desktop/src/renderer/src/stores/sessions.ts @@ -135,6 +135,21 @@ function applyBackendPermissionMode(sessionId: string, mode: PermissionMode): vo })); } +/** + * 后端会话被重建(GC 竞态恢复)后把权限档位拉回:新构造的会话一律 default 起步(D1:不落盘、不继承), + * 而 renderer 里还持有用户选过的档位——不拉回就会出现「UI 显示 fullAccess、后端按 default 走」的静默偏差。 + * 失败只 warn:档位不同步不能把恢复流程整个搞挂。 + */ +async function restorePermissionMode(sessionId: string): Promise { + const mode = useSessionsStore.getState().permissionModes[sessionId]; + if (!mode || mode === "default") return; + try { + await getPi().setPermissionMode({ sessionId, mode }); + } catch (error) { + console.warn("恢复会话权限模式失败", error); + } +} + /** * 权限模式 map 变异 helper(D4):default = 删 key 的「缺 key = default」语义单点化, * 应用/乐观写入/回滚/会话销毁四处共用。 @@ -219,13 +234,17 @@ async function optimisticSessionSetting( * settle(无论成败)后立即清 key,失败可以重试;不同文件互不影响。 * 返回 null = 失败(提示已在共享链里报过,调用者不再重复报)。 * + * `loadBundle: false`(GC 竞态恢复专用):只重建 backend 会话、刷新 meta,**不**重载磁盘历史—— + * idle transcript 必须原样保留(GC 不能把用户看到的对话内容重置成历史快照)。 + * 已有在途请求时用先到者的选项(不会重复发 IPC,也不会多跑一次 bundle)。 + * * 模块级 Map 只存正在执行的 Promise,不落盘、不进 store:没有任何「仅测试用的 reset 入口」。 */ -function ensureOpenSession(filePath: string): Promise { +function ensureOpenSession(filePath: string, opts?: { loadBundle?: boolean }): Promise { const key = filePath.trim(); const existing = openInFlight.get(key); if (existing) return existing; - const promise = ensureOpenSessionInner(key).finally(() => { + const promise = ensureOpenSessionInner(key, opts?.loadBundle !== false).finally(() => { if (openInFlight.get(key) === promise) openInFlight.delete(key); }); openInFlight.set(key, promise); @@ -234,7 +253,7 @@ function ensureOpenSession(filePath: string): Promise { const openInFlight = new Map>(); -async function ensureOpenSessionInner(filePath: string): Promise { +async function ensureOpenSessionInner(filePath: string, loadBundle: boolean): Promise { let meta: SessionMeta; try { meta = await getPi().openSession({ filePath }); @@ -249,6 +268,7 @@ async function ensureOpenSessionInner(filePath: string): Promise s.sessionId !== meta.sessionId), meta], lastUsedAt: { ...state.lastUsedAt, [meta.sessionId]: Date.now() }, })); + if (!loadBundle) return meta; try { // 运行中子会话的事件已按其 sessionId 实时转发;保留已有流式态, // 否则会在点击卡片时把 agent_start 建立的进度视图重置为静态历史。 @@ -465,6 +485,18 @@ export const useSessionsStore = create((set, get) => ({ // 后端拒绝(agent 在跑 / 等审批):**渲染层状态必须原样保留** —— 事务语义, // 不能出现「后端会话还在、前端条目已消失」的半个动作(内存策略也会看走眼) if (!closed) return { closed: false }; + // 自动卸载的选择竞态(spec D5):策略判定「可卸」时它还不是 active, + // 但 close 在我们这个 await 里真的关掉了它 —— 此时用户刚好选中了它。 + // 那样就把用户刚点的会话换成空白/消失 —— 所以重新打开后端会话,而不是把 UI 抹掉。 + // 只重建 backend(loadBundle:false):**不**重载磁盘历史,idle transcript 原样保留。 + if (intent === "gc" && get().activeSessionId === sessionId) { + const file = get().sessions.find((s) => s.sessionId === sessionId)?.sessionFile; + if (file && (await ensureOpenSession(file, { loadBundle: false }))) { + await restorePermissionMode(sessionId); + return { closed: false }; + } + // 恢复失败(或没有会话文件):下面的常规清理照走;提示已由共享 open 链报过一次 + } } catch (error) { // 会话关闭失败:UI 状态保留(用户可重试),显形不静默(曾「点了没反应」) console.error("关闭会话失败", error); From bf9024fb10ae81871fa9a94a623108e398e8d62f Mon Sep 17 00:00:00 2001 From: Jaxton07 Date: Sun, 20 Sep 2026 20:48:37 +0800 Subject: [PATCH 05/17] =?UTF-8?q?fix(sidebar-switch):=20=E9=98=B6=E6=AE=B5?= =?UTF-8?q?=203.1=20=E2=80=94=20=E6=9D=83=E9=99=90=E6=A1=A3=E4=BD=8D?= =?UTF-8?q?=E6=81=A2=E5=A4=8D=E5=A4=B1=E8=B4=A5=E6=97=B6=20renderer=20?= =?UTF-8?q?=E5=9B=9E=E8=90=BD=20default=EF=BC=88=E6=B6=88=E9=99=A4?= =?UTF-8?q?=E9=9D=99=E9=BB=98=E6=9D=83=E9=99=90=E5=88=86=E5=8F=89=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/renderer/src/stores/sessions.test.ts | 25 +++++++++++++++++++ .../src/renderer/src/stores/sessions.ts | 7 +++++- 2 files changed, 31 insertions(+), 1 deletion(-) diff --git a/packages/desktop/src/renderer/src/stores/sessions.test.ts b/packages/desktop/src/renderer/src/stores/sessions.test.ts index 202125da..40b59f7b 100644 --- a/packages/desktop/src/renderer/src/stores/sessions.test.ts +++ b/packages/desktop/src/renderer/src/stores/sessions.test.ts @@ -838,6 +838,31 @@ describe("GC close 在途的选择竞态(spec D5)", () => { expect(useSessionsStore.getState().permissionModes.b).toBe("fullAccess"); }); + it("reopen 成功后权限档位恢复失败:返回 closed:false,但 renderer 回落 default(不能显示后端没执行的档位)", async () => { + useSessionsStore.setState({ + sessions: [realMeta("a", "/p"), realMeta("b", "/p")], + activeSessionId: "a", + cwd: "/p", + permissionModes: { b: "fullAccess" }, + }); + const closing = deferred<{ closed: boolean }>(); + piMock.closeSession.mockImplementationOnce(() => closing.promise); + piMock.openSession.mockResolvedValueOnce(realMeta("b", "/p")); + piMock.setPermissionMode.mockRejectedValueOnce(new Error("mode boom")); + useTranscriptStore.getState().setFollowUpQueue("b", ["kept"]); + + const unloading = useSessionsStore.getState().unloadSession("b"); + useSessionsStore.getState().switchSession("b"); + closing.resolve({ closed: true }); + const result = await unloading; + + // 后端会话已重建成功 → GC 仍视为没卸成;但不能因此把后端真值(default)藏在 UI 后面 + expect(result).toEqual({ closed: false }); + expect(useSessionsStore.getState().permissionModes.b).toBeUndefined(); + expect(toastKeys()).toContain("toast.permissionModeFailed"); + expect(useSessionsStore.getState().sessions.map((s) => s.sessionId)).toEqual(["a", "b"]); + }); + it("reopen 失败:不留幽灵 active(按正常关闭清理并显形提示)", async () => { useSessionsStore.setState({ sessions: [realMeta("a", "/p"), realMeta("b", "/p")], diff --git a/packages/desktop/src/renderer/src/stores/sessions.ts b/packages/desktop/src/renderer/src/stores/sessions.ts index 55e437eb..e9273e65 100644 --- a/packages/desktop/src/renderer/src/stores/sessions.ts +++ b/packages/desktop/src/renderer/src/stores/sessions.ts @@ -138,7 +138,10 @@ function applyBackendPermissionMode(sessionId: string, mode: PermissionMode): vo /** * 后端会话被重建(GC 竞态恢复)后把权限档位拉回:新构造的会话一律 default 起步(D1:不落盘、不继承), * 而 renderer 里还持有用户选过的档位——不拉回就会出现「UI 显示 fullAccess、后端按 default 走」的静默偏差。 - * 失败只 warn:档位不同步不能把恢复流程整个搞挂。 + * + * 拉回失败时必须把 renderer 拉回 default(而不是保留旧档):UI 永远反映 backend 真值, + * 否则用户看到 fullAccess、实际后端在按 default 索要确认——静默权限分叉比多一个 toast 危险得多。 + * 持久偏好(uiPreferences.sessionPermissionModes)不动,下次打开/重启还能重试。 */ async function restorePermissionMode(sessionId: string): Promise { const mode = useSessionsStore.getState().permissionModes[sessionId]; @@ -147,6 +150,8 @@ async function restorePermissionMode(sessionId: string): Promise { await getPi().setPermissionMode({ sessionId, mode }); } catch (error) { console.warn("恢复会话权限模式失败", error); + applyBackendPermissionMode(sessionId, "default"); + pushToast("warning", "toast.permissionModeFailed", errText(error)); } } From b75d4334c2ff8ea05b242a02e23df3a84662944e Mon Sep 17 00:00:00 2001 From: Jaxton07 Date: Sun, 20 Sep 2026 21:01:15 +0800 Subject: [PATCH 06/17] =?UTF-8?q?test(sidebar-switch):=20=E9=98=B6?= =?UTF-8?q?=E6=AE=B5=204=20=E2=80=94=20SessionRow=20=E6=B5=8B=E8=AF=95?= =?UTF-8?q?=E5=B1=9E=E6=80=A7=20+=20INDEX/PITFALLS=20=E5=90=8C=E6=AD=A5?= =?UTF-8?q?=EF=BC=88CDP=20=E9=AA=8C=E6=94=B6=E8=AF=81=E6=8D=AE=E8=A7=81?= =?UTF-8?q?=E9=A2=91=E9=81=93=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/INDEX.md | 16 +++--- docs/PITFALLS.md | 52 +++++++++++++++++++ .../src/components/sidebar/SessionRow.tsx | 3 ++ 3 files changed, 64 insertions(+), 7 deletions(-) diff --git a/docs/INDEX.md b/docs/INDEX.md index ecb3e0db..9225a7a5 100644 --- a/docs/INDEX.md +++ b/docs/INDEX.md @@ -80,7 +80,7 @@ src/ ├── slash-commands.ts 斜杠命令清单(纯函数) ├── log.ts 结构化日志 ├── lan/ 局域网观察:config / projector / server(+ audit / sanitize) -├── session/ registry / naming / messages / trace+traces / event-slim+stream-guard / rates / ui-context / extension-dialog-host +├── session/ registry / meta / single-flight / naming / messages / trace+traces / event-slim+stream-guard / rates / ui-context / extension-dialog-host ├── permissions/ index(barrel) / bash-chain / pattern / config / tmp-zone / gate / extension / audit ├── project/ trust / trust-loader / workspace-store / files ├── settings/ settings / model-prefs / login / quota @@ -90,12 +90,14 @@ src/ | 文件 | 关键导出 | 职责 | |---|---|---| -| `src/pi-backend.ts` | `PiBackend` | **门面**:create/open/close/delete/prompt(followUp 排队,preflight 回执见 `use-composer-send`)/abort/fork/recall/compact/stats/listModels(附 `thinkingLevels`/`imageInput`)/会话权限模式(`registry entry.modeRef` 内存态 + get/setSessionPermissionMode,随 buildExtensionFactories 闭包注入扩展)/事件与权限·信任分发(emitter.ts 统一原语,9 emitter)(respondPermission **先 gate.respond 放行再持久化**,持久化失败只 log 不挂会话)。`buildCustomTools(gate, preferBuiltin)` 注册 webfetch+show_image+todo+subagent(preferBuiltin 优先级:构造参数 > model-prefs 实时值 > true);`buildExtensionFactories` 注册序 = context 钩子链序:权限门控 → 上下文蒸发 → channel-watch → todo-reminder(最后,注入不被折叠);subagent 子会话 `noExtensions`。**频道订阅快照**(`channelSubscriptionIds`:`reportChannelSubscriptions` 由 channel-watch 扩展回调维护、`getChannelSubscriptionSessionIds` 供 renderer GC 查询,只返回仍在 registry 的 ID;dispose 兜底清理)+ **`closeSession(id, intent)`**:缺省/`user` = 用户意图(仅 streaming 守卫);`gc`(renderer 自动卸载)额外拒绝有订阅的会话。`sessions-subagents/` 下会话记 readOnly,prompt/fork/recall/setModel 一律 throw | +| `src/pi-backend.ts` | `PiBackend` | **门面**:create/open/close/delete/prompt(followUp 排队,preflight 回执见 `use-composer-send`)/abort/fork/recall/compact/stats/listModels(附 `thinkingLevels`/`imageInput`)/会话权限模式(`registry entry.modeRef` 内存态 + get/setSessionPermissionMode,随 buildExtensionFactories 闭包注入扩展)/事件与权限·信任分发(emitter.ts 统一原语,9 emitter)(respondPermission **先 gate.respond 放行再持久化**,持久化失败只 log 不挂会话)。**`openSession` 幂等**:`resolve()` 规范化路径 → `KeyedSingleFlight`(同文件并发只构造一次)+ **读 header 取 sessionId 命中 registry 即短路返回现有 meta**(在 `getModelRuntime`/资源加载/扩展构造之前,不重复 subscribe/trace);`wireSession` 里 registry.add 冲突(并发/别名路径最后防线)会把刚构造的 unsubscribe/gate/dialogs/session 全 dispose 后重抛。`buildCustomTools(gate, preferBuiltin)` 注册 webfetch+show_image+todo+subagent(preferBuiltin 优先级:构造参数 > model-prefs 实时值 > true);`buildExtensionFactories` 注册序 = context 钩子链序:权限门控 → 上下文蒸发 → channel-watch → todo-reminder(最后,注入不被折叠);subagent 子会话 `noExtensions`。**频道订阅快照**(`channelSubscriptionIds`:`reportChannelSubscriptions` 由 channel-watch 扩展回调维护、`getChannelSubscriptionSessionIds` 供 renderer GC 查询,只返回仍在 registry 的 ID;dispose 兜底清理)+ **`closeSession(id, intent)`**:缺省/`user` = 用户意图(仅 streaming 守卫);`gc`(renderer 自动卸载)额外拒绝有订阅的会话。`sessions-subagents/` 下会话记 readOnly,prompt/fork/recall/setModel 一律 throw | | `src/json-store.ts` | `JsonStore`、`JsonStoreCorruptedError` | 统一 JSON 持久化:tmp+rename 原子写;read 损坏回退默认、update 损坏抛 CorruptedError 拒写;async 版 per-path 队列串行化、sync 变体热路径用;缓存/normalize 不进本层 | | `src/slash-commands.ts` | `BUILTIN_SLASH_COMMANDS` | 内置静态表(compact/name/export/settings)+ 模板/skill/扩展命令映射(纯函数) | | `src/log.ts` | `createLogger`、`initLogging` | 结构化日志:按天落盘 `main-<本地日期>.log`(`PI_LOG_LEVEL`/`PI_LOG_DIR`) | | `src/emitter.ts` | `Emitter` | 泛型订阅/分发:subscribe 返回退订、emit per-handler try-catch、clear/size(canAsk 探测);PiBackend 9 套事件管线共用 | -| `src/session/registry.ts` | `SessionRegistry`、`RegisteredSession` | sessionId → AgentSession 单条记录(session/unsubscribe/cwd/readOnly + **gate/dialogs/modeRef 会话级状态随 entry 生命周期**);delete/disposeAll 做 entry 级清理 | +| `src/session/registry.ts` | `SessionRegistry`、`RegisteredSession` | sessionId → AgentSession 单条记录(session/unsubscribe/cwd/readOnly + **gate/dialogs/modeRef 会话级状态随 entry 生命周期**);`add` 同 entry 幂等、**不同 entry 同 sessionId 抛错(不静默覆盖,防旧实例订阅/gate/dialogs 泄漏)**;delete/disposeAll 做 entry 级清理;`toMeta` 时间字段走 `session/meta.ts` | +| `src/session/meta.ts` | `deriveSessionTimes`、`fallbackSessionTimes` | **会话时间字段唯一口径**(与 SDK `buildSessionInfo` 对齐):`createdAt` = session header 时间(**不是** 文件 birthtime/mtime——复制/恢复文件会改它);`modifiedAt` = user/assistant 消息最大活动时间(message 数值 timestamp 优先、entry timestamp 兜底、无消息=created,custom/toolResult 一律不算);header 读不出来才 `fallbackSessionTimes`(文件 mtime → 当前时刻) | +| `src/session/single-flight.ts` | `KeyedSingleFlight` | 按 key 的并发 single-flight:同 key 在途复用同一个 Promise,settle(成败都算)后**只清自己那条**(旧 Promise 不删后继请求);`PiBackend.openSession` 按**规范化绝对路径**用它,保证同一会话文件并发 open 只构造一次 | | `src/settings/quota.ts` | `QuotaService`、`makeQuotaService` | opencode-go 套餐额度:官方 API + 5min TTL 缓存(无 key null / HTTP 失败带 error 空窗体) | | `src/lan/projector.ts` | `SessionProjection`、`seedProjection`、`applyEvent`、`deriveView` | LAN 会话投影 = **sanitize 事件流驱动的 shared reducer 态**(单一事实源)+ 非 reducer 底座(name/cwd/stats/lastActivity/pendingPermission);agentActive/todos/currentTool/assistantTail/lastError 全派生,无第二状态机 | | `src/lan/sanitize.ts` | `sanitizeSessionEvent/Message` | LAN 出网净化;事件白名单 = shared `REDUCED_EVENT_TYPES` − LAN 排除集(auto_retry_*/stream_guard_tripped,单一事实源派生) | @@ -175,17 +177,17 @@ src/ | `api.ts` | `getPi()`:window.pi 类型化访问 | | `lib/thinking.ts` | `THINKING_LEVELS`/`ThinkingLevel` re-export(单一事实源在 shared `src/thinking.ts`)+ `clampThinkingLevel` 就近向上收敛(仅 renderer UI 用;backend 不 import,交 SDK clamp) | | `lib/daily.ts` | 日常空间目录 renderer 缓存:`initDailyDir`(App 启动调一次,幂等)+ `isDailyCwd` 同步判定 + `setDailyDirForTest`;各处共享,不散落路径字符串 | -| `lib/sidebar-groups.ts` | 左栏纯派生层(有单测):`mergeSidebarSessions`(**历史 + 内存会话合并**:按 `sessionId` 去重、内存覆盖历史、历史保序;draft 与刚创建还没落盘的会话由此进左栏)+ `deriveSidebarGroups`(合并结果 + 项目表 → 日常/项目分组、置顶排序、搜索过滤、展开态推断)+ `toggleInList`(置顶切换)+ `toggleExpandedGroup`(第 4 参 `touched` 定起点:没操作过用派生层默认集,已操作过用用户记录——**空数组合法表示全部折叠**)。「项目」小标 v7 起不可折叠,不再有 `PROJECTS_GROUP_KEY` 这类专用 key | +| `lib/sidebar-groups.ts` | 左栏纯派生层(有单测):`mergeSidebarSessions`(**历史 + 内存会话合并**:按 `sessionId` 去重、内存覆盖历史、历史保序;同 ID 走 `mergeSessionMeta`——运行态字段(name/model/active/messageCount)以内存为准,但 **`createdAt` 始终取历史权威值、内存 `modifiedAt` 缺失时保留历史值**,否则排序键会从 `modifiedAt` 掉到 `createdAt`、行在打开/卸载时伪移动;draft 与刚创建还没落盘的会话由此进左栏)+ `deriveSidebarGroups`(合并结果 + 项目表 → 日常/项目分组、置顶排序、搜索过滤、展开态推断)+ `toggleInList`(置顶切换)+ `toggleExpandedGroup`(第 4 参 `touched` 定起点:没操作过用派生层默认集,已操作过用用户记录——**空数组合法表示全部折叠**)。「项目」小标 v7 起不可折叠,不再有 `PROJECTS_GROUP_KEY` 这类专用 key | | `lib/session-gc.ts` | 会话内存驻留策略(**纯函数,有单测**):`pickUnloadCandidates`(保护/草稿/活跃/刚用过 → 剔除;剩余按最久未用排序,保留 K 个,超的与晾过 `idleTimeoutMs` 的一并作候选)+ `isProtected`(保护条件唯一事实源:draft / **有频道订阅(`hasChannelSubscriptions`,订阅 = 明确驻留语义,卸载会停 watcher)** / 0 消息 / agentActive / 待审批 / 待应答 / 未读绿点 / 压缩中 / 排队跟发;**权限模式曾在清单里,D7 改成按会话持久化+打开时恢复后已移除**)+ `GC_DEFAULTS`(K=3、fresh **3s**、idle 5min)。不 import React / 不调 IPC | | `lib/session-gc-run.ts` | 一轮 GC 的**编排**(依赖全注入,有单测):先拉「有频道订阅的已加载会话 ID」快照 → 映射进 `hasChannelSubscriptions` → 纯策略层算候选 → 逐个再确认(活跃/已关跳过)后 `unload`;**fail-safe:快照拉取失败 = 保护范围未知 → 整轮跳过一个都不卸**(不退化成「让 backend 拒绝一次」,否则每轮每会话一次无效 IPC);后端 `closed=false` 记 `refused` | | `lib/use-session-gc.ts` | 内存策略接线层(薄):构建依赖调 `session-gc-run.ts` 的一轮编排 + 挂载跑一次 + **20s** 兜底 tick(实测:仅靠它时收敛延迟 ≤ 一个 tick) + 只订阅 `sessions`/`activeSessionId`(**不订阅 `transcript.bySession`**,它每 token 都变)+ 轮内不重入;K=3 常量在这里 | | `lib/diagnostics.ts` | `buildDiagnosticsText`(会话诊断纯文本,供「复制诊断信息」用;从 `components/projects/` 移入) | -| `stores/sessions.ts` | 会话列表/当前会话/cwd/模型/权限模式(`permissionModes` map,缺 key = default;draft 态纯 renderer,转正 ensureSession 应用到后端)。draft 会话(`draft:` 前缀,空 tab 重启消失;发首条消息 `ensureSession` 用 draft cwd 原地转正);信任前置 ensureProjectTrust(trustVersion 触发重拉);打开(含从历史按需打开)/fork/撤回统一 `loadSessionBundle` 四件套(history→queue→todos→permissionMode 对齐后端);**`selectBarSessions`(v8)算顶栏展示集 = 严格置顶表(meta 从 tabs → `allSessions` 兜底;draft 不再进顶栏,导航与丢弃都在左栏)**;**v10 起不持久化打开列表**——启动纯空(新会话页),会话只在你点开时才加载,退出即清(与 pi 原生 `AgentSessionRuntime` 的"单会话按需替换"对齐);切会话/打开/新建三处调 `rememberCwd` 记住**上次项目目录**(`lastCwd`,只记目录不恢复会话);**`loadSessionBundle` 末尾按 `sessionPermissionModes` 恢复档位(await IPC,保证「打开完即正确档位」)**;**会话内存驻留策略**:`lastUsedAt` LRU 打点 + `unloadSession`(自动卸载专用入口,走 `closeSession(id, intent:"gc")` 让后端区分自动 GC 与用户意图;受保护会话由纯策略层排除),接线在 `lib/use-session-gc.ts`(App 挂一次) | +| `stores/sessions.ts` | 会话列表/当前会话/cwd/模型/权限模式(`permissionModes` map,缺 key = default;draft 态纯 renderer,转正 ensureSession 应用到后端)。**导航 latest-wins**:用户导航动作先领进程内单调 `activationSeq` 令牌,异步返回时只有仍是最新令牌才写 `activeSessionId/cwd/rememberCwd`(过期的只落数据:meta 进 tabs、bundle 装载);`switchSession`/`createDraftSession` 同步领号,`openFromHistory`/`createSession`/`forkSession` 入口领号,`closeSession` 的 active 回退是关闭副作用**不领号**。**`openFromHistory` 走共享 open pipeline**(`openInFlight` 按规范化 filePath 去重:同文件双击只发一次 IPC、失败只 toast 一次、settle 必清 key;`bundleInFlight` 再按 sessionId 去重 bundle)。draft 会话(`draft:` 前缀,空 tab 重启消失;发首条消息 `ensureSession` 用 draft cwd 原地转正);**`createSession` 返回新会话 id(失败 null)——调用方必须用返回值定位,不能读 `activeSessionId`**(创建期间用户可能已切走);信任前置 ensureProjectTrust(trustVersion 触发重拉);打开(含从历史按需打开)/fork/撤回统一 `loadSessionBundle` 四件套(history→queue→todos→permissionMode 对齐后端);**`selectBarSessions`(v8)算顶栏展示集 = 严格置顶表(meta 从 tabs → `allSessions` 兜底;draft 不再进顶栏,导航与丢弃都在左栏)**;**v10 起不持久化打开列表**——启动纯空(新会话页),会话只在你点开时才加载,退出即清(与 pi 原生 `AgentSessionRuntime` 的"单会话按需替换"对齐);切会话/打开/新建三处调 `rememberCwd` 记住**上次项目目录**(`lastCwd`,只记目录不恢复会话);**`loadSessionBundle` 末尾按 `sessionPermissionModes` 恢复档位(await IPC,保证「打开完即正确档位」)**;**会话内存驻留策略**:`lastUsedAt` LRU 打点 + `unloadSession`(自动卸载专用入口,走 `closeSession(id, intent:"gc")` 让后端区分自动 GC 与用户意图;受保护会话由纯策略层排除),接线在 `lib/use-session-gc.ts`(App 挂一次)。**GC 选择竞态(D5)**:`closeSession` 在 `closed=true` 后、清本地状态前复检 `activeSessionId === sessionId`(仅 intent=`gc`)——命中则只重建 backend 会话(`loadBundle:false`,不重载磁盘历史、不 reset transcript、不领新令牌)、按 renderer 真值拉回权限档位(失败则回落 default + 提示),返回 `{closed:false}`(GC 视为本轮没卸成);reopen 失败才按常规关闭清理 | | `stores/transcript.ts` | re-export shared reducer + per-session 字段(agentActive/unseenCompletion/todos);reducer 细节见 shared `src/transcript/` | | `stores/event-conflator.ts` | 流式事件合流:纯追加型 delta 按会话/类型拼接 + rAF 每帧最多一次 flush,其余事件边界透传保序(可注入调度器,有测试) | | `stores/drafts.ts` | 草稿(文本/图片/slash 胶囊/@ 引用 attachments/选中引用 quotes)按会话持久 + `COMPOSER_FOCUS_EVENT`(撤回回填后聚焦输入框) | | `stores/settings.ts` / `catalog.ts` / `provider-login.ts` | 设置域(providers + 上下文管理/channel-watch 开关,乐观更新回滚;permissionGateOff = 手改 permissions.json 卸载门控的逃生舱态只读感知)/ 社区包目录(300ms 防抖 + seq 防陈旧)/ OAuth 登录状态机(**取消时机 = LoginDialog 卸载 cleanup**;先订阅事件再 invoke) | -| `stores/projects.ts` / `theme.ts` / `ui.ts` / `ui-preferences.ts` / `update.ts` / `ui-plugins.ts` / `toasts.ts` | 会话历史与项目表(`deriveProjects`:手动添加的按时间倒排;`deriveSessions`:置顶分区在左 + 其余按最后活动倒序;`deleteSession` / `deleteProject` / `openSession` / `applySessionName`)/ 主题与背景(init 在 render 前 await 防闪烁)/ todo 面板展开 + diff 侧栏开关(内存态)/ **左栏偏好 `barSessionsVisible` / `sidebarCollapsed` / `expandedGroups` + `expandedGroupsTouched`(成对语义:false = 从没手动开合过 → 走默认推断,true = 完全以 `expandedGroups` 为准,**空数组 = 全部折叠**;旧文件缺字段时按记录是否非空推断)/ `pinnedProjects` + 中央动画开关 + 置顶会话 `pinnedSessions`(v8 起它同时就是顶栏胶囊内容与顺序,拖动排序走 `reorderPinned`)+ **`sessionPermissionModes`(D7:按会话记住的权限模式,只存非 default;`rememberPermissionMode` / `forgetPermissionMode`)** + 上次项目目录 `lastCwd`(App 启动预填 cwd,只带目录不恢复会话)**(均持久化 ui-state;删会话时 unpin 清理)/ 更新态 / UI 插件面板 / 全局 Toast(顶栏右侧,非阻塞自动消失;扩展 notify 走 pushExtension:同源同文 8s 去重 + 可见栈 3 溢出折叠) | +| `stores/projects.ts` / `theme.ts` / `ui.ts` / `ui-preferences.ts` / `update.ts` / `ui-plugins.ts` / `toasts.ts` | 会话历史与项目表(`load` 有 **latest-wins** 单调 `loadSeq` 守卫:更晚的 load 一旦发起,旧请求的成功/失败都不写 `allSessions/loading/loaded`;`deriveProjects`:手动添加的按时间倒排;`deriveSessions`:置顶分区在左 + 其余按最后活动倒序;`deleteSession` / `deleteProject` / `openSession`(已加载会话走 switchSession,否则 openFromHistory)/ `applySessionName`)/ 主题与背景(init 在 render 前 await 防闪烁)/ todo 面板展开 + diff 侧栏开关(内存态)/ **左栏偏好 `barSessionsVisible` / `sidebarCollapsed` / `expandedGroups` + `expandedGroupsTouched`(成对语义:false = 从没手动开合过 → 走默认推断,true = 完全以 `expandedGroups` 为准,**空数组 = 全部折叠**;旧文件缺字段时按记录是否非空推断)/ `pinnedProjects` + 中央动画开关 + 置顶会话 `pinnedSessions`(v8 起它同时就是顶栏胶囊内容与顺序,拖动排序走 `reorderPinned`)+ **`sessionPermissionModes`(D7:按会话记住的权限模式,只存非 default;`rememberPermissionMode` / `forgetPermissionMode`)** + 上次项目目录 `lastCwd`(App 启动预填 cwd,只带目录不恢复会话)**(均持久化 ui-state;删会话时 unpin 清理)/ 更新态 / UI 插件面板 / 全局 Toast(顶栏右侧,非阻塞自动消失;扩展 notify 走 pushExtension:同源同文 8s 去重 + 可见栈 3 溢出折叠) | | `hooks/` | `use-context-usage`(上下文用量,事件驱动刷新)/ `use-language` / `use-session-state`(useSessionReadOnly/useSessionBusy 收敛)/ `use-session-event-bridge`(App 事件桥装配层专用) | | `plugins/` | UI 插件运行时:`slots.ts`(槽位名+props 契约单一来源)/ `registry.ts`(zustand:overrides + contributions 堆叠 + headless activate/cleanups + 崩溃计数/loadNonces)/ `Slot.tsx`(总开关门控 + PluginBoundary 包裹)/ `RegionHost.tsx`(区域挂载点,容器语义)/ `PluginBoundary.tsx`(class 错误边界,崩溃回退)/ `host-api.ts`(`window.PerchoUI` 挂载,main.tsx render 前 import)/ `loader.ts`(initUiPlugins/reloadAll/computeAssignedSlots) | | `i18n/` | zh/en 字典 + `useT()`(文案改这里,双字典) | @@ -200,7 +202,7 @@ src/ | `composer/` | **Composer**(装配层 ~330 行,键盘事件分发)+ 三 hook:`use-composer-send`(ensureSession 懒创建/followUp 排队/停止先 clearQueue/发送失败草稿回填)、`use-slash-menu`(命令拉取+胶囊回填+导航)、`use-at-completion`(@ token 探测/续钻/胶囊弹回)+ 展示件 QueueBar/ImageTray/AttachmentChip/**QuoteChip**(选中引用胶囊)/SendErrorBar + **ModelPicker** / **ThinkingPicker**(按模型 thinkingLevels 过滤+clamp)/ **PermissionPicker**(会话权限模式 chip:默认/完全访问)/ **SlashMenu** / **AtMenu** / **ContextRing** + 纯函数 `slash-filter`/`at-files`/`send-error`/`quote`/`model-filter`(各有测试) | | `session/` | **SessionTabBar** + SessionTab/TabPill(**v8:顶栏胶囊 = 置顶会话(不看 tab 开没开),严格只含置顶**,顺序就是 `pinnedSessions`;未置顶会话只在左栏;空态一行 12px `ink-faint` 提示;状态收拢到头像图标;dnd-kit 拖拽排序(改的也是 `pinnedSessions`),DragOverlay ghost + 轴锁定;拖拽期间退出 drag-region;**最左 = 左栏开合按钮 `PanelLeftIcon`**;胶囊右键 → `ui/ContextMenu`「重命名 / 置顶」;叉叉 = 取消置顶 + 从顶栏清除)/ **RenamePopover**(重命名浮层:锚在胶囊/指针锚点,160ms 进场 / 120ms 退场 + Enter/点外提交/Esc 取消)/ **SessionAvatar** + `session-status`(头像底色语义 + 状态/标题纯逻辑,顶栏与左栏共用;`SessionAvatar` 已无引用,留作备用)/ `session-menu`(菜单可用性与动作:`sidebarMenuKind` 定形态(draft / 普通 / 无菜单)、`draftSessionMenuItems` = 左栏 draft 专属最小菜单(只「丢弃新会话」,纯本地 `closeSession`)、只读会话不给菜单、置顶只动 `pinnedSessions`、重命名落盘(**同步 tabs 与历史列表两份拷贝**)+失败 toast、复制诊断、删除会话;顶栏胶囊与左栏会话行共用)/ **ProjectBranchPicker**(从 `projects/` 移入的 draft 态项目/分支选择器 chip)/ **DockSlot**(底部交换槽仲裁:权限 > 扩展对话框 > Composer)/ **ApprovalDock**(权限审批:async respond 成功才移除面板,失败保留重试)/ **InteractionDock**(扩展对话框四卡 select/input/editor/confirm:键盘 ↑↓/Enter/数字/Esc + 倒计时显示,裁决在 backend)/ **TrustDialog**(项目信任两选项)/ **UpdateButton**(顶栏更新按钮) | | `settings/` | **SettingsDialog**(PANELS 注册表;分类 = 静态 + 插件 settings.panel 贡献动态拼接)/ **GeneralPanel**(语言/上下文管理二态/channel-watch 开关)/ **AppearancePanel**(顶部 Tab 分栏:「基础」=主题三段/背景图/**显示顶栏开关**/中央动画,「UI 插件」= 原独立分类并入的 UiPluginsSection:总开关/插件卡/槽位指派,设计稿 .local/design/ux/appearance-ui-plugins)/ **SkillsPanel** / **McpPanel**(SDK 0.84 无 MCP,占位)/ **ExtensionsPanel** + `extensions/`(目录浏览/安装/卸载,subagent 包安装两段式确认)/ **UiPluginsSection**(挂在 AppearancePanel 的 UI 插件 Tab 下) / **LanObserverPanel** / **AboutPanel** / `providers/`(ProvidersPanel + ProviderRow 操作全图标化 / LoginDialog(交互登录对话框:OAuth + api_key 交互流)/ CustomProviderForm + ModelRowsEditor(逐模型行编辑器)/ BuiltinProviderEditForm(内置端点覆写)/ SubagentPanel(子代理执行器开关 + 逐代理模型/思考深度偏好)+ `model-rows` 纯函数) | -| `sidebar/` | **Sidebar**(常驻左栏容器:取 store 数据(**历史 + 内存会话经 `mergeSidebarSessions` 合并**)→ 调 `lib/sidebar-groups` 派生 → 分发 props,240↔0 push 过渡 + 收起时 `inert`)/ **SidebarHeader**(只有搜索框——v9 顶栏常驻,不再需要「顶栏关掉时补拖拽带 + +」)/ **SidebarGroup**(日常与各项目共用的分组骨架:分组行 + 可折叠会话列表 + 空态)/ **ProjectRow**(图标表达展开态:项目 = folder ↔ folder-open、日常 = 恒咖啡 + 行末 hover 箭头;hover 出 «⋯»(锚在**指针点**)、置顶标记、移除项目二次确认)/ **SessionRow**(无头像文字 + 行尾状态点 + 右键菜单锚点;**draft 行标题固定「新会话」**(`sidebar.newSession`))/ **SessionMenu**(会话行右键三层状态机:菜单 → 改名气泡 → 删除确认;draft 走 `draftSessionMenuItems`)/ **ProjectSection**(「项目」小标:**v7 起固定标题不可折叠** + 常显「+」)/ **SidebarFooter**(设置;顶栏关闭时补「本轮改动」)/ **ProjectMenu**(项目 «⋯» 菜单项 builder)/ `useExpandedGroups`(展开态读写:起点由 `expandedGroupsTouched` 决定;写入时 store 同一次 set + 单个补丁置 touched) | +| `sidebar/` | **Sidebar**(常驻左栏容器:取 store 数据(**历史 + 内存会话经 `mergeSidebarSessions` 合并**)→ 调 `lib/sidebar-groups` 派生 → 分发 props,240↔0 push 过渡 + 收起时 `inert`)/ **SidebarHeader**(只有搜索框——v9 顶栏常驻,不再需要「顶栏关掉时补拖拽带 + +」)/ **SidebarGroup**(日常与各项目共用的分组骨架:分组行 + 可折叠会话列表 + 空态)/ **ProjectRow**(图标表达展开态:项目 = folder ↔ folder-open、日常 = 恒咖啡 + 行末 hover 箭头;hover 出 «⋯»(锚在**指针点**)、置顶标记、移除项目二次确认)/ **SessionRow**(无头像文字 + 行尾状态点 + 右键菜单锚点;**draft 行标题固定「新会话」**(`sidebar.newSession`);行上带 `data-session-id` / `data-session-active` 只读属性供 CDP 验收定位与判定,不影响视觉)/ **SessionMenu**(会话行右键三层状态机:菜单 → 改名气泡 → 删除确认;draft 走 `draftSessionMenuItems`)/ **ProjectSection**(「项目」小标:**v7 起固定标题不可折叠** + 常显「+」)/ **SidebarFooter**(设置;顶栏关闭时补「本轮改动」)/ **ProjectMenu**(项目 «⋯» 菜单项 builder)/ `useExpandedGroups`(展开态读写:起点由 `expandedGroupsTouched` 决定;写入时 store 同一次 set + 单个补丁置 touched) | | `ui/` | Button(ghost/primary × sm/md × danger)/ Dropdown / Switch(统一受控开关,支持 indeterminate)/ **Tooltip**(自定义悬浮提示,新增提示一律用它不用原生 title;右缘元素传 `align="end"` 防幻影横向滚动条)/ **ContextMenu**(通用右键菜单:portal 到 body + `place-menu` 纯函数定位,点外/Esc/选中/滚动关闭;有边框卡片外观(`rounded-xl border border-border bg-surface shadow-pop`),项高 30px、支持 `danger` 红项与 `separatorBefore` 分隔线,不再有 veil 变体;会话胶囊 / 文件行 / 左栏会话行共用,文件行三项菜单见 **FilePathMenu**)/ `place-menu`(`placeMenu` 定位 + `anchorOfElement` 锚点转换,纯函数有测试;**左栏项目 «⋯» 与右键菜单一律传指针锚点** `{ left: clientX, top: clientY, width: 0, height: 0 }` —— 传元素矩形会把高度多算一遍,菜单掉到行下方)/ **ConfirmDialog**(破坏性操作二次确认:极淡蒙层 `bg-ink/6` + 392px 细边框卡片 + Portal 到 body 且 `z-[60]` 压过右键菜单;点卡片外/Esc/✕ = 取消,文案与按钮由调用方给) | | `icons/` | 内联 SVG 集中管理;`icons.test.ts` 校验所有 path `d` 语法(防残缺数据被 Chromium 丢弃) | diff --git a/docs/PITFALLS.md b/docs/PITFALLS.md index 86e8bd30..57fb910f 100644 --- a/docs/PITFALLS.md +++ b/docs/PITFALLS.md @@ -54,6 +54,10 @@ | 打开模型选择器后整页向左偷跑、左栏与顶栏左侧按钮被挤/裁切 | 四 · absolute 弹层越界 + autoFocus = 整页横向偷跑(2026-09-20) | | CDP 量测得出「弹层在视口内、也没滚动」但界面明明错位(量错元素) | 四 · 同章节「量测三纪律」(2026-09-20) | | 量测脚本报「draft 没进左栏」,实际是我的选择器点到了分组头 | 四 · 同章节「量测三纪律」→ 侧栏行选择器(2026-09-20) | +| 点一下历史会话行,它在左栏里跳到别处(卸载后又跳回) | 四 · 内存 meta 覆盖历史 meta = 排序键漂移到 createdAt(2026-09-20) | +| 快速连点两行,界面停在先点的那一行(或过一会才被抢回) | 四 · 异步导航必须 latest-wins(令牌 + 共享 open pipeline)(2026-09-20) | +| 同一会话文件并发 open 后订阅/扩展/trace 翻倍、旧实例泄漏 | 二 · openSession 幂等:registry 短路 + single-flight + add 不静默覆盖(2026-09-20) | +| 复制/恢复过会话文件后,它在列表里的时间/位置全变了 | 二 · 同章节「birthtime 不是会话创建时间」 | ## 一、事故复盘(含可复用诊断手法) @@ -149,6 +153,29 @@ LAN 页重连/中途进入时,快照种子经 `messagesToUIMessages` 重建— **本期未覆盖的已知限制(缺口)**:轮询降级模式的删除检测(`readdir` 快照里不含被删文件)与 watcher root 目录被删后重建——这两种异常场景下 watcher 会**漏事件**(订阅会话仍在,但那一次变化不会投递),不影响本期主故障(会话被卸载导致订阅整个消失)的验收,但它们是真实缺口、本期明确不修,不要当成「不是 bug」。 +### openSession 幂等:registry 短路 + single-flight + `add` 不静默覆盖(2026-09-20,sidebar-session-switch-stability) + +症状:同一个会话文件被重复打开(renderer 双击同一行、sidebar 与「子代理跑卡」两条路同时开、别处再调一次 open)后,这个会话的**订阅/扩展/trace 全变成两份**;关闭它只 dispose 掉后注册的那一份,前一份变成没人能关的活会话(内存、watcher、事件转发都翻倍)。查日志能看到同一 sessionId 的 `session opened` 出现两次。 + +根因有两层,都要堵: + +1. `SessionRegistry.add()` 原来是 `Map.set` —— **静默覆盖**旧 entry,旧实例没人再持有引用,永远不会被 dispose; +2. `PiBackend.openSession()` 每次都从头构造 AgentSession,**从不回头看 registry**(哪怕这个 sessionId 已经在内存里)。 + +对策(三层,缺一层都可能漏): + +- `openSession` 用 **`resolve(filePath)` 规范化绝对路径做 key** 跑 single-flight(同一文件并发 open 共享一次构造;settle 后必清 key → 失败可重试); +- 取到 header 拿到 sessionId 后**先查 registry,命中就直接返回现有 entry 的 meta**(`toMeta`)——短路必须放在 `getModelRuntime()`/资源 loader/扩展构造**之前**,否则照样白构造一遍; +- `registry.add()` 改成同 entry 幂等、**不同 entry 同 sessionId 抛错**;`wireSession` 里接住这个错,把刚构造的 `unsubscribe/gate.dispose/dialogs.dispose/session.dispose` 全做掉再重抛(这是并发/别名路径的最后防线,不能只抛错把资源漏出去)。 + +### birthtime 不是会话创建时间(2026-09-20,sidebar-session-switch-stability) + +症状:复制/恢复/迁移过会话文件(或从别的机器拷回来)后,这个会话在列表里的“创建时间”变成今天、置底或置顶、排序也乱;另一个更隐蔽的版本是我们把“内存活跃会话”的 meta 直接覆盖磁盘 meta,**一打开某个会话它就从列表当前位置跳走**(详情见四 · 内存 meta 覆盖历史 meta)。 + +根因:`statSync(file).birthtimeMs` 被当成 `createdAt` 用。birthtime 是**文件诞生时间**,copy/restore 就变,跟会话本身没关系;而 SDK 自己用的是 session header 的 `timestamp`(`buildSessionInfo()`:`created = header.timestamp`、`modified = user/assistant 消息最大活动时间`)。 + +对策:活跃会话的时间字段只从**会话内容**取(header + entries,见 `backend/src/session/meta.ts`),`stat`/`Date.now()` 只在 header 读不出来时兜底,且**兜底也优先 mtime 而不是 birthtime**。自己实现枚举时,`custom`/`toolResult` 类 entry 一律不算活动——否则 channel cursor 之类的扩展写入会把会话顶到最前。 + ## 三、构建 · 打包 · 环境 ### electron-vite dev 主进程 watcher 不可依赖:改 `src/main/` 后必须验产物(2026-09-17 实测) @@ -421,6 +448,31 @@ pi SDK 必须声明进 `packages/desktop/package.json` dependencies(electron-b - **滚动/改变窗口尺寸就关菜单**(而不是重定位):祖先滚动容器可能有很多层,跟踪成本远大于收益;不关会「菜单挂在原地、触发元素跑了」。 - `preventDefault()` 在 `contextmenu` 里必写(否则同时弹系统菜单);dnd-kit 的 `PointerSensor` 只认主键,右键不会误触发拖拽。 +### 内存 meta 覆盖历史 meta = 排序键漂移到 createdAt,行会“自己跳”(2026-09-20,sidebar-session-switch-stability) + +症状:点开左栏某条历史会话,那一行**当场移到别处**(项目里第 3 行 → 第 22 行);等它被自动卸载(或关掉再开另一个),又跳回原位。用户描述成“点一下行就乱跳”。 + +根因:左栏数据来自两份来源合并且**同 ID 后者整体覆盖前者**——磁盘历史(有完整 `modifiedAt`)与内存活跃会话(当时只有 `createdAt`,且是文件 birthtime)。合并且丢掉 `modifiedAt` 后,排序键 `modifiedAt ?? createdAt` 就从「最后活动时间」掉到「当时才诞生的 birthtime」——于是行位置随“打没打开”变。 + +对策: + +- 同 ID 合并要分字段:运行态字段(name/model/active/messageCount)以内存为准,但 **`createdAt` 取历史权威值、内存缺 `modifiedAt` 时保留历史值**(`lib/sidebar-groups.ts` 的 `mergeSessionMeta`); +- 更根上的一步是**让内存 meta 自己带正确的 `modifiedAt`**(后端 `toMeta` 改从 header/entries 算,见二 · birthtime 那条)——两层都要,上游漏一个字段下游不该跟着错; +- 回归验收不要只看“值对不对”:**记录点击前后这一行的 `top` 与序号**(CDP 读 `[data-session-id]` 顺序即可),比对比快照更能发现“位置漂移”。 + +### 异步导航必须 latest-wins(令牌 + 共享 open pipeline)(2026-09-20,sidebar-session-switch-stability) + +症状:快速点两行(或点一行再切回已加载的会话),界面**停在先点的那一行**——第一次点击的 `openSession` 后到,把刚切走的 `activeSessionId` 又写了回去;极端情况下还会把 `cwd` 一起带回旧项目。 + +根因:所有导航入口都是 `await IPC → set({activeSessionId, cwd, lastUsedAt})`,没有任何措施区分“这是不是我最后一次点击带来的结果”。更阴的一点:`createSession` 不返回新会话 id、调用方改写 `activeSessionId` 取回它(为了拿权限模式/发首条消息)——一旦 latest-wins 生效,调用方就会拿到**别人的** sessionId(消息和权限模式发错会话),所以「改导航语义」必须同步审计所有调用点。 + +对策: + +- 导航动作**入口就领**一个进程内单调令牌(不能等 IPC 回来才领,否则表达不了点击顺序);异步返回时只有令牌仍是最新才写 `activeSessionId/cwd`,过期的只落数据(meta 进 tabs、bundle 装载);同步切换(`switchSession`)领号即可瞬间作废所有在飞的 open/create/fork; +- **同一文件共享一个 open pipeline**(模块级 in-flight Map,key = 规范化路径):双击只发一次 IPC、失败只 toast 一次、settle 后必清 key(否则失败后永远重试不了);bundle 装载再按 sessionId 去重(open 完成时 meta 会先落进 tabs,用户再点那一行走的是懒加载,两条路会撞车); +- **异步创建/分叉要把新 id 作为返回值交给调用方**,禁止“创建完读 active 拿 id”; +- 自动卸载(GC)的“关完成”要**复检 active**:关的过程中用户可能刚好选中了这个会话(判定“可卸”时它还不是 active),此时应当**重新打开后端会话**而不是把它从 UI 抹掉——只重建 backend、不重载磁盘历史(否则 idle transcript 会被历史快照覆盖),不写 `activeSessionId`、不领新令牌。 + ## 五、工程纪律 ### renderer 单测跑在 node 环境:测不了 i18n 与返回 JSX 的模块函数(2026-09-20) diff --git a/packages/desktop/src/renderer/src/components/sidebar/SessionRow.tsx b/packages/desktop/src/renderer/src/components/sidebar/SessionRow.tsx index ae249b97..df7f26c5 100644 --- a/packages/desktop/src/renderer/src/components/sidebar/SessionRow.tsx +++ b/packages/desktop/src/renderer/src/components/sidebar/SessionRow.tsx @@ -43,6 +43,9 @@ export function SessionRow({ return (