Skip to content

feat(spike): Pace dev page as the third durable client (P3) - #430

Merged
bubbleptr merged 1 commit into
feat/durable-multiview-webfrom
feat/durable-multiview-pace
Oct 2, 2026
Merged

bubbleptr merged 1 commit into
feat/durable-multiview-webfrom
feat/durable-multiview-pace

Conversation

@bubbleptr

@bubbleptr bubbleptr commented Oct 2, 2026 •

Copy link
Copy Markdown
Owner

No linked issue: fourth stage (P3) of the Pi Durable multi-client spike (.scratch/durable-multiview/HANDOFF.md), stacked on #429. This is the first stage that touches Pace's app code. Every change is dev-only or inert, and marked for removal with the spike.

Summary

  • Backend relay (packages/backend/src/spikes/durable-bridge.ts): holds the WebSocket to a local Durable host on the renderer's behalf, since the renderer's CSP blocks one. It forwards frames over the existing IPC as unsequenced durable_spike.frame / durable_spike.closed envelopes (seq: 0, not journaled, like terminal streams) and never parses them. The renderer therefore applies every op exactly once (ADR-0044), and the hop reuses process IPC (ADR-0042).
    • Three commands in service.ts: durable_spike_connect, durable_spike_send and durable_spike_disconnect.
    • The token is read from the spike host's default data dir under the agent dir.
    • Only loopback hosts are accepted, because the token grants full control of the host's agent.
    • PACE_DURABLE_SPIKE_URL overrides the default host.
  • Renderer: a DEV-only route #/durable-spike in src/dev/durable-spike/ (outside the FSD layers, like src/dev/ui-intent/). Its route and module are registered inside the import.meta.env.DEV branch, and it is exempt from the preflight gate. backend-relay.ts adapts invoke / onBackendEvent to the spike's FrameRelay. The page reuses the web client's RemoteWorkbench, so all three clients share one presentation and differ only in transport.
  • runtime-gateway-client.ts: the two new event types join the list of ephemeral events that are dropped before runtime state and deduplication. The added cases failed first: without this, spike frames would have reached runtime state.
  • Spike: RemoteDurable gains a FrameTransport seam (webSocketTransport, relayTransport); the TUI, web and tests still pass { url, token }. scripts/three-clients.mjs (bun run verify:three) is the end-to-end proof described below.
  • Root config:
    • tsconfig.json gains allowImportingTsExtensions, needed because the spike's Node-run modules import with .ts extensions; this is safe under the root's noEmit. It also gains a path alias @pace/durable-spike/*.
    • vite.config.ts and the renderer config in electron.vite.config.ts get the same alias.

Test plan

  • bun run typecheck
  • bun run lint
  • bun run test: 168 files, 1975 tests. New: the service routes the relay commands and forwards relay events as unsequenced envelopes; the gateway client drops durable_spike.*; the renderer relay adapter maps commands and filters events.
  • bun run build; the production renderer bundle contains no spike page.
  • Spike bun run typecheck and bun run test: 28 tests. New: relay.test.ts runs the real backend relay against a faux host. A relayed client and a direct client see and drive the same conversation with identical views. Also covered: reconnecting through the relay after a host restart, the configured default host, and clear errors for a missing token and for a non-loopback host.
  • Three-client proof (bun run verify:three) against an isolated electron-vite dev Pace, with its own data dir, agent dir and --user-data-dir, PACE_E2E=1, driven over CDP, plus the web page and the TUI on one faux host:
    • The Pace prompt shows up in the web and TUI, and the web and TUI prompts show up in Pace.
    • A mid-stream kill -9 shows the lost-host banner in Pace. After the restart Pace resumes on its own, with an interrupted partial followed by the full answer.
    • Screenshots and TUI screen dumps were checked. No processes or temp dirs are left behind.
  • The verify-pace Playwright fixture cannot reach the page: it launches the production build, and electron-vite build always drops the DEV branch, even with --mode development or NODE_ENV=development. The proof uses electron-vite dev with --remoteDebuggingPort instead.

Checklist

  • Linked issue or a short reason this has none
  • Title follows Conventional Commits
  • bun run typecheck, bun run test, and bun run build are green
  • ADR added or updated if an architectural boundary or product term changed (none; the spike's conclusions, including how this sits next to ADR-0021's persistence tracks, go to docs/research/ in P5)
  • CONTEXT.md updated if vocabulary changed (none changed)
  • New or changed shared/ui/ components are registered on /design (none; the dev page reuses the spike's Astryx workbench)
  • Branch is feat/; this is not a push to main

Pace's backend relays frames between a local Durable host and the renderer
over the existing IPC as unsequenced events, without parsing them, so the
renderer applies every op once. RemoteDurable gains a transport seam; the
DEV-only #/durable-spike page reuses the web client's workbench over the
relay. The runtime gateway client drops the new ephemeral event types.
Includes a scripted three-client proof against an isolated electron-vite dev
instance.
@bubbleptr
bubbleptr added this pull request to stack #428 October 2, 2026 11:13
@bubbleptr
bubbleptr marked this pull request as ready for review October 2, 2026 11:39

@devin-ai-integration devin-ai-integration Bot left a comment

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.

Devin Review found 4 potential issues.

Devin Review

Comment on lines +17 to +19
onBackendEvent((event) => {
const relayed = relayEventOf(event);
if (relayed !== undefined) listener(relayed);

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.

Comment on lines +62 to +67
const socket = new WebSocket(address);
sockets.set(connectionId, socket);
socket.onmessage = (event) => emit({ kind: "frame", connectionId, data: String(event.data) });
socket.onclose = (event) => {
if (sockets.get(connectionId) === socket) sockets.delete(connectionId);
emit({ kind: "closed", connectionId, code: event.code, ...(event.reason ? { reason: event.reason } : {}) });

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.

🟡 Window reloads leak host connections

Reloading the Pace window skips renderer cleanup, so sockets retains its WebSocket. Each reload adds another host subscription and forwards its frames to a renderer that no longer uses them.

Learn more

A renderer reload does not run React effect cleanup; createMainWindow already handles this case for the embedded browser. Durable sockets remain in the backend's map until an explicit renderer disconnect, a host close, or backend disposal. A reload creates a new connection ID, leaving the previous host connection and subscriptions alive.

Example: Reload #/durable-spike three times while the host stays up. The page uses its latest connection, but the backend still owns the earlier WebSockets and forwards their frames.

Recommended fix: Tie bridge connections to the renderer's lifecycle in the main/backend IPC bridge and close them on main-frame reload or renderer detach. Alternatively, implement a lease or generation that retires connections from previous renderer instances.

Devin Review


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

Comment on lines +61 to +63
address.searchParams.set("token", await tokenFor(token));
const socket = new WebSocket(address);
sockets.set(connectionId, socket);

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.

🟡 Early disconnect creates an orphan socket

If disconnect runs during tokenFor, it finds no socket. The pending connect then installs a socket after the client has closed, leaving a live host connection.

Learn more

Opening the bridge first awaits a filesystem token read and only then inserts the socket into sockets. The relay's close can call disconnect before the backend has finished this read. Disconnect only looks at sockets already in the map, so it cannot cancel that pending connection. Once the read finishes, the backend opens and retains a socket for an already-closed client.

Example: Navigate away while the token read for connection c1 is pending. The c1 disconnect observes an empty map; afterward the read completes and c1 connects without any listener.

Recommended fix: Reserve the ID before awaiting tokenFor and mark it cancelled on disconnect. After each await, check cancellation before constructing and storing the socket; close any socket constructed after cancellation.

Devin Review


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

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.

@bubbleptr
bubbleptr merged commit 7bfcc16 into main Oct 2, 2026
2 checks passed
@bubbleptr
bubbleptr deleted the feat/durable-multiview-pace branch October 2, 2026 14:22
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant