Define the backend-neutral execution contract #6

Closed
opened 2026-08-22 22:21:17 -04:00 by jb · 2 comments
Owner

Part of #1

Blocked by: #2, #3

Question

What minimum ExecutionBackend and ProcessHandle contract should replace the ChildProcessWithoutNullStreams assumption while preserving the current local Pi path and establishing an OCI-oriented seam? Settle stream, readiness, exit, wait, identity, inspect, graceful/forced termination, and cleanup semantics, including where compatibility adaptation ends.

Part of #1 Blocked by: #2, #3 ## Question What minimum ExecutionBackend and ProcessHandle contract should replace the ChildProcessWithoutNullStreams assumption while preserving the current local Pi path and establishing an OCI-oriented seam? Settle stream, readiness, exit, wait, identity, inspect, graceful/forced termination, and cleanup semantics, including where compatibility adaptation ends.
jb added the wayfinder:grilling label 2026-08-22 22:21:17 -04:00
jb self-assigned this 2026-08-22 23:11:52 -04:00
Author
Owner

Claimed for this Wayfinder session. I will trace the existing Pi process assumptions, compare deep-module interface alternatives, and resolve the minimum backend-neutral contract without implementing downstream supervisor or persistence tickets.

Claimed for this Wayfinder session. I will trace the existing Pi process assumptions, compare deep-module interface alternatives, and resolve the minimum backend-neutral contract without implementing downstream supervisor or persistence tickets.
Author
Owner

Resolution

Choose a lifecycle-oriented, backend-neutral process seam. LocalExecutionBackend and OciSandboxBackend are the first two adapters. The seam sits below provider protocol handling and above process/resource ownership.

interface ExecutionBackend {
  start(spec: ProcessSpec): Promise<ProcessHandle>;
  attach(ref: ProcessRef): Promise<ProcessHandle>;
  inspect(ref: ProcessRef): Promise<ProcessInspection>;
  cleanup(ref: ProcessRef): Promise<void>;
}

interface ProcessHandle {
  readonly ref: ProcessRef;
  readonly stdin: Writable;
  readonly stdout: Readable;
  readonly stderr: Readable;

  wait(): Promise<ProcessExit>;
  terminate(mode: "graceful" | "force"): Promise<ProcessExit>;
  release(): Promise<void>;
}

Identity and state

ProcessRef contains a backend identifier, an opaque backend resource ID, and lifetime: "handle" | "backend".

  • handle means the resource is owned by the current daemon attachment and is not adoptable after daemon loss. Local execution uses this lifetime.
  • backend means the resource may outlive the daemon attachment and can be passed to attach, inspect, and cleanup. Supervisor-owned sandbox execution uses this lifetime.
  • The opaque resource ID is not a PID, container ID, bundle path, bridge token, or supervisor protocol object at this seam.

ProcessInspection reports only starting, running, exited, or missing; an unreachable backend is a typed error rather than an unknown state. An exited inspection contains the same immutable ProcessExit returned by wait().

Streams and readiness

  • The interface uses Node Writable/Readable byte streams so the local adapter does not copy or reinterpret provider traffic.
  • stdin, stdout, and stderr are attachment-scoped. Each output channel preserves byte order. No cross-channel ordering is promised.
  • Writes honor normal Writable backpressure and fail explicitly; adapters may not silently drop input or output.
  • start and attach resolve once the backend has accepted the operation and the attachment can accept input and deliver output. This is transport readiness, not provider-protocol readiness.
  • Pi retains its current behavior of sending RPC immediately. Providers that have a ready frame or negotiation keep that logic above this seam.
  • A reconnecting adapter must deliver replay before live output. Cursor, acknowledgment, duplicate suppression, and writer fencing belong to the reconnectable Pi transport/bridge protocol, not the generic process interface.

Exit, wait, termination, release, and cleanup

  • wait() is idempotent. Every call resolves to the same known exit and never treats a non-zero exit as a rejected promise.
  • Spawn/start failure rejects start. Attachment or control loss before a known exit rejects wait with a typed availability error; callers must use inspect(ref) before claiming the resource exited.
  • terminate("graceful") and terminate("force") are idempotent and return the known exit. Graceful termination has a bounded backend policy and throws a typed timeout if the resource remains alive; it never silently escalates. The caller may then request force explicitly.
  • For local Pi, JsonlRpcProcess.close() preserves the current sequence: end stdin, request graceful tree termination, log on timeout, request forced tree termination, and log if a terminal exit still cannot be observed.
  • release() drops the daemon attachment and never claims that the resource exited. It is allowed for a running backend-lifetime resource. Releasing a running handle-lifetime resource fails as not detachable; local callers terminate first.
  • cleanup(ref) removes exited/missing backend resources and bookkeeping. It is idempotent, does not imply termination, and fails while the resource is known running.

Compatibility boundary

  • LocalExecutionBackend is the only adapter that imports Node ChildProcess, spawnProcess, and tree-kill behavior.
  • JsonlRpcProcess accepts a ProcessHandle; it continues to own JSONL framing, chunk reassembly, request IDs, timeouts, response correlation, stderr diagnostics, and pending-request failure.
  • PiRuntimeSession remains unchanged. Provider orchestration does not learn backend kinds, PIDs, containers, runsc, Unix sockets, replay cursors, mounts, networks, or credentials.
  • OciSandboxBackend translates ProcessSpec into the restricted supervisor's approved launch profile. The supervisor protocol never receives arbitrary daemon-supplied OCI JSON, runtime flags, capabilities, mounts, or raw host paths.
  • The backend registry remains an internal core seam. Current Paseo plugins cannot register execution backends; future plugin registration can target this interface without changing provider callers.

Rejected shapes

  • A child-process-only adapter was too shallow: it would require a second interface for durable identity, attach, inspection, and cleanup.
  • A capability-rich transport exposed replay cursors, writer epochs, readiness promises, and policy hints to every caller. Those facts belong to the bridge, supervisor, mount, network, and lifecycle modules and remain open in their owning tickets.

Human decision: accept the lifecycle-oriented process seam.

## Resolution Choose a lifecycle-oriented, backend-neutral process seam. `LocalExecutionBackend` and `OciSandboxBackend` are the first two adapters. The seam sits below provider protocol handling and above process/resource ownership. ```ts interface ExecutionBackend { start(spec: ProcessSpec): Promise<ProcessHandle>; attach(ref: ProcessRef): Promise<ProcessHandle>; inspect(ref: ProcessRef): Promise<ProcessInspection>; cleanup(ref: ProcessRef): Promise<void>; } interface ProcessHandle { readonly ref: ProcessRef; readonly stdin: Writable; readonly stdout: Readable; readonly stderr: Readable; wait(): Promise<ProcessExit>; terminate(mode: "graceful" | "force"): Promise<ProcessExit>; release(): Promise<void>; } ``` ### Identity and state `ProcessRef` contains a backend identifier, an opaque backend resource ID, and `lifetime: "handle" | "backend"`. - `handle` means the resource is owned by the current daemon attachment and is not adoptable after daemon loss. Local execution uses this lifetime. - `backend` means the resource may outlive the daemon attachment and can be passed to `attach`, `inspect`, and `cleanup`. Supervisor-owned sandbox execution uses this lifetime. - The opaque resource ID is not a PID, container ID, bundle path, bridge token, or supervisor protocol object at this seam. `ProcessInspection` reports only `starting`, `running`, `exited`, or `missing`; an unreachable backend is a typed error rather than an `unknown` state. An exited inspection contains the same immutable `ProcessExit` returned by `wait()`. ### Streams and readiness - The interface uses Node `Writable`/`Readable` byte streams so the local adapter does not copy or reinterpret provider traffic. - stdin, stdout, and stderr are attachment-scoped. Each output channel preserves byte order. No cross-channel ordering is promised. - Writes honor normal `Writable` backpressure and fail explicitly; adapters may not silently drop input or output. - `start` and `attach` resolve once the backend has accepted the operation and the attachment can accept input and deliver output. This is transport readiness, not provider-protocol readiness. - Pi retains its current behavior of sending RPC immediately. Providers that have a ready frame or negotiation keep that logic above this seam. - A reconnecting adapter must deliver replay before live output. Cursor, acknowledgment, duplicate suppression, and writer fencing belong to the reconnectable Pi transport/bridge protocol, not the generic process interface. ### Exit, wait, termination, release, and cleanup - `wait()` is idempotent. Every call resolves to the same known exit and never treats a non-zero exit as a rejected promise. - Spawn/start failure rejects `start`. Attachment or control loss before a known exit rejects `wait` with a typed availability error; callers must use `inspect(ref)` before claiming the resource exited. - `terminate("graceful")` and `terminate("force")` are idempotent and return the known exit. Graceful termination has a bounded backend policy and throws a typed timeout if the resource remains alive; it never silently escalates. The caller may then request force explicitly. - For local Pi, `JsonlRpcProcess.close()` preserves the current sequence: end stdin, request graceful tree termination, log on timeout, request forced tree termination, and log if a terminal exit still cannot be observed. - `release()` drops the daemon attachment and never claims that the resource exited. It is allowed for a running backend-lifetime resource. Releasing a running handle-lifetime resource fails as not detachable; local callers terminate first. - `cleanup(ref)` removes exited/missing backend resources and bookkeeping. It is idempotent, does not imply termination, and fails while the resource is known running. ### Compatibility boundary - `LocalExecutionBackend` is the only adapter that imports Node `ChildProcess`, `spawnProcess`, and tree-kill behavior. - `JsonlRpcProcess` accepts a `ProcessHandle`; it continues to own JSONL framing, chunk reassembly, request IDs, timeouts, response correlation, stderr diagnostics, and pending-request failure. - `PiRuntimeSession` remains unchanged. Provider orchestration does not learn backend kinds, PIDs, containers, runsc, Unix sockets, replay cursors, mounts, networks, or credentials. - `OciSandboxBackend` translates `ProcessSpec` into the restricted supervisor's approved launch profile. The supervisor protocol never receives arbitrary daemon-supplied OCI JSON, runtime flags, capabilities, mounts, or raw host paths. - The backend registry remains an internal core seam. Current Paseo plugins cannot register execution backends; future plugin registration can target this interface without changing provider callers. ### Rejected shapes - A child-process-only adapter was too shallow: it would require a second interface for durable identity, attach, inspection, and cleanup. - A capability-rich transport exposed replay cursors, writer epochs, readiness promises, and policy hints to every caller. Those facts belong to the bridge, supervisor, mount, network, and lifecycle modules and remain open in their owning tickets. Human decision: accept the lifecycle-oriented process seam.
jb closed this issue 2026-08-23 00:01:34 -04:00
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: jb/paseo#6