diff --git a/docs-site/src/content/docs/guides/remote-workspace.md b/docs-site/src/content/docs/guides/remote-workspace.md index 1ebc0f725a8..ade9bc5ed6b 100644 --- a/docs-site/src/content/docs/guides/remote-workspace.md +++ b/docs-site/src/content/docs/guides/remote-workspace.md @@ -167,6 +167,8 @@ to another computer or folder silently. If the Executor disconnects, the session Prompt submission acknowledges acceptance immediately; the dashboard polls the session for progress and completion. If the acknowledgement is lost, the draft remains visible with an unknown-submission notice. Check session progress before sending it again; the dashboard never retries a prompt automatically. +If the Pi process exits or its extension fails while a turn is starting, the session reports the failure even if Pi has not acknowledged that turn yet. + **Stop** remains available while a prompt is running. It interrupts the Hub coding-agent turn, cancels an active Executor command, and prevents a late response from reopening the stopped session. diff --git a/docs-site/src/content/docs/ko/guides/remote-workspace.md b/docs-site/src/content/docs/ko/guides/remote-workspace.md index 338234f4da3..1dcdf4564ad 100644 --- a/docs-site/src/content/docs/ko/guides/remote-workspace.md +++ b/docs-site/src/content/docs/ko/guides/remote-workspace.md @@ -93,6 +93,8 @@ ocx remote-workspace status 프롬프트를 보내면 수락 여부를 즉시 응답하고, 대시보드는 진행과 완료 상태를 폴링합니다. 수락 응답을 잃으면 초안은 제출 여부를 알 수 없다는 알림과 함께 남습니다. 다시 보내기 전에 세션 진행 상태를 확인하세요. 대시보드는 프롬프트를 자동 재시도하지 않습니다. +턴을 시작하는 동안 Pi 프로세스가 종료되거나 확장 기능에서 오류가 발생하면, Pi가 아직 해당 턴의 수락을 응답하지 않았더라도 세션에 실패가 표시됩니다. + 프롬프트 실행 중에도 **Stop**을 사용할 수 있습니다. Hub 코딩 에이전트의 현재 턴을 중단하고 활성 Executor 명령을 취소하며, 늦게 온 응답이 중지된 세션을 다시 열지 못하게 합니다. ## 여러 세션을 분할해서 보기 diff --git a/src/remote-control/workspace-pi-runtime.ts b/src/remote-control/workspace-pi-runtime.ts index a5987a5fe1d..5170ab292c7 100644 --- a/src/remote-control/workspace-pi-runtime.ts +++ b/src/remote-control/workspace-pi-runtime.ts @@ -137,13 +137,18 @@ class PiRpcProcess { async prompt(message: string): Promise { if (this.activeSettle) throw new Error("Pi Remote Workspace turn is already active"); const settled = new Promise((resolve, reject) => { this.activeSettle = { resolve, reject }; }); + const active = this.activeSettle; try { - const accepted = await this.command("prompt", { message }); - if (accepted.success !== true) throw new Error(safeError(accepted.error, "Pi rejected the prompt")); - await settled; - } catch (error) { - this.activeSettle = null; - throw error; + // Either operation can fail first. Observe both immediately, including an + // extension error or child exit that arrives before the prompt response. + await Promise.all([ + this.command("prompt", { message }).then(accepted => { + if (accepted.success !== true) throw new Error(safeError(accepted.error, "Pi rejected the prompt")); + }), + settled, + ]); + } finally { + if (this.activeSettle === active) this.activeSettle = null; } } diff --git a/structure/remote-workspace.md b/structure/remote-workspace.md index 93e43c41221..ff97c1f0c4c 100644 --- a/structure/remote-workspace.md +++ b/structure/remote-workspace.md @@ -27,6 +27,8 @@ RPC v1 peers fail closed and must upgrade together rather than fall back to imme `src/remote-control/workspace-runtime.ts` is the lazy composition owner for Hub services. Codex, Claude and Pi adapters keep model processes on the Hub and expose selected remote tools. Their source configuration is not evidence of live CLI confinement. `src/cli/remote-workspace.ts` contains registered executor pair/agent/status handling; the machine-readable entries live in `src/cli/capabilities.ts`. +Pi prompt admission and turn settlement are observed concurrently in `src/remote-control/workspace-pi-runtime.ts`. Either failure rejects the prompt; success requires a successful prompt response and the turn's settlement event. An extension error or child exit before admission cannot leave an unobserved rejection. `tests/clients/remote-workspace-cli-runtimes.test.ts` covers both event orders and failure paths with isolated RPC children; this is not live Pi confinement evidence. + The explicit hub status/runtimes/sessions branch selects `src/cli/remote-workspace-hub.ts` before executor storage. It performs fixed management reads and projects public devices/root labels, named runtime availability and session events; it exposes no pairing or session mutation. The outer management dispatcher returns available:false with HTTP200 when activation is off; CLI reports that observation with exit1 and fixed recovery guidance, distinct from an available Hub with empty lists. Client-role refusal, transport errors and malformed observations remain nonzero. The optional terminal prototype in `src/remote-control/host.ts` invokes only a caller-supplied factory after authenticated traffic. `src/remote-control/relay.ts` routes opaque prototype envelopes after caller authorization. Neither is a production terminal service. diff --git a/tests/clients/remote-workspace-cli-runtimes.test.ts b/tests/clients/remote-workspace-cli-runtimes.test.ts index 33785b64e7f..51f544e3843 100644 --- a/tests/clients/remote-workspace-cli-runtimes.test.ts +++ b/tests/clients/remote-workspace-cli-runtimes.test.ts @@ -1,4 +1,4 @@ -import { fixturePath } from "../helpers/repo-root"; +import { fixturePath, repoPath } from "../helpers/repo-root"; import { expect, test } from "bun:test"; import { resolve } from "node:path"; import { @@ -43,6 +43,29 @@ test("Claude runtime keeps the CLI on the Hub and emits its answer", async () => const piPath = process.env.OCX_PI_BIN; const piTest = piPath ? test : test.skip; +test.each([ + ["exit-before-ack", "Pi RPC exited with code 7"], + ["malformed-before-ack", "JSON"], + ["extension-before-ack", "synthetic extension failure"], + ["extension-after-ack", "synthetic extension failure"], + ["rejected", "synthetic rejection"], + ["success", null], + ["settle-before-ack", null], +] as const)("Pi prompt observes both RPC lifetimes: %s", async (mode, expected) => { + const child = Bun.spawn([ + process.execPath, fixturePath("pi-prompt-lifetime.ts"), + repoPath("src/remote-control/workspace-pi-runtime.ts"), mode, + ], { stdout: "pipe", stderr: "pipe" }); + const [code, stdout, stderr] = await Promise.all([ + child.exited, new Response(child.stdout).text(), new Response(child.stderr).text(), + ]); + expect({ code, stderr }).toEqual({ code: 0, stderr: "" }); + const result = JSON.parse(stdout); + if (expected === null) expect(result.error).toBeNull(); + else expect(result.error).toContain(expected); + expect(result.unhandled).toEqual([]); +}); + piTest("real Pi RPC starts with only the explicit Remote Workspace extension", async () => { if (!piPath) return; const factory = new PiRemoteWorkspaceRuntimeFactory({ command: [piPath], version: "test" }); diff --git a/tests/fixtures/pi-prompt-lifetime.ts b/tests/fixtures/pi-prompt-lifetime.ts new file mode 100644 index 00000000000..2f040d68035 --- /dev/null +++ b/tests/fixtures/pi-prompt-lifetime.ts @@ -0,0 +1,54 @@ +// A real RPC child and an isolated parent let tests observe unhandled rejections +// without changing the test runner's process-wide rejection policy. +export {}; + +const mode = process.argv[3]!; +if (process.argv[2] === "child") { + const send = (value: object) => process.stdout.write(`${JSON.stringify(value)}\n`); + let input = ""; + const decoder = new TextDecoder(); + for await (const chunk of Bun.stdin.stream()) { + input += decoder.decode(chunk, { stream: true }); + let end: number; + while ((end = input.indexOf("\n")) >= 0) { + const message = JSON.parse(input.slice(0, end)); + input = input.slice(end + 1); + if (message.type !== "prompt") { + send({ type: "response", id: message.id, success: true }); + continue; + } + if (mode === "exit-before-ack") process.exit(7); + if (mode === "malformed-before-ack") { + process.stdout.write("{invalid JSON}\n"); + continue; + } + if (mode === "extension-before-ack") { + send({ type: "extension_error", error: "synthetic extension failure" }); + await Bun.sleep(25); + } + if (mode === "settle-before-ack") send({ type: "agent_settled" }); + send({ type: "response", id: message.id, success: mode !== "rejected", error: "synthetic rejection" }); + if (mode === "success") send({ type: "agent_settled" }); + if (mode === "extension-after-ack") send({ type: "extension_error", error: "synthetic extension failure" }); + } + } +} else { + const { PiRemoteWorkspaceRuntimeFactory } = await import(process.argv[2]!); + const unhandled: string[] = []; + const errorText = (error: unknown) => error instanceof Error ? error.message : String(error); + process.on("unhandledRejection", error => { unhandled.push(errorText(error)); }); + const factory = new PiRemoteWorkspaceRuntimeFactory({ command: [process.execPath, import.meta.path, "child", mode] }); + const handle = await factory.start({ + sessionId: "fixture", deviceId: "fixture-device", deviceName: "Fixture", rootId: "fixture-root", rootLabel: "Fixture", + capabilities: ["workspace.read"], tools: ["read_file"], emit: () => {}, + coordinator: { async invokeTool() { throw new Error("No remote tool should run"); } }, + }); + let error: string | null = null; + try { + try { await handle.prompt("synthetic prompt"); } catch (caught) { error = errorText(caught); } + } finally { + await handle.stop(); + } + await new Promise(resolve => setImmediate(resolve)); + console.log(JSON.stringify({ error, unhandled })); +}