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
8 changes: 8 additions & 0 deletions docs/development/OPENPI_WEB_DEVELOPMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,14 @@ bun run dev:web -- /absolute/path/to/workspace

异常进程恢复会在 Web Session 目录的 `.openpi-web-host.artifacts/` 中保留安全围栏。只有确认没有存活或暂停的 Web Host 仍依赖这些记录后,才可人工删除其中过期的 `candidate-*`、`released-*` 或 `stale-*` 目录。OpenPI 不会自动删除围栏;达到 128 个租约产物或 64 个 stale 围栏时会 fail closed,并在错误信息中给出该目录。普通 Session 文件不占用这个预算。

## 活动回合取消协议

Web 的 Stop 请求 Pi 停止当前 agent execution,不等同于停止 Host,也不会清空已经排队的 follow-up。取消请求必须回传当前快照或 `turn_started` 事件给出的 `sessionId`、`commandId` 和 `epoch`;Runtime 在自己的串行 mutation 边界内重新核对三者,再调用 Pi 原生 `AgentSession.abort()`。这不是“一条输入一个回合”的额外队列:Pi 可以在同一次 execution 中继续处理已排队的 follow-up;当前 execution 终结后,下一次 execution 的真实 `agent_start` 才会取得新的 Stop identity;同一 execution 内的 retry 或 continue 保留原 identity。

Host 返回 `accepted`、`already-settled`、`stale-session`、`stale-turn` 或 `failed` 的明确收据。浏览器不会因点击按钮而乐观结束运行态;只有 Pi 的完整 execution 发出 `agent_settled`,并且其中有被 Stop 目标对应的 assistant 结果 `stopReason: "aborted"`,才投影为 `turn_settled(outcome: "cancelled")`。这个 outcome 只描述被请求停止的 provider 结果,不概括同一次 execution 中 Pi 随后处理的 follow-up 是否成功。单条 `message_end` 只提供结果证据,不能单独结束 execution;若 Pi settled 时没有终态 assistant 证据,Runtime 投影 `uncertain` 并返回 `failed`,不会猜测取消成功。活动回合身份也包含在快照中,因此刷新和 SSE 重连仍能恢复正确的 Stop 控件。多客户端的旧请求不能取消更新的回合,重复请求则按已终结回合幂等返回。

这个边界源自 [Issue #342](https://github.com/openpi-dev/openpi/issues/342)。Host disposal 仍由独立生命周期处理;全局暂停属于其他设计范围。

`dev:web` 和 `dev:web:backend` 默认会在启动它们的终端输出 Web 诊断日志;设置 `OPENPI_WEB_DEBUG=0` 可关闭。正式运行 `openpi web` 默认关闭日志,排查时设置 `OPENPI_WEB_DEBUG=1`。

## 对话无响应排查
Expand Down
70 changes: 70 additions & 0 deletions tests/web/app-render.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -418,6 +418,10 @@ async function renderApp(
"sendPrompt",
context as vm.Context,
) as () => Promise<void>,
cancelActiveTurn: vm.runInContext(
"cancelActiveTurn",
context as vm.Context,
) as () => Promise<void>,
updateComposer: vm.runInContext(
"updateComposer",
context as vm.Context,
Expand Down Expand Up @@ -915,6 +919,72 @@ test("app.js settles an admitted prompt that Pi handles without an agent turn",
assert.equal((app.state.terminalPromptIds as Set<string>).size, 32);
});

test("app.js stops only the canonical active turn without optimistic settlement", async () => {
const app = await renderApp();
const cancellation = deferred<ReturnType<typeof response>>();
app.context.fetch = async (url: unknown) => {
if (String(url) === "/api/turns/cancel") return cancellation.promise;
if (String(url).startsWith("/api/snapshot")) return response(SNAPSHOT);
throw new Error(`unexpected request: ${String(url)}`);
};
vm.runInContext(
'applyRuntimeEvent({sequence: 2, type: "turn_started", detail: {sessionId: "s1", commandId: "c1", epoch: 4}})',
app.context as vm.Context,
);

assert.equal(app.state.liveRunning, true);
assert.equal(app.elements.get("stop-turn")?.hidden, false);
assert.equal(app.elements.get("send-prompt")?.hidden, true);
const stopping = app.cancelActiveTurn();
await new Promise((resolve) => setTimeout(resolve, 0));
assert.equal(app.state.liveRunning, true);
assert.equal(app.state.turnCancellationPending, true);

vm.runInContext(
'applyRuntimeEvent({sequence: 3, type: "turn_settled", detail: {sessionId: "s1", commandId: "c1", epoch: 4, outcome: "cancelled"}})',
app.context as vm.Context,
);
cancellation.resolve(
response({
sessionId: "s1",
commandId: "c1",
epoch: 4,
state: "accepted",
accepted: true,
}),
);
await stopping;

assert.equal(app.state.liveRunning, false);
assert.equal(app.state.activeTurn, null);
assert.equal(app.elements.get("stop-turn")?.hidden, true);
assert.equal(app.elements.get("send-prompt")?.hidden, false);
assert.equal(
app.elements.get("composer-hint")?.textContent,
"Current turn stopped.",
);
});

test("app.js restores the active turn and Stop control from a snapshot", async () => {
const running = structuredClone(SNAPSHOT) as SnapshotFixture & {
runtime: typeof SNAPSHOT.runtime & {
activeTurn: { sessionId: string; commandId: string; epoch: number };
};
};
running.runtime.status = "running";
running.runtime.activeTurn = {
sessionId: "s1",
commandId: "c1",
epoch: 9,
};
const app = await renderApp({ snapshot: running });

assert.deepEqual(app.state.activeTurn, running.runtime.activeTurn);
assert.equal(app.state.liveRunning, true);
assert.equal(app.elements.get("stop-turn")?.hidden, false);
assert.equal(app.elements.get("send-prompt")?.hidden, true);
});

test("app.js keeps an active agent running when a handled prompt settles", async () => {
const app = await renderApp();
vm.runInContext(
Expand Down
2 changes: 2 additions & 0 deletions tests/web/pi-adapter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,8 @@ function runtimeFor(
sessionDirectory,
sessionManager,
isIdle: () => true,
getActiveTurn: () => undefined,
cancelTurn: async (options) => ({ ...options, state: "stale-turn" }),
sendPrompt: async () => {},
newSession: async () => ({ cancelled: false }),
switchSession: async () => ({ cancelled: false }),
Expand Down
Loading
Loading