From 12343e7b3b812f6c8df2100420072f8dbfe08aef Mon Sep 17 00:00:00 2001 From: Jaxton07 Date: Sun, 20 Sep 2026 16:40:45 +0800 Subject: [PATCH 1/8] =?UTF-8?q?test(channel-watch):=20=E9=98=B6=E6=AE=B5?= =?UTF-8?q?=200=20=E2=80=94=20P0/P1=20=E5=A5=91=E7=BA=A6=E7=BA=A2=E6=B5=8B?= =?UTF-8?q?=E4=B8=8E=E6=B5=8B=E8=AF=95=20seam=EF=BC=88=E8=AE=A2=E9=98=85?= =?UTF-8?q?=E5=BF=AB=E7=85=A7/=E6=B8=B8=E6=A0=87/intent/GC=20=E4=BF=9D?= =?UTF-8?q?=E6=8A=A4=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/backend/src/pi-backend.ts | 23 +- .../src/tools/channel-watch/extension.ts | 35 +- .../src/tools/channel-watch/subscriptions.ts | 8 + .../test/channel-watch-extension.test.ts | 365 +++++++++++++++++- .../backend/test/session-close-guard.test.ts | 73 +++- .../src/renderer/src/lib/session-gc.test.ts | 30 ++ .../src/renderer/src/lib/session-gc.ts | 5 + .../src/renderer/src/stores/sessions.test.ts | 23 ++ 8 files changed, 550 insertions(+), 12 deletions(-) diff --git a/packages/backend/src/pi-backend.ts b/packages/backend/src/pi-backend.ts index fef1fc1b..bd927060 100644 --- a/packages/backend/src/pi-backend.ts +++ b/packages/backend/src/pi-backend.ts @@ -173,6 +173,12 @@ export interface PiBackendOptions { */ export class PiBackend { private readonly registry = new SessionRegistry(); + /** + * 已加载会话的频道订阅快照(spec §6.1/§6.2):sessionId → 有效运行态 topic 集, + * 由 channel-watch 扩展经 `reportChannelSubscriptions` 维护(只存 topic 名)。 + * 阶段 0 只建管道,GC 守卫/查询 IPC 见 plan 阶段 1。 + */ + private readonly channelSubscriptionIds = new Map>(); /** 用户级模型可见性与子代理模型偏好(独立于 CLI 共用 settings.json)。readonly 直暴露 */ readonly modelPrefs = new ModelPrefsService(join(getAgentDir(), "model-prefs.json")); /** 社区包管理(安装/卸载 + 会话热重载)。readonly 直暴露 */ @@ -529,12 +535,27 @@ export class PiBackend { return new Set(this.registry.list().map((e) => e.session.sessionId)); } + /** + * channel-watch 扩展回调入口(spec §6.1):上报该会话当前的「有效运行态订阅」快照。 + * 只存 topic 名(不存消息内容);空集 = 移出快照(退订最后一个 topic 即恢复普通 GC 资格)。 + * 这里只存取快照,不做保护策略(策略在 closeSession 的 intent 守卫与 renderer 纯策略层)。 + */ + reportChannelSubscriptions(sessionId: string, topics: ReadonlySet): void { + if (topics.size === 0) this.channelSubscriptionIds.delete(sessionId); + else this.channelSubscriptionIds.set(sessionId, new Set(topics)); + } + + /** renderer GC 查询用(spec §6.2):当前有有效频道订阅的会话 ID(顺序不构成契约) */ + getChannelSubscriptionSessionIds(): string[] { + return [...this.channelSubscriptionIds.keys()]; + } + /** * 关会话(**用户主动关**):agent 还在跑(含等审批)时拒绝——run 中途把后端会话 dispose 会直接掐断本轮, * 内存策略(自动卸载)靠这道兵底兼固(策略层已保护,这里是最后一道门)。 * 自动卸载走同一个方法:行为与主动关完全一致,只是调用点分开(renderer 侧 `unloadSession`)。 */ - async closeSession(sessionId: string): Promise<{ closed: boolean }> { + async closeSession(sessionId: string, _intent?: "user" | "gc"): Promise<{ closed: boolean }> { const entry = this.registry.get(sessionId); // 没有该会话 = 它本来就没在跑(幂等成功):调用方该照常清掉自己的会话条目 if (!entry) return { closed: true }; diff --git a/packages/backend/src/tools/channel-watch/extension.ts b/packages/backend/src/tools/channel-watch/extension.ts index 4cb4bdb7..492ce3bc 100644 --- a/packages/backend/src/tools/channel-watch/extension.ts +++ b/packages/backend/src/tools/channel-watch/extension.ts @@ -8,7 +8,7 @@ import { channelRoot, ensureAgentWorkInit, validateTopic } from "./init"; import { appendPost, MESSAGES_FILE } from "./post"; import { buildSubsPayload, restoreSubscriptions, SUBSCRIPTION_CUSTOM_TYPE } from "./subscriptions"; import { makeChannelTools } from "./tools"; -import { type ChannelWatchEvent, ChannelWatcher } from "./watcher"; +import { type ChannelWatchEvent, ChannelWatcher, type ChannelWatcherOptions } from "./watcher"; const log = createLogger("channel-watch"); @@ -46,6 +46,25 @@ export interface ChannelWatchOptions { sendWake?: (text: string) => void; /** notify(测试注入;缺省 lastCtx.ui.notify) */ notify?: (text: string) => void; + /** + * 有效运行态订阅快照变化回调(spec §6.1;PiBackend 记录用)。 + * 触发点:session_start 恢复完成后(含空集)、subscribe、unsubscribe、session_shutdown。 + * 阶段 0 只固定注入点,调用点见 plan 阶段 1.1。 + */ + onSubscriptionsChanged?: (sessionId: string, topics: ReadonlySet) => void; + /** watcher 工厂(缺省 new ChannelWatcher;测试注入短防抖,生产默认 3s/5s 不变) */ + watcherFactory?: (options: ChannelWatcherOptions) => ChannelWatcher; + /** 读内容 hash(缺省读文件,不存在 → null;测试注入可复现「基线读取 ↔ watcher 就绪」竞态) */ + readFileHash?: (absPath: string) => Promise; +} + +/** 读文件内容 hash(文件不存在/不可读 → null)。阶段 0 只是把注入点接上,语义与改动前一致 */ +async function defaultReadFileHash(absPath: string): Promise { + try { + return contentHash(await readFile(absPath, "utf8")); + } catch { + return null; + } } export function makeChannelWatchExtension(options: ChannelWatchOptions): InlineExtension { @@ -56,6 +75,9 @@ export function makeChannelWatchExtension(options: ChannelWatchOptions): InlineE const guard = new LoopGuard({ now: options.now }); const sendWake = options.sendWake ?? ((text: string) => pi.sendUserMessage(text, { deliverAs: "followUp" })); + const readHash = options.readFileHash ?? defaultReadFileHash; + const makeWatcher = + options.watcherFactory ?? ((opts: ChannelWatcherOptions) => new ChannelWatcher(opts)); // --- 会话闭包状态 --- let active = false; @@ -106,12 +128,9 @@ export function makeChannelWatchExtension(options: ChannelWatchOptions): InlineE if (basename(event.relPath) !== MESSAGES_FILE) return; const abs = join(channelRoot(options.cwd), event.relPath); void (async () => { - let hash: string; - try { - hash = contentHash(await readFile(abs, "utf8")); - } catch { - return; // 文件已删/不可读:无内容可比,跳过(删除场景无「查收」语义) - } + const hash = await readHash(abs); + // 文件已删/不可读:无内容可比,跳过(删除场景无「查收」语义) + if (hash === null) return; const decision = guard.shouldDeliver(event.topic, event.relPath, hash, abs); if (!decision.deliver) { log.info("唤醒抑制", { @@ -144,7 +163,7 @@ export function makeChannelWatchExtension(options: ChannelWatchOptions): InlineE const ensureWatcher = async (): Promise => { if (watcher) return; - const w = new ChannelWatcher({ + const w = makeWatcher({ channelRoot: channelRoot(options.cwd), onEvent: onWatchEvent, }); diff --git a/packages/backend/src/tools/channel-watch/subscriptions.ts b/packages/backend/src/tools/channel-watch/subscriptions.ts index f53cf3f9..40b976fb 100644 --- a/packages/backend/src/tools/channel-watch/subscriptions.ts +++ b/packages/backend/src/tools/channel-watch/subscriptions.ts @@ -11,6 +11,14 @@ export const SUBSCRIPTION_CUSTOM_TYPE = "channel-subs"; export interface SubsPayload { topics: string[]; + /** + * topic → 最后已确认的 `MESSAGES.md` 内容 hash。 + * - string:最后已确认的内容 hash; + * - `null`:确认当时文件不存在; + * - **key 缺失**:旧版(只有 topics)/未知基线,不等价于 `null`; + * 只保留仍在 topics 中的安全 topic。 + */ + cursors?: Record; } /** 构造 appendEntry 载荷(全量快照,排序稳定便于测试) */ diff --git a/packages/backend/test/channel-watch-extension.test.ts b/packages/backend/test/channel-watch-extension.test.ts index f664568c..eff63faf 100644 --- a/packages/backend/test/channel-watch-extension.test.ts +++ b/packages/backend/test/channel-watch-extension.test.ts @@ -2,14 +2,21 @@ import { appendFile, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/pro import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterAll, beforeAll, describe, expect, it } from "vitest"; -import { buildWakeMessage, makeChannelWatchExtension } from "../src/tools/channel-watch/extension"; +import { + buildWakeMessage, + type ChannelWatchOptions, + makeChannelWatchExtension, +} from "../src/tools/channel-watch/extension"; +import { contentHash } from "../src/tools/channel-watch/guard"; import { formatPostEntry } from "../src/tools/channel-watch/post"; import { buildSubsPayload, restoreSubscriptions, SUBSCRIPTION_CUSTOM_TYPE, + type SubsPayload, } from "../src/tools/channel-watch/subscriptions"; import { makeChannelTools } from "../src/tools/channel-watch/tools"; +import { ChannelWatcher } from "../src/tools/channel-watch/watcher"; /** 假 pi:记录 handler / 工具注册 / appendEntry / 唤醒消息 */ function makeFakePi() { @@ -73,13 +80,24 @@ function sleep(ms: number): Promise { return new Promise((r) => setTimeout(r, ms)); } -async function wire(opts: { cwd: string; trusted?: boolean; entries?: unknown[]; enabled?: boolean }) { +async function wire(opts: { + cwd: string; + trusted?: boolean; + entries?: unknown[]; + enabled?: boolean; + onSubscriptionsChanged?: ChannelWatchOptions["onSubscriptionsChanged"]; + readFileHash?: ChannelWatchOptions["readFileHash"]; + watcherFactory?: ChannelWatchOptions["watcherFactory"]; +}) { const pi = makeFakePi(); const ext = makeChannelWatchExtension({ agentDir, cwd: opts.cwd, isEnabled: () => opts.enabled ?? true, notify: (t) => notifications.push(t), + onSubscriptionsChanged: opts.onSubscriptionsChanged, + readFileHash: opts.readFileHash, + watcherFactory: opts.watcherFactory, }); (ext as { factory: (pi: unknown) => void }).factory(pi); const ctx = makeFakeCtx(opts.entries ?? [], opts.trusted ?? true); @@ -87,6 +105,71 @@ async function wire(opts: { cwd: string; trusted?: boolean; entries?: unknown[]; return { pi }; } +/** 频道目录(生产路径 `.local/agent-work/channel/`) */ +function topicDir(cwd: string, topic: string): string { + return join(cwd, ".local/agent-work/channel", topic); +} + +/** 频道主文件 MESSAGES.md */ +function messagesFile(cwd: string, topic: string): string { + return join(topicDir(cwd, topic), "MESSAGES.md"); +} + +/** 造一条 channel-subs entry(不给 cursors = legacy topics-only 形态) */ +function subsEntries(topics: string[], cursors?: Record): unknown[] { + return [ + { + type: "custom", + customType: SUBSCRIPTION_CUSTOM_TYPE, + data: cursors ? { topics, cursors } : { topics }, + }, + ]; +} + +/** 把一条快照包成 entries(模拟「扩展自己 append 的快照 → 再次 resume」) */ +function payloadEntries(payload: SubsPayload): unknown[] { + return [{ type: "custom", customType: SUBSCRIPTION_CUSTOM_TYPE, data: payload }]; +} + +/** 最后一条 appendEntry 的订阅快照 */ +function lastPayload(pi: ReturnType): SubsPayload { + const entry = pi.appended.at(-1); + if (!entry) throw new Error("没有任何 appendEntry 记录"); + return entry.data as SubsPayload; +} + +/** 真实读文件算 hash(文件不存在 → null) */ +async function contentHashOrNull(absPath: string): Promise { + try { + return contentHash(await readFile(absPath, "utf8")); + } catch { + return null; + } +} + +/** + * P1 契约测试接线:watcher 防抖 3s → 20ms(生产默认值不变,只由测试注入), + * 并透传新契约的注入点(订阅快照回调 / hash 读取 / watcher 工厂)。 + */ +async function wireCur(opts: { + cwd: string; + entries?: unknown[]; + trusted?: boolean; + enabled?: boolean; + onSubscriptionsChanged?: ChannelWatchOptions["onSubscriptionsChanged"]; + readFileHash?: ChannelWatchOptions["readFileHash"]; +}) { + let watcherCount = 0; + const { pi } = await wire({ + ...opts, + watcherFactory: (o) => { + watcherCount += 1; + return new ChannelWatcher({ ...o, debounceMs: 20, pollIntervalMs: 20 }); + }, + }); + return { pi, watcherCount: () => watcherCount }; +} + /** 订阅(经工具 execute 全链路) */ async function subscribe(pi: ReturnType, topic: string) { const tool = pi.tools.find((t) => t.name === "channel_subscribe"); @@ -94,6 +177,22 @@ async function subscribe(pi: ReturnType, topic: string) { return (await tool.execute("tc1", { topic })) as { content: Array<{ type: "text"; text: string }> }; } +/** 退订(经工具 execute 全链路) */ +async function unsubscribe(pi: ReturnType, topic: string) { + const tool = pi.tools.find((t) => t.name === "channel_unsubscribe"); + if (!tool) throw new Error("channel_unsubscribe 未注册"); + return (await tool.execute("tu1", { topic })) as { content: Array<{ type: "text"; text: string }> }; +} + +/** 发消息(经工具 execute 全链路) */ +async function post(pi: ReturnType, topic: string, message: string) { + const tool = pi.tools.find((t) => t.name === "channel_post"); + if (!tool) throw new Error("channel_post 未注册"); + return (await tool.execute("tp1", { topic, message })) as { + content: Array<{ type: "text"; text: string }>; + }; +} + describe("subscriptions 持久化", () => { it("buildSubsPayload / restoreSubscriptions 往返;多条 entry last-wins(最后一条整体覆盖)", () => { const payload1 = buildSubsPayload(["b", "a"]); @@ -414,3 +513,265 @@ describe("channel_post", () => { expect(r.content[0]?.text).toContain("受信任"); }, 60_000); }); + +// --------------------------------------------------------------------------- +// P1 契约(spec §6.1/§6.3–§6.6):持久 cursor、离线补投、订阅快照上报、watcher single-flight +// 以下用例为阶段 0 红测:固定契约,实现见 plan 阶段 1~2。 +// --------------------------------------------------------------------------- + +describe("P1 · onSubscriptionsChanged 订阅快照上报", () => { + it("session_start 恢复 / subscribe / unsubscribe / shutdown 各上报一次有效快照(传的是副本)", async () => { + const cwd = join(testRoot, "snapshot-cb"); + const calls: Array<{ id: string; topics: string[] }> = []; + const seen: ReadonlySet[] = []; + const { pi } = await wire({ + cwd, + entries: subsEntries(["t1"]), + onSubscriptionsChanged: (sessionId, topics) => { + calls.push({ id: sessionId, topics: [...topics].sort() }); + seen.push(topics); + }, + }); + expect(calls).toEqual([{ id: "s-test", topics: ["t1"] }]); + + await subscribe(pi, "t2"); + expect(calls.at(-1)).toEqual({ id: "s-test", topics: ["t1", "t2"] }); + + await unsubscribe(pi, "t1"); + expect(calls.at(-1)).toEqual({ id: "s-test", topics: ["t2"] }); + // 防御性副本:先前上报的快照不能随后续变化改动(否则 backend 快照会“自动”跟着变) + expect([...(seen[1] as ReadonlySet)]).toEqual(["t1", "t2"]); + + await pi.emit({ type: "session_shutdown" }, makeFakeCtx()); + expect(calls.at(-1)).toEqual({ id: "s-test", topics: [] }); + }); + + it("disabled / untrusted 上报空集(不受保护的订阅不进入 backend 快照)", async () => { + const offCalls: string[][] = []; + await wire({ + cwd: join(testRoot, "snapshot-off"), + enabled: false, + onSubscriptionsChanged: (_id, topics) => offCalls.push([...topics]), + }); + expect(offCalls.at(-1)).toEqual([]); + + const untrustedCalls: string[][] = []; + await wire({ + cwd: join(testRoot, "snapshot-untrusted"), + trusted: false, + entries: subsEntries(["t1"]), + onSubscriptionsChanged: (_id, topics) => untrustedCalls.push([...topics]), + }); + expect(untrustedCalls.at(-1)).toEqual([]); + }); + + it("回调抛错只记日志:不破坏 subscribe/post/shutdown 生命周期", async () => { + const cwd = join(testRoot, "snapshot-throw"); + let calls = 0; + const { pi } = await wire({ + cwd, + onSubscriptionsChanged: () => { + calls += 1; + throw new Error("回调炸了"); + }, + }); + const r = await subscribe(pi, "t1"); + expect(calls).toBeGreaterThan(0); + expect(r.content[0]?.text).toContain("已订阅频道 [t1]"); + expect(lastPayload(pi).topics).toEqual(["t1"]); + }); +}); + +describe("P1 · 首次订阅基线与 watcher 就绪竞态", () => { + it("首次订阅已有 MESSAGES.md:0 条旧历史提醒,只记基线;订阅建立后内容变化只投一次", async () => { + const cwd = join(testRoot, "first-sub"); + await mkdir(topicDir(cwd, "t1"), { recursive: true }); + const mf = messagesFile(cwd, "t1"); + await writeFile(mf, "old\n"); + + const { pi } = await wireCur({ cwd }); + await subscribe(pi, "t1"); + expect(pi.wakes).toHaveLength(0); + expect(lastPayload(pi).cursors).toEqual({ t1: contentHash("old\n") }); + + await appendFile(mf, "new\n"); + await sleep(200); + expect(pi.wakes).toHaveLength(1); + expect(lastPayload(pi).cursors).toEqual({ t1: contentHash("old\nnew\n") }); + }); + + it("订阅建立竞态:基线读取与 watcher 就绪之间的写入补投一次,且不被随后的 watcher 事件重复投递", async () => { + const cwd = join(testRoot, "sub-race"); + await mkdir(topicDir(cwd, "t1"), { recursive: true }); + const mf = messagesFile(cwd, "t1"); + let reads = 0; + const { pi } = await wireCur({ + cwd, + readFileHash: async (abs) => { + reads += 1; + // 第 1 次读 = 订阅基线(文件尚不存在 → null);第 2 次读 = 订阅后的版本复检 + if (reads === 2 && abs === mf) await writeFile(mf, "race\n"); + return contentHashOrNull(abs); + }, + }); + await subscribe(pi, "t1"); + await sleep(250); + expect(reads).toBeGreaterThanOrEqual(2); + expect(pi.wakes).toHaveLength(1); + expect(lastPayload(pi).cursors).toEqual({ t1: contentHash("race\n") }); + }); + + it("订阅时文件不存在:cursor 记 null(不是 key 缺失)", async () => { + const cwd = join(testRoot, "null-baseline"); + const { pi } = await wireCur({ cwd }); + await subscribe(pi, "t1"); + expect(lastPayload(pi)).toEqual({ topics: ["t1"], cursors: { t1: null } }); + }); +}); + +describe("P1 · 恢复补投(catch-up)", () => { + it("cursor 与当前内容不同 → 补投一条;用推进后的快照再恢复 → 0 条", async () => { + const cwd = join(testRoot, "catchup-once"); + await mkdir(topicDir(cwd, "t1"), { recursive: true }); + await writeFile(messagesFile(cwd, "t1"), "v2\n"); + + const { pi } = await wireCur({ cwd, entries: subsEntries(["t1"], { t1: "stale-hash" }) }); + await sleep(80); + expect(pi.wakes).toHaveLength(1); + expect(pi.wakes[0]).toContain("[channel:t1]"); + expect(lastPayload(pi).cursors).toEqual({ t1: contentHash("v2\n") }); + + await pi.emit({ type: "session_shutdown" }, makeFakeCtx()); + const again = await wireCur({ cwd, entries: payloadEntries(lastPayload(pi)) }); + await sleep(80); + expect(again.pi.wakes).toHaveLength(0); + }); + + it("legacy topics-only(无 cursors key):只建基线不补历史,并持久化升级后的快照", async () => { + const cwd = join(testRoot, "legacy-baseline"); + await mkdir(topicDir(cwd, "t1"), { recursive: true }); + const mf = messagesFile(cwd, "t1"); + await writeFile(mf, "v1\n"); + + const { pi } = await wireCur({ cwd, entries: subsEntries(["t1"]) }); + await sleep(80); + expect(pi.wakes).toHaveLength(0); + expect(lastPayload(pi).cursors).toEqual({ t1: contentHash("v1\n") }); + + // 基线已建立 → 之后的变化照常提醒 + await appendFile(mf, "v2\n"); + await sleep(200); + expect(pi.wakes).toHaveLength(1); + }); + + it("cursor=null 且文件在会话关闭期间创建 → 恢复补投一次", async () => { + const cwd = join(testRoot, "null-then-created"); + const { pi } = await wireCur({ cwd }); + await subscribe(pi, "t1"); + expect(lastPayload(pi).cursors).toEqual({ t1: null }); + await pi.emit({ type: "session_shutdown" }, makeFakeCtx()); + + await mkdir(topicDir(cwd, "t1"), { recursive: true }); + await writeFile(messagesFile(cwd, "t1"), "offline\n"); + + const again = await wireCur({ cwd, entries: payloadEntries(lastPayload(pi)) }); + await sleep(80); + expect(again.pi.wakes).toHaveLength(1); + expect(lastPayload(again.pi).cursors).toEqual({ t1: contentHash("offline\n") }); + }); + + it("文件从存在变为不存在:不唤醒且 cursor 更新为 null;重新创建后再变化可提醒", async () => { + const cwd = join(testRoot, "file-deleted"); + await mkdir(topicDir(cwd, "t1"), { recursive: true }); + const mf = messagesFile(cwd, "t1"); + await writeFile(mf, "v1\n"); + const legacy = subsEntries(["t1"], { t1: contentHash("v1\n") }); + const first = await wireCur({ cwd, entries: legacy }); + await sleep(80); + expect(first.pi.wakes).toHaveLength(0); + await first.pi.emit({ type: "session_shutdown" }, makeFakeCtx()); + + await rm(mf); + const second = await wireCur({ cwd, entries: legacy }); + await sleep(80); + expect(second.pi.wakes).toHaveLength(0); + expect(lastPayload(second.pi).cursors).toEqual({ t1: null }); + + await writeFile(mf, "recreated\n"); + const third = await wireCur({ cwd, entries: payloadEntries(lastPayload(second.pi)) }); + await sleep(80); + expect(third.pi.wakes).toHaveLength(1); + }); + + it("本会话 post 后 shutdown/resume:0 条自我补投(post 主动推进 cursor)", async () => { + const cwd = join(testRoot, "self-post-cursor"); + const { pi } = await wireCur({ cwd }); + await subscribe(pi, "t1"); + await post(pi, "t1", "给自己的备注"); + const persisted = contentHash(await readFile(messagesFile(cwd, "t1"), "utf8")); + expect(lastPayload(pi).cursors).toEqual({ t1: persisted }); + await pi.emit({ type: "session_shutdown" }, makeFakeCtx()); + + const again = await wireCur({ cwd, entries: payloadEntries(lastPayload(pi)) }); + await sleep(200); + expect(again.pi.wakes).toHaveLength(0); + }); + + it("paused 期间的写入不推进 cursor;显式重新 subscribe 后合并补投一次", async () => { + const cwd = join(testRoot, "paused-cursor"); + const { pi } = await wireCur({ cwd }); + await subscribe(pi, "t1"); + const mf = messagesFile(cwd, "t1"); + // 订阅时目录还不存在(首次写入由对端建立):这里先建目录再逐次写入 + await mkdir(topicDir(cwd, "t1"), { recursive: true }); + for (let i = 0; i < 6; i++) { + await writeFile(mf, `v${i}\n`); + await sleep(150); + } + // 前 6 次投递,第 6 次触发乒乓暂停 + expect(pi.wakes).toHaveLength(6); + expect(lastPayload(pi).cursors).toEqual({ t1: contentHash("v5\n") }); + + await writeFile(mf, "v6\n"); + await sleep(150); + await writeFile(mf, "v7\n"); + await sleep(150); + expect(pi.wakes).toHaveLength(6); // 暂停中不投递 + expect(lastPayload(pi).cursors).toEqual({ t1: contentHash("v5\n") }); // 也不推进 cursor + + await subscribe(pi, "t1"); // 显式重新订阅 → 恢复暂停 + 合并补投一次 + await sleep(80); + expect(pi.wakes).toHaveLength(7); + expect(lastPayload(pi).cursors).toEqual({ t1: contentHash("v7\n") }); + }); + + it("unsubscribe 删除 cursor(不给幽灵复活留基线)", async () => { + const cwd = join(testRoot, "unsub-cursor"); + const { pi } = await wireCur({ cwd }); + await subscribe(pi, "t1"); + await subscribe(pi, "t2"); + await unsubscribe(pi, "t1"); + expect(lastPayload(pi).topics).toEqual(["t2"]); + expect(lastPayload(pi).cursors).not.toHaveProperty("t1"); + }); +}); + +describe("P1 · watcher single-flight", () => { + it("并发 subscribe 只创建一个 watcher;同一内容变化不重复投递", async () => { + const cwd = join(testRoot, "single-flight"); + const { pi, watcherCount } = await wireCur({ cwd }); + const tool = pi.tools.find((t) => t.name === "channel_subscribe"); + if (!tool) throw new Error("channel_subscribe 未注册"); + await Promise.all([ + tool.execute("c1", { topic: "t1" }), + tool.execute("c2", { topic: "t1" }), + tool.execute("c3", { topic: "t2" }), + ]); + expect(watcherCount()).toBe(1); + + await mkdir(topicDir(cwd, "t1"), { recursive: true }); + await writeFile(messagesFile(cwd, "t1"), "hello\n"); + await sleep(250); + expect(pi.wakes.filter((w) => w.includes("[channel:t1]"))).toHaveLength(1); + }); +}); diff --git a/packages/backend/test/session-close-guard.test.ts b/packages/backend/test/session-close-guard.test.ts index 95553d24..2fe5228b 100644 --- a/packages/backend/test/session-close-guard.test.ts +++ b/packages/backend/test/session-close-guard.test.ts @@ -15,7 +15,7 @@ import type { SessionRegistry } from "../src/session/registry"; const root = mkdtempSync(join(tmpdir(), "pi-close-guard-")); afterAll(() => rmSync(root, { recursive: true, force: true })); -function makeBackend(sessionId: string, streaming: boolean, sessionFile?: string) { +function makeBackend(sessionId: string, streaming: boolean, sessionFile?: string, topics: string[] = []) { const backend = new PiBackend({ projectTrust: false, permissionGates: false }); const registry = (backend as unknown as { registry: SessionRegistry }).registry; const dispose = vi.fn(); @@ -33,6 +33,8 @@ function makeBackend(sessionId: string, streaming: boolean, sessionFile?: string dialogs: { dispose: () => {} }, modeRef: { get: () => "default" }, } as never); + // 频道订阅快照(生产路径:channel-watch 扩展经 session_start/subscribe/unsubscribe 回调上报) + backend.reportChannelSubscriptions(sessionId, new Set(topics)); return { backend, registry, dispose }; } @@ -73,3 +75,72 @@ describe("PiBackend.deleteSession 绕开守卫", () => { await expect(backend.closeSession("s4")).resolves.toEqual({ closed: true }); // 删完再关 = 幂等 }); }); + +// --------------------------------------------------------------------------- +// P0(spec channel-watch-retention-catchup §6.2):GC 意图守卫 + 订阅快照查询 +// 以下用例为阶段 0 红测:固定契约,实现见 plan 阶段 1.1~1.2。 +// --------------------------------------------------------------------------- + +describe("PiBackend · GC 意图(intent)与频道订阅守卫", () => { + it("intent:'gc' + 会话有有效订阅 → 拒绝:closed=false、不 dispose、会话仍在 registry", async () => { + const { backend, registry, dispose } = makeBackend("g1", false, undefined, ["t1"]); + await expect(backend.closeSession("g1", "gc")).resolves.toEqual({ closed: false }); + expect(dispose).not.toHaveBeenCalled(); + expect(registry.has("g1")).toBe(true); + }); + + it("intent:'user' / 缺省 + 有订阅 → 正常关(用户明确意图不被订阅挡)", async () => { + const asUser = makeBackend("g2", false, undefined, ["t1"]); + await expect(asUser.backend.closeSession("g2", "user")).resolves.toEqual({ closed: true }); + expect(asUser.dispose).toHaveBeenCalledTimes(1); + + const asDefault = makeBackend("g3", false, undefined, ["t1"]); + await expect(asDefault.backend.closeSession("g3")).resolves.toEqual({ closed: true }); + expect(asDefault.dispose).toHaveBeenCalledTimes(1); + }); + + it("intent:'gc' + 已退订(无订阅)→ 正常关(退订最后一个 topic 即恢复 GC 资格)", async () => { + const { backend, registry, dispose } = makeBackend("g4", false); + await expect(backend.closeSession("g4", "gc")).resolves.toEqual({ closed: true }); + expect(dispose).toHaveBeenCalledTimes(1); + expect(registry.has("g4")).toBe(false); + }); + + it("intent:'gc' + 有订阅 + agent 在跑 → 照样拒绝(streaming 门不受影响)", async () => { + const { backend, dispose } = makeBackend("g5", true, undefined, ["t1"]); + await expect(backend.closeSession("g5", "gc")).resolves.toEqual({ closed: false }); + expect(dispose).not.toHaveBeenCalled(); + }); + + it("有订阅的会话显式 delete 仍生效(明确用户意图优先于订阅保护)", async () => { + const dir = join(root, "proj-sub"); + mkdirSync(dir, { recursive: true }); + const file = join(dir, "g6.jsonl"); + writeFileSync(file, ""); + const { backend, registry, dispose } = makeBackend("g6", true, file, ["t1"]); + + await backend.deleteSession("g6", file); + + expect(dispose).toHaveBeenCalledTimes(1); + expect(registry.has("g6")).toBe(false); + }); +}); + +describe("PiBackend · 频道订阅快照查询", () => { + it("上报非空进快照、空集移出(disabled/untrusted 上报空集即解除保护)", () => { + const { backend } = makeBackend("s1", false, undefined, ["t1", "t2"]); + makeBackend("s2", false, undefined, []); + expect(backend.getChannelSubscriptionSessionIds()).toEqual(["s1"]); + + backend.reportChannelSubscriptions("s1", new Set(["t1"])); + expect(backend.getChannelSubscriptionSessionIds()).toEqual(["s1"]); + backend.reportChannelSubscriptions("s1", new Set()); + expect(backend.getChannelSubscriptionSessionIds()).toEqual([]); + }); + + it("dispose 后无残留(扩展 shutdown 回调之外的双保险)", async () => { + const { backend } = makeBackend("s3", false, undefined, ["t1"]); + await backend.closeSession("s3", "user"); + expect(backend.getChannelSubscriptionSessionIds()).toEqual([]); + }); +}); diff --git a/packages/desktop/src/renderer/src/lib/session-gc.test.ts b/packages/desktop/src/renderer/src/lib/session-gc.test.ts index 5583beb4..46a5ada7 100644 --- a/packages/desktop/src/renderer/src/lib/session-gc.test.ts +++ b/packages/desktop/src/renderer/src/lib/session-gc.test.ts @@ -230,3 +230,33 @@ describe("pickUnloadCandidates · 兜底超时与边界", () => { expect(pickUnloadCandidates(input({ activeSessionId: null, open: [] }))).toEqual([]); }); }); + +// --------------------------------------------------------------------------- +// P0(spec channel-watch-retention-catchup §6.2):有频道订阅的会话不被自动 GC +// 以下用例为阶段 0 红测:固定契约,实现见 plan 阶段 1.3。 +// --------------------------------------------------------------------------- + +describe("pickUnloadCandidates · 频道订阅保护(P0)", () => { + it("订阅会话不成为候选,也不占 K 名额(超 K 只卸别人)", () => { + const open = [ + // 订阅者是最久未用的那个:若没被剔除,它一定是 first 候选 + session("sub", NOW - 10_000_000, { hasChannelSubscriptions: true }), + session("b", NOW - 60_000), + session("c", NOW - 40_000), + session("d", NOW - 20_000), + ]; + expect(ids({ open })).toEqual([]); // 3 个可卸空闲正好 K=3,订阅者不算名额 + expect(ids({ open: [...open, session("e", NOW - 15_000)] })).toEqual(["b"]); + }); + + it("晾过 idleTimeoutMs 的订阅会话照样不卸(订阅 = 明确驻留语义)", () => { + const stale = NOW - (GC_DEFAULTS.idleTimeoutMs + 1); + expect(ids({ open: [session("sub", stale, { hasChannelSubscriptions: true })] })).toEqual([]); + }); + + it("isProtected 是保护条件的唯一事实源(订阅条件也在这里)", () => { + expect(isProtected(session("a", NOW, { hasChannelSubscriptions: true }), IDLE_ENTRY)).toBe(true); + expect(isProtected(session("a", NOW, { hasChannelSubscriptions: false }), IDLE_ENTRY)).toBe(false); + expect(isProtected(session("a", NOW), IDLE_ENTRY)).toBe(false); // 缺省 = 无订阅 + }); +}); diff --git a/packages/desktop/src/renderer/src/lib/session-gc.ts b/packages/desktop/src/renderer/src/lib/session-gc.ts index 6c4e66b9..379e11b2 100644 --- a/packages/desktop/src/renderer/src/lib/session-gc.ts +++ b/packages/desktop/src/renderer/src/lib/session-gc.ts @@ -44,6 +44,11 @@ export interface SessionGcOpen { isDraft: boolean; /** 消息条数(磁盘元数据,打开时读一次):与 transcript 条数取大值判断「有没有会话文件」 */ messageCount: number; + /** + * 该会话在 backend 有有效频道订阅(GC 每轮拉一次订阅会话 ID 快照算出)。 + * 缺省 false = 不受保护;保护判定见 `isProtected`(阶段 1 接入)。 + */ + hasChannelSubscriptions?: boolean; } export interface SessionGcInput { diff --git a/packages/desktop/src/renderer/src/stores/sessions.test.ts b/packages/desktop/src/renderer/src/stores/sessions.test.ts index bd91d2aa..443ffea7 100644 --- a/packages/desktop/src/renderer/src/stores/sessions.test.ts +++ b/packages/desktop/src/renderer/src/stores/sessions.test.ts @@ -544,3 +544,26 @@ describe("记住上次项目目录(lastCwd)", () => { expect(piMock.saveUiState).not.toHaveBeenCalled(); }); }); + +// --------------------------------------------------------------------------- +// P0(spec channel-watch-retention-catchup §6.2):自动卸载与用户关闭走不同 intent +// 阶段 0 红测:固定契约,实现见 plan 阶段 1.2。 +// --------------------------------------------------------------------------- + +describe("unloadSession(自动 GC)的 intent 标记", () => { + it("自动卸载传 intent:'gc'(后端据此区分自动 GC 与用户意图,只对 GC 做订阅守卫)", async () => { + useSessionsStore.setState({ sessions: [realMeta("r1", "/proj/a")], activeSessionId: null }); + + await useSessionsStore.getState().unloadSession("r1"); + + expect(piMock.closeSession).toHaveBeenCalledWith({ sessionId: "r1", intent: "gc" }); + }); + + it("用户主动关闭不传 intent(保持既有语义:订阅不挡用户)", async () => { + useSessionsStore.setState({ sessions: [realMeta("r2", "/proj/a")], activeSessionId: null }); + + await useSessionsStore.getState().closeSession("r2"); + + expect(piMock.closeSession).toHaveBeenCalledWith({ sessionId: "r2" }); + }); +}); From 6f4153b9a1e9eb63ce449edcad72cda168df6962 Mon Sep 17 00:00:00 2001 From: Jaxton07 Date: Sun, 20 Sep 2026 17:00:37 +0800 Subject: [PATCH 2/8] =?UTF-8?q?test(channel-watch):=20=E9=98=B6=E6=AE=B5?= =?UTF-8?q?=200.1=20=E2=80=94=20=E8=A1=A5=20review=20=E6=8C=87=E5=87=BA?= =?UTF-8?q?=E7=9A=84=E4=B8=89=E5=A4=84=E7=BA=A2=E6=B5=8B=EF=BC=88=E6=B8=B8?= =?UTF-8?q?=E6=A0=87=E8=A7=A3=E6=9E=90=E5=A5=91=E7=BA=A6/=E8=87=AA?= =?UTF-8?q?=E5=86=99=E6=8E=A8=E8=BF=9B=E6=B8=B8=E6=A0=87/=E5=AD=A4?= =?UTF-8?q?=E5=84=BF=E8=AE=A2=E9=98=85=20ID=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../backend/src/tools/channel-watch/index.ts | 9 +- .../src/tools/channel-watch/subscriptions.ts | 24 +++- .../test/channel-watch-extension.test.ts | 117 ++++++++++++++++++ .../backend/test/session-close-guard.test.ts | 6 + 4 files changed, 154 insertions(+), 2 deletions(-) diff --git a/packages/backend/src/tools/channel-watch/index.ts b/packages/backend/src/tools/channel-watch/index.ts index eced33f6..fb5ed34a 100644 --- a/packages/backend/src/tools/channel-watch/index.ts +++ b/packages/backend/src/tools/channel-watch/index.ts @@ -25,6 +25,13 @@ export { topicDir, validateTopic, } from "./init"; -export { buildSubsPayload, restoreSubscriptions, SUBSCRIPTION_CUSTOM_TYPE } from "./subscriptions"; +export { + buildSubsPayload, + type RestoredSubscriptions, + restoreSubscriptionState, + restoreSubscriptions, + SUBSCRIPTION_CUSTOM_TYPE, + type SubsPayload, +} from "./subscriptions"; export { makeChannelTools } from "./tools"; export { ChannelWatcher, parseWatchFilename } from "./watcher"; diff --git a/packages/backend/src/tools/channel-watch/subscriptions.ts b/packages/backend/src/tools/channel-watch/subscriptions.ts index 40b976fb..26a5ec38 100644 --- a/packages/backend/src/tools/channel-watch/subscriptions.ts +++ b/packages/backend/src/tools/channel-watch/subscriptions.ts @@ -22,10 +22,32 @@ export interface SubsPayload { } /** 构造 appendEntry 载荷(全量快照,排序稳定便于测试) */ -export function buildSubsPayload(topics: Iterable): SubsPayload { +export function buildSubsPayload( + topics: Iterable, + cursors?: ReadonlyMap, +): SubsPayload { + // 阶段 0.1 占位 seam:cursors 载荷生成见 plan 阶段 2.1(此处不实现) + void cursors; return { topics: [...topics].sort() }; } +/** + * 恢复结果:topics + 每个 topic 的 cursor。 + * **`cursors` 里没有 key = 未知基线(旧 payload)**,与值 `null`(确认当时文件不存在)语义不同。 + */ +export interface RestoredSubscriptions { + topics: string[]; + cursors: Map; +} + +/** + * 从会话 entries 恢复「订阅 + 游标」完整状态(spec §6.3/§6.4)。 + * 阶段 0.1 占位 seam:真实解析(last-wins / 脏值容错 / missing≠null)见 plan 阶段 2.1。 + */ +export function restoreSubscriptionState(_entries: unknown): RestoredSubscriptions { + throw new Error("restoreSubscriptionState 尚未实现(plan 阶段 2.1)"); +} + /** * 从会话 entries 恢复订阅集。entries 为 SDK SessionEntry 数组(含 message/custom 等), * 取**最后一条** customType 匹配的 data.topics(last-wins:退订后 append 过空集合, diff --git a/packages/backend/test/channel-watch-extension.test.ts b/packages/backend/test/channel-watch-extension.test.ts index eff63faf..d04d71a7 100644 --- a/packages/backend/test/channel-watch-extension.test.ts +++ b/packages/backend/test/channel-watch-extension.test.ts @@ -11,6 +11,7 @@ import { contentHash } from "../src/tools/channel-watch/guard"; import { formatPostEntry } from "../src/tools/channel-watch/post"; import { buildSubsPayload, + restoreSubscriptionState, restoreSubscriptions, SUBSCRIPTION_CUSTOM_TYPE, type SubsPayload, @@ -519,6 +520,93 @@ describe("channel_post", () => { // 以下用例为阶段 0 红测:固定契约,实现见 plan 阶段 1~2。 // --------------------------------------------------------------------------- +describe("P1 · restoreSubscriptionState 纯解析契约(spec §6.3)", () => { + it("payload 往返:带 cursors 的快照排序稳定,且只保留仍在 topics 中的 key", () => { + const payload = buildSubsPayload( + ["b", "a"], + new Map([ + ["b", "h2"], + ["a", null], + ["gone", "h9"], + ]), + ); + expect(payload).toEqual({ topics: ["a", "b"], cursors: { a: null, b: "h2" } }); + + const restored = restoreSubscriptionState(payloadEntries(payload)); + expect(restored.topics).toEqual(["a", "b"]); + expect(restored.cursors.has("a")).toBe(true); + expect(restored.cursors.get("a")).toBeNull(); + expect(restored.cursors.get("b")).toBe("h2"); + }); + + it("last-wins:最后一条合法 entry 同时覆盖 topics 与 cursors(旧 cursor 不残留)", () => { + const restored = restoreSubscriptionState([ + { + type: "custom", + customType: SUBSCRIPTION_CUSTOM_TYPE, + data: { topics: ["a"], cursors: { a: "h1" } }, + }, + { + type: "custom", + customType: SUBSCRIPTION_CUSTOM_TYPE, + data: { topics: ["b"], cursors: { b: "h2" } }, + }, + ]); + expect(restored.topics).toEqual(["b"]); + expect([...restored.cursors]).toEqual([["b", "h2"]]); + }); + + it("游标 key 缺失(legacy topics-only)与显式 null 可区分", () => { + const legacy = restoreSubscriptionState(subsEntries(["a"])); + expect(legacy.topics).toEqual(["a"]); + expect(legacy.cursors.size).toBe(0); // 未知基线:首次恢复不补历史 + expect(legacy.cursors.has("a")).toBe(false); + + const explicitNull = restoreSubscriptionState(subsEntries(["a"], { a: null })); + expect(explicitNull.cursors.has("a")).toBe(true); + expect(explicitNull.cursors.get("a")).toBeNull(); // 已知「当时文件不存在」:之后创建要补投 + }); + + it("脏 cursor 值被忽略,但合法 topics 不能因此丢失", () => { + const restored = restoreSubscriptionState([ + { + type: "custom", + customType: SUBSCRIPTION_CUSTOM_TYPE, + data: { topics: ["a", "b", "", 7], cursors: { a: 123, b: "h" } }, + }, + ]); + expect(restored.topics).toEqual(["a", "b"]); + expect([...restored.cursors]).toEqual([["b", "h"]]); + }); + + it("不在最终 topics 中的 cursor key 被过滤(退订不留幽灵基线)", () => { + const restored = restoreSubscriptionState([ + { + type: "custom", + customType: SUBSCRIPTION_CUSTOM_TYPE, + data: { topics: ["a"], cursors: { a: "h1", gone: "h9" } }, + }, + ]); + expect(restored.topics).toEqual(["a"]); + expect([...restored.cursors]).toEqual([["a", "h1"]]); + }); + + it("中途非法 entry 不覆盖此前合法完整快照(既有 last-wins 纪律)", () => { + const restored = restoreSubscriptionState([ + { + type: "custom", + customType: SUBSCRIPTION_CUSTOM_TYPE, + data: { topics: ["a"], cursors: { a: "h1" } }, + }, + { type: "custom", customType: SUBSCRIPTION_CUSTOM_TYPE }, // data 缺失 + { type: "custom", customType: SUBSCRIPTION_CUSTOM_TYPE, data: { topics: "oops" } }, // topics 非数组 + { type: "message", message: { role: "user", content: "hi" } }, // 非订阅 entry + ]); + expect(restored.topics).toEqual(["a"]); + expect([...restored.cursors]).toEqual([["a", "h1"]]); + }); +}); + describe("P1 · onSubscriptionsChanged 订阅快照上报", () => { it("session_start 恢复 / subscribe / unsubscribe / shutdown 各上报一次有效快照(传的是副本)", async () => { const cwd = join(testRoot, "snapshot-cb"); @@ -717,6 +805,35 @@ describe("P1 · 恢复补投(catch-up)", () => { expect(again.pi.wakes).toHaveLength(0); }); + it("本会话 write/edit 自写被抑制时仍推进并持久化 cursor;shutdown/resume 不自唤醒", async () => { + const cwd = join(testRoot, "self-write-cursor"); + const { pi } = await wireCur({ cwd }); + await subscribe(pi, "t1"); + await subscribe(pi, "t2"); + + for (const [topic, toolName] of [ + ["t1", "write"], + ["t2", "edit"], + ] as const) { + await mkdir(topicDir(cwd, topic), { recursive: true }); + const mf = messagesFile(cwd, topic); + // 模拟本会话 write/edit 工具调用(tool_call 事件)+ 真实落盘 + await pi.emit( + { type: "tool_call", toolName, toolCallId: `tc-${topic}`, input: { path: mf, content: "x" } }, + makeFakeCtx(), + ); + await writeFile(mf, "x\n"); + await sleep(200); + expect(pi.wakes.filter((w) => w.includes(`[channel:${topic}]`))).toHaveLength(0); + expect(lastPayload(pi).cursors?.[topic]).toBe(contentHash("x\n")); + } + + await pi.emit({ type: "session_shutdown" }, makeFakeCtx()); + const again = await wireCur({ cwd, entries: payloadEntries(lastPayload(pi)) }); + await sleep(200); + expect(again.pi.wakes).toHaveLength(0); + }); + it("paused 期间的写入不推进 cursor;显式重新 subscribe 后合并补投一次", async () => { const cwd = join(testRoot, "paused-cursor"); const { pi } = await wireCur({ cwd }); diff --git a/packages/backend/test/session-close-guard.test.ts b/packages/backend/test/session-close-guard.test.ts index 2fe5228b..730f8db7 100644 --- a/packages/backend/test/session-close-guard.test.ts +++ b/packages/backend/test/session-close-guard.test.ts @@ -138,6 +138,12 @@ describe("PiBackend · 频道订阅快照查询", () => { expect(backend.getChannelSubscriptionSessionIds()).toEqual([]); }); + it("查询只返回仍在 registry 的会话:孤儿键(回调/构造失败残留)不得影响 GC", () => { + const { backend } = makeBackend("alive", false, undefined, ["t1"]); + backend.reportChannelSubscriptions("ghost", new Set(["t1"])); + expect(backend.getChannelSubscriptionSessionIds()).toEqual(["alive"]); + }); + it("dispose 后无残留(扩展 shutdown 回调之外的双保险)", async () => { const { backend } = makeBackend("s3", false, undefined, ["t1"]); await backend.closeSession("s3", "user"); From 7953073a7f1d97d5a6628c707b41edeb2a371185 Mon Sep 17 00:00:00 2001 From: Jaxton07 Date: Sun, 20 Sep 2026 17:07:15 +0800 Subject: [PATCH 3/8] =?UTF-8?q?feat(channel-watch):=20=E9=98=B6=E6=AE=B5?= =?UTF-8?q?=201=20=E2=80=94=20P0=20=E8=AE=A2=E9=98=85=E4=BC=9A=E8=AF=9D?= =?UTF-8?q?=E4=B8=8D=E8=A2=AB=E8=87=AA=E5=8A=A8=20GC=EF=BC=88=E8=AE=A2?= =?UTF-8?q?=E9=98=85=E5=BF=AB=E7=85=A7=20+=20=E6=9F=A5=E8=AF=A2=20IPC=20+?= =?UTF-8?q?=20gc=20intent=20=E5=AE=88=E5=8D=AB=20+=20renderer=20fail-safe?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/INDEX.md | 11 +- packages/backend/src/pi-backend.ts | 33 +++- .../src/tools/channel-watch/extension.ts | 47 +++++- packages/desktop/src/main/ipc/sessions.ts | 3 +- .../renderer/src/lib/session-gc-run.test.ts | 148 ++++++++++++++++++ .../src/renderer/src/lib/session-gc-run.ts | 81 ++++++++++ .../src/renderer/src/lib/session-gc.ts | 3 + .../src/renderer/src/lib/use-session-gc.ts | 55 +++---- .../src/renderer/src/stores/sessions.ts | 22 +-- packages/shared/src/ipc-channels.ts | 14 +- packages/shared/src/session.ts | 7 + 11 files changed, 369 insertions(+), 55 deletions(-) create mode 100644 packages/desktop/src/renderer/src/lib/session-gc-run.test.ts create mode 100644 packages/desktop/src/renderer/src/lib/session-gc-run.ts diff --git a/docs/INDEX.md b/docs/INDEX.md index 2d10c4ec..67361595 100644 --- a/docs/INDEX.md +++ b/docs/INDEX.md @@ -90,7 +90,7 @@ 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`。`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 不挂会话)。`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`) | @@ -121,7 +121,7 @@ src/ | `src/tools/todo.ts` + `todo-reminder.ts` | `makeTodoTool`、`makeTodoReminderExtension` | todo 工具(全量替换协议,`details.todos` 供 UI)+ 恢复扩展(context 钩子注入 `customType:"todo-reminder"`——用 context 而非 before_agent_start:overflow willRetry 同 run 重试也覆盖) | | `src/tools/subagent/` | `makeSubagentTool`、`runSubagent`、`applySubagentMutex`、`resolveSubagentPreferBuiltin`、`discoverAgents` | 内置进程内 subagent:single `{agent,task}` + parallel `{tasks[]}` cap 8/并发 4;子代理 = 共享 modelRuntime 的隔离 `AgentSession`(资源最小化 + 权限桥父 gate,完成等 `agent_settled`);**思考档位链**:设置页覆盖 > agent frontmatter > SDK 默认链(显式传 `thinkingLevel`,实际生效值回传 `SingleResult`);会话文件落 `sessions-subagents/`(打开即只读);**参数 schema 必须拍平单 object**(DeepSeek 对顶层无 `type:"object"` 直接 400,严禁顶层 Union/Intersect);互斥 = 同名覆盖 + `subagent_*` 家族停用(发 `subagent_mutex` 事件);runner 转发子会话原生事件(检视页实时收流);agent 定义:内置 scout → user `~/.pi/agent/agents/` → project `.pi/agents/`(仅 trusted) | | `src/tools/context-evaporation/` | `makeEvapExtension`、`readContextManagerMode`、`writeContextManagerMode` | 内置上下文蒸发扩展(**默认开启**,二态蒸发/off):context 钩子把到龄工具输出按四级水位线蒸发为 stub(零 LLM、决策单调持久化保 KV cache);核心三文件(types/estimate/evaporate)零 SDK/零仓库 import(replay `--core` 同构验证);二态配置单 key 单一写者(写侧顺带清遗留键);调参走 `scripts/replay-evaporation.mts`;批次观测 = log + trace_custom 行 | -| `src/tools/channel-watch/` | `makeChannelWatchExtension` | 内置跨会话频道协作扩展(默认开):订阅 `.local/agent-work/channel//`,**消息 = 意图:写文件 ≠ 通知,channel_post 工具才唤醒**(根治一次任务写 N 文件 = N 唤醒);防环三层(自写窗口 + hash 去重 + 乒乓上限暂停);`closed:true` 终态退订;分模块 config/init/guard/watcher/subscriptions/post/tools/extension;配套 skill channel-pickup/design-handoff | +| `src/tools/channel-watch/` | `makeChannelWatchExtension` | 内置跨会话频道协作扩展(默认开):订阅 `.local/agent-work/channel//`,**消息 = 意图:写文件 ≠ 通知,channel_post 工具才唤醒**(根治一次任务写 N 文件 = N 唤醒);防环三层(自写窗口 + hash 去重 + 乒乓上限暂停);`closed:true` 终态退订;**有效订阅快照上报 backend**(`onSubscriptionsChanged`:enabled+trusted 才计入,disabled/untrusted/shutdown 上报空集,回调异常只记日志)→ 有订阅的会话不被 renderer 自动 GC;分模块 config/init/guard/watcher/subscriptions/post/tools/extension;配套 skill channel-pickup/design-handoff | | `src/settings/settings.ts` | `SettingsService` | provider/模型/凭证读写(key 走环境变量引用,绝不落明文)。listProviders 默认本地 refresh(`allowNetwork:false`),显式 forceNetwork 才联网;custom provider 增改走 `buildCustomEntry`(未设字段不落盘);模型列表留空 = 覆写 baseUrl 共享官方列表;`setProviderBaseUrl` = 内置 provider 端点覆写专用;移除凭证走 `runtime.logout()`(直接删文件残留内存态);`apiKeyLogin` 标记 = 内置 provider 有交互式 api_key 登录(UI 显示「登录」入口) | | `src/settings/model-prefs.ts` | `ModelPrefsService` | `model-prefs.json`:隐藏模型 + 停用 provider + per-agent 子代理模型/思考深度(读侧白名单,空 map 不落盘)+ `subagentPreferBuiltin` 执行器开关(缺省/脏值 = true);`listModels()` 唯一出口过滤 | | `src/settings/login.ts` | `LoginService` | provider 交互登录桥接:AuthInteraction → IPC 事件(prompt 挂起等 renderer 应答;浏览器先到则拒挂起 prompt)。支持 OAuth + api_key 交互登录(如 Google Vertex);`filterAuthSelectOptions` 对 google-vertex 剔除必败的 api-key 选项(Vertex 不接受 API key,见 PITFALLS) | @@ -176,10 +176,11 @@ src/ | `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/session-gc.ts` | 会话内存驻留策略(**纯函数,有单测**):`pickUnloadCandidates`(保护/草稿/活跃/刚用过 → 剔除;剩余按最久未用排序,保留 K 个,超的与晾过 `idleTimeoutMs` 的一并作候选)+ `isProtected`(保护条件唯一事实源:draft / 0 消息 / agentActive / 待审批 / 待应答 / 未读绿点 / 压缩中 / 排队跟发;**权限模式曾在清单里,D7 改成按会话持久化+打开时恢复后已移除**)+ `GC_DEFAULTS`(K=3、fresh **3s**、idle 5min)。不 import React / 不调 IPC | -| `lib/use-session-gc.ts` | 内存策略接线层:挂载跑一次 + **20s** 兜底 tick(实测:仅靠它时收敛延迟 ≤ 一个 tick) + 只订阅 `sessions`/`activeSessionId`(**不订阅 `transcript.bySession`**,它每 token 都变)+ 卸载前逐个再确认(活跃/已关就跳过)+ dev 打 `[session-gc] unload reason=...`;K=3 常量在这里 | +| `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`(自动卸载专用入口,受保护会话由纯策略层排除),接线在 `lib/use-session-gc.ts`(App 挂一次) | +| `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/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`(撤回回填后聚焦输入框) | diff --git a/packages/backend/src/pi-backend.ts b/packages/backend/src/pi-backend.ts index bd927060..ec082bb5 100644 --- a/packages/backend/src/pi-backend.ts +++ b/packages/backend/src/pi-backend.ts @@ -36,6 +36,7 @@ import type { PermissionRequest, PermissionResolved, QuotaInfo, + SessionCloseIntent, SessionEvent, SessionMessage, SessionMeta, @@ -312,7 +313,14 @@ export class PiBackend { ); // channel-watch 跨会话协作(开关默认开;session_start 检查开关 + trusted 门, // 无订阅时零 fs 监听;钩子与蒸发/todo 无语义交互,位置不敏感,放 todo 前) - factories.push(makeChannelWatchExtension({ agentDir: getAgentDir(), cwd })); + // 订阅快照回报给 backend:有有效订阅的会话不被 renderer 自动 GC 卸载(retention) + factories.push( + makeChannelWatchExtension({ + agentDir: getAgentDir(), + cwd, + onSubscriptionsChanged: (sessionId, topics) => this.reportChannelSubscriptions(sessionId, topics), + }), + ); // todo-reminder 最后:compaction 后恢复注入的任务列表不被上游折叠 factories.push(makeTodoReminderExtension()); return factories; @@ -545,17 +553,22 @@ export class PiBackend { else this.channelSubscriptionIds.set(sessionId, new Set(topics)); } - /** renderer GC 查询用(spec §6.2):当前有有效频道订阅的会话 ID(顺序不构成契约) */ + /** + * renderer GC 查询用(spec §6.2):**仍在 registry 中**且有效频道订阅非空的会话 ID + * (顺序不构成契约)。按 registry 过滤是兜底:回调/构造异常路径可能留下孤儿键, + * 不能让一个已不存在的会话 ID 永久影响 GC 判定。 + */ getChannelSubscriptionSessionIds(): string[] { - return [...this.channelSubscriptionIds.keys()]; + return [...this.channelSubscriptionIds.keys()].filter((id) => this.registry.has(id)); } /** - * 关会话(**用户主动关**):agent 还在跑(含等审批)时拒绝——run 中途把后端会话 dispose 会直接掐断本轮, + * 关会话(默认**用户主动关**):agent 还在跑(含等审批)时拒绝——run 中途把后端会话 dispose 会直接掐断本轮, * 内存策略(自动卸载)靠这道兵底兼固(策略层已保护,这里是最后一道门)。 - * 自动卸载走同一个方法:行为与主动关完全一致,只是调用点分开(renderer 侧 `unloadSession`)。 + * 自动卸载走同一个方法:行为与主动关基本一致,只是调用点分开(renderer 侧 `unloadSession`,intent="gc"), + * 且 intent="gc" 时多一道频道订阅守卫(订阅场景下卸了就收不到唤醒——这是显式订阅语义,见 spec §6.2)。 */ - async closeSession(sessionId: string, _intent?: "user" | "gc"): Promise<{ closed: boolean }> { + async closeSession(sessionId: string, intent: SessionCloseIntent = "user"): Promise<{ closed: boolean }> { const entry = this.registry.get(sessionId); // 没有该会话 = 它本来就没在跑(幂等成功):调用方该照常清掉自己的会话条目 if (!entry) return { closed: true }; @@ -564,6 +577,12 @@ export class PiBackend { log.warn("closeSession ignored: streaming", sessionId); return { closed: false }; } + // 自动 GC 不得卸掉有频道订阅的会话(快照按 sessionId 精确查,退订后自然恢复资格)。 + // 显式关(intent="user"/缺省,含 delete 路径)不受此限:用户意图优先,订阅也不能绑架用户。 + if (intent === "gc" && this.channelSubscriptionIds.has(sessionId)) { + log.info("closeSession ignored: channel subscriptions", sessionId); + return { closed: false }; + } await this.disposeSession(entry); return { closed: true }; } @@ -575,6 +594,8 @@ export class PiBackend { // entry 级清理:unsubscribe + gate/dialogs dispose(pending 对话框按 sessionClosed 结算, // 广播 resolved 让 renderer 撤卡;扩展 Promise 落取消值) this.registry.delete(sessionId); + // 订阅快照兜底清理(正常路径 shutdown 会先上报空集;这里接住 session_start 前/异常路径) + this.channelSubscriptionIds.delete(sessionId); // 全局键控子系统(本就独立于 registry) this.streamGuard.cleanup(sessionId); this.eventRates.delete(sessionId); diff --git a/packages/backend/src/tools/channel-watch/extension.ts b/packages/backend/src/tools/channel-watch/extension.ts index 492ce3bc..f2d31625 100644 --- a/packages/backend/src/tools/channel-watch/extension.ts +++ b/packages/backend/src/tools/channel-watch/extension.ts @@ -19,7 +19,7 @@ const log = createLogger("channel-watch"); * * 接线一览(钩子全 try/catch 绝不 throw): * - session_start:开关 → trusted 门 → 目录协议 init(首次 notify)→ 恢复订阅(appendEntry) - * → 非空订阅惰性起 watcher → 注册三工具(幂等) + * → 非空订阅惰性起 watcher → 注册三工具(幂等)→ 上报有效订阅快照(enabled+trusted 才计入) * - input:真人/rpc 消息介入 → 清乒乓计数(source!=="extension") * - tool_call:write/edit 目标 → guard.markSelfWrite(自写抑制) * - watcher onEvent:订阅过滤 → 仅 MESSAGES.md → 读文件 hash → guard.shouldDeliver(自写/hash/暂停) @@ -111,6 +111,37 @@ export function makeChannelWatchExtension(options: ChannelWatchOptions): InlineE } }; + /** 完整 sessionId(上报 backend 订阅快照的 key,必须与 registry 一致;取不到不算错) */ + const fullSessionId = (): string | undefined => { + try { + return lastCtx?.sessionManager.getSessionId(); + } catch { + return undefined; + } + }; + + /** + * 上报「有效运行态订阅」快照(spec §6.1):只有 enabled + trusted 才计入—— + * 未启用/未受信任的会话根本收不到唤醒,不该因此被永久排除在内存回收之外。 + * 只传副本(调用方不能反向改到本闭包的订阅集);回调抛错只记日志,绝不打断订阅/发消息/生命周期。 + */ + const reportSubscriptions = (): void => { + const onChanged = options.onSubscriptionsChanged; + if (!onChanged) return; + try { + const sessionId = fullSessionId(); + if (!sessionId) { + log.warn("订阅快照未上报:拿不到 sessionId"); + return; + } + onChanged(sessionId, new Set(active && trusted ? subscriptions : [])); + } catch (err) { + log.warn("订阅快照上报失败", { + error: err instanceof Error ? err.message : String(err), + }); + } + }; + const persist = (): void => { try { pi.appendEntry(SUBSCRIPTION_CUSTOM_TYPE, buildSubsPayload(subscriptions)); @@ -198,6 +229,7 @@ export function makeChannelWatchExtension(options: ChannelWatchOptions): InlineE guard.resumeTopic(topic); subscriptions.add(topic); persist(); + reportSubscriptions(); void ensureWatcher(); return { ok: true, resumed }; }, @@ -223,6 +255,7 @@ export function makeChannelWatchExtension(options: ChannelWatchOptions): InlineE if (!subscriptions.delete(topic)) return { ok: false, error: `未订阅频道 [${topic}]` }; guard.forgetTopic(topic); persist(); + reportSubscriptions(); if (subscriptions.size === 0) stopWatcher(); return { ok: true }; }, @@ -234,13 +267,16 @@ export function makeChannelWatchExtension(options: ChannelWatchOptions): InlineE // --- 生命周期 --- pi.on("session_start", async (_event, ctx) => { + // 先取 ctx:disabled/untrusted 分支也要能上报(空集) + lastCtx = ctx; try { if (!enabled()) { active = false; + trusted = false; + reportSubscriptions(); return; } active = true; - lastCtx = ctx; trusted = ctx.isProjectTrusted() === true; if (trusted) { const init = await ensureAgentWorkInit(options.cwd); @@ -269,12 +305,16 @@ export function makeChannelWatchExtension(options: ChannelWatchOptions): InlineE subscriptions: subscriptions.size, watcher: watcher?.mode ?? "idle", }); + // 恢复完成后上报(含空集):backend 据此把本会话标为「有频道订阅」 + reportSubscriptions(); } catch (err) { // init/恢复失败 → 降级为不激活(会话照常) active = false; + trusted = false; log.error("channel-watch session_start 失败,降级关闭", { error: err instanceof Error ? err.message : String(err), }); + reportSubscriptions(); } }); @@ -309,6 +349,9 @@ export function makeChannelWatchExtension(options: ChannelWatchOptions): InlineE stopWatcher(); guard.reset(); active = false; + trusted = false; + // 上报空集:会话已不驻留,订阅保护随之解除(backend 也有 dispose 兜底) + reportSubscriptions(); } catch (err) { log.warn("channel-watch shutdown 清理失败", { error: err instanceof Error ? err.message : String(err), diff --git a/packages/desktop/src/main/ipc/sessions.ts b/packages/desktop/src/main/ipc/sessions.ts index ab0bf80f..c7101a8d 100644 --- a/packages/desktop/src/main/ipc/sessions.ts +++ b/packages/desktop/src/main/ipc/sessions.ts @@ -10,7 +10,8 @@ export function registerSessionsIpc(backend: PiBackend): void { listSessions: ({ cwd }) => backend.listSessions(cwd), listAllSessions: () => backend.listAllSessions(), openSession: ({ filePath }) => backend.openSession(filePath), - closeSession: ({ sessionId }) => backend.closeSession(sessionId), + closeSession: ({ sessionId, intent }) => backend.closeSession(sessionId, intent), + getChannelSubscriptionSessionIds: () => backend.getChannelSubscriptionSessionIds(), deleteSession: ({ sessionId, sessionFile }) => backend.deleteSession(sessionId, sessionFile), prompt: ({ sessionId, text, images }) => backend.prompt(sessionId, text, images), abort: ({ sessionId }) => backend.abort(sessionId), diff --git a/packages/desktop/src/renderer/src/lib/session-gc-run.test.ts b/packages/desktop/src/renderer/src/lib/session-gc-run.test.ts new file mode 100644 index 00000000..bc8548e7 --- /dev/null +++ b/packages/desktop/src/renderer/src/lib/session-gc-run.test.ts @@ -0,0 +1,148 @@ +import { describe, expect, it, vi } from "vitest"; +import type { SessionGcEntry, SessionGcOpen } from "./session-gc"; +import { runSessionGcRound, type SessionGcRoundDeps } from "./session-gc-run"; + +/** 空闲会话的 transcript 侧状态(全 false / 全空) */ +const IDLE_ENTRY: SessionGcEntry = { + agentActive: false, + pendingPermissions: [], + pendingDialogs: [], + unseenCompletion: false, + compacting: false, + followUpQueue: [], + messageCount: 0, +}; + +const NOW = 1_000_000_000; + +function openSession( + sessionId: string, + lastUsedAt: number, + extra: Partial = {}, +): SessionGcOpen { + return { sessionId, lastUsedAt, isDraft: false, messageCount: 4, ...extra }; +} + +function makeDeps( + opts: { + open?: SessionGcOpen[]; + activeSessionId?: string | null; + subscribed?: string[]; + keep?: number; + listError?: Error; + /** 覆盖状态读取(用于「判定与执行之间状态变了」的场景) */ + getOpen?: () => { activeSessionId: string | null; open: SessionGcOpen[] }; + /** 后端是否同意卸(closed) */ + accepted?: (sessionId: string) => boolean; + } = {}, +) { + const log = vi.fn(); + const unload = vi.fn(async (sessionId: string) => ({ closed: opts.accepted?.(sessionId) ?? true })); + const deps: SessionGcRoundDeps = { + getOpen: + opts.getOpen ?? (() => ({ activeSessionId: opts.activeSessionId ?? null, open: opts.open ?? [] })), + entryOf: () => IDLE_ENTRY, + listSubscriptionSessionIds: opts.listError + ? () => Promise.reject(opts.listError) + : () => Promise.resolve(opts.subscribed ?? []), + unload, + now: NOW, + keep: opts.keep, + log, + }; + return { deps, unload, log }; +} + +describe("runSessionGcRound · 频道订阅快照消费", () => { + it("快照里的会话进保护:最久未用也不卸、不占 K 名额(IPC 快照真被消费)", async () => { + const open = [ + openSession("sub", NOW - 10_000_000), + openSession("b", NOW - 60_000), + openSession("c", NOW - 40_000), + openSession("d", NOW - 20_000), + openSession("e", NOW - 15_000), + ]; + const { deps, unload } = makeDeps({ open, subscribed: ["sub"] }); + + const result = await runSessionGcRound(deps); + + expect(result.skipped).toBeNull(); + expect(result.unloaded).toEqual(["b"]); // 4 个可卸空闲抢 3 个名额 → 只卸最久未用的 b + expect(unload).not.toHaveBeenCalledWith("sub"); + }); + + it("不在快照里 → 行为与改动前一致(按纯策略正常回收)", async () => { + const open = [openSession("a", NOW - 10_000_000), openSession("b", NOW - 60_000)]; + const { deps } = makeDeps({ open, keep: 1, subscribed: [] }); + + const result = await runSessionGcRound(deps); + + expect(result.unloaded).toEqual(["a"]); + }); + + it("快照拉取失败 → 整轮跳过:一个都不卸、unload 零调用(未知保护范围 ≠ 无人订阅)", async () => { + const open = [openSession("a", NOW - 10_000_000), openSession("b", NOW - 60_000)]; + const { deps, unload, log } = makeDeps({ + open, + keep: 1, + listError: new Error("IPC boom"), + }); + + const result = await runSessionGcRound(deps); + + expect(result).toEqual({ skipped: "subscription-snapshot-failed", unloaded: [], refused: [] }); + expect(unload).not.toHaveBeenCalled(); + expect(log).toHaveBeenCalledWith("[session-gc] 跳过本轮:频道订阅快照获取失败", expect.any(Error)); + }); +}); + +describe("runSessionGcRound · 卸载前的再确认与后端拒绝", () => { + it("判定与执行之间被切为活跃 → 不卸(用户的当前会话不能被抢)", async () => { + const open = [openSession("old", NOW - 10_000_000)]; + let calls = 0; + const { deps, unload } = makeDeps({ + keep: 1, + getOpen: () => { + calls += 1; + // 第 1 次 = 算候选;第 2 次 = 卸载前再确认(此时用户切过来了) + return { activeSessionId: calls === 1 ? null : "old", open }; + }, + }); + + const result = await runSessionGcRound(deps); + + expect(unload).not.toHaveBeenCalled(); + expect(result.unloaded).toEqual([]); + }); + + it("判定与执行之间会话已被关掉(不在打开集合)→ 不卸、不发无效 IPC", async () => { + const open = [openSession("old", NOW - 10_000_000)]; + let calls = 0; + const { deps, unload } = makeDeps({ + keep: 1, + getOpen: () => { + calls += 1; + return { activeSessionId: null, open: calls === 1 ? open : [] }; + }, + }); + + const result = await runSessionGcRound(deps); + + expect(unload).not.toHaveBeenCalled(); + expect(result.unloaded).toEqual([]); + }); + + it("后端拒绝(closed=false)→ 记 refused,不算已卸(竞态:判定后又跑起来/刚订阅)", async () => { + const open = [openSession("a", NOW - 10_000_000), openSession("b", NOW - 60_000)]; + const { deps } = makeDeps({ + open, + keep: 1, + accepted: (sessionId) => sessionId !== "a", + }); + + const result = await runSessionGcRound(deps); + + expect(result.refused).toEqual(["a"]); + expect(result.unloaded).toEqual([]); + }); +}); diff --git a/packages/desktop/src/renderer/src/lib/session-gc-run.ts b/packages/desktop/src/renderer/src/lib/session-gc-run.ts new file mode 100644 index 00000000..51a77aa6 --- /dev/null +++ b/packages/desktop/src/renderer/src/lib/session-gc-run.ts @@ -0,0 +1,81 @@ +import { pickUnloadCandidates, type SessionGcEntry, type SessionGcOpen } from "./session-gc"; + +/** + * 内存策略的**一轮编排**(依赖全注入,故可脱离 React/IPC 单测): + * + * 1. 先拉一次「有有效频道订阅的已加载会话 ID」快照(订阅会话不能被自动卸载,spec §6.2); + * 2. 把快照映射进每个会话的 `hasChannelSubscriptions`,交给纯策略层算候选(lib/session-gc.ts); + * 3. 逐个再确认(活跃/已关就跳过)后调 `unload`,后端拒绝(closed=false)不算卸成功。 + * + * **fail-safe 语义**:快照拉不到 = 保护范围未知 → **整轮跳过**,一个都不卸。宁可多留一点内存, + * 也不能把未知当「无人订阅」而卸掉正在等人的订阅会话;下一轮(store 变化或 20s tick)自然重试。 + * 注意不能退化成「让 backend 拒绝一次」:那会把 GC 变成每会话一次无效 IPC,且策略层判定先于守卫。 + */ +export interface SessionGcRoundDeps { + /** 当前打开集合 + 活跃会话(每轮现读;不能在轮内缓存) */ + getOpen: () => { activeSessionId: string | null; open: SessionGcOpen[] }; + /** 逐会话的 transcript 侧状态 */ + entryOf: (sessionId: string) => SessionGcEntry | undefined; + /** 批量取有有效频道订阅的已加载会话 ID(IPC;抛错 = 本轮跳过) */ + listSubscriptionSessionIds: () => Promise; + /** 卸载一个会话(renderer `unloadSession`,带 intent="gc") */ + unload: (sessionId: string) => Promise<{ closed: boolean }>; + now: number; + keep?: number; + /** 日志(缺省 dev 打 console.debug,prod 静默) */ + log?: (message: string, ...args: unknown[]) => void; +} + +export interface SessionGcRoundResult { + /** 非 null = 本轮整体跳过且原因在此 */ + skipped: "subscription-snapshot-failed" | null; + /** 实际卸掉的会话(按卸载顺序) */ + unloaded: string[]; + /** 后端拒绝卸载的(closed=false;竞态:判定后又跑起来/刚订阅) */ + refused: string[]; +} + +const defaultLog = (message: string, ...args: unknown[]): void => { + if (import.meta.env.DEV) console.debug(message, ...args); +}; + +export async function runSessionGcRound(deps: SessionGcRoundDeps): Promise { + const log = deps.log ?? defaultLog; + let subscribedIds: Set; + try { + subscribedIds = new Set(await deps.listSubscriptionSessionIds()); + } catch (error) { + log("[session-gc] 跳过本轮:频道订阅快照获取失败", error); + return { skipped: "subscription-snapshot-failed", unloaded: [], refused: [] }; + } + + const state = deps.getOpen(); + const candidates = pickUnloadCandidates({ + activeSessionId: state.activeSessionId, + open: state.open.map((item) => ({ + ...item, + hasChannelSubscriptions: subscribedIds.has(item.sessionId), + })), + entryOf: deps.entryOf, + now: deps.now, + keep: deps.keep, + }); + + const unloaded: string[] = []; + const refused: string[] = []; + for (const { sessionId, reason } of candidates) { + // 卸载前再确认:判定与执行之间有 await(快照),其间用户可能切过去/关掉/又跑起来 + const latest = deps.getOpen(); + if (latest.activeSessionId === sessionId) continue; + if (!latest.open.some((item) => item.sessionId === sessionId)) continue; + const { closed } = await deps.unload(sessionId); + if (closed) { + unloaded.push(sessionId); + log("[session-gc] unload", sessionId, `reason=${reason}`); + } else { + refused.push(sessionId); + log("[session-gc] skipped", sessionId, `reason=${reason} refused-by-backend`); + } + } + return { skipped: null, unloaded, refused }; +} diff --git a/packages/desktop/src/renderer/src/lib/session-gc.ts b/packages/desktop/src/renderer/src/lib/session-gc.ts index 379e11b2..d388f616 100644 --- a/packages/desktop/src/renderer/src/lib/session-gc.ts +++ b/packages/desktop/src/renderer/src/lib/session-gc.ts @@ -89,6 +89,9 @@ export const GC_DEFAULTS = { */ export function isProtected(item: SessionGcOpen, entry: SessionGcEntry | undefined): boolean { if (item.isDraft) return true; + // 有频道订阅 = 明确驻留语义(spec channel-watch retention §6.2):卸载会停 watcher, + // 会话从此收不到频道唤醒——用户显式订阅了就该留在内存里(退订即恢复普通 GC 资格)。 + if (item.hasChannelSubscriptions) return true; // 0 消息会话还没有会话文件(SDK 只在追加 entry 时才建文件):磁盘历史里查不到它, // 卸掉 = 会话条目从 UI 消失、用户刚选的模型/档位丢失(审计实测)。 // 判据取「磁盘 meta」与「transcript 实时条数」的较大值 —— 只信 meta 会把本次进程内 diff --git a/packages/desktop/src/renderer/src/lib/use-session-gc.ts b/packages/desktop/src/renderer/src/lib/use-session-gc.ts index bc3f17a2..3e6c952b 100644 --- a/packages/desktop/src/renderer/src/lib/use-session-gc.ts +++ b/packages/desktop/src/renderer/src/lib/use-session-gc.ts @@ -1,14 +1,17 @@ import { useEffect } from "react"; +import { getPi } from "../api"; import { isDraftSessionId, useSessionsStore } from "../stores/sessions"; import { useTranscriptStore } from "../stores/transcript"; -import { pickUnloadCandidates, type SessionGcEntry } from "./session-gc"; +import type { SessionGcEntry } from "./session-gc"; +import { runSessionGcRound } from "./session-gc-run"; /** - * 会话内存策略接线层:把 `lib/session-gc.ts` 的纯判定接到 store 上(判定逻辑全在那边,这里只订阅与执行)。 + * 会话内存策略接线层:把 store/IPC 接到 `lib/session-gc-run.ts` 的一轮编排上 + * (判定与 fail-safe 语义全在那边,这里只订阅与时序)。 * * **为什么不订阅 transcript**:`bySession` 每来一个 token 就变一次,订阅它等于每个 token 白跑一轮判定。 - * 改为「挂载跑一次 + 60s 兜底 tick + 只订阅 sessions/activeSessionId」,回调里一律 `getState()` 现读 - * (60s tick 也保证 `idleTimeoutMs` 那条时间规则会随真实时间推进而生效)。 + * 改为「挂载跑一次 + 20s 兜底 tick + 只订阅 sessions/activeSessionId」,回调里一律 `getState()` 现读 + * (tick 也保证 `idleTimeoutMs` 那条时间规则会随真实时间推进而生效)。 * * 空闲热会话保留数(K=3,用户拍板)。想调体验改这里。 */ @@ -41,35 +44,29 @@ export function useSessionGc(): void { if (running) return; running = true; try { - const state = useSessionsStore.getState(); - const candidates = pickUnloadCandidates({ - activeSessionId: state.activeSessionId, - open: state.sessions.map((session) => ({ - sessionId: session.sessionId, - // 缺打点理论不可达(每个进 sessions 的路径都打点);真缺了当「最久未用」处理 - lastUsedAt: state.lastUsedAt[session.sessionId] ?? 0, - isDraft: isDraftSessionId(session.sessionId), - // 磁盘元数据(打开时读一次;本次进程内新建的会话恒为 0):与 transcript 实时条数 - // 一起交给纯层判「有没有会话文件」,见 lib/session-gc.ts 的 isProtected - messageCount: session.messageCount, - })), + await runSessionGcRound({ + getOpen: () => { + const state = useSessionsStore.getState(); + return { + activeSessionId: state.activeSessionId, + open: state.sessions.map((session) => ({ + sessionId: session.sessionId, + // 缺打点理论不可达(每个进 sessions 的路径都打点);真缺了当「最久未用」处理 + lastUsedAt: state.lastUsedAt[session.sessionId] ?? 0, + isDraft: isDraftSessionId(session.sessionId), + // 磁盘元数据(打开时读一次;本次进程内新建的会话恒为 0):与 transcript 实时条数 + // 一起交给纯层判「有没有会话文件」,见 lib/session-gc.ts 的 isProtected; + // hasChannelSubscriptions 由本轮编排从 backend 快照映射进来 + messageCount: session.messageCount, + })), + }; + }, entryOf, + listSubscriptionSessionIds: () => getPi().getChannelSubscriptionSessionIds(), + unload: (sessionId) => useSessionsStore.getState().unloadSession(sessionId), now: Date.now(), keep: KEEP, }); - - for (const { sessionId, reason } of candidates) { - // 算完候选到真正卸载之间,用户可能正好切过来、会话也可能已被关掉 → 逐个再确认一次 - const latest = useSessionsStore.getState(); - if (latest.activeSessionId === sessionId) continue; - if (!latest.sessions.some((s) => s.sessionId === sessionId)) continue; - // 日志打在**成功之后**:后端在跑会拒绝(closed=false),打在前面会误报「已卸载」 - const { closed } = await latest.unloadSession(sessionId); - if (import.meta.env.DEV) { - if (closed) console.debug("[session-gc] unload", sessionId, `reason=${reason}`); - else console.debug("[session-gc] skipped", sessionId, `reason=${reason} refused-by-backend`); - } - } } finally { running = false; } diff --git a/packages/desktop/src/renderer/src/stores/sessions.ts b/packages/desktop/src/renderer/src/stores/sessions.ts index 17e94e87..95fe9d1b 100644 --- a/packages/desktop/src/renderer/src/stores/sessions.ts +++ b/packages/desktop/src/renderer/src/stores/sessions.ts @@ -1,4 +1,4 @@ -import type { AvailableModel, PermissionMode, SessionMeta } from "@percho/shared"; +import type { AvailableModel, PermissionMode, SessionCloseIntent, SessionMeta } from "@percho/shared"; import { messagesToUIMessages } from "@percho/shared"; import { create } from "zustand"; import { getPi } from "../api"; @@ -210,8 +210,8 @@ interface SessionsStore { /** 设置新会话的目标项目目录;活跃 tab 是 draft 时同步更新其条目(切 tab 往返不丢选择) */ setDraftCwd: (cwd: string) => void; switchSession: (sessionId: string) => void; - closeSession: (sessionId: string) => Promise<{ closed: boolean }>; - /** 自动卸载(内存策略用,见实现处注释;与 closeSession 行为一致) */ + closeSession: (sessionId: string, intent?: SessionCloseIntent) => Promise<{ closed: boolean }>; + /** 自动卸载(内存策略专用,见实现处注释);传 intent="gc" 让后端区分自动 GC 与用户意图 */ unloadSession: (sessionId: string) => Promise<{ closed: boolean }>; openFromHistory: (filePath: string) => Promise; /** 在指定 assistant 消息处分叉:新会话以新 tab 打开并切换过去(原会话保留原样);成功返回新 sessionId */ @@ -351,12 +351,13 @@ export const useSessionsStore = create((set, get) => ({ sessions: state.sessions.map((s) => (s.sessionId === sessionId ? { ...s, name } : s)), })), - closeSession: async (sessionId) => { + closeSession: async (sessionId, intent) => { const isDraft = isDraftSessionId(sessionId); // draft 没有后端会话,纯本地移除 if (!isDraft) { try { - const { closed } = await getPi().closeSession({ sessionId }); + // intent 只在本轮是「内存策略自动卸载」时带上,用户主动关不传(保持既有语义) + const { closed } = await getPi().closeSession(intent ? { sessionId, intent } : { sessionId }); // 后端拒绝(agent 在跑 / 等审批):**渲染层状态必须原样保留** —— 事务语义, // 不能出现「后端会话还在、前端条目已消失」的半个动作(内存策略也会看走眼) if (!closed) return { closed: false }; @@ -386,11 +387,12 @@ export const useSessionsStore = create((set, get) => ({ return { closed: true }; }, - /** 自动卸载(内存策略专用):语义与 closeSession 完全一致,只是把调用点区分开—— - * 「用户主动关/删」走 closeSession,「内存策略判定该卸」走这里(便于日后单独调整任一侧)。 - * 受保护会话不会走到这里(保护判定在 lib/session-gc.ts 的 isProtected); - * 返回 `closed=false` 表示后端拒绝(竞态:策略判定后它恰好又开始跑了)。 */ - unloadSession: (sessionId) => get().closeSession(sessionId), + /** 自动卸载(内存策略专用):与 closeSession 同一套行为,只是把调用点与意图区分开—— + * 「用户主动关/删」走 closeSession(intent 缺省 = user),「内存策略判定该卸」走这里并带 intent="gc": + * 后端据此对「有频道订阅」的会话拒绝(订阅 = 明确驻留语义,见 spec channel-watch retention)。 + * 受保护会话不会走到这里(保护判定在 lib/session-gc.ts 的 isProtected,订阅条件也在那边); + * 返回 `closed=false` 表示后端拒绝(竞态:策略判定后它恰好又开始跑了/刚订阅了)。 */ + unloadSession: (sessionId) => get().closeSession(sessionId, "gc"), openFromHistory: async (filePath) => { try { diff --git a/packages/shared/src/ipc-channels.ts b/packages/shared/src/ipc-channels.ts index 0f05dd8a..1ace05b9 100644 --- a/packages/shared/src/ipc-channels.ts +++ b/packages/shared/src/ipc-channels.ts @@ -17,6 +17,7 @@ import type { PermissionMode, QueuedMessages, QuotaInfo, + SessionCloseIntent, SessionMessage, SessionMeta, SessionStats, @@ -79,9 +80,13 @@ export const SESSION_CHANNELS = { /** 跨全部项目目录枚举历史会话(项目管理页用,含活跃) */ listAllSessions: ch("session:listAll")(), openSession: ch("session:open")<{ filePath: string }, SessionMeta>(), - // closed=false = 后端拒绝(agent 正在跑/等审批,见 PiBackend.closeSession 的 isStreaming 守卫); + // closed=false = 后端拒绝(agent 正在跑/等审批,见 PiBackend.closeSession 的 isStreaming 守卫; + // intent:"gc" 且有频道订阅时也拒绝,见 channel-watch retention); // 没有该会话(已关/从未打开)也算 closed=true(幂等「它就是没在跑」) - closeSession: ch("session:close")<{ sessionId: string }, { closed: boolean }>(), + closeSession: ch("session:close")< + { sessionId: string; intent?: SessionCloseIntent }, + { closed: boolean } + >(), /** 删除会话(含磁盘 jsonl 文件,不可恢复) */ deleteSession: ch("session:delete")<{ sessionId: string; sessionFile?: string }, void>(), /** 发送消息;images 为随消息附带的图片(base64) */ @@ -116,6 +121,11 @@ export const SESSION_CHANNELS = { >(), /** 读取会话已加载的资源(skills/扩展;设置页展示用) */ getLoadedResources: ch("session:getLoadedResources")<{ sessionId: string }, LoadedResources>(), + /** + * 有有效频道订阅的**已加载**会话 ID(renderer 内存策略保护用;顺序不构成契约)。 + * 会话被卸载/关闭后不再返回(backend 快照同步清理)。 + */ + getChannelSubscriptionSessionIds: ch("session:getChannelSubscriptionSessionIds")(), /** 可用模型列表(providers × models,含 authed/thinkingLevels/imageInput 元数据) */ listModels: ch("models:list")(), /** @ 补全数据源:项目文件相对路径列表(目录带尾 /) */ diff --git a/packages/shared/src/session.ts b/packages/shared/src/session.ts index 65ebbd81..fa7e2ec2 100644 --- a/packages/shared/src/session.ts +++ b/packages/shared/src/session.ts @@ -282,6 +282,13 @@ export interface PermissionConfigInfo { /** 会话权限模式:default = 按规则审批;fullAccess = 一切放行 + 高危调用写审计。按会话内存态,不落盘、不跨会话继承、重启归零。 */ export type PermissionMode = "default" | "fullAccess"; +/** + * 关闭会话的调用意图: + * - `user`(缺省)= 用户主动关闭/删除,订阅不能绑架用户; + * - `gc` = renderer 内存策略的自动卸载,有频道订阅时后端拒绝(订阅 = 明确驻留语义)。 + */ +export type SessionCloseIntent = "user" | "gc"; + /** 上下文管理模式(二态;缺省 evaporation。 * 物理存储 = settings.json 单一决策 key contextEvaporation.enabled * (缺整个 key 或 enabled≠false → evaporation;遗留 acpCompressionEnabled 键读侧忽略、写侧顺带清除) */ From 1f28c6c72c078ee992d8606548afbe6e3b2bc75a Mon Sep 17 00:00:00 2001 From: Jaxton07 Date: Sun, 20 Sep 2026 17:19:50 +0800 Subject: [PATCH 4/8] =?UTF-8?q?feat(channel-watch):=20=E9=98=B6=E6=AE=B5?= =?UTF-8?q?=202=20=E2=80=94=20P1=20=E6=8C=81=E4=B9=85=E5=86=85=E5=AE=B9?= =?UTF-8?q?=E6=B8=B8=E6=A0=87=E3=80=81=E7=A6=BB=E7=BA=BF=E8=A1=A5=E6=8A=95?= =?UTF-8?q?=E4=B8=8E=20watcher=20single-flight?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/INDEX.md | 2 +- .../src/tools/channel-watch/extension.ts | 218 ++++++++++++++---- .../src/tools/channel-watch/subscriptions.ts | 123 ++++++---- .../backend/src/tools/channel-watch/tools.ts | 4 +- .../test/channel-watch-extension.test.ts | 89 ++++++- 5 files changed, 345 insertions(+), 91 deletions(-) diff --git a/docs/INDEX.md b/docs/INDEX.md index 67361595..bc54fb81 100644 --- a/docs/INDEX.md +++ b/docs/INDEX.md @@ -121,7 +121,7 @@ src/ | `src/tools/todo.ts` + `todo-reminder.ts` | `makeTodoTool`、`makeTodoReminderExtension` | todo 工具(全量替换协议,`details.todos` 供 UI)+ 恢复扩展(context 钩子注入 `customType:"todo-reminder"`——用 context 而非 before_agent_start:overflow willRetry 同 run 重试也覆盖) | | `src/tools/subagent/` | `makeSubagentTool`、`runSubagent`、`applySubagentMutex`、`resolveSubagentPreferBuiltin`、`discoverAgents` | 内置进程内 subagent:single `{agent,task}` + parallel `{tasks[]}` cap 8/并发 4;子代理 = 共享 modelRuntime 的隔离 `AgentSession`(资源最小化 + 权限桥父 gate,完成等 `agent_settled`);**思考档位链**:设置页覆盖 > agent frontmatter > SDK 默认链(显式传 `thinkingLevel`,实际生效值回传 `SingleResult`);会话文件落 `sessions-subagents/`(打开即只读);**参数 schema 必须拍平单 object**(DeepSeek 对顶层无 `type:"object"` 直接 400,严禁顶层 Union/Intersect);互斥 = 同名覆盖 + `subagent_*` 家族停用(发 `subagent_mutex` 事件);runner 转发子会话原生事件(检视页实时收流);agent 定义:内置 scout → user `~/.pi/agent/agents/` → project `.pi/agents/`(仅 trusted) | | `src/tools/context-evaporation/` | `makeEvapExtension`、`readContextManagerMode`、`writeContextManagerMode` | 内置上下文蒸发扩展(**默认开启**,二态蒸发/off):context 钩子把到龄工具输出按四级水位线蒸发为 stub(零 LLM、决策单调持久化保 KV cache);核心三文件(types/estimate/evaporate)零 SDK/零仓库 import(replay `--core` 同构验证);二态配置单 key 单一写者(写侧顺带清遗留键);调参走 `scripts/replay-evaporation.mts`;批次观测 = log + trace_custom 行 | -| `src/tools/channel-watch/` | `makeChannelWatchExtension` | 内置跨会话频道协作扩展(默认开):订阅 `.local/agent-work/channel//`,**消息 = 意图:写文件 ≠ 通知,channel_post 工具才唤醒**(根治一次任务写 N 文件 = N 唤醒);防环三层(自写窗口 + hash 去重 + 乒乓上限暂停);`closed:true` 终态退订;**有效订阅快照上报 backend**(`onSubscriptionsChanged`:enabled+trusted 才计入,disabled/untrusted/shutdown 上报空集,回调异常只记日志)→ 有订阅的会话不被 renderer 自动 GC;分模块 config/init/guard/watcher/subscriptions/post/tools/extension;配套 skill channel-pickup/design-handoff | +| `src/tools/channel-watch/` | `makeChannelWatchExtension` | 内置跨会话频道协作扩展(默认开):订阅 `.local/agent-work/channel//`,**消息 = 意图:写文件 ≠ 通知,channel_post 工具才唤醒**(根治一次任务写 N 文件 = N 唤醒);防环三层(自写窗口 + hash 去重 + 乒乓上限暂停);**持久内容游标**(`channel-subs` 快照 = topics + `cursors`,`null` = 确认时文件不存在、key 缺失 = 旧载荷未知基线):首次订阅先记基线,session_start 就绪后逐 topic 对账补投**一次**(离线期间的新消息不丢、不重复),live 投递/自写抑制推进游标,paused 不推进(重新 subscribe 合并补投一次),文件删除推 null 不唤醒,`unsubscribe` 删游标;watcher `ensureWatcher` 为 single-flight(失败可重试,shutdown 不留孤儿);`closed:true` 终态信号(查收后退订仍靠 skill 协议);**有效订阅快照上报 backend**(`onSubscriptionsChanged`:enabled+trusted 才计入,disabled/untrusted/shutdown 上报空集,回调异常只记日志)→ 有订阅的会话不被 renderer 自动 GC;分模块 config/init/guard/watcher/subscriptions/post/tools/extension;配套 skill channel-pickup/design-handoff | | `src/settings/settings.ts` | `SettingsService` | provider/模型/凭证读写(key 走环境变量引用,绝不落明文)。listProviders 默认本地 refresh(`allowNetwork:false`),显式 forceNetwork 才联网;custom provider 增改走 `buildCustomEntry`(未设字段不落盘);模型列表留空 = 覆写 baseUrl 共享官方列表;`setProviderBaseUrl` = 内置 provider 端点覆写专用;移除凭证走 `runtime.logout()`(直接删文件残留内存态);`apiKeyLogin` 标记 = 内置 provider 有交互式 api_key 登录(UI 显示「登录」入口) | | `src/settings/model-prefs.ts` | `ModelPrefsService` | `model-prefs.json`:隐藏模型 + 停用 provider + per-agent 子代理模型/思考深度(读侧白名单,空 map 不落盘)+ `subagentPreferBuiltin` 执行器开关(缺省/脏值 = true);`listModels()` 唯一出口过滤 | | `src/settings/login.ts` | `LoginService` | provider 交互登录桥接:AuthInteraction → IPC 事件(prompt 挂起等 renderer 应答;浏览器先到则拒挂起 prompt)。支持 OAuth + api_key 交互登录(如 Google Vertex);`filterAuthSelectOptions` 对 google-vertex 剔除必败的 api-key 选项(Vertex 不接受 API key,见 PITFALLS) | diff --git a/packages/backend/src/tools/channel-watch/extension.ts b/packages/backend/src/tools/channel-watch/extension.ts index f2d31625..e7138a35 100644 --- a/packages/backend/src/tools/channel-watch/extension.ts +++ b/packages/backend/src/tools/channel-watch/extension.ts @@ -4,9 +4,9 @@ import type { ExtensionContext, InlineExtension } from "@earendil-works/pi-codin import { createLogger } from "../../log"; import { readChannelWatchEnabled } from "./config"; import { contentHash, LoopGuard } from "./guard"; -import { channelRoot, ensureAgentWorkInit, validateTopic } from "./init"; +import { channelRoot, ensureAgentWorkInit, topicDir, validateTopic } from "./init"; import { appendPost, MESSAGES_FILE } from "./post"; -import { buildSubsPayload, restoreSubscriptions, SUBSCRIPTION_CUSTOM_TYPE } from "./subscriptions"; +import { buildSubsPayload, restoreSubscriptionState, SUBSCRIPTION_CUSTOM_TYPE } from "./subscriptions"; import { makeChannelTools } from "./tools"; import { type ChannelWatchEvent, ChannelWatcher, type ChannelWatcherOptions } from "./watcher"; @@ -18,11 +18,13 @@ const log = createLogger("channel-watch"); * sendUserMessage 唤醒本会话按协议查收(仅 MESSAGES.md 触发唤醒,其余频道文件静默)。 * * 接线一览(钩子全 try/catch 绝不 throw): - * - session_start:开关 → trusted 门 → 目录协议 init(首次 notify)→ 恢复订阅(appendEntry) - * → 非空订阅惰性起 watcher → 注册三工具(幂等)→ 上报有效订阅快照(enabled+trusted 才计入) + * - session_start:开关 → trusted 门 → 目录协议 init(首次 notify)→ 恢复订阅+游标(appendEntry) + * → 非空订阅惰性起 watcher(single-flight)→ 上报有效订阅快照(enabled+trusted 才计入) + * → 逐 topic 对账补投(离线期间的新消息) * - input:真人/rpc 消息介入 → 清乒乓计数(source!=="extension") * - tool_call:write/edit 目标 → guard.markSelfWrite(自写抑制) - * - watcher onEvent:订阅过滤 → 仅 MESSAGES.md → 读文件 hash → guard.shouldDeliver(自写/hash/暂停) + * - watcher onEvent:订阅过滤 → 仅 MESSAGES.md → 读文件 hash → cursor 去重 → guard.shouldDeliver + * (自写/hash/暂停)→ 投递(投递必推进 cursor;自写抑制也推进;paused 不推进) * → sendUserMessage({deliverAs:"followUp"})(流式中排队、空闲立即——SDK prompt 语义) * → recordDelivered(hash 快照 + 乒乓计数,上限触发暂停 + notify) * - session_shutdown:watcher.stop + guard.reset(幂等) @@ -86,6 +88,14 @@ export function makeChannelWatchExtension(options: ChannelWatchOptions): InlineE let lastCtx: ExtensionContext | null = null; let toolsBound = false; const subscriptions = new Set(); + /** + * topic → 最后已确认的 `MESSAGES.md` 内容 hash | null(null = 确认当时文件不存在)。 + * 与订阅集同一份持久快照:每次推进都 append 全量(spec §6.3/§6.5)。key 缺失 = 未知基线。 + */ + const cursors = new Map(); + /** watcher 世代:stop/shutdown 时 +1,用于作废「启动中」的那一轮(不挂孤儿 watcher) */ + let watcherEpoch = 0; + let watcherPromise: Promise | null = null; const notify = (text: string): void => { try { @@ -144,7 +154,8 @@ export function makeChannelWatchExtension(options: ChannelWatchOptions): InlineE const persist = (): void => { try { - pi.appendEntry(SUBSCRIPTION_CUSTOM_TYPE, buildSubsPayload(subscriptions)); + // 全量快照 = topics + cursors(两者必须一起写:分开写会出现「新订阅配旧游标」的中间态) + pi.appendEntry(SUBSCRIPTION_CUSTOM_TYPE, buildSubsPayload(subscriptions, cursors)); } catch (err) { log.warn("订阅 appendEntry 失败", { error: err instanceof Error ? err.message : String(err), @@ -152,6 +163,68 @@ export function makeChannelWatchExtension(options: ChannelWatchOptions): InlineE } }; + /** 订阅频道主文件绝对路径(topic 已在 subscribe/restore 侧过 validateTopic) */ + const messagesPath = (topic: string): string => join(topicDir(options.cwd, topic), MESSAGES_FILE); + + /** 当前版本(文件不存在/不可读 → null) */ + const currentHash = (topic: string): Promise => readHash(messagesPath(topic)); + + /** + * 推进 cursor 并落盘(at-least-once:落盘失败只告警,内存保留——重启后允许重复提醒, + * 不允许静默漏提醒)。值未变不写 JSONL。 + */ + const advanceCursor = (topic: string, hash: string | null): void => { + if (cursors.has(topic) && cursors.get(topic) === hash) return; + cursors.set(topic, hash); + persist(); + }; + + /** + * 投递一次唤醒(live 与恢复补投共用的唯一入口):sendWake → guard 记账(乒乓保护不变) + * → 推进 cursor。`sendUserMessage` 是同步 void,无 throw 视为已受理(spec §6.5)。 + */ + const deliverWake = (topic: string, relPath: string, hash: string): void => { + sendWake(buildWakeMessage(topic)); + log.info("channel 唤醒已投递", { sessionId: shortId(), topic, relPath }); + const pausedNow = guard.recordDelivered(topic, relPath, hash); + advanceCursor(topic, hash); + if (pausedNow) { + notify( + `channel-watch:频道 [${topic}] 10 分钟内互触发达到上限,已暂停该频道的自动唤醒(防 token 环烧)。如需恢复,让模型重新执行 channel_subscribe(${topic})。`, + ); + } + }; + + /** + * 单 topic 版本对账(session_start 恢复后 / 首次订阅 watcher 就绪后): + * - 已知 cursor 与当前版本不同 → 补投一次(离线期间的写入); + * - cursor key 缺失(旧载荷)→ 只记基线,不补历史; + * - 文件不存在 → 不唤醒,cursor 记为 null; + * - 期间 live 已投递(cursor 变了)→ 直接让位,避免重复与 cursor 回退。 + */ + const reconcileTopic = async (topic: string): Promise => { + try { + const before = cursors.get(topic); + const hash = await currentHash(topic); + if (cursors.get(topic) !== before) return; + if (before === hash) return; + if (hash === null) { + advanceCursor(topic, null); + return; + } + if (before === undefined) { + advanceCursor(topic, hash); + return; + } + deliverWake(topic, `${topic}/${MESSAGES_FILE}`, hash); + } catch (err) { + log.warn("channel-watch 补投对账失败", { + topic, + error: err instanceof Error ? err.message : String(err), + }); + } + }; + const onWatchEvent = (event: ChannelWatchEvent): void => { try { if (!active || !subscriptions.has(event.topic)) return; @@ -159,30 +232,45 @@ export function makeChannelWatchExtension(options: ChannelWatchOptions): InlineE if (basename(event.relPath) !== MESSAGES_FILE) return; const abs = join(channelRoot(options.cwd), event.relPath); void (async () => { - const hash = await readHash(abs); - // 文件已删/不可读:无内容可比,跳过(删除场景无「查收」语义) - if (hash === null) return; - const decision = guard.shouldDeliver(event.topic, event.relPath, hash, abs); - if (!decision.deliver) { - log.info("唤醒抑制", { - sessionId: shortId(), - topic: event.topic, - relPath: event.relPath, - reason: decision.reason, + // fire-and-forget 分支内部必须完整 catch(不得漏出 unhandled rejection) + try { + const hash = await readHash(abs); + if (hash === null) { + // 文件被删/不可读:不唤醒;已知 cursor 则推进为 null(未来重建能识别为变化) + if (cursors.has(event.topic) && cursors.get(event.topic) !== null) { + advanceCursor(event.topic, null); + } + return; + } + // live 先比 cursor(跨重启的持久去重):同 hash 直接抑制 + if (cursors.get(event.topic) === hash) { + log.info("唤醒抑制", { + sessionId: shortId(), + topic: event.topic, + reason: "cursor-unchanged", + }); + return; + } + const decision = guard.shouldDeliver(event.topic, event.relPath, hash, abs); + if (!decision.deliver) { + log.info("唤醒抑制", { + sessionId: shortId(), + topic: event.topic, + reason: decision.reason, + }); + // 自写抑制:那是本会话自己的写入,无需唤醒;但 cursor 必须推进—— + // 否则 shutdown 发生在 watcher 防抖前时,重开会把自己的写入当离线新消息补投(§6.5) + if (decision.reason === "self-write" || decision.reason === "hash-unchanged") { + advanceCursor(event.topic, hash); + } + // paused:不推进(显式重新 subscribe 后可合并补投一次) + return; + } + deliverWake(event.topic, event.relPath, hash); + } catch (err) { + log.warn("channel-watch 事件处理失败", { + error: err instanceof Error ? err.message : String(err), }); - return; - } - sendWake(buildWakeMessage(event.topic)); - log.info("channel 唤醒已投递", { - sessionId: shortId(), - topic: event.topic, - relPath: event.relPath, - }); - const pausedNow = guard.recordDelivered(event.topic, event.relPath, hash); - if (pausedNow) { - notify( - `channel-watch:频道 [${event.topic}] 10 分钟内互触发达到上限,已暂停该频道的自动唤醒(防 token 环烧)。如需恢复,让模型重新执行 channel_subscribe(${event.topic})。`, - ); } })(); } catch (err) { @@ -192,18 +280,44 @@ export function makeChannelWatchExtension(options: ChannelWatchOptions): InlineE } }; - const ensureWatcher = async (): Promise => { - if (watcher) return; + /** + * 启动 watcher(**single-flight**,spec §6.6):并发 subscribe/session_start 只创建一个实例; + * 失败不抛(清空 promise,下次订阅可重试);启动期间 shutdown 已作废本轮 → 停掉自己,不挂孤儿。 + */ + const ensureWatcher = (): Promise => { + if (watcher) return Promise.resolve(); + if (watcherPromise) return watcherPromise; + const epoch = watcherEpoch; const w = makeWatcher({ channelRoot: channelRoot(options.cwd), onEvent: onWatchEvent, }); - const mode = await w.start(); // fs.watch 失败自动降级轮询,无 failed 分支 - watcher = w; - log.info("channel watcher 启动", { mode, root: channelRoot(options.cwd) }); + const run = (async () => { + try { + const mode = await w.start(); // fs.watch 失败自动降级轮询,无 failed 分支 + if (epoch !== watcherEpoch) { + w.stop(); + return; + } + watcher = w; + log.info("channel watcher 启动", { mode, root: channelRoot(options.cwd) }); + } catch (err) { + log.warn("channel watcher 启动失败,下次订阅重试", { + error: err instanceof Error ? err.message : String(err), + }); + } + })(); + watcherPromise = run; + void run.finally(() => { + // 只清自己这一轮(期间可能已 stop/重建过) + if (watcherPromise === run) watcherPromise = null; + }); + return run; }; const stopWatcher = (): void => { + watcherEpoch += 1; // 作废「启动中」的那一轮(它会在 start 返回后自停) + watcherPromise = null; // 让下一次 ensureWatcher 能真起新实例 if (watcher) { watcher.stop(); watcher = null; @@ -216,7 +330,7 @@ export function makeChannelWatchExtension(options: ChannelWatchOptions): InlineE for (const tool of makeChannelTools({ cwd: options.cwd, getSubscriptions: () => new Set(subscriptions), - subscribe(topic) { + subscribe: async (topic) => { const invalid = validateTopic(topic); if (invalid) return { ok: false, error: invalid }; if (!trusted) { @@ -228,9 +342,16 @@ export function makeChannelWatchExtension(options: ChannelWatchOptions): InlineE const resumed = guard.isPaused(topic); guard.resumeTopic(topic); subscriptions.add(topic); + // 首次订阅:先把当前版本记为基线(已有历史不提醒),再起 watcher,最后二次对账: + // 「读基线 ↔ watcher 就绪」之间写入的新消息不能丢(spec §6.4) + if (!cursors.has(topic)) { + cursors.set(topic, await currentHash(topic)); + } persist(); reportSubscriptions(); - void ensureWatcher(); + await ensureWatcher(); + // 二次对账:封住启动竞态;也是 paused 恢复后的「合并补投一次」入口(§6.5) + await reconcileTopic(topic); return { ok: true, resumed }; }, async post(topic, message, closed) { @@ -246,6 +367,17 @@ export function makeChannelWatchExtension(options: ChannelWatchOptions): InlineE // appendFile 不经 write/edit tool_call 钩子,手动标记自写 // (否则「自订阅频道自 post」会自我唤醒) guard.markSelfWrite(file); + // 本会话也订阅了该 topic 时主动推进 cursor(spec §6.5):防 shutdown 死在 watcher + // 防抖之前,重开时把自己的写入当离线新消息补投 + if (subscriptions.has(topic)) { + try { + advanceCursor(topic, await readHash(file)); + } catch (err) { + log.warn("post 后推进 cursor 失败(后续 watcher 事件会收敛)", { + error: err instanceof Error ? err.message : String(err), + }); + } + } return { ok: true }; } catch (err) { return { ok: false, error: err instanceof Error ? err.message : String(err) }; @@ -254,6 +386,7 @@ export function makeChannelWatchExtension(options: ChannelWatchOptions): InlineE unsubscribe(topic) { if (!subscriptions.delete(topic)) return { ok: false, error: `未订阅频道 [${topic}]` }; guard.forgetTopic(topic); + cursors.delete(topic); // 退订不留幽灵基线(重订时才重读基线) persist(); reportSubscriptions(); if (subscriptions.size === 0) stopWatcher(); @@ -289,8 +422,10 @@ export function makeChannelWatchExtension(options: ChannelWatchOptions): InlineE // 恢复订阅(resume/restart);appendEntry 只在文件存在时有意义(冒烟 V3: // 首条 assistant 前不落盘——但订阅必发生在对话后,resume 场景文件必存在) try { - const restored = restoreSubscriptions(ctx.sessionManager.getEntries()); - for (const topic of restored) subscriptions.add(topic); + // 订阅 + 游标一起恢复(last-wins 全量快照;旧载荷只有 topics = 未知基线) + const restored = restoreSubscriptionState(ctx.sessionManager.getEntries()); + for (const topic of restored.topics) subscriptions.add(topic); + for (const [topic, hash] of restored.cursors) cursors.set(topic, hash); } catch (err) { log.warn("订阅恢复失败(按空订阅处理)", { error: err instanceof Error ? err.message : String(err), @@ -305,8 +440,13 @@ export function makeChannelWatchExtension(options: ChannelWatchOptions): InlineE subscriptions: subscriptions.size, watcher: watcher?.mode ?? "idle", }); - // 恢复完成后上报(含空集):backend 据此把本会话标为「有频道订阅」 + // 恢复完成后再上报(含空集):backend 据此把本会话标为「有频道订阅」 reportSubscriptions(); + // watcher 就绪后逐 topic 对账(spec §6.4):已知 cursor 不同 → 每 topic 补投一次; + // 旧载荷只建基线;会话离线期间的写入在这里被补上 + if (trusted && subscriptions.size > 0) { + for (const topic of subscriptions) await reconcileTopic(topic); + } } catch (err) { // init/恢复失败 → 降级为不激活(会话照常) active = false; diff --git a/packages/backend/src/tools/channel-watch/subscriptions.ts b/packages/backend/src/tools/channel-watch/subscriptions.ts index 26a5ec38..ab92ebd8 100644 --- a/packages/backend/src/tools/channel-watch/subscriptions.ts +++ b/packages/backend/src/tools/channel-watch/subscriptions.ts @@ -1,76 +1,107 @@ +import { validateTopic } from "./init"; + /** - * 订阅表持久化(spec D4):`pi.appendEntry()` 写入会话文件 JSONL, + * 订阅表持久化(spec D4 / §6.3):`pi.appendEntry()` 写入会话文件 JSONL, * session_start(resume/restart)时从 `ctx.sessionManager.getEntries()` 恢复。 * - * 形态(冒烟 V3 实证):`{type:"custom", customType:"channel-subs", data:{topics:[...]}, ...}`。 - * 每次订阅变化 append 全量最新集合;恢复取**最后一条** channel-subs entry。 + * 形态(冒烟 V3 实证):`{type:"custom", customType:"channel-subs", data:{topics:[...], cursors:{...}}, ...}`。 + * 每次订阅变化 append 全量最新快照(topics + 每个 topic 的内容游标);恢复取**最后一条** channel-subs entry。 + * 旧版载荷只有 topics(无 cursors)= 「未知基线」:恢复时把当前版本记为基线,不补历史(spec §6.4)。 * appendEntry 不进 LLM 上下文,零 token 成本。 */ export const SUBSCRIPTION_CUSTOM_TYPE = "channel-subs"; +/** + * 持久 payload(spec §6.3): + * - `cursors` 整个 key 缺失 = 旧版载荷(未知基线),**不等价于某项值为 `null`**; + * - `string` = 最后已确认的 `MESSAGES.md` 内容 hash; + * - `null` = 确认当时文件不存在(后续文件被创建要能识别为变化)。 + */ export interface SubsPayload { topics: string[]; - /** - * topic → 最后已确认的 `MESSAGES.md` 内容 hash。 - * - string:最后已确认的内容 hash; - * - `null`:确认当时文件不存在; - * - **key 缺失**:旧版(只有 topics)/未知基线,不等价于 `null`; - * 只保留仍在 topics 中的安全 topic。 - */ cursors?: Record; } -/** 构造 appendEntry 载荷(全量快照,排序稳定便于测试) */ -export function buildSubsPayload( - topics: Iterable, - cursors?: ReadonlyMap, -): SubsPayload { - // 阶段 0.1 占位 seam:cursors 载荷生成见 plan 阶段 2.1(此处不实现) - void cursors; - return { topics: [...topics].sort() }; -} - -/** - * 恢复结果:topics + 每个 topic 的 cursor。 - * **`cursors` 里没有 key = 未知基线(旧 payload)**,与值 `null`(确认当时文件不存在)语义不同。 - */ +/** 恢复结果:topics + 每个 topic 的 cursor(`cursors` 里没有 key = 未知基线,与值 null 不同) */ export interface RestoredSubscriptions { topics: string[]; cursors: Map; } +/** topic 是否可安全拼路径(与 channel_subscribe 工具同一套校验,单一事实源) */ +function isSafeTopic(topic: string): boolean { + return typeof topic === "string" && validateTopic(topic) === null; +} + +/** cursor 值是否合法(非空 string 或 null;其余脏值忽略) */ +function isSafeCursor(value: unknown): value is string | null { + return value === null || (typeof value === "string" && value.length > 0); +} + /** - * 从会话 entries 恢复「订阅 + 游标」完整状态(spec §6.3/§6.4)。 - * 阶段 0.1 占位 seam:真实解析(last-wins / 脏值容错 / missing≠null)见 plan 阶段 2.1。 + * 构造 appendEntry 载荷(全量快照:topics 排序稳定便于测试;cursors 只留仍在订阅集的安全 topic)。 + * 没传 `cursors`(旧调用点)= 只写 topics;传了(含空 map)就写 cursors 字段。 */ -export function restoreSubscriptionState(_entries: unknown): RestoredSubscriptions { - throw new Error("restoreSubscriptionState 尚未实现(plan 阶段 2.1)"); +export function buildSubsPayload( + topics: Iterable, + cursors?: ReadonlyMap, +): SubsPayload { + const safeTopics = [...new Set(topics)].filter(isSafeTopic).sort(); + if (!cursors) return { topics: safeTopics }; + const subscribed = new Set(safeTopics); + const safeCursors: Record = {}; + // 排序写入:快照内容只随真实变化变化,便于人读与 diff + for (const topic of [...subscribed].sort()) { + const value = cursors.get(topic); + if (isSafeCursor(value)) safeCursors[topic] = value; + } + return { topics: safeTopics, cursors: safeCursors }; } /** - * 从会话 entries 恢复订阅集。entries 为 SDK SessionEntry 数组(含 message/custom 等), - * 取**最后一条** customType 匹配的 data.topics(last-wins:退订后 append 过空集合, - * 恢复必须得到空集,否则退订的频道会在 resume 后幽灵复活)。非法形态容错(跳过),绝不 throw。 + * 从会话 entries 恢复「订阅 + 游标」完整状态(spec §6.3/§6.4):单次遍历、last-wins 整体覆盖。 + * + * - 非法 entry(非 custom/channel-subs、data 缺失、topics 非数组)跳过且**不覆盖**此前合法快照; + * - 非法 cursor 项(非 string/null、空串)忽略,但不影响同条目里的 topics; + * - 只保留安全 topic(validateTopic)与其游标——游标 key 不在最终订阅集里的一律丢弃 + * (退订不留幽灵基线);非安全 topic 丢弃(防从会话文件注入越界路径)。 + * + * 绝不 throw(恢复失败 = 空订阅,用户重新 subscribe 即可)。 */ -export function restoreSubscriptions(entries: unknown): Set { - let result: string[] = []; +export function restoreSubscriptionState(entries: unknown): RestoredSubscriptions { + let topics: string[] = []; + let cursors = new Map(); try { - if (!Array.isArray(entries)) return new Set(result); + if (!Array.isArray(entries)) return { topics, cursors }; for (const entry of entries) { - const e = entry as { - type?: string; - customType?: string; - data?: { topics?: unknown }; - }; - if (e?.type !== "custom" || e?.customType !== SUBSCRIPTION_CUSTOM_TYPE) continue; - const topics = e?.data?.topics; - if (!Array.isArray(topics)) continue; - // last-wins:每条匹配 entry 整体覆盖(非法项跳过,不中断后续 entry) - result = topics.filter((t): t is string => typeof t === "string" && t.length > 0); + const e = entry as { type?: unknown; customType?: unknown; data?: unknown } | null | undefined; + if (e?.type !== "custom" || e.customType !== SUBSCRIPTION_CUSTOM_TYPE) continue; + const data = e.data as { topics?: unknown; cursors?: unknown } | null | undefined; + const rawTopics = data?.topics; + if (!Array.isArray(rawTopics)) continue; // 非法 entry:保留此前合法快照(不覆盖) + // last-wins:本条目整体覆盖(含 cursors——旧载荷没有这个 key,就退化为未知基线) + topics = [...new Set(rawTopics.filter((t): t is string => isSafeTopic(t as string)))]; + cursors = new Map(); + const rawCursors = data?.cursors; + if (rawCursors && typeof rawCursors === "object" && !Array.isArray(rawCursors)) { + const subscribed = new Set(topics); + for (const [topic, value] of Object.entries(rawCursors as Record)) { + if (!subscribed.has(topic) || !isSafeCursor(value)) continue; + cursors.set(topic, value); + } + } } } catch { - // 容错:恢复失败 = 空订阅(用户重新 subscribe 即可) + // 容错:恢复失败 = 空订阅 } - return new Set(result); + return { topics, cursors }; +} + +/** + * 兼容旧 API(只要订阅集):与 `restoreSubscriptionState` 同一套解析(单一事实源), + * 不再单独实现一份 last-wins 逻辑。 + */ +export function restoreSubscriptions(entries: unknown): Set { + return new Set(restoreSubscriptionState(entries).topics); } diff --git a/packages/backend/src/tools/channel-watch/tools.ts b/packages/backend/src/tools/channel-watch/tools.ts index 35538525..a634c960 100644 --- a/packages/backend/src/tools/channel-watch/tools.ts +++ b/packages/backend/src/tools/channel-watch/tools.ts @@ -22,7 +22,7 @@ export interface ChannelToolDeps { /** 项目根(会话 cwd) */ cwd: string; /** 订阅一个频道(extension 闭包:改订阅集 + appendEntry + 惰性激活 watcher + 恢复暂停) */ - subscribe(topic: string): SubscribeOutcome; + subscribe(topic: string): Promise; /** 退订(空集时停 watcher;guard.forgetTopic) */ unsubscribe(topic: string): { ok: boolean; error?: string }; /** 发消息到频道(extension 闭包:append MESSAGES.md + guard.markSelfWrite 自写抑制) */ @@ -65,7 +65,7 @@ export function makeChannelTools(deps: ChannelToolDeps): ToolDefinition[] { const topic = String((params as { topic?: unknown }).topic ?? "").trim(); const invalid = validateTopic(topic); if (invalid) return textResult(`订阅失败:${invalid}`); - const result = deps.subscribe(topic); + const result = await deps.subscribe(topic); if (!result.ok) return textResult(`订阅失败:${result.error ?? "未知错误"}`); const resumedNote = result.resumed ? "(该频道此前因频繁互触发被暂停,本次订阅已恢复)" : ""; return textResult( diff --git a/packages/backend/test/channel-watch-extension.test.ts b/packages/backend/test/channel-watch-extension.test.ts index d04d71a7..71582e5d 100644 --- a/packages/backend/test/channel-watch-extension.test.ts +++ b/packages/backend/test/channel-watch-extension.test.ts @@ -1,7 +1,7 @@ import { appendFile, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; import { buildWakeMessage, type ChannelWatchOptions, @@ -258,7 +258,11 @@ describe("extension 全链路", () => { const { pi } = await wire({ cwd }); const r = await subscribe(pi, "t1"); expect(r.content[0]?.text).toContain("已订阅频道 [t1]"); - expect(pi.appended.at(-1)).toEqual({ customType: SUBSCRIPTION_CUSTOM_TYPE, data: { topics: ["t1"] } }); + expect(pi.appended.at(-1)).toEqual({ + customType: SUBSCRIPTION_CUSTOM_TYPE, + // 全量快照从阶段 2 起带 cursors(首次订阅尚无文件 → 基线 null) + data: { topics: ["t1"], cursors: { t1: null } }, + }); // init 已建目录 await mkdir(join(cwd, ".local/agent-work/channel/t1"), { recursive: true }); // 写文件 ≠ 通知:非 MESSAGES 文件静默(多文件写入不再产生多条唤醒) @@ -326,7 +330,11 @@ describe("extension 全链路", () => { await subscribe(pi, "t1"); const unsub = pi.tools.find((t) => t.name === "channel_unsubscribe"); await unsub?.execute("tc2", { topic: "t1" }); - expect(pi.appended.at(-1)).toEqual({ customType: SUBSCRIPTION_CUSTOM_TYPE, data: { topics: [] } }); + expect(pi.appended.at(-1)).toEqual({ + customType: SUBSCRIPTION_CUSTOM_TYPE, + // 退订最后一个 topic:订阅集与游标一起清空(不留幽灵基线) + data: { topics: [], cursors: {} }, + }); // 对端 post 也不唤醒(无订阅) await mkdir(join(cwd, ".local/agent-work/channel/t1"), { recursive: true }); await writeFile(join(cwd, ".local/agent-work/channel/t1/MESSAGES.md"), "## t\n\nx\n\n---\n"); @@ -891,4 +899,79 @@ describe("P1 · watcher single-flight", () => { await sleep(250); expect(pi.wakes.filter((w) => w.includes("[channel:t1]"))).toHaveLength(1); }); + + it("启动失败:promise 清空、订阅不报错,下次订阅能重试成功(不把失败缓存住)", async () => { + const cwd = join(testRoot, "watcher-retry"); + let attempts = 0; + const { pi } = await wire({ + cwd, + watcherFactory: (o) => { + attempts += 1; + if (attempts === 1) { + // 第一次:start 直接拒(模拟 fs.watch / 轮询初始化失败) + return { + start: () => Promise.reject(new Error("watch boom")), + stop: () => {}, + mode: "idle", + } as unknown as ChannelWatcher; + } + return new ChannelWatcher({ ...o, debounceMs: 20, pollIntervalMs: 20 }); + }, + }); + const r1 = await subscribe(pi, "t1"); + expect(r1.content[0]?.text).toContain("已订阅频道 [t1]"); // 启动失败不影响订阅本身 + const r2 = await subscribe(pi, "t2"); + expect(r2.content[0]?.text).toContain("已订阅频道 [t2]"); + expect(attempts).toBe(2); // 失败那轮的 promise 已清空 → 真的重试了 + + // 重试后的实例真在工作(写文件能唤醒) + await mkdir(topicDir(cwd, "t2"), { recursive: true }); + await writeFile(messagesFile(cwd, "t2"), "after-retry\n"); + await sleep(250); + expect(pi.wakes.filter((w) => w.includes("[channel:t2]"))).toHaveLength(1); + }); + + it("启动期间 shutdown:完成后自停,不留孤儿 watcher;之后还能重新起", async () => { + const cwd = join(testRoot, "watcher-orphan"); + const stop = vi.fn(); + let releaseStart: (() => void) | undefined; + let startEntered: (() => void) | undefined; + const entered = new Promise((resolve) => { + startEntered = resolve; + }); + let attempts = 0; + const { pi } = await wire({ + cwd, + watcherFactory: (o) => { + attempts += 1; + if (attempts === 1) { + return { + start: () => { + startEntered?.(); + return new Promise<"watch">((resolve) => { + releaseStart = () => resolve("watch"); + }); + }, + stop, + mode: "idle", + } as unknown as ChannelWatcher; + } + return new ChannelWatcher({ ...o, debounceMs: 20, pollIntervalMs: 20 }); + }, + }); + const tool = pi.tools.find((t) => t.name === "channel_subscribe"); + if (!tool) throw new Error("channel_subscribe 未注册"); + const pending = tool.execute("c1", { topic: "t1" }); + await entered; // 确定已走到 start() + + await pi.emit({ type: "session_shutdown" }, makeFakeCtx()); + releaseStart?.(); + await pending; + expect(stop).toHaveBeenCalledTimes(1); // 作废的那一轮在 start 返回后自停 + + // 不留死结:再次 session_start(shutdown 后 trusted 已置 false)后订阅能起新实例 + await pi.emit({ type: "session_start" }, makeFakeCtx()); + await subscribe(pi, "t2"); + expect(attempts).toBe(2); + }); }); From c181e4e8a853430535746eab97a3eedceac22d90 Mon Sep 17 00:00:00 2001 From: Jaxton07 Date: Sun, 20 Sep 2026 18:02:32 +0800 Subject: [PATCH 5/8] =?UTF-8?q?fix(channel-watch):=20=E9=98=B6=E6=AE=B5=20?= =?UTF-8?q?2.1=20=E2=80=94=20=E4=BF=AE=E4=B8=89=E5=A4=84=E6=8C=81=E4=B9=85?= =?UTF-8?q?=E6=8A=95=E9=80=92=E7=AB=9E=E6=80=81=EF=BC=88=E8=87=AA=E5=86=99?= =?UTF-8?q?=E4=B8=8D=E8=B7=A8=E8=BF=87=E6=9C=AA=E7=A1=AE=E8=AE=A4=E5=A4=96?= =?UTF-8?q?=E9=83=A8=E7=89=88=E6=9C=AC=20/=20=E8=B7=A8=20await=20=E7=94=9F?= =?UTF-8?q?=E5=91=BD=E5=91=A8=E6=9C=9F=E5=A4=8D=E6=9F=A5=20/=20=E5=8F=AA?= =?UTF-8?q?=E8=AE=A4=E4=B8=BB=E9=A2=98=E6=A0=B9=20MESSAGES.md=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/INDEX.md | 2 +- .../src/tools/channel-watch/extension.ts | 161 +++++++++--- .../test/channel-watch-extension.test.ts | 246 ++++++++++++++++++ 3 files changed, 376 insertions(+), 33 deletions(-) diff --git a/docs/INDEX.md b/docs/INDEX.md index bc54fb81..66a332c4 100644 --- a/docs/INDEX.md +++ b/docs/INDEX.md @@ -121,7 +121,7 @@ src/ | `src/tools/todo.ts` + `todo-reminder.ts` | `makeTodoTool`、`makeTodoReminderExtension` | todo 工具(全量替换协议,`details.todos` 供 UI)+ 恢复扩展(context 钩子注入 `customType:"todo-reminder"`——用 context 而非 before_agent_start:overflow willRetry 同 run 重试也覆盖) | | `src/tools/subagent/` | `makeSubagentTool`、`runSubagent`、`applySubagentMutex`、`resolveSubagentPreferBuiltin`、`discoverAgents` | 内置进程内 subagent:single `{agent,task}` + parallel `{tasks[]}` cap 8/并发 4;子代理 = 共享 modelRuntime 的隔离 `AgentSession`(资源最小化 + 权限桥父 gate,完成等 `agent_settled`);**思考档位链**:设置页覆盖 > agent frontmatter > SDK 默认链(显式传 `thinkingLevel`,实际生效值回传 `SingleResult`);会话文件落 `sessions-subagents/`(打开即只读);**参数 schema 必须拍平单 object**(DeepSeek 对顶层无 `type:"object"` 直接 400,严禁顶层 Union/Intersect);互斥 = 同名覆盖 + `subagent_*` 家族停用(发 `subagent_mutex` 事件);runner 转发子会话原生事件(检视页实时收流);agent 定义:内置 scout → user `~/.pi/agent/agents/` → project `.pi/agents/`(仅 trusted) | | `src/tools/context-evaporation/` | `makeEvapExtension`、`readContextManagerMode`、`writeContextManagerMode` | 内置上下文蒸发扩展(**默认开启**,二态蒸发/off):context 钩子把到龄工具输出按四级水位线蒸发为 stub(零 LLM、决策单调持久化保 KV cache);核心三文件(types/estimate/evaporate)零 SDK/零仓库 import(replay `--core` 同构验证);二态配置单 key 单一写者(写侧顺带清遗留键);调参走 `scripts/replay-evaporation.mts`;批次观测 = log + trace_custom 行 | -| `src/tools/channel-watch/` | `makeChannelWatchExtension` | 内置跨会话频道协作扩展(默认开):订阅 `.local/agent-work/channel//`,**消息 = 意图:写文件 ≠ 通知,channel_post 工具才唤醒**(根治一次任务写 N 文件 = N 唤醒);防环三层(自写窗口 + hash 去重 + 乒乓上限暂停);**持久内容游标**(`channel-subs` 快照 = topics + `cursors`,`null` = 确认时文件不存在、key 缺失 = 旧载荷未知基线):首次订阅先记基线,session_start 就绪后逐 topic 对账补投**一次**(离线期间的新消息不丢、不重复),live 投递/自写抑制推进游标,paused 不推进(重新 subscribe 合并补投一次),文件删除推 null 不唤醒,`unsubscribe` 删游标;watcher `ensureWatcher` 为 single-flight(失败可重试,shutdown 不留孤儿);`closed:true` 终态信号(查收后退订仍靠 skill 协议);**有效订阅快照上报 backend**(`onSubscriptionsChanged`:enabled+trusted 才计入,disabled/untrusted/shutdown 上报空集,回调异常只记日志)→ 有订阅的会话不被 renderer 自动 GC;分模块 config/init/guard/watcher/subscriptions/post/tools/extension;配套 skill channel-pickup/design-handoff | +| `src/tools/channel-watch/` | `makeChannelWatchExtension` | 内置跨会话频道协作扩展(默认开):订阅 `.local/agent-work/channel//`,**消息 = 意图:写文件 ≠ 通知,channel_post 工具才唤醒**(根治一次任务写 N 文件 = N 唤醒);防环三层(自写窗口 + hash 去重 + 乒乓上限暂停);**持久内容游标**(`channel-subs` 快照 = topics + `cursors`,`null` = 确认时文件不存在、key 缺失 = 旧载荷未知基线):首次订阅先记基线,session_start 就绪后逐 topic 对账补投**一次**(离线期间的新消息不丢、不重复),live 投递推进游标;**自写抑制仅在「写前磁盘版本 == 游标」时推进**,否则保留 pending(否则会把对端已写未提醒的内容吞掉),paused 不推进(重新 subscribe 合并补投一次),文件删除推 null 不唤醒,`unsubscribe` 删游标;**跨 await 续体一律复查 active/trusted/订阅**(退订/shutdown 落在读 hash 或 watcher 启动期间不产生幽灵投递/游标/孤儿 watcher);只接受 `/MESSAGES.md`(嵌套同名文件不污染游标);watcher `ensureWatcher` 为 single-flight(失败可重试,shutdown 不留孤儿);`closed:true` 终态信号(查收后退订仍靠 skill 协议);**有效订阅快照上报 backend**(`onSubscriptionsChanged`:enabled+trusted 才计入,disabled/untrusted/shutdown 上报空集,回调异常只记日志)→ 有订阅的会话不被 renderer 自动 GC;分模块 config/init/guard/watcher/subscriptions/post/tools/extension;配套 skill channel-pickup/design-handoff | | `src/settings/settings.ts` | `SettingsService` | provider/模型/凭证读写(key 走环境变量引用,绝不落明文)。listProviders 默认本地 refresh(`allowNetwork:false`),显式 forceNetwork 才联网;custom provider 增改走 `buildCustomEntry`(未设字段不落盘);模型列表留空 = 覆写 baseUrl 共享官方列表;`setProviderBaseUrl` = 内置 provider 端点覆写专用;移除凭证走 `runtime.logout()`(直接删文件残留内存态);`apiKeyLogin` 标记 = 内置 provider 有交互式 api_key 登录(UI 显示「登录」入口) | | `src/settings/model-prefs.ts` | `ModelPrefsService` | `model-prefs.json`:隐藏模型 + 停用 provider + per-agent 子代理模型/思考深度(读侧白名单,空 map 不落盘)+ `subagentPreferBuiltin` 执行器开关(缺省/脏值 = true);`listModels()` 唯一出口过滤 | | `src/settings/login.ts` | `LoginService` | provider 交互登录桥接:AuthInteraction → IPC 事件(prompt 挂起等 renderer 应答;浏览器先到则拒挂起 prompt)。支持 OAuth + api_key 交互登录(如 Google Vertex);`filterAuthSelectOptions` 对 google-vertex 剔除必败的 api-key 选项(Vertex 不接受 API key,见 PITFALLS) | diff --git a/packages/backend/src/tools/channel-watch/extension.ts b/packages/backend/src/tools/channel-watch/extension.ts index e7138a35..cae65df6 100644 --- a/packages/backend/src/tools/channel-watch/extension.ts +++ b/packages/backend/src/tools/channel-watch/extension.ts @@ -1,5 +1,5 @@ import { readFile } from "node:fs/promises"; -import { basename, isAbsolute, join, resolve } from "node:path"; +import { isAbsolute, join, resolve } from "node:path"; import type { ExtensionContext, InlineExtension } from "@earendil-works/pi-coding-agent"; import { createLogger } from "../../log"; import { readChannelWatchEnabled } from "./config"; @@ -22,9 +22,9 @@ const log = createLogger("channel-watch"); * → 非空订阅惰性起 watcher(single-flight)→ 上报有效订阅快照(enabled+trusted 才计入) * → 逐 topic 对账补投(离线期间的新消息) * - input:真人/rpc 消息介入 → 清乒乓计数(source!=="extension") - * - tool_call:write/edit 目标 → guard.markSelfWrite(自写抑制) - * - watcher onEvent:订阅过滤 → 仅 MESSAGES.md → 读文件 hash → cursor 去重 → guard.shouldDeliver - * (自写/hash/暂停)→ 投递(投递必推进 cursor;自写抑制也推进;paused 不推进) + * - tool_call:write/edit 目标 → guard.markSelfWrite + 记「写前磁盘版本」(自写推进 cursor 的前置条件) + * - watcher onEvent:订阅过滤 → **仅 `/MESSAGES.md`** → 读文件 hash → 跨 await 复查生命周期 + * → cursor 去重 → guard.shouldDeliver(自写/hash/暂停)→ 投递 * → sendUserMessage({deliverAs:"followUp"})(流式中排队、空闲立即——SDK prompt 语义) * → recordDelivered(hash 快照 + 乒乓计数,上限触发暂停 + notify) * - session_shutdown:watcher.stop + guard.reset(幂等) @@ -93,6 +93,11 @@ export function makeChannelWatchExtension(options: ChannelWatchOptions): InlineE * 与订阅集同一份持久快照:每次推进都 append 全量(spec §6.3/§6.5)。key 缺失 = 未知基线。 */ const cursors = new Map(); + /** + * 自写前的磁盘版本(topic → hash|null):write/edit 的 tool_call 在工具真正落盘**之前**记录。 + * 自写抑制只有在这个值等于当前 cursor 时才能推进 cursor(= 期间没有未确认的外部变化)。 + */ + const selfWritePreVersion = new Map(); /** watcher 世代:stop/shutdown 时 +1,用于作废「启动中」的那一轮(不挂孤儿 watcher) */ let watcherEpoch = 0; let watcherPromise: Promise | null = null; @@ -163,8 +168,23 @@ export function makeChannelWatchExtension(options: ChannelWatchOptions): InlineE } }; - /** 订阅频道主文件绝对路径(topic 已在 subscribe/restore 侧过 validateTopic) */ - const messagesPath = (topic: string): string => join(topicDir(options.cwd, topic), MESSAGES_FILE); + /** 订阅频道主文件绝对路径(已 normalize:可与 tool_call 的 resolve 结果直接比较) */ + const messagesPath = (topic: string): string => resolve(topicDir(options.cwd, topic), MESSAGES_FILE); + + /** + * 本会话当前是否仍处于「可工作的有效运行态」(enabled + trusted + 至少一个订阅)。 + * **所有跨 await 的续体都要复查它**:await 期间可能发生 shutdown、退订、 + * 甚至 watcher 重建,若不复查就会出现幽灵投递/幽灵 cursor/孤儿 watcher(REVIEW 2.1 阻塞 2)。 + */ + const isLive = (): boolean => active && trusted && subscriptions.size > 0; + + /** abs 路径若正是某个已订阅频道的主消息文件,返回该 topic(其余一律 null) */ + const subscribedTopicOfPath = (abs: string): string | null => { + for (const topic of subscriptions) { + if (messagesPath(topic) === abs) return topic; + } + return null; + }; /** 当前版本(文件不存在/不可读 → null) */ const currentHash = (topic: string): Promise => readHash(messagesPath(topic)); @@ -195,6 +215,26 @@ export function makeChannelWatchExtension(options: ChannelWatchOptions): InlineE } }; + /** + * 自写抑制后的 cursor 处理(spec §6.5 + REVIEW 2.1 阻塞 1):**只有写前磁盘版本就等于当前 cursor** + * (= 期间没有未确认的外部变化)才能直接推进。否则保留 pending:直接推进会将 + * 「对端已写、本会话还没被提醒」的内容永久吞掉(paused 期间的写入、或落在防抖窗口里的外部写入)。 + * pending 会在下一次外部变化/live 事件/重新 subscribe/session_start 对账时被命中并补投一次。 + */ + const advanceAfterSelfWrite = (topic: string, hash: string): void => { + const before = selfWritePreVersion.get(topic); + selfWritePreVersion.delete(topic); // 用完即弃(下次 write/edit 重新记录) + if (before !== undefined && before === cursors.get(topic)) { + advanceCursor(topic, hash); + return; + } + log.info("自写抑制但保留 pending", { + sessionId: shortId(), + topic, + reason: before === undefined ? "pre-version-unknown" : "unconfirmed-external-change", + }); + }; + /** * 单 topic 版本对账(session_start 恢复后 / 首次订阅 watcher 就绪后): * - 已知 cursor 与当前版本不同 → 补投一次(离线期间的写入); @@ -204,8 +244,11 @@ export function makeChannelWatchExtension(options: ChannelWatchOptions): InlineE */ const reconcileTopic = async (topic: string): Promise => { try { + if (!subscriptions.has(topic)) return; const before = cursors.get(topic); const hash = await currentHash(topic); + // 跨 await 复查:退订/生命周期结束 → 不投递也不复活 cursor + if (!isLive() || !subscriptions.has(topic)) return; if (cursors.get(topic) !== before) return; if (before === hash) return; if (hash === null) { @@ -228,13 +271,16 @@ export function makeChannelWatchExtension(options: ChannelWatchOptions): InlineE const onWatchEvent = (event: ChannelWatchEvent): void => { try { if (!active || !subscriptions.has(event.topic)) return; - // 触发收窄(spec channel-post):仅 MESSAGES.md 投递唤醒,其余频道文件静默 - if (basename(event.relPath) !== MESSAGES_FILE) return; + // 只接受频道根目录下的主消息文件:`/MESSAGES.md` + // (嵌套同名文件 topic/sub/MESSAGES.md 不是频道主文件,不得唤醒也不得污染 cursor) + if (event.relPath !== `${event.topic}/${MESSAGES_FILE}`) return; const abs = join(channelRoot(options.cwd), event.relPath); void (async () => { // fire-and-forget 分支内部必须完整 catch(不得漏出 unhandled rejection) try { const hash = await readHash(abs); + // 跨 await 复查:退订/shutdown 落在读 hash 期间 → 既不投递也不改 cursor + if (!isLive() || !subscriptions.has(event.topic)) return; if (hash === null) { // 文件被删/不可读:不唤醒;已知 cursor 则推进为 null(未来重建能识别为变化) if (cursors.has(event.topic) && cursors.get(event.topic) !== null) { @@ -258,10 +304,10 @@ export function makeChannelWatchExtension(options: ChannelWatchOptions): InlineE topic: event.topic, reason: decision.reason, }); - // 自写抑制:那是本会话自己的写入,无需唤醒;但 cursor 必须推进—— - // 否则 shutdown 发生在 watcher 防抖前时,重开会把自己的写入当离线新消息补投(§6.5) - if (decision.reason === "self-write" || decision.reason === "hash-unchanged") { - advanceCursor(event.topic, hash); + // 自写抑制:那是本会话自己的写入,无需唤醒;cursor 是否可直接推进看写前版本 + // (仅当写前版本 == cursor,即期间无未确认外部变化时才能跟到 hash) + if (decision.reason === "self-write") { + advanceAfterSelfWrite(event.topic, hash); } // paused:不推进(显式重新 subscribe 后可合并补投一次) return; @@ -285,18 +331,35 @@ export function makeChannelWatchExtension(options: ChannelWatchOptions): InlineE * 失败不抛(清空 promise,下次订阅可重试);启动期间 shutdown 已作废本轮 → 停掉自己,不挂孤儿。 */ const ensureWatcher = (): Promise => { + if (!isLive()) return Promise.resolve(); // 无有效订阅/未受信任/已 shutdown:不起 watcher if (watcher) return Promise.resolve(); if (watcherPromise) return watcherPromise; const epoch = watcherEpoch; - const w = makeWatcher({ - channelRoot: channelRoot(options.cwd), - onEvent: onWatchEvent, - }); + let w: ChannelWatcher; + try { + w = makeWatcher({ + channelRoot: channelRoot(options.cwd), + onEvent: onWatchEvent, + }); + } catch (err) { + // 工厂同步抛错也要隔开(钩子不得 throw) + log.warn("channel watcher 工厂失败,下次订阅重试", { + error: err instanceof Error ? err.message : String(err), + }); + return Promise.resolve(); + } const run = (async () => { try { const mode = await w.start(); // fs.watch 失败自动降级轮询,无 failed 分支 - if (epoch !== watcherEpoch) { - w.stop(); + // 跨 await 复查:启动期间 shutdown/退订 → 停掉自己,不挂孤儿 + if (epoch !== watcherEpoch || !isLive()) { + try { + w.stop(); + } catch (err) { + log.warn("丢弃的 watcher 停止失败", { + error: err instanceof Error ? err.message : String(err), + }); + } return; } watcher = w; @@ -308,10 +371,12 @@ export function makeChannelWatchExtension(options: ChannelWatchOptions): InlineE } })(); watcherPromise = run; - void run.finally(() => { - // 只清自己这一轮(期间可能已 stop/重建过) + // 清理引用:用双 handler 的 then(而非 `void run.finally(...)`——finally 会派生出新 promise, + // 若 run 意外 reject 会变成未处理拒绝);只在还是自己这一轮时清空 + const settle = (): void => { if (watcherPromise === run) watcherPromise = null; - }); + }; + run.then(settle, settle); return run; }; @@ -319,7 +384,11 @@ export function makeChannelWatchExtension(options: ChannelWatchOptions): InlineE watcherEpoch += 1; // 作废「启动中」的那一轮(它会在 start 返回后自停) watcherPromise = null; // 让下一次 ensureWatcher 能真起新实例 if (watcher) { - watcher.stop(); + try { + watcher.stop(); + } catch (err) { + log.warn("watcher 停止失败", { error: err instanceof Error ? err.message : String(err) }); + } watcher = null; } }; @@ -345,11 +414,18 @@ export function makeChannelWatchExtension(options: ChannelWatchOptions): InlineE // 首次订阅:先把当前版本记为基线(已有历史不提醒),再起 watcher,最后二次对账: // 「读基线 ↔ watcher 就绪」之间写入的新消息不能丢(spec §6.4) if (!cursors.has(topic)) { - cursors.set(topic, await currentHash(topic)); + const baseline = await currentHash(topic); + // 跨 await 复查:(a)期间可能已被 live/另一个并发 subscribe 推进——陈旧基线不得回退; + // (b)期间可能已退订或 shutdown——此时写回就是幽灵状态 + if (!isLive() || !subscriptions.has(topic)) return { ok: true, resumed }; + if (!cursors.has(topic)) cursors.set(topic, baseline); } + if (!isLive() || !subscriptions.has(topic)) return { ok: true, resumed }; persist(); reportSubscriptions(); await ensureWatcher(); + // 跨 await 复查(shutdown/退订可能落在基线读或 watcher 启动期间) + if (!isLive() || !subscriptions.has(topic)) return { ok: true, resumed }; // 二次对账:封住启动竞态;也是 paused 恢复后的「合并补投一次」入口(§6.5) await reconcileTopic(topic); return { ok: true, resumed }; @@ -363,17 +439,30 @@ export function makeChannelWatchExtension(options: ChannelWatchOptions): InlineE } try { const sessionId = lastCtx?.sessionManager.getSessionId(); - const file = await appendPost(options.cwd, { topic, message, closed, sessionId }); + const file = messagesPath(topic); + // 帖前版本:只有「本会话已确认到帖前版本」时,post 才能把 cursor 推到帖后版本 + const before = subscriptions.has(topic) ? await readHash(file) : null; + await appendPost(options.cwd, { topic, message, closed, sessionId }); // appendFile 不经 write/edit tool_call 钩子,手动标记自写 // (否则「自订阅频道自 post」会自我唤醒) guard.markSelfWrite(file); - // 本会话也订阅了该 topic 时主动推进 cursor(spec §6.5):防 shutdown 死在 watcher - // 防抖之前,重开时把自己的写入当离线新消息补投 - if (subscriptions.has(topic)) { + // 跨 await 复查订阅状态(期间可能退订/已 shutdown) + if (isLive() && subscriptions.has(topic)) { try { - advanceCursor(topic, await readHash(file)); + if (before === cursors.get(topic)) { + // 帖前磁盘版本就是 cursor(含「文件不存在且 cursor 也是 null」)→ 帖子不含未确认 + // 外部内容,可直接推进(避免 shutdown 死在 watcher 防抖前时把自己的写入当离线补投) + const after = await readHash(file); + if (after !== null && isLive() && subscriptions.has(topic)) { + advanceCursor(topic, after); + } + } else { + // 帖前磁盘版本 ≠ cursor:帖子里混着未确认的外部内容(paused 期间的对端写入 / + // 落在防抖窗口里的写入)→ 保留 pending,交给下一次 live 事件或对账补投 + log.info("post 后保留 pending cursor", { sessionId: shortId(), topic }); + } } catch (err) { - log.warn("post 后推进 cursor 失败(后续 watcher 事件会收敛)", { + log.warn("post 后推进 cursor 失败(后续对账会收敛)", { error: err instanceof Error ? err.message : String(err), }); } @@ -470,16 +559,24 @@ export function makeChannelWatchExtension(options: ChannelWatchOptions): InlineE }); // 自写抑制:write/edit 目标路径(bash 写入不可靠,由 hash 去重 + 防抖兜底) - pi.on("tool_call", (event) => { + pi.on("tool_call", async (event) => { try { if (event.toolName !== "write" && event.toolName !== "edit") return undefined; const input = event.input as { path?: unknown; file?: unknown }; const raw = typeof input?.path === "string" ? input.path : input?.file; if (typeof raw !== "string" || raw.length === 0) return undefined; - const abs = isAbsolute(raw) ? raw : resolve(options.cwd, raw); + const abs = isAbsolute(raw) ? resolve(raw) : resolve(options.cwd, raw); guard.markSelfWrite(abs); + // 记「写前磁盘版本」:工具还没真正落盘,此刻读到的是写入前的版本。 + // 只对我们订阅的频道主消息文件记(其他路径的自写与 cursor 无关)。 + const topic = subscribedTopicOfPath(abs); + if (topic) selfWritePreVersion.set(topic, await readHash(abs)); return undefined; - } catch { + } catch (err) { + // 钩子不得 throw(读盘失败只影响自写推进判断,一律按「写前版本未知」保守处理) + log.warn("tool_call 自写标记失败", { + error: err instanceof Error ? err.message : String(err), + }); return undefined; } }); diff --git a/packages/backend/test/channel-watch-extension.test.ts b/packages/backend/test/channel-watch-extension.test.ts index 71582e5d..7cc34105 100644 --- a/packages/backend/test/channel-watch-extension.test.ts +++ b/packages/backend/test/channel-watch-extension.test.ts @@ -975,3 +975,249 @@ describe("P1 · watcher single-flight", () => { expect(attempts).toBe(2); }); }); + +// --------------------------------------------------------------------------- +// 阶段 2.1(REVIEW 2026-09-20 17:58 的阻塞 1/2/3):持久投递竞态 +// 以下用例先红后修:钉住「自写不得跨过未确认的外部版本」「跨 await 续体服从最新生命周期」 +// 「只认 topic 根目录的主消息文件」三条不变量。 +// --------------------------------------------------------------------------- + +/** 轮询等待条件成立(避免用真实防抖时长凑时序) */ +async function waitFor(predicate: () => boolean, timeoutMs = 2000): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + if (predicate()) return; + await sleep(10); + } + throw new Error("waitFor 超时"); +} + +describe("P2.1 · 阻塞 1:自写不得跨过未确认的外部版本", () => { + it("paused 期间的外部变化 + 本会话 post:cursor 保留 pending,重新 subscribe 合并补投恰好一次", async () => { + const cwd = join(testRoot, "post-pending"); + const { pi } = await wireCur({ cwd }); + await subscribe(pi, "t1"); + const mf = messagesFile(cwd, "t1"); + await mkdir(topicDir(cwd, "t1"), { recursive: true }); + + // 前 6 次变化投递,第 6 次触发乒乓暂停(之后外部写入被抑制且不推进 cursor) + for (let i = 0; i < 6; i++) { + await writeFile(mf, `v${i}\n`); + await sleep(150); + } + expect(pi.wakes).toHaveLength(6); + const stuck = contentHash("v5\n"); + expect(lastPayload(pi).cursors).toEqual({ t1: stuck }); + + await writeFile(mf, "v6\n"); + await sleep(150); + await writeFile(mf, "v7\n"); + await sleep(150); + expect(pi.wakes).toHaveLength(6); + + // 本会话 post:写前磁盘版本(v7)已超前 cursor(v5)→ 不得直接推进 + await post(pi, "t1", "本会话的话"); + expect(lastPayload(pi).cursors).toEqual({ t1: stuck }); + + // 重新 subscribe 恢复 → 合并补投恰好一次 + await subscribe(pi, "t1"); + await sleep(80); + expect(pi.wakes).toHaveLength(7); + expect(lastPayload(pi).cursors).toEqual({ t1: contentHash(await readFile(mf, "utf8")) }); + }); + + it("自写前的磁盘版本已超前 cursor(外部变化尚未确认)→ 不被 self-write 吞掉", async () => { + const cwd = join(testRoot, "selfwrite-pending"); + await mkdir(topicDir(cwd, "t1"), { recursive: true }); + const mf = messagesFile(cwd, "t1"); + await writeFile(mf, "v1\n"); + + // 注入读序:watcher 对本文件的下一次读返回陈旧版本(模拟「外部写入尚未被确认」) + let staleOnce = false; + const { pi } = await wireCur({ + cwd, + readFileHash: async (abs) => { + if (abs === mf && staleOnce) { + staleOnce = false; + return contentHash("v1\n"); + } + return contentHashOrNull(abs); + }, + }); + await subscribe(pi, "t1"); + const seen = contentHash("v1\n"); + expect(lastPayload(pi).cursors).toEqual({ t1: seen }); + + // 对端写 v2:watcher 读到陈旧值 → 不投递,cursor 仍停在 v1(磁盘已超前) + staleOnce = true; + await writeFile(mf, "v2\n"); + await sleep(200); + expect(pi.wakes).toHaveLength(0); + expect(lastPayload(pi).cursors).toEqual({ t1: seen }); + + // 本会话 edit 自写:写前版本 v2 ≠ cursor v1 → 不得直接推进(否则外部 v2 被永久吞掉) + await pi.emit( + { type: "tool_call", toolName: "edit", toolCallId: "te1", input: { path: mf, content: "self\n" } }, + makeFakeCtx(), + ); + await writeFile(mf, "v2\nself\n"); + await sleep(200); + expect(pi.wakes).toHaveLength(0); // 自写不唤醒 + expect(lastPayload(pi).cursors).toEqual({ t1: seen }); // 也不跨过未确认的 v2 + + // 重新 subscribe → 合并补投恰好一次 + await subscribe(pi, "t1"); + await sleep(80); + expect(pi.wakes).toHaveLength(1); + expect(lastPayload(pi).cursors).toEqual({ t1: contentHash(await readFile(mf, "utf8")) }); + }); +}); + +describe("P2.1 · 阻塞 2:跨 await 续体服从最新生命周期状态", () => { + it("同 topic 并发 subscribe:陈旧基线读不回退 cursor、不双投", async () => { + const cwd = join(testRoot, "concurrent-baseline"); + let reads = 0; + let releaseStale: (() => void) | undefined; + const staleGate = new Promise((resolve) => { + releaseStale = resolve; + }); + const { pi } = await wireCur({ + cwd, + readFileHash: async () => { + reads += 1; + if (reads === 1) return contentHash("v1\n"); + if (reads === 2) { + await staleGate; // 挂住第二个 subscribe 的基线读,等第一个走完一整轮 + return contentHash("v1\n"); // 陈旧值(磁盘上已是 v2) + } + return contentHash("v2\n"); + }, + }); + const tool = pi.tools.find((t) => t.name === "channel_subscribe"); + if (!tool) throw new Error("channel_subscribe 未注册"); + + const first = tool.execute("c1", { topic: "t1" }); + const second = tool.execute("c2", { topic: "t1" }); + await waitFor(() => pi.wakes.length === 1); // 第一个 subscribe 已补投(cursor 推进到 v2) + releaseStale?.(); + await Promise.all([first, second]); + await sleep(80); + + expect(pi.wakes).toHaveLength(1); // 不双投 + expect(lastPayload(pi).cursors).toEqual({ t1: contentHash("v2\n") }); // 不被陈旧基线读回退 + }); + + it("订阅等 hash 期间退订:不 persist、不起 watcher、不复活 cursor", async () => { + const cwd = join(testRoot, "unsub-during-baseline"); + await mkdir(topicDir(cwd, "t1"), { recursive: true }); + await writeFile(messagesFile(cwd, "t1"), "v1\n"); + let release: (() => void) | undefined; + let reads = 0; + const { pi, watcherCount } = await wireCur({ + cwd, + readFileHash: async (abs) => { + reads += 1; + if (reads === 1) { + await new Promise((resolve) => { + release = resolve; + }); + } + return contentHashOrNull(abs); + }, + }); + const subTool = pi.tools.find((t) => t.name === "channel_subscribe"); + const unsubTool = pi.tools.find((t) => t.name === "channel_unsubscribe"); + if (!subTool || !unsubTool) throw new Error("工具未注册"); + + const pending = subTool.execute("c1", { topic: "t1" }); + await waitFor(() => reads >= 1); + await unsubTool.execute("u1", { topic: "t1" }); + const appendedAfterUnsub = pi.appended.length; + release?.(); + await pending; + await sleep(200); + + expect(watcherCount()).toBe(0); // 不起孤儿 watcher + expect(pi.wakes).toHaveLength(0); // 不投递 + expect(pi.appended).toHaveLength(appendedAfterUnsub); // 不 persist(不复活 cursor) + expect(lastPayload(pi)).toEqual({ topics: [], cursors: {} }); + }); + + it("订阅等 hash 期间 shutdown:不起 watcher、不投递、不 persist", async () => { + const cwd = join(testRoot, "shutdown-during-baseline"); + await mkdir(topicDir(cwd, "t1"), { recursive: true }); + await writeFile(messagesFile(cwd, "t1"), "v1\n"); + let release: (() => void) | undefined; + let reads = 0; + const { pi, watcherCount } = await wireCur({ + cwd, + readFileHash: async (abs) => { + reads += 1; + if (reads === 1) { + await new Promise((resolve) => { + release = resolve; + }); + } + return contentHashOrNull(abs); + }, + }); + const subTool = pi.tools.find((t) => t.name === "channel_subscribe"); + if (!subTool) throw new Error("channel_subscribe 未注册"); + + const pending = subTool.execute("c1", { topic: "t1" }); + await waitFor(() => reads >= 1); + await pi.emit({ type: "session_shutdown" }, makeFakeCtx()); + const appendedAfterShutdown = pi.appended.length; + release?.(); + await pending; + await sleep(200); + + expect(watcherCount()).toBe(0); + expect(pi.wakes).toHaveLength(0); + expect(pi.appended).toHaveLength(appendedAfterShutdown); + }); + + it("watcher 读 hash 期间退订:不投递、不复活 cursor", async () => { + const cwd = join(testRoot, "unsub-during-watch"); + let pendingRead: (() => void) | undefined; + let armHang = false; + const { pi } = await wireCur({ + cwd, + readFileHash: async (abs) => { + if (armHang) { + armHang = false; + await new Promise((resolve) => { + pendingRead = resolve; + }); + } + return contentHashOrNull(abs); + }, + }); + await subscribe(pi, "t1"); + await mkdir(topicDir(cwd, "t1"), { recursive: true }); + + armHang = true; // 挂住下次 hash 读(= watcher 事件路径) + await writeFile(messagesFile(cwd, "t1"), "external\n"); + await waitFor(() => pendingRead !== undefined); + await unsubscribe(pi, "t1"); + pendingRead?.(); + await sleep(200); + + expect(pi.wakes).toHaveLength(0); + expect(lastPayload(pi)).toEqual({ topics: [], cursors: {} }); // cursor 不被复活 + }); +}); + +describe("P2.1 · 阻塞 3:只接受 topic 根目录的主消息文件", () => { + it("topic/sub/MESSAGES.md 既不唤醒也不污染 cursor", async () => { + const cwd = join(testRoot, "nested-messages"); + const { pi } = await wireCur({ cwd }); + await subscribe(pi, "t1"); // 基线:主文件不存在 → null + await mkdir(join(topicDir(cwd, "t1"), "sub"), { recursive: true }); + await writeFile(join(topicDir(cwd, "t1"), "sub", "MESSAGES.md"), "nested\n"); + await sleep(250); + + expect(pi.wakes).toHaveLength(0); + expect(lastPayload(pi)).toEqual({ topics: ["t1"], cursors: { t1: null } }); + }); +}); From 3a899638139d5757a15fd392c78700ffca832b57 Mon Sep 17 00:00:00 2001 From: Jaxton07 Date: Sun, 20 Sep 2026 18:11:30 +0800 Subject: [PATCH 6/8] =?UTF-8?q?docs(channel-watch):=20=E9=98=B6=E6=AE=B5?= =?UTF-8?q?=203=20=E2=80=94=20=E5=86=92=E7=83=9F=20V7/V8=EF=BC=88=E8=AE=A2?= =?UTF-8?q?=E9=98=85=E5=BF=AB=E7=85=A7+GC=20=E5=AE=88=E5=8D=AB=E3=80=81?= =?UTF-8?q?=E7=A6=BB=E7=BA=BF=E8=A1=A5=E6=8A=95=E7=9C=9F=E5=AE=9E=E9=93=BE?= =?UTF-8?q?=E8=B7=AF=EF=BC=89=E3=80=81INDEX=20=E5=90=8C=E6=AD=A5=E4=B8=8E?= =?UTF-8?q?=20PITFALLS=20=E6=9D=A1=E7=9B=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/INDEX.md | 2 +- docs/PITFALLS.md | 14 ++ scripts/smoke-channel-watch.mts | 235 +++++++++++++++++++++++++++++++- 3 files changed, 246 insertions(+), 5 deletions(-) diff --git a/docs/INDEX.md b/docs/INDEX.md index 66a332c4..ecb3e0db 100644 --- a/docs/INDEX.md +++ b/docs/INDEX.md @@ -32,7 +32,7 @@ packages/ | `scripts/smoke-error-events.mts` | 报错系统冒烟:本地 HTTP 伪造 provider(401/429)驱动 PiBackend,零凭证离线 | | `scripts/smoke-subagent.mts` | subagent 冒烟 | | `scripts/smoke-evaporation-ui.mjs` | 蒸发设置二态 CDP 冒烟(dev 实例带 `--remote-debugging-port=9224` 运行后执行) | -| `scripts/smoke-channel-watch.mts` | channel-watch 机制冒烟 V1–V6 | +| `scripts/smoke-channel-watch.mts` | channel-watch 机制冒烟 V1–V8:V1–V3 需真实模型;**V4–V8 零凭证离线**(V6 扩展钩子/todo 工具、V7 订阅快照上报 + GC intent 守卫走真 PiBackend、V8 关重开后离线变化补投一次+游标落盘) | | `scripts/replay-trace.mts` | 事件 trace 离线重放(`--last` 自动找最新;排查 UI 状态问题首选) | | `scripts/replay-evaporation.mts` | 上下文蒸发离线 replay 调参(误杀率/体积曲线;`--core` 与线上实现同构对比;默认数据指 `.local/replay-data/sessions` 副本,指向正式 `~/.pi/agent` 会直接 abort) | | `scripts/verify-vertex-auth.mts` | google-vertex 认证链路隔离验证(临时 `PI_CODING_AGENT_DIR`,不碰正式配置):api_key 路径会 401「API keys are not supported by this API」→ 只有 ADC/服务账号可用 | diff --git a/docs/PITFALLS.md b/docs/PITFALLS.md index 8561cbc2..6a8d2a7b 100644 --- a/docs/PITFALLS.md +++ b/docs/PITFALLS.md @@ -47,6 +47,9 @@ | hover 才现的控件刚截完图就点不到、点击静默落空 | 四 · 鼠标事件 + `:hover` → 补「截图会清掉 hover」(2026-09-19) | | 改完自定义 hook 后整页报「Rendered fewer hooks than expected」 | 四 · HMR 改 hook 数量会假报错(2026-09-19) | | 清理 dev 进程后端口还占着、CDP 连上但页面全空 | 五 · `pkill -f` 杀 Electron 会留下孤儿 main(2026-09-19) | +| 跨会话频道订阅后,会话被卸载/关闭期间的消息永久丢失(或反过来重复提醒) | 二 · 长生命周期订阅不能挂在可被自动 GC 的会话上(2026-09-20) | +| 有频道订阅的会话仍被自动卸载(或被用户关)→ 从此收不到唤醒 | 二 · 同章节「订阅 = 明确驻留语义」 | +| 恢复后把对端历史消息当新消息提醒、或每次重开都重提醒一次 | 二 · 同章节「游标缺 key ≠ null」 | | 组里最后一个展开的项目折不掉、切会话又自己展开(空数组身兼两义) | 四 · 空数组不能同时当「未初始化」与「有效空值」(2026-09-20) | | 打开模型选择器后整页向左偷跑、左栏与顶栏左侧按钮被挤/裁切 | 四 · absolute 弹层越界 + autoFocus = 整页横向偷跑(2026-09-20) | | CDP 量测得出「弹层在视口内、也没滚动」但界面明明错位(量错元素) | 四 · 同章节「量测三纪律」(2026-09-20) | @@ -135,6 +138,17 @@ LAN 页重连/中途进入时,快照种子经 `messagesToUIMessages` 重建— **教训**:想让另一个会话及时收到消息,先看它的 turn 什么时候结束——`followUp` 的送达时机由**对方**的 turn 边界决定,不由发送方决定;要「立即送达」只有 `steer`(GUI 里用户自己发的消息目前也走 followUp 排队,`pi-backend.ts:572`)。所以「让双方停在同一节奏上」比引入锁/快照沙箱便宜得多。 +### 长生命周期订阅不能挂在「可被自动 GC 的会话实例」上(2026-09-20,channel-watch retention + 持久补投) + +背景:channel-watch 的 watcher 与订阅集都是**会话实例内的内存态**,而 renderer 的内存策略会把「空闲且不受保护」的会话 `dispose()` 掉。结果就是:会话一被卸载(或用户关掉),订阅无声消失——对端以为「已经通知过」,消息永久丢失;更糟的是用户看不出来(左栏那个会话还在,只是它不再收唤醒)。修复落在两处(`PiBackend.closeSession(id, intent)` + renderer 内存策略 + 落盘内容游标),四条不易自己想到的事实: + +1. **订阅 = 明确驻留语义**:有有效运行态订阅的会话必须排除在自动回收之外(含「晾过 idleTimeout」的兜底路径),且**不占** 保留名额(K=3 是「可回收空闲」的名额,不是总会话数)。判定要用 backend 的**运行态快照**(扩展上报),不要用 UI 列表/设置偏好:`disabled`/`untrusted` 的订阅根本收不到唤醒,保护它只会白占内存。自动卸载与用户主动关/删要走**两个意图**(`intent:"gc"` 才被订阅挡,用户意图优先),否则用户会发现「这会话关不掉」。 +2. **恢复只能靠会话文件条目回放**:游标(`MESSAGES.md` 内容 hash)必须和订阅集写在**同一条**快照里(分开写会出现「新订阅配旧游标」的中间态,恢复时要么补历史要么吞消息);`cursors` 里**缺 key(旧载荷,未知基线)= 只记当前版本不补历史**,与值为 `null`(确认时文件不存在)语义不同,别用 `Map.get()` 把两者混淆。 +3. **「自己写的」不能无条件推进游标**:必须先确认**写前磁盘版本 == 游标**,否则会把「对端已写、我们还没提醒」的内容吞掉(`paused` 期间的写入、或落在 watcher 防抖窗口里的外部写入同样算未确认)。要拿到写前版本,得在 `tool_call` 钩子里读(SDK 会在工具真正执行前 `await` 这些钩子);拿到之前先想清楚「这条推进会不会跨过一个还没提醒的版本」。 +4. **所有跨 `await` 的续体都要复查生命周期**(`active/trusted/订阅`):shutdown/退订完全可能落在「读盘 ↔ 起 watcher」之间,不复查就会出现幽灵投递、幽灵游标、孤儿 watcher。watcher 的 single-flight 推荐用 `p.then(settle, settle)` 而不是 `void p.finally(settle)`——`finally` 会派生新 promise,`p` 意外 reject 时变成未处理拒绝。 + +已知未做的边界(不是 bug,但别误判):轮询降级模式的删除检测(`readdir` 快照里不含被删文件)、watcher root 目录被删后重建。 + ## 三、构建 · 打包 · 环境 ### electron-vite dev 主进程 watcher 不可依赖:改 `src/main/` 后必须验产物(2026-09-17 实测) diff --git a/scripts/smoke-channel-watch.mts b/scripts/smoke-channel-watch.mts index 54dc5c1a..2d380ec5 100644 --- a/scripts/smoke-channel-watch.mts +++ b/scripts/smoke-channel-watch.mts @@ -1,5 +1,6 @@ // 阶段 0 冒烟:channel-watch(跨会话频道协作)集成前提验证。 -// 断言依据 .local/docs/design/plan/channel-automation-plan.md(V1–V6)。 +// 断言依据:V1–V6 = .local/docs/design/plan/channel-automation-plan.md; +// V7–V8 = .local/agent-work/spec/channel-watch-retention-catchup.md(订阅保护 + 持久游标/离线补投) // 用法: // npx tsx scripts/smoke-channel-watch.mts v4 # fs.watch recursive(无模型) // npx tsx scripts/smoke-channel-watch.mts v5 # skill seed 目录约定(无模型) @@ -7,11 +8,14 @@ // npx tsx scripts/smoke-channel-watch.mts v1 # sendUserMessage 空闲唤醒(真实模型) // npx tsx scripts/smoke-channel-watch.mts v2 # sendUserMessage 运行中排队(真实模型) // npx tsx scripts/smoke-channel-watch.mts v3 # appendEntry 持久化读回(真实模型) -// npx tsx scripts/smoke-channel-watch.mts all # 按依赖序全跑 +// npx tsx scripts/smoke-channel-watch.mts v7 # 订阅快照上报 + GC intent 守卫(无模型) +// npx tsx scripts/smoke-channel-watch.mts v8 # 关闭重开补投一次、不重复(无模型) +// npx tsx scripts/smoke-channel-watch.mts all # 按依赖序全跑(含 v7/v8) // 仅使用 dev agent 目录(~/.pi/agent-dev),正式目录零写入。 import { appendFile, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import { watch } from "node:fs"; -import { homedir } from "node:os"; +import { randomUUID } from "node:crypto"; +import { homedir, tmpdir } from "node:os"; import { join, resolve } from "node:path"; import { performance } from "node:perf_hooks"; @@ -47,6 +51,105 @@ function pickModel(runtime: InstanceType, spec: string) { return model; } +// ---------- V7/V8 共用:PiBackend / channel-watch 扩展 ---------- + +const { makeChannelWatchExtension } = await import("../packages/backend/src/tools/channel-watch/extension.ts"); +const { contentHash } = await import("../packages/backend/src/tools/channel-watch/guard.ts"); +const { PiBackend } = await import("../packages/backend/src/index.ts"); + +/** 手写最小会话文件(V3 实证的格式):header + 一条 channel-subs 订阅快照 */ +function sessionFileText(opts: { + sessionId: string; + cwd: string; + subs: { topics: string[]; cursors: Record }; +}): string { + const ts = new Date().toISOString(); + return ( + [ + JSON.stringify({ type: "session", version: 3, id: opts.sessionId, timestamp: ts, cwd: opts.cwd }), + JSON.stringify({ + type: "custom", + customType: "channel-subs", + data: opts.subs, + id: "smoke-subs-1", + parentId: null, + timestamp: ts, + }), + ].join("\n") + "\n" + ); +} + +/** 读会话文件里最后一条 channel-subs 快照(游标推进的证据) */ +async function lastSubsPayload(file: string): Promise<{ topics: string[]; cursors?: Record }> { + const raw = await readFile(file, "utf8"); + const lines = raw.trim().split("\n"); + for (let i = lines.length - 1; i >= 0; i--) { + const entry = JSON.parse(lines[i]) as { type?: string; customType?: string; data?: unknown }; + if (entry.type === "custom" && entry.customType === "channel-subs") { + return entry.data as { topics: string[]; cursors?: Record }; + } + } + throw new Error("会话文件里没有 channel-subs entry"); +} + +async function waitUntil(predicate: () => boolean, timeoutMs: number): Promise { + const deadline = performance.now() + timeoutMs; + while (performance.now() < deadline) { + if (predicate()) return true; + await sleep(50); + } + return false; +} + +/** 用真 AgentSession + 真 channel-watch 扩展开一个会话(wake/快照都注入 sink,不需要 provider) */ +async function openWatchedSession(opts: { + runtime: InstanceType; + project: string; + sessionsDir: string; + sessionFile?: string; + sinks: { wakes: string[]; reports: Array<{ sessionId: string; topics: string[] }> }; +}) { + const ext = makeChannelWatchExtension({ + agentDir, + cwd: opts.project, + isEnabled: () => true, + sendWake: (text) => opts.sinks.wakes.push(text), + notify: () => {}, + onSubscriptionsChanged: (sessionId, topics) => + opts.sinks.reports.push({ sessionId, topics: [...topics].sort() }), + }); + const settingsManager = SettingsManager.create(opts.project, agentDir, { projectTrusted: false }); + const loader = new DefaultResourceLoader({ + cwd: opts.project, + agentDir, + settingsManager, + noExtensions: true, + noSkills: true, + noPromptTemplates: true, + extensionFactories: [ext] as never, + }); + // 项目受信:channel-watch 的 init/恢复/watcher 都只在 trusted 时工作 + await loader.reload({ resolveProjectTrust: async () => true }); + const sessionManager = opts.sessionFile + ? SessionManager.open(opts.sessionFile) + : SessionManager.create(opts.project, opts.sessionsDir); + const result = await createAgentSession({ + cwd: opts.project, + modelRuntime: opts.runtime, + model: pickModel(opts.runtime, "deepseek/deepseek-v4-flash"), + tools: undefined, + sessionManager, + settingsManager, + resourceLoader: loader, + }); + await result.session.bindExtensions({ mode: "rpc" }); + return { + session: result.session, + sessionFile: result.session.sessionFile, + dispose: () => result.session.dispose(), + }; +} + /** 探针扩展:存 ExtensionAPI 引用,记录 session_start 的 reason / trust / entries */ interface ProbeState { pi: { @@ -453,21 +556,145 @@ async function phaseV3(runtime: InstanceType) { } } + +// ---------- V7:订阅快照上报 + GC intent 守卫(真 PiBackend 链路,无模型) ---------- + +async function phaseV7() { + const root = await mkdtemp(join(tmpdir(), "percho-smoke-cw-v7-")); + const project = join(root, "project"); + const sessionsDir = join(root, "sessions"); + try { + await mkdir(join(project, ".local", "agent-work", "channel", "t1"), { recursive: true }); + await mkdir(sessionsDir, { recursive: true }); + const sessionId = randomUUID(); + const sessionFile = join(sessionsDir, `${sessionId}.jsonl`); + // 预先写一条 channel-subs 快照(模拟「上次订阅过 t1 的会话文件」); + // cursor=null + 无 MESSAGES.md = 无变化 → 恢复不会补投(无需 provider 就能验证整条链) + await writeFile( + sessionFile, + sessionFileText({ sessionId, cwd: project, subs: { topics: ["t1"], cursors: { t1: null } } }), + "utf8", + ); + + const backend = new PiBackend({ defaultCwd: project, projectTrust: false, permissionExtension: false }); + await backend.init(); + try { + const meta = await backend.openSession(sessionFile); + const reported = await waitUntil( + () => backend.getChannelSubscriptionSessionIds().includes(meta.sessionId), + 8000, + ); + if (!reported) throw new Error("V7: 恢复后订阅快照未上报(扩展 → PiBackend 链路断)"); + console.log(`[V7] 恢复后快照: ${JSON.stringify(backend.getChannelSubscriptionSessionIds())}`); + + const gc = await backend.closeSession(meta.sessionId, "gc"); + if (gc.closed !== false) throw new Error(`V7: intent:"gc" 应被订阅守卫拒绝,实测 ${JSON.stringify(gc)}`); + if (!backend.getChannelSubscriptionSessionIds().includes(meta.sessionId)) { + throw new Error("V7: 被拒后订阅快照不应被清空"); + } + console.log("[V7] intent:'gc' 被拒(会话仍在 registry,快照仍在)"); + + const user = await backend.closeSession(meta.sessionId, "user"); + if (user.closed !== true) throw new Error(`V7: intent:"user" 应正常关闭,实测 ${JSON.stringify(user)}`); + const leftover = backend.getChannelSubscriptionSessionIds(); + if (leftover.length !== 0) throw new Error(`V7: dispose 后快照应无残留,实测 ${JSON.stringify(leftover)}`); + console.log("[V7] PASS: 订阅恢复 → 快照上报 → intent:'gc' 拒绝 → intent:'user' 放行 → dispose 无残留"); + } finally { + backend.dispose(); + } + } finally { + await rm(root, { recursive: true, force: true }); + } +} + +// ---------- V8:关闭重开后对离线变化补投一次、重开不重复(真 session_start + 真 appendEntry) ---------- + +async function phaseV8(runtime: InstanceType) { + const root = await mkdtemp(join(tmpdir(), "percho-smoke-cw-v8-")); + const project = join(root, "project"); + const sessionsDir = join(root, "sessions"); + const topicDir = join(project, ".local", "agent-work", "channel", "t1"); + const messages = join(topicDir, "MESSAGES.md"); + try { + await mkdir(topicDir, { recursive: true }); + await mkdir(sessionsDir, { recursive: true }); + await writeFile(messages, "v1\n", "utf8"); + const h1 = contentHash("v1\n"); + const sessionId = randomUUID(); + const sessionFile = join(sessionsDir, `${sessionId}.jsonl`); + await writeFile( + sessionFile, + sessionFileText({ sessionId, cwd: project, subs: { topics: ["t1"], cursors: { t1: h1 } } }), + "utf8", + ); + + // 开 1:cursor == 磁盘版本 → 不补投(恢复路径跑通且不误报) + const first = { wakes: [] as string[], reports: [] as Array<{ sessionId: string; topics: string[] }> }; + const a = await openWatchedSession({ runtime, project, sessionsDir, sessionFile, sinks: first }); + await sleep(800); + if (first.wakes.length !== 0) throw new Error(`V8: 已同步会话不应补投,实测 ${JSON.stringify(first.wakes)}`); + if (first.reports.at(-1)?.topics.join(",") !== "t1") { + throw new Error(`V8: 恢复后订阅快照应为 [t1],实测 ${JSON.stringify(first.reports)}`); + } + console.log(`[V8] 开 1(cursor==磁盘): wakes=0,快照=${JSON.stringify(first.reports.at(-1)?.topics)}`); + a.dispose(); // session_shutdown → 停 watcher + await sleep(300); + + // 会话关闭期间对端写入(离线变化) + await writeFile(messages, "v2\n", "utf8"); + const h2 = contentHash("v2\n"); + + // 开 2:重开同一会话 → 补投恰好一次 + cursor 推进落盘 + const second = { wakes: [] as string[], reports: [] as Array<{ sessionId: string; topics: string[] }> }; + const b = await openWatchedSession({ runtime, project, sessionsDir, sessionFile, sinks: second }); + await sleep(800); + if (second.wakes.length !== 1) { + throw new Error(`V8: 离线变化应补投恰好一次,实测 ${second.wakes.length} 次:${JSON.stringify(second.wakes)}`); + } + if (!second.wakes[0]?.includes("[channel:t1]")) { + throw new Error(`V8: 唤醒文案缺少 [channel:t1]:${second.wakes[0]}`); + } + const persisted = await lastSubsPayload(sessionFile); + if (persisted.cursors?.t1 !== h2) { + throw new Error(`V8: 补投后 cursor 未推进落盘:${JSON.stringify(persisted)}`); + } + console.log(`[V8] 开 2(离线变化): wakes=1,cursor 已推进落盘(${String(h2).slice(0, 8)}…)`); + b.dispose(); + await sleep(300); + + // 开 3:用推进后的快照再重开 → 不重复补投 + const third = { wakes: [] as string[], reports: [] as Array<{ sessionId: string; topics: string[] }> }; + const c = await openWatchedSession({ runtime, project, sessionsDir, sessionFile, sinks: third }); + await sleep(800); + if (third.wakes.length !== 0) { + throw new Error(`V8: 重复补投了 ${third.wakes.length} 次:${JSON.stringify(third.wakes)}`); + } + c.dispose(); + console.log("[V8] PASS: 离线变化补投恰好一次、游标落盘、重开不重复"); + } finally { + await rm(root, { recursive: true, force: true }); + } +} + // ---------- main ---------- async function main() { const phase = process.argv[2] ?? "all"; if (phase === "v4") return phaseV4(); if (phase === "v5") return phaseV5(); + if (phase === "v7") return phaseV7(); const runtime = await setup(); try { - if (phase === "v6") await phaseV6(runtime); + if (phase === "v8") await phaseV8(runtime); + else if (phase === "v6") await phaseV6(runtime); else if (phase === "v1") await phaseV1(runtime); else if (phase === "v2") await phaseV2(runtime); else if (phase === "v3") await phaseV3(runtime); else if (phase === "all") { await phaseV4(); await phaseV5(); + await phaseV7(); + await phaseV8(runtime); await phaseV6(runtime); await phaseV1(runtime); await phaseV2(runtime); From 0d861c8896bcb74fe6d63ae1ac6666771e977769 Mon Sep 17 00:00:00 2001 From: Jaxton07 Date: Sun, 20 Sep 2026 18:16:24 +0800 Subject: [PATCH 7/8] =?UTF-8?q?docs(pitfalls):=20=E9=98=B6=E6=AE=B5=203.1?= =?UTF-8?q?=20=E2=80=94=20poll=20=E5=88=A0=E9=99=A4=E6=A3=80=E6=B5=8B=20/?= =?UTF-8?q?=20watch=20root=20=E9=87=8D=E5=BB=BA=20=E6=98=8E=E7=A1=AE?= =?UTF-8?q?=E4=B8=BA=E3=80=8C=E6=9C=AC=E6=9C=9F=E6=9C=AA=E8=A6=86=E7=9B=96?= =?UTF-8?q?=E7=9A=84=E5=B7=B2=E7=9F=A5=E9=99=90=E5=88=B6=EF=BC=88=E6=BC=8F?= =?UTF-8?q?=E4=BA=8B=E4=BB=B6=EF=BC=89=E3=80=8D=E8=80=8C=E9=9D=9E=E3=80=8C?= =?UTF-8?q?=E4=B8=8D=E6=98=AF=20bug=E3=80=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/PITFALLS.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/PITFALLS.md b/docs/PITFALLS.md index 6a8d2a7b..cff9a6da 100644 --- a/docs/PITFALLS.md +++ b/docs/PITFALLS.md @@ -147,7 +147,7 @@ LAN 页重连/中途进入时,快照种子经 `messagesToUIMessages` 重建— 3. **「自己写的」不能无条件推进游标**:必须先确认**写前磁盘版本 == 游标**,否则会把「对端已写、我们还没提醒」的内容吞掉(`paused` 期间的写入、或落在 watcher 防抖窗口里的外部写入同样算未确认)。要拿到写前版本,得在 `tool_call` 钩子里读(SDK 会在工具真正执行前 `await` 这些钩子);拿到之前先想清楚「这条推进会不会跨过一个还没提醒的版本」。 4. **所有跨 `await` 的续体都要复查生命周期**(`active/trusted/订阅`):shutdown/退订完全可能落在「读盘 ↔ 起 watcher」之间,不复查就会出现幽灵投递、幽灵游标、孤儿 watcher。watcher 的 single-flight 推荐用 `p.then(settle, settle)` 而不是 `void p.finally(settle)`——`finally` 会派生新 promise,`p` 意外 reject 时变成未处理拒绝。 -已知未做的边界(不是 bug,但别误判):轮询降级模式的删除检测(`readdir` 快照里不含被删文件)、watcher root 目录被删后重建。 +已知未做的**本期未覆盖的已知限制(缺口)**:轮询降级模式的删除检测(`readdir` 快照里不含被删文件)与 watcher root 目录被删后重建——这两种异常场景下 watcher 会**漏事件**(订阅会话仍在,但那一次变化不会投递),不影响本期主故障(会话被卸载导致订阅整个消失)的验收,但它们是真实缺口、本期明确不修,不要当成「不是 bug」。 ## 三、构建 · 打包 · 环境 From 5cadff6d46eeb4a208239f5bc78ac2080db71952 Mon Sep 17 00:00:00 2001 From: Jaxton07 Date: Sun, 20 Sep 2026 18:17:41 +0800 Subject: [PATCH 8/8] =?UTF-8?q?docs(pitfalls):=20=E9=98=B6=E6=AE=B5=203.2?= =?UTF-8?q?=20=E2=80=94=20=E5=8E=BB=E6=8E=89=E3=80=8C=E5=B7=B2=E7=9F=A5?= =?UTF-8?q?=E6=9C=AA=E5=81=9A=E7=9A=84=E6=9C=AC=E6=9C=9F=E6=9C=AA=E8=A6=86?= =?UTF-8?q?=E7=9B=96=E7=9A=84=E5=B7=B2=E7=9F=A5=E9=99=90=E5=88=B6=E3=80=8D?= =?UTF-8?q?=E8=AF=AD=E4=B9=89=E9=87=8D=E5=A4=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/PITFALLS.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/PITFALLS.md b/docs/PITFALLS.md index cff9a6da..86e8bd30 100644 --- a/docs/PITFALLS.md +++ b/docs/PITFALLS.md @@ -147,7 +147,7 @@ LAN 页重连/中途进入时,快照种子经 `messagesToUIMessages` 重建— 3. **「自己写的」不能无条件推进游标**:必须先确认**写前磁盘版本 == 游标**,否则会把「对端已写、我们还没提醒」的内容吞掉(`paused` 期间的写入、或落在 watcher 防抖窗口里的外部写入同样算未确认)。要拿到写前版本,得在 `tool_call` 钩子里读(SDK 会在工具真正执行前 `await` 这些钩子);拿到之前先想清楚「这条推进会不会跨过一个还没提醒的版本」。 4. **所有跨 `await` 的续体都要复查生命周期**(`active/trusted/订阅`):shutdown/退订完全可能落在「读盘 ↔ 起 watcher」之间,不复查就会出现幽灵投递、幽灵游标、孤儿 watcher。watcher 的 single-flight 推荐用 `p.then(settle, settle)` 而不是 `void p.finally(settle)`——`finally` 会派生新 promise,`p` 意外 reject 时变成未处理拒绝。 -已知未做的**本期未覆盖的已知限制(缺口)**:轮询降级模式的删除检测(`readdir` 快照里不含被删文件)与 watcher root 目录被删后重建——这两种异常场景下 watcher 会**漏事件**(订阅会话仍在,但那一次变化不会投递),不影响本期主故障(会话被卸载导致订阅整个消失)的验收,但它们是真实缺口、本期明确不修,不要当成「不是 bug」。 +**本期未覆盖的已知限制(缺口)**:轮询降级模式的删除检测(`readdir` 快照里不含被删文件)与 watcher root 目录被删后重建——这两种异常场景下 watcher 会**漏事件**(订阅会话仍在,但那一次变化不会投递),不影响本期主故障(会话被卸载导致订阅整个消失)的验收,但它们是真实缺口、本期明确不修,不要当成「不是 bug」。 ## 三、构建 · 打包 · 环境