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
35 changes: 34 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–P2 完成(见 §10–§12),下一步 P3(Pace dev 页面)。PR 栈:P0 [#426](https://github.com/bubbleptr/pace/pull/426) ← P1 [#427](https://github.com/bubbleptr/pace/pull/427) ← P2 `feat/durable-multiview-web`。前置的 Pi 1.0 升级 [PR #425](https://github.com/bubbleptr/pace/pull/425) 已合入,分支 `feat/durable-multiview-spike` 从 `origin/main` 开出。
- 状态: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` 开出。
- 关联:[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 @@ -234,3 +234,36 @@ Pace 页面放在 `apps/desktop/src/pages/` 下的 dev-only 路由,backend 侧
- **根会话的 ID 是 1,不是 0。** 用 `ROOT_CONVERSATION_ID`,不要写死。
- **修复一个上游带过来的不一致**:网关启动时扫描会话列表照搬了上游的写法,不给 main 标题,但运行中提交监听器会给 main 补上标题。结果宿主重启后,main 在列表里的标题就没了。现在启动时 main 也取第一条用户消息作为标题;有测试覆盖重启前后列表一致。
- Astryx 的 `ChatComposer` 在 `isStopShown` 时 Enter 照样调用 `onSubmit`,只有按钮变成停止,所以 Enter 在忙碌时就是 steer,不需要单独的 steer 按钮。

## 13. P3 进展(2026-10-02):Pace dev 页面(三端同屏)

### 13.1 落地的内容(§3.3 选了 a)

- **传输抽象**(`protocol/transport.ts`):`RemoteDurable` 不再直接 new WebSocket,而是依赖 `FrameTransport`。实现有两种:`webSocketTransport`(TUI、网页、测试用)和 `relayTransport`(经注入的 `FrameRelay` 收发帧,Pace 用)。中继只搬运帧,ops 仍由客户端应用。
- **后端中继** `packages/backend/src/spikes/durable-bridge.ts`:按 `connectionId` 持有到宿主的 WebSocket(Electron utilityProcess 自带全局 `WebSocket`),帧原样转成 `seq: 0` 的临时事件 `durable_spike.frame` / `durable_spike.closed`,不写 journal,不分配序号,和终端流一样。`service.ts` 新增 `durable_spike_connect` / `_send` / `_disconnect` 三个命令。
- token 从 `<agentDir>/experimental/durable-multiview/default/token` 读取,即宿主默认数据目录里的那个文件;没有 token 时报「先 `bun run host`」。
- **只接受回环地址**:token 等于宿主 Agent 的完全控制权,不能发到别的机器。
- 默认地址可以用 `PACE_DURABLE_SPIKE_URL` 覆盖,验证脚本靠它避开操作者自己的 7420。
- **渲染进程**:`src/dev/durable-spike/` 不属于 FSD 的任何一层(同 `src/dev/ui-intent/`)。`backend-relay.ts` 把 `invoke` / `onBackendEvent` 适配成 `FrameRelay`。`durable-spike-page.tsx` 直接复用网页版的 `RemoteWorkbench`,**三端用的是同一份展示代码**,只有传输不同。路由 `#/durable-spike` 只在 `import.meta.env.DEV` 分支里懒加载注册,并加入了 preflight 豁免。
- **`runtime-gateway-client.ts` 的临时事件忽略列表加入了两个新类型**。测试先失败过:不加的话,spike 帧会进入运行时状态和去重集合。
- 根配置:`tsconfig.json` 加了 `allowImportingTsExtensions`(spike 在 Node 下运行的文件带 `.ts` 扩展名导入,根 `noEmit` 下无副作用)和路径别名 `@pace/durable-spike/*`;`vite.config.ts` 和 `electron.vite.config.ts` 的 renderer 也加了同名别名。
- spike 和 `apps/desktop` 解析到同一份 Astryx、主题、StyleX、React,Pace 的 `<Theme>` 可以覆盖复用进来的组件(已核对符号链接)。

### 13.2 验收

- `test/relay.test.ts`(spike,5 条):用**真实的后端中继**连假模型宿主。经中继的客户端和直连客户端看到并驱动同一个对话,最终视图相等;宿主重启后经中继自动重连;可配置默认地址;没有 token 和非回环地址都给出明确错误。
- `service.test.ts`:三个命令转发给中继;中继事件变成 `seq: 0` 的 envelope;`dispose` 时关闭中继。
- `runtime-gateway-client.test.ts`:两个新事件类型在运行时状态和去重之前就被丢弃。
- `backend-relay.test.ts`:命令映射;事件按类型和字段过滤。
- **三端同屏实证** `scripts/three-clients.mjs`(`bun run verify:three`):假模型宿主 + 隔离的 `electron-vite dev` Pace(独立 `PACE_DATA_DIR`、`PI_CODING_AGENT_DIR`、`--user-data-dir`,加 `PACE_E2E=1`,通过 CDP 驱动)+ 网页 + pty TUI。Pace 提问,网页和 TUI 都看到;网页和 TUI 提问,Pace 都看到;流式中途 `kill -9`,Pace 显示断线,重启后自动续上,出现 `interrupted` 片段和完整答案。截图和 TUI 屏幕转储写入证据目录。结束时按 `--user-data-dir` 精确结束 Electron(electron-vite 拉起的 Electron 不在它的进程组里),并删除临时目录。
- 生产构建的 renderer 里查不到 spike 页面。后端 bundle 里有中继代码,但不被调用就什么都不做。

### 13.3 新发现(P5 结论文档要用)

- **`electron-vite build` 总会去掉 DEV 分支**,`--mode development` 或 `NODE_ENV=development` 都不行。所以 verify-pace 的 Playwright 夹具(启动生产构建)够不着 dev-only 页面,要用 `electron-vite dev --remoteDebuggingPort` 加 CDP。
- **Pace 接入 Durable 的最小改动面**:
- 后端:一个不解析帧的 WebSocket 中继,加三个命令。
- 核心协议:不需要改,复用 `seq: 0` 的临时 envelope。
- 渲染进程:一个传输适配器,加上把新事件类型列入忽略列表。
- 视图状态由渲染进程持有,ops 只应用一次,没有经过 Session Projection 和 Journal。这一点和 ADR-0044「渲染进程单写者投影」一致,但**和 ADR-0021 的双持久化轨道是并列的另一条路**:Durable 自己的 SQLite 才是真相,Pace 不再需要 Journal。结论文档要讨论这一点。
- 后端只做透传,所以 Durable 的背压(`watch()` 积压 100 帧后折叠)在宿主到后端这一段生效。后端到渲染进程这一段的 IPC 没有背压:慢渲染进程会在 Electron IPC 队列里积压。spike 里看不到问题,但真实接入要考虑。
2 changes: 2 additions & 0 deletions apps/desktop/electron.vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -223,6 +223,8 @@ const reactPackage = dirname(requireFromRepo.resolve("react/package.json"));
const reactDomPackage = dirname(requireFromRepo.resolve("react-dom/package.json"));
const rendererReactAlias = {
...coreAlias,
// Dev-only Durable multiview spike page; removed with spikes/durable-multiview.
"@pace/durable-spike": resolve(__dirname, "../../spikes/durable-multiview"),
react: reactPackage,
"react-dom": reactDomPackage,
};
Expand Down
11 changes: 11 additions & 0 deletions apps/desktop/src/app/main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ function isPreflightExemptPath(pathname: string) {
return (
pathname === "/preflight" ||
pathname === "/design" ||
pathname === "/durable-spike" ||

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.

🟡 Preflight errors block the Durable page

If the preflight status request fails, PreflightGate renders its error before checking the new route exemption. The Durable page never opens, even with its host running.

Learn more

The route exemption is calculated by PreflightGate, but that gate returns loading or error screens before allowing any exempt route through. A failed preflight status request blocks the new page even though the route is explicitly exempt. This already applies to other exempt routes, but the newly added page inherits the failure.

Example: With a working Durable host and a failed get_environment_preflight_status request, navigating to #/durable-spike shows the preflight error instead of the workbench.

Recommended fix: Let exempt paths render their children before the preflight loading and error early returns, while keeping the redirect and error gate for nonexempt paths.

Devin Review


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

pathname === "/settings" ||
pathname.startsWith("/settings/")
);
Expand Down Expand Up @@ -226,6 +227,16 @@ const devOnlyRoutes = import.meta.env.DEV
default: (await import("@/pages/design")).DesignPage,
})),
}),
// Durable multiview spike; removed with spikes/durable-multiview.
createRoute({
getParentRoute: () => rootRoute,
path: "/durable-spike",
wrapInSuspense: true,
errorComponent: DevRouteError,
component: React.lazy(async () => ({
default: (await import("@/dev/durable-spike/durable-spike-page")).DurableSpikePage,
})),
}),
]
: [];

Expand Down
49 changes: 49 additions & 0 deletions apps/desktop/src/dev/durable-spike/backend-relay.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import type { BackendRpcEvent } from "@pace/backend";
import { describe, expect, it, vi } from "vitest";
import { backendRelay } from "./backend-relay";

function envelope(type: string, payload: Record<string, unknown>): BackendRpcEvent {
return { type: "event", event: { id: "evt-1", seq: 0, sessionId: "", piSessionId: "", type, ts: "2026-10-02T00:00:00Z", payload } };
}

describe("backendRelay", () => {
it("maps relay calls to backend commands", async () => {
const invoke = vi.fn(async () => null) as unknown as <T>(command: string, args?: Record<string, unknown>) => Promise<T>;
const relay = backendRelay({ invoke, onBackendEvent: () => () => {} });

await relay.connect("c1");
relay.send("c1", "frame");
relay.disconnect("c1");

expect(vi.mocked(invoke).mock.calls).toEqual([
["durable_spike_connect", { connectionId: "c1" }],
["durable_spike_send", { connectionId: "c1", data: "frame" }],
["durable_spike_disconnect", { connectionId: "c1" }],
]);
});

it("passes on frames and closes from backend events and drops everything else", () => {
let emit: (event: BackendRpcEvent) => void = () => {};
const relay = backendRelay({
invoke: vi.fn() as never,
onBackendEvent: (listener) => {
emit = listener;
return () => {};
},
});
const received: unknown[] = [];
relay.subscribe((event) => received.push(event));

emit(envelope("durable_spike.frame", { connectionId: "c1", data: "{}" }));
emit(envelope("terminal_output", { terminalId: "t", data: "x" }));
emit(envelope("durable_spike.frame", { data: "no connection id" }));
emit(envelope("durable_spike.closed", { connectionId: "c1", code: 4401, reason: "unauthorized" }));
emit(envelope("durable_spike.closed", { connectionId: "c2", code: 1006 }));

expect(received).toEqual([
{ kind: "frame", connectionId: "c1", data: "{}" },
{ kind: "closed", connectionId: "c1", code: 4401, reason: "unauthorized" },
{ kind: "closed", connectionId: "c2", code: 1006 },
]);
});
});
40 changes: 40 additions & 0 deletions apps/desktop/src/dev/durable-spike/backend-relay.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import type { BackendRpcEvent } from "@pace/backend";
import type { FrameRelay, RelayEvent } from "@pace/durable-spike/protocol/transport.ts";

type Invoke = <T>(command: string, args?: Record<string, unknown>) => Promise<T>;
type SubscribeBackendEvent = (listener: (event: BackendRpcEvent) => void) => () => void;

/**
* The spike's relay over Pace's backend: the renderer cannot open a WebSocket
* under its CSP, so the backend holds it and frames cross the existing IPC.
*/
export function backendRelay({ invoke, onBackendEvent }: { invoke: Invoke; onBackendEvent: SubscribeBackendEvent }): FrameRelay {
return {
connect: (connectionId) => invoke<null>("durable_spike_connect", { connectionId }).then(() => {}),
send: (connectionId, data) => void invoke("durable_spike_send", { connectionId, data }),
disconnect: (connectionId) => void invoke("durable_spike_disconnect", { connectionId }),
subscribe: (listener) =>
onBackendEvent((event) => {
const relayed = relayEventOf(event);
if (relayed !== undefined) listener(relayed);
Comment on lines +17 to +19

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.

🔴 Backend restart strands the Pace client

When the backend restarts, backendRelay ignores its lifecycle event, leaving the page connected to a dead socket. The replacement bridge has no socket for that connection ID, so prompts disappear instead of reconnecting.

Learn more

Pace's main process emits backend lifecycle envelopes on backend exit and restart through sendBackendLifecycleEvent. The spike page only listens for frame and closed envelopes, while its WebSocket lives in the backend process. If the backend exits, the socket dies without a relay close event. The new backend has an empty connection map, so the existing client keeps its connected state and the bridge silently discards its sends.

Example: The page connects as c1; kill the backend utility process while the Durable host stays up. The backend restarts, but c1 remains marked connected in the page and a submitted prompt reaches no host.

Recommended fix: Translate backend-disconnected lifecycle envelopes into close notifications for all active relay connection IDs. Ensure the page reopens through the new backend generation rather than retaining the old connected transport.

Devin Review


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

}),
};
}

function relayEventOf(event: BackendRpcEvent): RelayEvent | undefined {
if (event.type !== "event") return undefined;
const { type, payload } = event.event;
if (typeof payload.connectionId !== "string") return undefined;
if (type === "durable_spike.frame" && typeof payload.data === "string") {
return { kind: "frame", connectionId: payload.connectionId, data: payload.data };
}
if (type === "durable_spike.closed" && typeof payload.code === "number") {
return {
kind: "closed",
connectionId: payload.connectionId,
code: payload.code,
...(typeof payload.reason === "string" ? { reason: payload.reason } : {}),
};
}
return undefined;
}
16 changes: 16 additions & 0 deletions apps/desktop/src/dev/durable-spike/durable-spike-page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { relayTransport } from "@pace/durable-spike/protocol/transport.ts";
import { RemoteWorkbench } from "@pace/durable-spike/web/App.tsx";
import { invoke, onBackendEvent } from "@/shared/runtime";
import { backendRelay } from "./backend-relay";

const label = "Pace backend → ws://127.0.0.1:7420";
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).
*/
export function DurableSpikePage() {
return <RemoteWorkbench options={{ transport }} connectionKey="pace-backend" label={label} />;
}
Original file line number Diff line number Diff line change
Expand Up @@ -1141,7 +1141,7 @@ describe("Runtime Gateway client", () => {
]);
});

it.each(["workspace.invalidated", "model_catalog.invalidated", "terminal_output", "terminal_exit"])("ignores ephemeral %s before runtime state and deduplication", async (type) => {
it.each(["workspace.invalidated", "model_catalog.invalidated", "terminal_output", "terminal_exit", "durable_spike.frame", "durable_spike.closed"])("ignores ephemeral %s before runtime state and deduplication", async (type) => {
let receive: ((event: BackendRpcEvent) => void) | undefined;
const snapshot: RuntimeGatewaySnapshot = { sessionId: "session-1", runtimeId: "pi-sdk:session-1", piSessionId: "pi-session-1", projectId: "pig", cwd: "/repo", status: "idle", events: [], updatedAt: "2026-09-05T00:00:00.000Z" };
const client = createRuntimeGatewayClient({
Expand Down
11 changes: 9 additions & 2 deletions apps/desktop/src/entities/runtime/runtime-gateway-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -654,8 +654,15 @@ export function createRuntimeGatewayClient(
return;
}

// Ephemeral workspace and terminal signals never belong to runtime truth.
if (["workspace.invalidated", "model_catalog.invalidated", "terminal_output", "terminal_exit"].includes(event.event.type)) {
// Ephemeral workspace, terminal, and dev-spike signals never belong to runtime truth.
if ([
"workspace.invalidated",
"model_catalog.invalidated",
"terminal_output",
"terminal_exit",
"durable_spike.frame",
"durable_spike.closed",
].includes(event.event.type)) {
return;
}

Expand Down
3 changes: 3 additions & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

45 changes: 45 additions & 0 deletions packages/backend/src/service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
import { createInMemorySessionProjectionStore } from "./persistence/session-projection-store";
import type { SessionSummary } from "@pace/core";
import type { PiRuntimeDriver } from "./gateway/runtime-gateway";
import type { DurableSpikeBridge, DurableSpikeBridgeEvent } from "./spikes/durable-bridge";
import type {
TerminalManager,
TerminalManagerEvent,
Expand Down Expand Up @@ -2243,3 +2244,47 @@ it("delivers external Git changes to linked checkout siblings without a runtime
await rm(root, { recursive: true, force: true });
}
});

it("relays the Durable spike's frames between the renderer and the bridge as unsequenced events", async () => {
const bridgeListeners = new Set<(event: DurableSpikeBridgeEvent) => void>();
const bridge = {
connect: vi.fn(async () => {}),
send: vi.fn(),
disconnect: vi.fn(),
onEvent: vi.fn((listener: (event: DurableSpikeBridgeEvent) => void) => {
bridgeListeners.add(listener);
return () => bridgeListeners.delete(listener);
}),
dispose: vi.fn(),
} satisfies DurableSpikeBridge;
const service = createBackendService({
agentDir: fixtureAgentDir(),
sessionProjectionStore: createInMemorySessionProjectionStore(),
runtimeJournal: createInMemorySessionEventJournal(),
runtimeDriver: { onEvent: () => () => {} } as unknown as PiRuntimeDriver,
durableSpikeBridge: bridge,
});
const events: import("./service").BackendRpcEvent[] = [];
service.onEvent((event) => events.push(event));

const connect = { connectionId: "c1", url: "ws://127.0.0.1:7420" };
await expect(service.handleRequest({ id: "r1", method: "durable_spike_connect", params: connect })).resolves.toEqual({ id: "r1", result: null });
await service.handleRequest({ id: "r2", method: "durable_spike_send", params: { connectionId: "c1", data: "{\"type\":\"subscribe\"}" } });
await service.handleRequest({ id: "r3", method: "durable_spike_disconnect", params: { connectionId: "c1" } });
expect(bridge.connect).toHaveBeenCalledWith(connect);
expect(bridge.send).toHaveBeenCalledWith({ connectionId: "c1", data: "{\"type\":\"subscribe\"}" });
expect(bridge.disconnect).toHaveBeenCalledWith({ connectionId: "c1" });

for (const listener of bridgeListeners) {
listener({ kind: "frame", connectionId: "c1", data: "{\"type\":\"hello\"}" });
listener({ kind: "closed", connectionId: "c1", code: 4401, reason: "unauthorized" });
}
const envelope = { id: expect.stringMatching(/^evt-/), seq: 0, sessionId: "", piSessionId: "", ts: expect.any(String) };
expect(events).toEqual([
{ type: "event", event: { ...envelope, type: "durable_spike.frame", payload: { connectionId: "c1", data: "{\"type\":\"hello\"}" } } },
{ type: "event", event: { ...envelope, type: "durable_spike.closed", payload: { connectionId: "c1", code: 4401, reason: "unauthorized" } } },
]);

await service.dispose();
expect(bridge.dispose).toHaveBeenCalled();
});
Loading
Loading