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
41 changes: 40 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(演示扩展与剧本)。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 `feat/durable-multiview-pace`。前置的 Pi 1.0 升级 [PR #425](https://github.com/bubbleptr/pace/pull/425) 已合入,分支 `feat/durable-multiview-spike` 从 `origin/main` 开出。
- 状态: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` 开出。
- 关联:[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 @@ -267,3 +267,42 @@ Pace 页面放在 `apps/desktop/src/pages/` 下的 dev-only 路由,backend 侧
- 渲染进程:一个传输适配器,加上把新事件类型列入忽略列表。
- 视图状态由渲染进程持有,ops 只应用一次,没有经过 Session Projection 和 Journal。这一点和 ADR-0044「渲染进程单写者投影」一致,但**和 ADR-0021 的双持久化轨道是并列的另一条路**:Durable 自己的 SQLite 才是真相,Pace 不再需要 Journal。结论文档要讨论这一点。
- 后端只做透传,所以 Durable 的背压(`watch()` 积压 100 帧后折叠)在宿主到后端这一段生效。后端到渲染进程这一段的 IPC 没有背压:慢渲染进程会在 Electron IPC 队列里积压。spike 里看不到问题,但真实接入要考虑。

## 14. P4a 进展(2026-10-02):宿主侧演示扩展与协议

P4 拆成两个 PR:P4a 是宿主扩展、网关和客户端协议,三端界面暂时不变;P4b 做三端界面(审批、待办、fork、思考块、窄屏、用量)和完整剧本的验证脚本。

### 14.1 落地的内容

- `host/demo/`(`node host/main.ts --demo` 走真模型,`--faux-demo` 走剧本模型;`bun run demo` / `bun run demo:faux`):
- `oncall.ts`:只给 main 和它的 fork 用的扩展。`playbook` section 把 §6 的流程写死给模型;`plan` section 把待办渲染进系统提示。工具有 `update_plan`(写 `demo.plan` 文档)、`subagent`(照示例 22,`replay: "safe"`,按 `area` 只给子会话对应的那一个调查工具,并去掉 `oncall` 扩展)、`rollback`、`schedule_check`。
- **审批(§8.1 选了 a)**:`beforeTool` 钩子只拦 `rollback`。先读 memo,再读 `demo.approvals` 文档,都没有才去内存里的 `ApprovalBoard` 登记等待;拿到决定后写 memo。客户端点「批准」走网关的 `approve`,宿主在一次提交里 `decisions[id] ??= 决定`,先写者胜,后点的人收到「已被 X 批准」。等待随钩子的 `context` 取消(Esc)。
- `tasks.ts`:`demo.rollback` 一次建 3 个区域子任务,`waiting` + `failFast`。`eu-west` 的健康检查必失败,另外两个区域正在切换时被中止,在 abort 处理里「恢复流量」作为补偿(自下而上,补偿完才落 `aborted`)。进度写进 `demo.rollout` 文档,`rollback` 工具轮询它作为运行中输出。`rollback` 也是 `replay: "safe"`:在同一次提交里先按 kind 扫描任务、按 `owner` 过滤,找到就复用,所以崩溃后不会再建第二个回滚。
- `demo.reminder`:`schedule_check` 建的 background 任务,`until` 是绝对时间存在输入里,`runtime.sleep` 到点后以 `requestId: reminder:<taskId>` 发 follow-up。
- `investigation.ts`:三个模拟调查工具,逐行慢速 `api.output`。按路径加载,`Demo.reload()` 用带 `?v=N` 的 URL 重新 import,`registry.install` 同名替换。`main.ts` 在 `--demo` 下监听这个文件,保存后自动热替换并通知三端。改 `const FORMAT = "plain"` 为 `"table"` 就能看到下一次输出变成表格。
- `faux-script.ts`:剧本模型。主会话和三个并行子会话的请求交错到达,所以每次都只按请求本身决定下一步:请求里提供了哪些工具,以及最新一条用户输入之后的消息。
- 网关与协议:
- 新流 `approvals`(待审批列表)和 `doc:<kind>:<conversationId>`(`hello.docs` 列出可订阅的文档 kind)。
- 新调用 `approve`、`fork`(`removeTools` 按名字从父会话当前提供的工具里去掉)。
- 会话标签区分 `subagent N` / `fork N`。fork 的标题取它自己的第一条用户消息,不取继承来的。
- `DurableView` 多了 `docs`(当前会话的扩展文档)和 `approvals`;`DurableController` 多了 `approve(approval, approved)`、`fork(entryId, prompt, removeTools?)`。客户端有 `clientName`,审批记录里的 `by` 就是它。
- `openHost` 多了 `approvals`、`docs`、`onOpen`,返回值多了 `notify`。演示的 `onOpen` 给 main 去掉三个调查工具,逼它委派给子代理。

### 14.2 验收

- `test/demo.test.ts`(6 条,剧本模型 + 进程内宿主):
- 待办同步;3 个子代理同时在跑(任务图里 3 个调用同时挂着子会话);子会话只提供自己那个工具。
- 两端同时看到审批;A 批准、B 拒绝,B 收到「已被 web 批准」;回滚结果是 `eu-west` 失败、另外两个区域补偿。
- 批准后、回滚进行中关掉宿主再开:不再弹审批,也只有一次回滚。
- `schedule_check` 之后 Esc,再重启宿主:提醒照样以 follow-up 到达,只到一次。
- 在答案处 fork 并去掉 `rollback`:会话列表出现 `fork N`,fork 里的待办是 fork 那一刻的(`asOf`),工具里没有 `rollback`。
- 热替换后,下一次子代理的工具输出变成表格。
- `test/crash-recovery.test.ts` 新增一条:宿主作为子进程,3 个子代理都在跑调查工具时 `kill -9`,重启后会话列表里仍是同样的 3 个子会话,调查完成并给出建议。
- 真模型冒烟(`radius/deepseek-v4.1-flash`,`--demo`):模型按 playbook 写了待办,一次派出 3 个子代理(任务图里同时 3 个),汇总后建议回滚到 v2.2。说「回滚」后弹出审批,脚本批准;`eu-west` 失败,另外两个区域补偿。第一次跑时模型因为回滚失败没有安排复查,playbook 已改成「不论成败都 `schedule_check`」。

### 14.3 新发现

- **section 的变化会以 system 消息插在工具结果后面。** `update_plan` 改了待办,下一次请求里,`toolResult` 之后会多一条只带 `sections` 的 system 消息。剧本模型取「最后一条消息」时要跳过它;Pace 渲染转录时也要决定这类 `pi.system` 条目显示不显示。
- **`ConversationView.docs` 只有 4 个内置文档**,扩展文档要单独 `watchDoc`。`watchDoc` 不会创建文档,文档不存在时返回 `undefined`;网关先发 `null`,再等创建它的那次提交(`document` 或 fork 时的 `document.copy` 变更)到来后挂上 watch。
- **钩子不能提交,所以「谁在等审批」不是持久状态。** 决定是持久的(文档 + memo),等待只在宿主内存里;宿主重启后钩子重跑,会重新登记。这一点对 Pace 有影响:待审批列表要么走 spike 这样的旁路流,要么改成 §8.1 的 b)方案,用工具加任务把审批变成持久状态。
- `TaskQuery` 没有按 owner 过滤的字段,只能按 `conversationId` + `kind` 扫,再按 `owner` 过滤。
1 change: 1 addition & 0 deletions spikes/durable-multiview/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
.tmp/
92 changes: 92 additions & 0 deletions spikes/durable-multiview/host/demo/approvals.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import type { Context } from "@earendil-works/chord";
import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context";
import type { ConversationId, Harness } from "@earendil-works/pi-durable";
import type { ApprovalDecision, PendingApproval } from "../../protocol/demo.ts";
import { ApprovalsDoc } from "./state.ts";

type Waiter = { readonly request: PendingApproval; resolve(decision: ApprovalDecision): void };

/**
* Who is waiting for a decision. A hook can neither commit nor sleep durably, so the waiting
* itself lives here, in memory; a restarted host's hook runs again and asks again. Decisions
* are committed to `ApprovalsDoc`, which is what makes them stand.
*/
export class ApprovalBoard {
readonly #waiters = new Map<string, Waiter[]>();
readonly #decided = new Map<string, ApprovalDecision>();
readonly #listeners = new Set<(pending: readonly PendingApproval[]) => void>();
#harness: Harness | undefined;

attach(harness: Harness): void {
this.#harness = harness;
}

get value(): readonly PendingApproval[] {
return [...this.#waiters.values()].map((waiters) => waiters[0]!.request);
}

subscribe(listener: (pending: readonly PendingApproval[]) => void): () => void {
this.#listeners.add(listener);
return () => this.#listeners.delete(listener);
}

/** Resolves with the decision; rejects when `context` is cancelled, for example by Esc. */
wait(request: PendingApproval, context: Context): Promise<ApprovalDecision> {
const decided = this.#decided.get(request.id);
if (decided !== undefined) return Promise.resolve(decided);
return new Promise((resolve, reject) => {
const signal = context.abortSignal;
const waiter: Waiter = {
request,
resolve: (decision) => {
signal?.removeEventListener("abort", cancel);
resolve(decision);
},
};
const cancel = (): void => {
this.#remove(waiter);
reject(signal?.reason ?? new Error("Approval wait cancelled"));
};
if (signal?.aborted) return cancel();
signal?.addEventListener("abort", cancel, { once: true });
this.#waiters.set(request.id, [...(this.#waiters.get(request.id) ?? []), waiter]);
this.#changed();
});
}

/** Commit a decision unless one exists; returns the one that stands and whether it is this one. */
async decide(
conversationId: ConversationId,
id: string,
approved: boolean,
by: string,
): Promise<{ decision: ApprovalDecision; first: boolean }> {
const harness = this.#harness;
if (harness === undefined) throw new Error("Approvals are not attached to a Harness");
const candidate: ApprovalDecision = { approved, by, at: Date.now() };
const decision = await harness.commit(async (tx) => {
const doc = await tx.doc(ApprovalsDoc, conversationId);
if (doc.decisions[id] === undefined && !this.#waiters.has(id)) throw new Error(`No pending approval ${id}`);
doc.decisions[id] ??= { ...candidate };
Comment on lines +67 to +70

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟥 Approval decision bypasses conversation binding

A client can send an approval ID with a different conversation ID. ApprovalBoard checks pending IDs globally, then records the decision in the supplied conversation while resolving the original waiter. The decision does not survive replay of the original conversation.

Devin Review


Was this helpful? React with 👍 or 👎 to provide feedback.

return { ...doc.decisions[id] } as ApprovalDecision;
}, BACKGROUND_CONTEXT);
this.#decided.set(id, decision);
const waiters = this.#waiters.get(id) ?? [];
this.#waiters.delete(id);
for (const waiter of waiters) waiter.resolve(decision);
if (waiters.length > 0) this.#changed();
return { decision, first: decision.by === candidate.by && decision.at === candidate.at };

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Losing approval reports a winning decision

When the same client name votes twice within one millisecond, first can be true for both calls. The later vote is ignored, but its caller sees a success notice.

Learn more

The transaction persists only the first decision, but the returned first flag is inferred from by and at. These are not unique: client names default to client, and two calls can share a millisecond. The second request can therefore report that it won when the persisted decision was different.

Example: Two controls named client approve and deny the same call at timestamp 1000. If approval commits first, the denial receives {decision: approved, first: true} and reports You approved instead of Already approved by client.

Recommended fix: Compute a boolean inside the transaction when the decision is inserted, and return that boolean alongside the stored decision.

Devin Review


Was this helpful? React with 👍 or 👎 to provide feedback.

}

#remove(waiter: Waiter): void {
const rest = (this.#waiters.get(waiter.request.id) ?? []).filter((candidate) => candidate !== waiter);
if (rest.length > 0) this.#waiters.set(waiter.request.id, rest);
else this.#waiters.delete(waiter.request.id);
this.#changed();
}

#changed(): void {
const pending = this.value;
for (const listener of this.#listeners) listener(pending);
}
}
86 changes: 86 additions & 0 deletions spikes/durable-multiview/host/demo/faux-script.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
// A scripted stand-in for a real model that walks the on-call playbook. Requests of the main
// 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 { AREAS } from "./oncall.ts";

const INVESTIGATION: readonly string[] = Object.values(AREAS).map((area) => area.tool);

function textOf(message: Message | undefined): string {
if (message === undefined || message.role === "system") return "";
if (typeof message.content === "string") return message.content;
return message.content.flatMap((block) => (block.type === "text" ? [block.text] : [])).join("");
}

/** The tools a request offers, from its system messages' additions and removals. */
function offeredTools(messages: readonly Message[]): string[] {
const tools = new Set<string>();
for (const message of messages) {
if (message.role !== "system") continue;
for (const tool of message.toolsAdded ?? []) tools.add(tool.name);
for (const tool of message.toolsRemoved ?? []) tools.delete(tool.name);
}
return [...tools];
}

const PLAN = ["Search the deploy logs", "Compare error metrics", "Review the commits in v2.3"];

export function oncallScript({ reminderSeconds = 60 }: { reminderSeconds?: number } = {}): FauxResponseFactory {
return (context, _options, state) => {
const { messages } = context;
const call = (name: string, args: Parameters<typeof fauxToolCall>[1], index = 0) =>
fauxToolCall(name, args, { id: `call-${state.callCount}-${index}` });
const calls = (...blocks: ReturnType<typeof call>[]) => fauxAssistantMessage(blocks, { stopReason: "toolUse" });
const tools = offeredTools(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");

// A subagent: one investigation tool, called once, then reported on.
const own = tools.length === 1 && INVESTIGATION.includes(tools[0]!) ? tools[0]! : undefined;
if (own !== undefined) {
if (last?.role !== "toolResult") return calls(call(own, { query: input }));
const first = textOf(last).split("\n").find((line) => /ERROR|->|pg driver/.test(line)) ?? textOf(last).split("\n")[0];
return fauxAssistantMessage(`Found in ${own}: ${first}`);
}

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 calls(
call("subagent", { area: "logs", task: "find the errors around the v2.3 deploy in the logs" }, 0),
call("subagent", { area: "metrics", task: "compare error metrics before and after v2.3" }, 1),
call("subagent", { area: "commits", task: "review the commits in v2.3 for risky changes" }, 2),
);
case "subagent":
return calls(call("update_plan", { items: PLAN.map((text) => ({ text, status: "done" })) }));
case "rollback":
return calls(call("schedule_check", { seconds: reminderSeconds, note: "verify the error rate after the rollback" }));
case "schedule_check":
return fauxAssistantMessage("Check scheduled; I will re-check when the reminder arrives.");
default:
return fauxAssistantMessage("Done.");
}
}

if (input.startsWith("Reminder:")) return fauxAssistantMessage("Re-checked: the error rate is back to baseline (0.2%) in every region.");
if (/postmortem/i.test(input)) {
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 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")) {
return calls(call("update_plan", { items: PLAN.map((text) => ({ text, status: "todo" })) }));
Comment on lines +81 to +82

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Later investigations skip the demo playbook

After one investigation, oncallScript finds its old update_plan result and skips planning for later requests. A second investigation gets only Noted instead of running the investigation.

Learn more

The scripted model selects the latest user message at userAt, and last already considers only messages after it. The plan guard instead searches all messages, including earlier turns. Once an update_plan result exists, an unrelated new user prompt cannot start another scripted investigation.

Example: The user asks to investigate v2.3, receives a recommendation, then asks to investigate the release again. The second request contains the old update_plan result, so the faux model responds Noted: without delegating.

Recommended fix: Scope the update_plan result check to messages.slice(userAt + 1), as the other per-turn decisions are scoped.

Devin Review


Was this helpful? React with 👍 or 👎 to provide feedback.

}
return fauxAssistantMessage(`Noted: ${input}`);
};
}
63 changes: 63 additions & 0 deletions spikes/durable-multiview/host/demo/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
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 type { OpenHostOptions } from "../host.ts";
import { ApprovalBoard } from "./approvals.ts";
import { AREAS, createOncall } from "./oncall.ts";
import { PlanDoc, RolloutDoc } from "./state.ts";
import { DemoTasks } from "./tasks.ts";

export interface DemoOptions {
/** Delay between streamed lines of an investigation tool's output. */
readonly paceMs?: number;
/** Length of one rollback step in a region. */
readonly stepMs?: number;
/** The investigation tools' module; `reload()` imports it again. */
readonly investigationModule?: string;
}

export interface Demo {
readonly registry: Registry;
readonly investigationModule: string;
/** What `openHost` needs to run the demo: its registry, approvals, documents, and root setup. */
readonly hostOptions: Pick<OpenHostOptions, "registry" | "approvals" | "docs" | "onOpen">;
/** Reinstall the investigation tools from their module; returns the module's load count. */
reload(): Promise<number>;
}

const defaultModule = fileURLToPath(new URL("./investigation.ts", import.meta.url));

export async function createDemo(options: DemoOptions = {}): Promise<Demo> {
const { paceMs = 400, stepMs = 2000, investigationModule = defaultModule } = options;
const registry = createRegistry();
const approvals = new ApprovalBoard();
let loads = 0;
const load = async (): Promise<Extension> => {
// A fresh URL is a fresh module instance; the registry replaces the extension of the same name in place.
const module = (await import(`${pathToFileURL(investigationModule).href}?v=${++loads}`)) as typeof import("./investigation.ts");
return module.createInvestigation({ paceMs });
};
registry.install(DemoTasks);
registry.install(await load());
registry.install(createOncall({ approvals, stepMs }));
return {
registry,
investigationModule,
hostOptions: {
registry,
approvals,
docs: [PlanDoc, RolloutDoc],
async onOpen(harness, root) {
approvals.attach(harness);
// The main agent delegates instead of investigating itself; tools are stored by name, so this survives reloads.
const investigation = registry.snapshot().extension("investigation")!.tools ?? [];
const own = investigation.filter((tool) => Object.values(AREAS).some((area) => area.tool === tool.name));
await root.configure({ tools: { remove: own } }, BACKGROUND_CONTEXT);
},
},
async reload() {
registry.install(await load());
return loads;
},
};
}
Loading
Loading