plans/distrbuted-machines.md
Alternative architecture proposal.
This plan is an alternative to completing the full rollout in
plans/codex-cleanup-state-machines.md. It keeps the no-regrets ownership,
selector, and simple projection-removal work from that plan, but replaces the
later controller-by-controller cleanup with a shared actor runtime capable of
hosting authoritative state machines in either Electron's main process or the
renderer.
The filename intentionally matches the requested
plans/distrbuted-machines.md. The architecture and code should use the
correct term “distributed.”
Dyad currently has several related but distinct state-machine architectures:
The proposed runtime unifies these under one actor model:
one authoritative actor
|
+----------------------+----------------------+
| |
local typed ActorRef remote typed ActorRef
| |
v v
TransactionalDispatcher validated IPC transport
| |
+----------------------+----------------------+
|
committed immutable snapshot
|
pure selectors / events
|
React hooks or another actor facade
Every actor has exactly one authoritative host. “Distributed” means that the typed reference, snapshot subscription, tracing, and protocol contracts work across process boundaries. It does not mean shared memory, multi-primary replication, or transparent synchronous calls across IPC.
The first remote pilot is app_run, hosted in the main process because the
main process owns the child process, stdout producer, and teardown. The first
local pilot is voice_to_text, hosted in the renderer because it owns browser
media resources. Together they must prove that host location is an adapter
rather than a second controller architecture.
The incremental cleanup plan would improve the current design, but it would retain a structural distinction between:
That distinction causes much of the current boilerplate. app_run is the
clearest example: the renderer owns lifecycle state while the main process
owns the process whose lifecycle is being modeled. Producer correlation,
renderer disposal, projection atoms, and IPC settlement all compensate for
that separation.
A distributed runtime can move authority next to the resource while exposing the same read/send API to React. If successful, it removes entire classes of code instead of only standardizing them.
This is worthwhile only if the framework replaces existing infrastructure. Adding it underneath the current controllers, managers, projections, and IPC adapters without deleting them would make the architecture worse.
Every actor instance is authoritative in exactly one process:
main: privileged work, durable workflows, child processes, filesystem,
git, OAuth listeners, or workflows that should survive renderer reload;renderer: DOM, iframe, browser media, and presentation workflows tied to
one renderer lifetime.There is no bidirectional writable replication.
The existing shape remains:
state.ts: TypeScript domain types;transition.ts: pure total transition;commands.ts: command data and adapters;machine.ts or definition.ts: runtime definition and placement metadata;transport.ts: Zod wire codecs for remotely visible keys, events,
snapshots, and receipts;hooks.ts: renderer bindings.state.ts and transition.ts do not import Electron, React, Jotai, IPC,
Zod, timers, Date, or another machine.
Remote calls must not masquerade as synchronous local calls.
const actor = useDistributedMachine(appRunMachine, appId);
const receipt = await actor.dispatch({
type: "RESTART_REQUESTED",
options,
});
The API and types distinguish local synchronous enqueue from remote committed dispatch. A caller can always determine whether failure means:
The generic transport acknowledges only event admission and state commit. Long-running command completion remains domain state/events.
The runtime does not offer a misleading generic dispatchAndWaitForEffects.
A domain may provide a typed waiter facade correlated to an invocation when
the workflow genuinely requires it.
A generic machine channel is not permission to dispatch arbitrary events.
The main host:
Renderer reload, window destruction, main shutdown, hydration races, duplicate messages, stale snapshots, and version mismatch are first-class contracts.
High-volume content such as console logs and LLM chunks does not travel as whole machine snapshots. Existing batched or streaming IPC channels remain appropriate. Machines own lifecycle and correlation, not every byte of runtime output.
The runtime standardizes:
It does not standardize:
Pure transition plus runtime metadata, codecs, command scheduler factory, and host placement.
One live keyed instance of a machine definition.
Stable domain key such as appId, chatId, provider, or OAuth port.
Globally unique identity for one actor lifetime. Recreating the same machine and key produces a new actor instance ID.
Typed capability for reading and sending to one actor. It may be local or remote.
Monotonically increasing revision within one actor instance when the committed snapshot reference changes. It orders remote snapshot delivery but is not a globally unique operation identity.
Monotonically increasing sequence for every processed event, including command-only applied events and ignored events. It orders receipts and traces without forcing a value-equal snapshot publication.
Identity for one domain operation within or across actor lifetimes. It remains separate from actor identity and message identity.
Transport identity used to deduplicate one remote dispatch.
Durable domain identity used when a receiver must deduplicate acceptance across retries or process restarts. A message ID is not automatically an idempotency key.
src/distributed_machines/
definition.ts
actor_host.ts
actor_ref.ts
local_actor_ref.ts
remote_actor_ref.ts
registry.ts
transport_types.ts
remote_snapshot_store.ts
persistence.ts
protocol.ts
tracing.ts
react.ts
testing/
fake_transport.ts
host_conformance.ts
remote_conformance.ts
crash_harness.ts
src/ipc/types/distributed_machines.ts
src/ipc/handlers/distributed_machine_handlers.ts
src/ipc/services/distributed_machine_host.ts
The existing primitives under src/state_machines/ remain the pure/runtime
kernel. The distributed layer composes them; it does not duplicate
TransactionalDispatcher, SnapshotStore, TaskScope, timer leases,
invocation references, trace buffers, or transition test utilities.
If the pilots show that “distributed machines” are the normal form, the folders may later be consolidated. Do not move existing files during the pilot merely for naming symmetry.
The exact API should be proven by pilots, but it should express the following contracts:
interface DistributedMachineDefinition<
Id extends string,
Key,
State,
Event,
Command,
Reason extends string,
> {
readonly id: Id;
readonly host: "main" | "renderer";
readonly initialState: (key: Key) => State;
readonly transition: (
state: State,
event: Event,
) => TransitionResult<State, Command, Reason>;
readonly createScheduler: (key: Key) => CommandScheduler<Command>;
readonly createCommandRunner: (
context: MachineHostContext<Key, State, Event>,
) => CommandRunner<Command, Event>;
readonly lifecycle: ActorLifecyclePolicy<Key, State>;
readonly persistence?: MachinePersistencePolicy<Key, State>;
readonly remote?: RemoteMachineContract<Key, State, Event>;
}
Important constraints:
Avoid elaborate type-level inference in the first implementation. Clear generic annotations and useful compiler errors are more valuable than a clever DSL.
ActorHost owns keyed actors for one process:
interface ActorHost {
register(definition: DistributedMachineDefinition<...>): void;
ensure(machineId: string, key: unknown): HostedActor;
peek(machineId: string, key: unknown): HostedActor | undefined;
disposeKey(machineId: string, key: unknown): void;
disposeMachine(machineId: string): void;
dispose(): Promise<void>;
}
Each hosted actor owns:
TransactionalDispatcher;For one admitted event:
The receipt resolves after commit and publication has been scheduled, not after commands finish.
Ignored events:
Applied command-only transitions also retain the snapshot revision and publish no snapshot. Their receipt records the new transaction sequence and current snapshot revision.
The existing TransactionalDispatcher.send() returns void. Remote dispatch
requires an outcome for the exact queued event, including an event enqueued
re-entrantly while another transaction is processing.
Extend the dispatcher with a ticketed API:
interface DispatchTicket<State, Reason> {
readonly settled: Promise<
| {
kind: "applied";
state: State;
}
| {
kind: "ignored";
state: State;
reason: Reason;
}
| {
kind: "failed";
stage: "transition" | "validation" | "before-admission";
error: unknown;
}
| {
kind: "disposed";
}
>;
}
dispatcher.enqueue(event): DispatchTicket<State, Reason>;
Requirements:
send(event) remains a compatibility wrapper that intentionally
discards the ticket;The actor host converts the dispatcher ticket into a transport receipt. Do not infer outcomes by comparing snapshots or listening to global observers.
The host delegates scheduling to the definition. Supported reusable schedulers may include:
The runtime does not select one based on command shape.
Definitions state:
Actor disposal:
A local reference is a thin typed facade over the host:
interface LocalActorRef<State, Event> {
readonly kind: "local";
getSnapshot(): State;
subscribe(listener: () => void): () => void;
send(event: Event): void;
}
Local sends preserve the synchronous enqueue behavior of
TransactionalDispatcher. Domain APIs may wrap send with waiters where
needed.
Renderer hooks use useSyncExternalStore and pure selectors. They do not copy
snapshots into Jotai.
Add contracts through the existing IPC architecture:
defineContract for subscribe/bootstrap, dispatch, and unsubscribe;defineEvent for snapshot and actor-disposed broadcasts;createTypedHandler for all main handlers;Do not call ipcMain.handle directly.
The generic envelope is validated twice:
This preserves a static channel surface without treating inner payloads as
trusted unknown.
Remote definitions are assembled into a main-owned manifest:
const remoteMachineManifest = createRemoteMachineManifest([
appRunMachine,
githubOpsMachine,
]);
The manifest:
The renderer receives generated/inferred typed clients by importing the shared definition contract. It cannot register new main-hosted machines.
interface MachineDispatchEnvelope {
protocolVersion: number;
machineId: string;
encodedKey: unknown;
expectedActorInstanceId?: string;
messageId: string;
causationId?: string;
correlationId?: string;
expectedRevision?: number;
encodedEvent: unknown;
}
Semantics:
expectedActorInstanceId prevents a stale renderer from addressing a
replacement actor;expectedRevision is optional optimistic concurrency, not required for
ordinary events;messageId deduplicates retry of one transport send within the configured
retention window;correlationId connects traces and domain waiters;causationId reconstructs event chains;type MachineDispatchReceipt<Reason> =
| {
kind: "applied";
actorInstanceId: string;
revision: number;
transactionSequence: number;
messageId: string;
}
| {
kind: "ignored";
actorInstanceId: string;
revision: number;
transactionSequence: number;
messageId: string;
reason: Reason;
}
| {
kind: "rejected";
messageId: string;
reason:
| "unknown-machine"
| "invalid-key"
| "invalid-event"
| "unauthorized"
| "stale-actor"
| "revision-conflict"
| "host-disposing"
| "protocol-version";
};
Expected user/environment failures crossing the main boundary use
DyadError/DyadErrorKind where the existing IPC error path is more
appropriate. Domain-level ignored events remain successful receipts, not
exceptions.
Unexpected transition, validation, scheduler, or command failures are reported as programming errors. A command failure does not retroactively change an already-applied receipt.
interface MachineSnapshotEnvelope {
protocolVersion: number;
machineId: string;
encodedKey: unknown;
actorInstanceId: string;
revision: number;
encodedState: unknown;
}
Snapshots are immutable, schema-versioned, and validated on both sides.
revision changes only when the snapshot changes; transaction-only sequencing
is carried by receipts and traces rather than snapshot envelopes.
The main handler must:
webContents subscriber;There must be no await between subscriber registration and snapshot capture.
Broadcasts can arrive before the invoke promise resolves. The renderer remote store buffers them and applies envelopes monotonically after bootstrap:
webContents.destroyed.webContents, machine, and encoded key.Main publishes a typed disposed envelope containing the actor instance ID and final revision. The remote store transitions to the definition's unavailable or initial view and must not accept a late snapshot from the disposed actor.
One renderer-owned RemoteMachineClient manages remote stores:
interface RemoteActorRef<State, Event, Reason> {
readonly kind: "remote";
getStatus(): "connecting" | "ready" | "disconnected" | "incompatible";
getSnapshot(): State;
subscribe(listener: () => void): () => void;
dispatch(event: Event): Promise<MachineDispatchReceipt<Reason>>;
resync(): Promise<void>;
}
The React hook exposes transport status explicitly:
const { state, projection, connection, dispatch } = useDistributedMachine(
appRunMachine,
appId,
);
Rules:
state is never silently fabricated as current authoritative state while
disconnected;Moving an existing renderer machine to main requires a serializability audit.
Snapshots/events may not contain:
AbortController or resource handles;Map, Set, Error, class instances, or platform objects.Wire codecs may deliberately encode supported domain values, but JSON stringification is not the implicit contract.
Each remote machine contract has:
CORRECTION (2026-07-25, see plans/cleanup-state-machines.md Phase D): main and renderer ALWAYS ship together in production — dyad updates via update-electron-app/Squirrel, applied on restart; renderer reloads load the running bundle. Live-IPC version skew is dev-only (HMR). Schema versioning below applies to persisted state; for live transport a version assert (reject + reload) suffices. On incompatibility:
Persisted work crossing an application update retains or migrates its complete invocation and idempotency identity. Missing identity is never accepted for cancellation or state-sensitive intent. Each domain either reconstructs identity through a documented structural claim during migration, explicitly reconciles or terminates legacy work, or rejects it with a recoverable incompatibility result.
The framework must not invent a universal “accept missing identity” rule.
Commands execute beside the authoritative actor by default.
For main-hosted actors:
For renderer-hosted actors:
The first version does not support arbitrary commands marked
execution: "main" | "renderer". Split-location command routing would create
a second distributed workflow with its own failure and acknowledgement
semantics. Add it only after a real pilot proves snapshots/events cannot model
the need.
Toasts, navigation, focus, and other one-shot UI effects cannot always be derived safely from retained state. Main-hosted actors may publish typed, correlated presentation events after commit.
Requirements:
An actor receives a typed facade/reference through its command adapter or composition root. It never imports another actor's host, manager, or registry.
If both actors are in the same process, the facade dispatches locally. If they are in different processes, it uses the same validated transport.
The dependency graph remains explicit and acyclic for ordinary command dependencies.
Cross-machine work requiring acknowledgement is not an ordinary message.
Examples:
user_input -> chat_stream follow-up;Use a protocol actor with states such as:
created
-> awaiting-receiver-acceptance
-> durably-accepted
-> executing
-> acknowledged
-> settled
created/awaiting/executing
-> cancelling
-> rejected or settled
The protocol definition names:
The actor transport provides message delivery; it does not claim exactly-once execution.
Persistence is optional per definition.
interface MachinePersistencePolicy<Key, State> {
load(key: Key): Promise<PersistedSnapshot<State> | undefined>;
save(key: Key, snapshot: PersistedSnapshot<State>): Promise<void>;
delete(key: Key): Promise<void>;
flushOnShutdown: boolean;
}
The host does not accept domain events against an unhydrated persisted actor unless the definition explicitly defines buffering/merge semantics.
The runtime tracks:
This runtime status is not silently inserted into the domain state union. Definitions may model a domain-visible recovery state when the UI needs it.
Events arriving during hydration are:
The definition must choose.
before-quit re-entry rules.The plan must document separately:
Main-hosted ephemeral actors survive renderer reload but not main crash. Persisted actors recover only to the last committed durable snapshot/protocol record.
All invoke handlers use the existing trusted-main-frame enforcement. Machine transport does not accept messages from arbitrary frames or webviews.
Only definitions registered in the main manifest are remotely addressable. Machine IDs are constants, not paths or module names.
Each definition's Zod event codec is the event allowlist. Avoid a schema such
as { type: string, payload: unknown }.
Definitions provide an authorization function where the key or event scopes access:
authorizeDispatch({
sender,
key,
event,
currentState,
}): void | Promise<void>;
Authorization occurs before transition and before actor creation where possible. A renderer-supplied app ID never grants access by itself.
The main derives commands only from its registered pure transition. The renderer cannot submit serialized commands, scheduler choices, state, or transition functions.
Remote codecs explicitly project renderer-visible state. Main-only secrets, tokens, resource handles, paths, or large internal payloads are not exposed merely because they exist in the host snapshot.
When remote state is a safe subset, distinguish:
The read model remains revisioned and single-writer, but it need not serialize the complete host state.
Bound:
Every transition trace includes:
A single wall-clock timestamp is not a causal order. Use:
Debug tooling reconstructs causal chains without pretending to create a total order across independent actors.
Machine definitions provide safe event/state descriptions. Raw untagged objects are never retained in production traces. Replay traces remain dev/test-only and use explicit serializers/redactors.
Extend the dev-only machine inspector to show:
Do not expose this surface in production.
Existing transition requirements remain:
For every actor host:
Use an in-memory fake duplex transport to test:
Provide deterministic harnesses that can:
Use real contract registration and the handler test harness to verify:
DyadError preservation;Use test hosts/references rather than writable lifecycle atoms. Verify:
The app-run pilot requires packaged Electron tests for:
Build before every E2E run that changes runtime code.
app_runapp_run is the proving ground because it currently spans both processes and
contains the most compensating architecture.
RunState.preview_iframe infers restart through an atom projection.Main owns the keyed app_run actor and:
Renderer owns:
AppRunProjection;preview_iframe actor.Refine events into intent and producer events:
type AppRunEvent =
| { type: "START_REQUESTED"; operationId: string; startedAt: number }
| {
type: "RESTART_REQUESTED";
operationId: string;
startedAt: number;
options: RestartOptions;
}
| { type: "STOP_REQUESTED"; operationId: string; startedAt: number }
| { type: "PROCESS_SPAWNED"; invocationRef: AppRunInvocationRef }
| { type: "PROCESS_FAILED"; invocationRef: AppRunInvocationRef; error: ... }
| { type: "PROXY_READY"; invocationRef: AppRunInvocationRef; url: RunUrl }
| { type: "PROCESS_EXITED"; invocationRef: AppRunInvocationRef; ... };
The exact transition preserves existing behavior, including proxy-ready before spawn settlement when that ordering is real.
Callbacks and renderer stores do not enter remote state/events.
Move app lifecycle orchestration behind a main service consumed directly by the actor:
Existing IPC app-run handlers become temporary adapters that dispatch the actor for legacy callers. The new renderer uses the machine transport.
Publish only renderer-safe lifecycle fields:
Do not include process handles, internal paths, or command runtime data.
Console stdout/stderr continues through the existing batched app-output channel. Lifecycle-significant producer events enter the main actor before output broadcasting. Renderer display buffers cannot drive lifecycle.
preview_iframe compositionThe renderer composition layer observes committed app-run remote snapshots or typed post-commit lifecycle events and sends:
APP_URL_CHANGED;RUNTIME_RESTARTED;RUNTIME_STOPPED if required.Carry actor/invocation identity. Do not deduplicate by timestamps or atom map edges.
The pilot is not complete until it deletes or makes obsolete:
AppRunController;AppRunManager;previewRunStateByAppIdAtom;Retain only independently justified console, warning, and UI state.
voice_to_textThis pilot proves the same definition/actor-reference/React API works without IPC.
Renderer remains authoritative because it owns:
getUserMedia;Requirements:
ActorHost and local actor reference;TransactionalDispatcher;The pilot should delete domain-specific wrapper boilerplate where the shared host replaces it. It should not force local events through IPC for symmetry.
This is a hypothesis to validate, not an automatic migration list.
| Domain | Likely host | Reason |
|---|---|---|
app_run | Main | Owns child processes and producer identity |
connection_flow | Main | Owns OAuth flow, timeouts, deep-link claims |
mcp_oauth | Main | Owns loopback listeners and provider exchange |
user_input | Main | Must survive renderer lifecycle and owns waiters |
github_ops | Main | Owns privileged git mutation/recovery |
version_preview | Main | Owns checkout, branch recovery, filesystem effects |
chat_stream | Main, pending study | Main already owns stream admission and durable acceptance |
plan_handoff | Main/durable, pending study | Cross-chat workflow should survive renderer reload |
first_prompt | Renderer initially | Presentation-heavy; persistence value unclear |
image_generation | Main if reload survival is desired | Long-running job with IPC-backed execution |
screenshot | Renderer | Owns iframe capture/DOM readiness |
preview_iframe | Renderer | Owns DOM and iframe identity |
voice_to_text | Renderer | Owns browser media resources |
Before moving any machine:
Do not move chat_stream to main merely for consistency. First resolve:
StreamRequest;A main-hosted chat lifecycle is attractive because main already owns stream admission and can survive renderer reload, but callbacks and presentation state must be replaced with IDs, receipts, and read models. Produce a dedicated design before implementation.
codex-cleanup-state-machines.mdThese are no-regrets prerequisites:
first_prompt Jotai projection;Do not complete these cleanup items before the pilots:
app_run to another renderer controller shape;The cleanup plan's desired outcomes remain:
This plan changes how those outcomes are reached.
Write an ADR recording:
For each pilot, list the files/types expected to be deleted. The pilot fails architecturally if it adds more permanent layers than it removes.
No production code.
Implement:
ActorHost;TransactionalDispatcher;Exercise with synthetic machines only. Do not migrate a production domain in the kernel PR.
Implement:
Use a test-only machine registered in handler integration tests. Do not expose arbitrary production machine dispatch yet.
Implement:
RemoteMachineClient;Prove against fake transport and the test-only IPC machine.
Land in reviewable steps while keeping one authority:
Avoid running old and new command side effects in parallel. A pure shadow transition may consume copied events for trace comparison, but it must run no commands and publish no application state.
Gate further adoption on a written pilot review.
Evaluate:
Possible decisions:
If approved, migrate one ordinary actor domain per PR. The current order and
recorded exceptions are owned by plans/cleanup-state-machines.md.
connection_flow and mcp_oauth are already main-authoritative specialized
resource registries. Audit their renderer consumers before exposing a common
read model; retain their listener, timer, waiter, claim, and close-barrier
internals unless ActorHost demonstrably deletes code or fixes a named
deficiency. A documented narrow boundary is an acceptable end state.
Each PR:
Design separately before implementation:
user_input -> chat_stream proof;Do not generalize durable protocols beyond needs demonstrated by the pilot.
Consider first_prompt, preview_iframe, screenshot, and other local
machines. Migrate only where the shared local host reduces code. A stable,
small local controller need not move merely for numerical consistency.
After migrations:
The runtime could become a second application framework with excessive generics and indirection.
Mitigation:
Callers may assume a remote actor behaves like a local object.
Mitigation:
send and async dispatch;A router accepting arbitrary machine/event payloads could widen renderer privilege.
Mitigation:
Large or frequent snapshots could saturate IPC and rerender the UI.
Mitigation:
Hot-module replacement can briefly pair incompatible contracts during development. Production updates apply on restart and load main and renderer from the same bundle.
Mitigation:
Persisted state written by one application version and read after an update is a separate compatibility boundary. Version and migrate durable schemas where required; reject or recover explicitly when migration is unavailable.
Main may commit an event while the renderer loses the receipt and retries.
Mitigation:
Remote subscriptions or one-shot keys could retain actors indefinitely.
Mitigation:
Moving more orchestration to main could increase CPU/memory pressure or block Electron.
Mitigation:
Compatibility could leave old renderer and new main actors both active.
Mitigation:
An actor transport can tempt contributors to treat remote delivery as exactly once.
Mitigation:
The architecture is accepted only if both pilots demonstrate:
Broader rollout succeeds when:
rules/state-machines.md, rules/electron-ipc.md,
rules/jotai-state.md, and architecture documentation match production.The desired end state is not “everything is remote.” It is:
If the app-run and voice-to-text pilots cannot achieve this while deleting their old infrastructure, stop after the no-regrets cleanup. The goal is a smaller and more truthful architecture, not adoption of an ambitious framework for its own sake.