Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 58 additions & 1 deletion .scratch/durable-multiview/HANDOFF.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# Handoff:Pi Durable 多端同屏 spike

- 日期:2026-10-02
- 状态:P0–P3 完成(见 §10–§13),P4 拆成两段:P4a 宿主侧演示扩展与协议已完成(§14),下一步 P4b(三端界面与剧本验证)。PR 栈:P0 [#426](https://github.com/bubbleptr/pace/pull/426) ← P1 [#427](https://github.com/bubbleptr/pace/pull/427) ← P2 [#429](https://github.com/bubbleptr/pace/pull/429) ← P3 [#430](https://github.com/bubbleptr/pace/pull/430) ← P4a `feat/durable-multiview-demo`。前置的 Pi 1.0 升级 [PR #425](https://github.com/bubbleptr/pace/pull/425) 已合入,分支 `feat/durable-multiview-spike` 从 `origin/main` 开出。
- 状态:P0–P4 完成(见 §10–§15);P5 结论文档另列栈顶 PR。PR 栈:P0 [#426](https://github.com/bubbleptr/pace/pull/426) ← P1 [#427](https://github.com/bubbleptr/pace/pull/427) ← P2 [#429](https://github.com/bubbleptr/pace/pull/429) ← P3 [#430](https://github.com/bubbleptr/pace/pull/430) ← P4a [#431](https://github.com/bubbleptr/pace/pull/431) ← P4b `feat/durable-multiview-demo-ui`。前置的 Pi 1.0 升级 [PR #425](https://github.com/bubbleptr/pace/pull/425) 已合入。
- 关联:[docs/research/pi-durable-analysis.md](../../docs/research/pi-durable-analysis.md)(框架分析,用户的写作素材,只在本地工作区,不提交)、[ADR-0040](../../docs/adr/0040-root-session-process-isolation.md)、[ADR-0042](../../docs/adr/0042-pace-as-chord-presentation-host.md)、[ADR-0044](../../docs/adr/0044-single-writer-session-projection-in-renderer.md)
- 目的:把上一个会话里已核实的事实、已定的决策和待决问题交给新会话,避免重查。

Expand Down Expand Up @@ -306,3 +306,60 @@ P4 拆成两个 PR:P4a 是宿主扩展、网关和客户端协议,三端界
- **`ConversationView.docs` 只有 4 个内置文档**,扩展文档要单独 `watchDoc`。`watchDoc` 不会创建文档,文档不存在时返回 `undefined`;网关先发 `null`,再等创建它的那次提交(`document` 或 fork 时的 `document.copy` 变更)到来后挂上 watch。
- **钩子不能提交,所以「谁在等审批」不是持久状态。** 决定是持久的(文档 + memo),等待只在宿主内存里;宿主重启后钩子重跑,会重新登记。这一点对 Pace 有影响:待审批列表要么走 spike 这样的旁路流,要么改成 §8.1 的 b)方案,用工具加任务把审批变成持久状态。
- `TaskQuery` 没有按 owner 过滤的字段,只能按 `conversationId` + `kind` 扫,再按 `owner` 过滤。

## 15. P4b 完成(2026-10-02):三端界面与完整剧本

### 15.1 落地的内容

- Web 与 Pace 共用审批横幅、待办、用量、思考块、fork 对话框;fork 在指定答案处分支并去掉 `rollback`。审批记录使用各端名字,TUI 支持 `/approve`、`/deny`,晚到操作会显示先前的决定。
- 窄屏将会话导航放入抽屉、live state 放入对话框、控制项放入菜单;390px 下聊天输入仍可用,无横向溢出。Pace 根容器声明 Astryx 表面背景,避免透明窗口下正文落到黑底。
- 失联时保留未发送草稿,禁用 Follow-up、审批、fork 和模型/压缩等写操作;恢复后仍从宿主快照续接。
- 演示模型支持中途 steer、压缩与可调提醒时间。宿主可加载工具模块的临时副本,验收不改仓库中的演示工具。
- `scripts/demo-scenario.mjs` / `bun run verify:demo` 驱动独立的宿主进程、Pace Electron dev 页面、Chromium 网页和 PTY TUI,保存截图及终端屏幕。数据目录、profile 和端口均独立;生成临时 Vite 配置并校验 CDP 页面的 URL,避免误连已有 Pace。

### 15.2 本轮验收结果

2026-10-02 实际运行输出:

```text
bun run typecheck exit 0(仓库根)
bun run lint exit 0(仓库根)
bun run test 168 files / 1975 tests passed(仓库根)
bun run build Exited with code 0(仓库根)
bun run typecheck exit 0(spike)
bun run test 12 files / 46 tests passed(spike)
bun run verify:demo <evidence> DONE; exit 0(spike)
```

本机证据在 `output/durable-multiview-p4b-20261002/`(gitignored,不提交截图):

- `02-subagents-*`:三个子会话同时调查。
- `03-host-lost-*`、`03-recovered-*`:SIGKILL 后三端断线、重启恢复且三个子会话 ID 不变。
- `05-pace-subagent-*`:Pace 直接驱动子会话,并在工具运行时 steer。
- `06-approval-and-queue-*`:三端审批、TUI steer、Web follow-up;Pace 先批准,TUI 再操作得到 Already approved by pace。
- `07-rollback-running-*`、`08-reminder-*`:三个区域并行,一个失败、另两个 abort 补偿;Esc 后 background 提醒仍到达。
- `09-fork-*`、`10-compacted-*`:Web fork;Pace 手动压缩,Web 和 TUI 同步看到 summary marker。TUI 的 marker 位于保留的近期消息之前,使用 Home 滚到顶部断言后再 End 返回底部。
- `11-reloaded-*`、`12-usage-thinking-*`、`12-narrow-web.png`、`12-phone-*.png`:热替换改变下一次工具输出,用量、思考块、900px 状态弹窗、390px 手机聊天及导航。截图已经人工查看。

完整三端剧本使用确定性 `--faux-demo`,没有把它称作真模型验收。历史真模型冒烟仍以 §10、§14 为准。复跑:

```sh
cd spikes/durable-multiview
bun run verify:demo ../../output/durable-multiview-rerun
```

### 15.3 本轮修复与边界

- TUI 连接时已存在审批,另一端先决定后,本端原先无法解释晚到 `/approve`;先失败测试,再从初始快照缓存审批。
- 监听单文件 inode 会在编辑器连续原子保存后失效;先以真实宿主进程和两次 rename 保存复现,再改为监听父目录并过滤文件名。`test/host-reload.test.ts` 验证表格→纯文本的实际工具输出。
- 两项浏览器回归保护手机宽度和断线草稿;Web 测试显式分配端口,避免 Vite 的 `port: 0` 回退默认端口造成并行碰撞。
- 验收脚本原先因固定端口误连旧 CDP,真实运行复现后修正;脚本本身是验收工具,不再为脚本字面量另加单测。背景颜色属于样式,使用截图验收。
- `failFast` 只中止仍在运行的兄弟任务。这里的恢复流量是演示自定义 abort 逻辑,不表示 Durable 会撤销已完成副作用。
- 尚未生产化:手机控件尚未按 44px 触控目标全面处理。P3 的旧 `verify:three` 使用固定端口,已有 dev 实例时可能冲突;本次完整验收使用独立端口的 `verify:demo`。

### 15.4 PR #432 检视修复(2026-10-02)

- 演示模型原先对所有压缩请求返回固定的“已尝试回滚”,即使只完成调查。现改为摘录实际工具结果并去重;拒绝记录不会被改写成执行记录,二次压缩也保留事实。测试检查回滚前、批准后、拒绝后及连续压缩的摘要和模型上下文。
- Fork 和 interrupted 标记移到 assistant 消息级 metadata,不再依赖正文气泡。纯思考、纯工具条目可从真实持久 ID 创建分支;流式和中断条目仍没有 Fork,纯思考中断也有标记。浏览器回归覆盖实际分支创建与中断行为。
- 上述行为先观察到失败测试再修复。根 typecheck、lint、1975 项测试及 build 通过;完整三端剧本再次通过,本机证据在 `output/durable-multiview-pr432-fixes-20261002/`。无正文消息的桌面与 390px 截图在 `output/durable-multiview-review-fork-20261002/`,已人工检查。
- 复验期间旧 `web.test.ts` 出现页面未加载导致 textbox 超时;单独 Web 测试和加入临时诊断后的完整 51 项测试均通过。共享 Vite 缓存的假设尚未复现同样故障,未据此修改运行配置,也未增加超时;这是尚未定位的测试启动不稳定性。移除全部诊断后,最后一次原始 `bun run test` 为 12 文件 / 51 项全部通过(15.10s),spike typecheck 也通过。
4 changes: 2 additions & 2 deletions apps/desktop/src/dev/durable-spike/durable-spike-page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,8 @@ const transport = relayTransport(backendRelay({ invoke, onBackendEvent }), label
/**
* Dev-only third client of the Durable multiview spike (spikes/durable-multiview):
* the web client's workbench, reached through Pace's backend instead of a
* browser WebSocket. Start the host first (`bun run host` in the spike).
* browser WebSocket. Start the host first (`bun run host` or `bun run demo` in the spike).
*/
export function DurableSpikePage() {
return <RemoteWorkbench options={{ transport }} connectionKey="pace-backend" label={label} />;
return <RemoteWorkbench options={{ transport, clientName: "pace" }} connectionKey="pace-backend" label={label} />;
}
36 changes: 31 additions & 5 deletions spikes/durable-multiview/host/demo/faux-script.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
// conversation and its parallel subagents arrive interleaved, so every answer is decided from
// the request itself: the tools it offers and the messages since the newest user input.
import type { Message } from "@earendil-works/pi-ai";
import { fauxAssistantMessage, type FauxResponseFactory, fauxText, fauxToolCall } from "@earendil-works/pi-ai/providers/faux";
import { fauxAssistantMessage, type FauxResponseFactory, fauxText, fauxThinking, fauxToolCall } from "@earendil-works/pi-ai/providers/faux";
import { AREAS } from "./oncall.ts";

const INVESTIGATION: readonly string[] = Object.values(AREAS).map((area) => area.tool);
Expand All @@ -24,7 +24,20 @@ function offeredTools(messages: readonly Message[]): string[] {
return [...tools];
}

function summarizeResults(messages: readonly Message[]): string {
const input = textOf(messages.findLast((message) => message.role === "user"));
const history = /<conversation>\n([\s\S]*)\n<\/conversation>/.exec(input)?.[1] ?? "";
// Keep the serialized result markers so another compaction can retain these same facts.
const results = history.match(/(?:^|\n)\[Tool result\]: [\s\S]*?(?=\n\n\[(?:User|Assistant(?: thinking| tool calls)?|Tool result)\]:|\n<\/summary>|$)/g) ?? [];
const excerpts = [...new Set(results.map((result) => result.trim()))];
return excerpts.length === 0
? "No tool results were recorded in the summarized history."
: `Recorded tool results:\n\n${excerpts.join("\n\n")}`;
}

const PLAN = ["Search the deploy logs", "Compare error metrics", "Review the commits in v2.3"];
/** Inputs that start something of their own; anything else arriving mid-run is a steer. */
const COMMANDS = [/^Reminder:/, /postmortem/i, /roll ?back/i, /check the (logs|metrics|commits) again/i, /check again in \d+ seconds?/i];

export function oncallScript({ reminderSeconds = 60 }: { reminderSeconds?: number } = {}): FauxResponseFactory {
return (context, _options, state) => {
Expand All @@ -33,10 +46,13 @@ export function oncallScript({ reminderSeconds = 60 }: { reminderSeconds?: numbe
fauxToolCall(name, args, { id: `call-${state.callCount}-${index}` });
const calls = (...blocks: ReturnType<typeof call>[]) => fauxAssistantMessage(blocks, { stopReason: "toolUse" });
const tools = offeredTools(messages);
// A summarization request offers no tools; its input quotes the selected history prefix.
if (tools.length === 0) return fauxAssistantMessage(summarizeResults(messages));
const userAt = messages.findLastIndex((message) => message.role === "user");
const input = textOf(messages[userAt]);
// A changed section, such as the plan after update_plan, lands as a system message after the results.
const last = messages.slice(userAt + 1).findLast((message) => message.role !== "system");
const spoken = (message: Message) => message.role !== "system";
let last = messages.slice(userAt + 1).findLast(spoken);

// A subagent: one investigation tool, called once, then reported on.
const own = tools.length === 1 && INVESTIGATION.includes(tools[0]!) ? tools[0]! : undefined;
Expand All @@ -46,14 +62,22 @@ export function oncallScript({ reminderSeconds = 60 }: { reminderSeconds?: numbe
return fauxAssistantMessage(`Found in ${own}: ${first}`);
}

const command = COMMANDS.some((pattern) => pattern.test(input));
// A steer is placed after the tool round it interrupted: keep following that round.
const before = messages.slice(0, userAt).findLast(spoken);
if (last === undefined && !command && before?.role === "toolResult") last = before;

if (last?.role === "toolResult") {
if (last.isError) return fauxAssistantMessage(`${last.toolName} did not run: ${textOf(last)} Standing by.`);
switch (last.toolName) {
case "update_plan":
if (messages.some((message) => message.role === "toolResult" && message.toolName === "subagent")) {
return fauxAssistantMessage(
"The pg driver bump in a1b2c3d cut the pool from 100 to 20; eu-west exhausted it at 09:14 and errors went to 14.8%. I recommend rolling back to v2.2.",
);
return fauxAssistantMessage([
fauxThinking("Logs show pool exhaustion in eu-west, metrics show saturated connections, and a1b2c3d changed the pool default. Same cause."),
fauxText(
"The pg driver bump in a1b2c3d cut the pool from 100 to 20; eu-west exhausted it at 09:14 and errors went to 14.8%. I recommend rolling back to v2.2.",
),
]);
}
return calls(
call("subagent", { area: "logs", task: "find the errors around the v2.3 deploy in the logs" }, 0),
Expand All @@ -76,6 +100,8 @@ export function oncallScript({ reminderSeconds = 60 }: { reminderSeconds?: numbe
return fauxAssistantMessage([fauxText("Postmortem: v2.3 shipped a pg driver bump that shrank the connection pool; eu-west saturated first.")]);
}
if (/roll ?back/i.test(input)) return calls(call("rollback", { version: "v2.2", reason: "pg driver pool regression in v2.3" }));
const again = /check the (logs|metrics|commits) again/i.exec(input);
if (again !== null) return calls(call("subagent", { area: again[1]!.toLowerCase(), task: `check the ${again[1]} again` }));
const delay = /check again in (\d+) seconds?/i.exec(input);
if (delay !== null) return calls(call("schedule_check", { seconds: Number(delay[1]), note: "re-check the error rate" }));
if (tools.includes("update_plan") && !messages.some((message) => message.role === "toolResult" && message.toolName === "update_plan")) {
Expand Down
11 changes: 10 additions & 1 deletion spikes/durable-multiview/host/demo/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { fileURLToPath, pathToFileURL } from "node:url";
import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context";
import { createRegistry, type Extension, type Registry } from "@earendil-works/pi-durable";
import { createRegistry, type Extension, type HarnessSettings, type Registry } from "@earendil-works/pi-durable";
import type { OpenHostOptions } from "../host.ts";
import { ApprovalBoard } from "./approvals.ts";
import { AREAS, createOncall } from "./oncall.ts";
Expand All @@ -23,6 +23,8 @@ export interface Demo {
readonly hostOptions: Pick<OpenHostOptions, "registry" | "approvals" | "docs" | "onOpen">;
/** Reinstall the investigation tools from their module; returns the module's load count. */
reload(): Promise<number>;
/** `base` with a short verbatim tail, so a manual compaction of a demo-sized transcript has something to summarize. */
settings(base?: HarnessSettings): HarnessSettings;
}

const defaultModule = fileURLToPath(new URL("./investigation.ts", import.meta.url));
Expand Down Expand Up @@ -59,5 +61,12 @@ export async function createDemo(options: DemoOptions = {}): Promise<Demo> {
registry.install(await load());
return loads;
},
settings(base = {}) {
// Descriptors, not a spread: pi's settings are getters that read the settings file at each use.
return Object.defineProperties({}, {
...Object.getOwnPropertyDescriptors(base),
compaction: { enumerable: true, get: () => ({ ...base.compaction, keepRecentTokens: 400 }) },
});
},
};
}
18 changes: 14 additions & 4 deletions spikes/durable-multiview/host/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
// node host/main.ts --faux-demo # the same demo driven by a scripted model, for tests and rehearsals
// node host/main.ts --faux "scripted answer" [--faux-tps 40] # no real model, for tests
import { watch } from "node:fs";
import { join, resolve } from "node:path";
import { basename, dirname, join, resolve } from "node:path";
import { parseArgs } from "node:util";
import { createModels } from "@earendil-works/pi-ai/models";
import { type FauxResponseStep, fauxAssistantMessage, fauxProvider } from "@earendil-works/pi-ai/providers/faux";
Expand All @@ -30,6 +30,7 @@ const { values } = parseArgs({
"pace-ms": { type: "string" },
"step-ms": { type: "string" },
"reminder-seconds": { type: "string" },
"investigation-module": { type: "string" },
},
});

Expand All @@ -41,6 +42,7 @@ const demo: Demo | undefined =
? await createDemo({
...(values["pace-ms"] === undefined ? {} : { paceMs: Number(values["pace-ms"]) }),
...(values["step-ms"] === undefined ? {} : { stepMs: Number(values["step-ms"]) }),
...(values["investigation-module"] === undefined ? {} : { investigationModule: resolve(values["investigation-module"]) }),
})
: undefined;
const common = {
Expand All @@ -59,7 +61,13 @@ function fauxOptions(responses: () => FauxResponseStep): OpenHostOptions {
faux.setResponses(Array.from({ length: 1000 }, responses));
const model = faux.getModel();
const summary = { provider: model.provider, modelId: model.id, name: model.name, contextWindow: model.contextWindow };
return { ...common, models, modelSummaries: () => [summary], initialModel: { provider: model.provider, modelId: model.id } };
return {
...common,
models,
modelSummaries: () => [summary],
initialModel: { provider: model.provider, modelId: model.id },
...(demo === undefined ? {} : { settings: demo.settings() }),
};
}

async function piOptions(): Promise<OpenHostOptions> {
Expand All @@ -71,7 +79,7 @@ async function piOptions(): Promise<OpenHostOptions> {
...common,
models: modelRuntime,
modelSummaries: () => modelSummaries(modelRuntime),
settings: createHarnessSettings(settingsManager),
settings: demo === undefined ? createHarnessSettings(settingsManager) : demo.settings(createHarnessSettings(settingsManager)),
...(initialModel === undefined ? {} : { initialModel }),
};
}
Expand All @@ -92,7 +100,9 @@ console.log(JSON.stringify({ event: "ready", url: host.url, token: host.token, d
if (demo !== undefined) {
// Editors save in bursts; reload once the burst settles.
let timer: NodeJS.Timeout | undefined;
const watcher = watch(demo.investigationModule, () => {
// Watch the directory because an editor's atomic save replaces the file's inode.
const watcher = watch(dirname(demo.investigationModule), (_event, filename) => {
if (filename !== null && filename !== basename(demo.investigationModule)) return;
clearTimeout(timer);
timer = setTimeout(() => {
demo.reload().then(
Expand Down
3 changes: 2 additions & 1 deletion spikes/durable-multiview/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,8 @@
"typecheck": "tsc --noEmit -p tsconfig.json && tsc --noEmit -p web/tsconfig.json",
"tui": "node tui/main.ts",
"web": "vite --config web/vite.config.ts",
"verify:three": "node scripts/three-clients.mjs"
"verify:three": "node scripts/three-clients.mjs",
"verify:demo": "node scripts/demo-scenario.mjs"
},
"dependencies": {
"@astryxdesign/core": "^0.3.0",
Expand Down
Loading
Loading