plans/codex-cleanup-state-machines.md
Proposal.
This plan follows plans/state-machines-hardening.md. The hardening work made
the state-machine layer substantially safer: transition results are
discriminated, operation identity is explicit, lifecycle cleanup is shared,
selected controllers use transactional dispatch, and transition traces are
more useful. It did not attempt to make the resulting architecture small or
uniform.
The next problem is comprehensibility. Several workflows now have an
authoritative state-machine snapshot and a second Jotai representation of the
same lifecycle. Some machines use TransactionalDispatcher; others still
implement their own dispatch loop. Some cross-machine signals travel through
typed facades, while others travel through atoms that act as mailboxes or
observable flags. Providers and managers repeat similar ownership plumbing.
This plan removes those transitional structures without weakening the race, identity, disposal, persistence, or observability guarantees established by the hardening work.
The target architecture is:
producer event
|
v
typed machine facade
|
v
TransactionalDispatcher -> committed immutable snapshot -> pure selectors
| |
v v
command adapter React domain hook
| |
v v
IPC / Query / UI-only runtime stores component
The central rule is:
A lifecycle fact represented in a machine snapshot is not also stored in Jotai.
Jotai remains appropriate for client-only state that is not owned by a machine: edit buffers, navigation preferences, high-frequency console and stream content, transient selections, and independently sourced diagnostics. React Query remains authoritative for IPC-backed entities. Main-process machines may expose renderer read models across IPC, because that is a process boundary rather than a second same-process authority.
The cleanup is incremental. Each domain migrates its consumers first, deletes its compatibility projection in the same PR, and retains focused regression tests. There is no repository-wide flag day and no period in which two independent writers are accepted as a steady state.
The largest example is app_run.
RunState already contains:
idle, starting, ready, reloading, stopping,
stopped, or errored);startedAt;The renderer also stores related values in:
previewRunStateByAppIdAtom;appUrlByAppIdAtom;previewAppExitByAppIdAtom;previewErrorByAppIdAtom;previewReloadTokenByAppIdAtom.Some of these are exact projections, some combine independent sources, and
some are imperative UI epochs. Keeping them together in
previewRuntimeAtoms.ts obscures ownership and makes one output event update
the machine and atoms in separate commits.
chat_stream, first_prompt, and image_generation also publish
same-process machine projections into Jotai:
isStreamingByIdAtom;firstPromptSagaAtom;imageGenerationJobsAtom and its derived atoms.Single-writer enforcement prevents the worst races, but it does not remove the second representation, projection lifecycle, cleanup ordering, or reviewer burden.
Three current dependencies use Jotai as an event or status bus:
preview_iframe watches previewRunStateByAppIdAtom to infer that
app_run restarted;plan_handoff watches isStreamingByIdAtom to infer that chat_stream
became idle;pendingScreenshotAppIdsAtom, which the
screenshot provider consumes as a mailbox.These dependencies are difficult to discover from the machine types. They also lose domain information: a boolean edge or map mutation is weaker than a typed event carrying the relevant operation identity.
There are thirteen machine domains:
app_run, chat_stream, first_prompt,
github_ops, image_generation, plan_handoff, preview_iframe,
screenshot, version_preview, and voice_to_text;connection_flow, mcp_oauth, and user_input.Only image_generation, screenshot, and voice_to_text currently use
TransactionalDispatcher. The remaining runtimes use combinations of
SnapshotStore, hand-written re-entrancy queues, direct observer calls,
registry maps, timers, and domain-specific command drains.
Some deviations are legitimate, especially main-process registries owning external resources. The current code does not make the distinction obvious: custom mechanics and necessary domain policy are interleaved.
Examples:
activeCheckoutCounterAtom mirrors whether any version_preview controller
is mutating;isStreamingByIdAtom is an aggregate index over per-chat snapshots;Aggregate views are useful, but they should be read-only external-store selectors over authoritative snapshots. They should not require another general-purpose state container.
Machine command adapters legitimately perform UI effects, but several also read lifecycle flags or write lifecycle projections. This makes a pure machine look authoritative while its effective behavior still depends on atom state.
For example, plan_handoff reads streaming status from Jotai, and
app_run commands separately write URL and error atoms. These should be
explicit dependencies or machine events, not implicit access to a shared
store.
TransactionalDispatcher unless a documented resource-owning registry
genuinely requires a different runtime.Every stateful value touched by a machine migration must be classified before code changes begin.
Examples:
Rules:
Examples:
Rules:
Examples:
Rules:
Examples:
user_input registry.Rules:
Examples:
Rules:
| Domain | Runtime today | Duplicate/implicit state | Disposition |
|---|---|---|---|
app_run | Custom SnapshotStore, FIFO, command queue | Loading, URL, operation error, exit overlap in preview atoms | Migrate runtime; remove lifecycle projections; split independent diagnostics |
chat_stream | Custom SnapshotStore and command orchestration | isStreamingByIdAtom; queue/status dependencies through Jotai | Migrate runtime; direct per-chat selectors; typed status facade |
first_prompt | Custom controller | firstPromptSagaAtom mirrors snapshot projection | Expose snapshot/projection in provider context; delete atoms |
github_ops | Custom controller | No lifecycle atom projection | Preserve hook shape; migrate transaction mechanics |
image_generation | TransactionalDispatcher per job | Manager projection copied into Jotai | Expose manager projection directly through hooks; keep dismissal UI atom |
plan_handoff | Custom controller | Reads stream-idle through isStreamingByIdAtom | Inject chat-stream status facade; migrate runtime |
preview_iframe | Custom controller | Restart inferred through app-run atom; error command writes mixed preview atom | Wire typed app-run event; split iframe diagnostics; migrate runtime |
screenshot | TransactionalDispatcher | pendingScreenshotAppIdsAtom is a producer mailbox | Replace mailbox with injected screenshot request facade |
version_preview | Custom controller | Global activeCheckoutCounterAtom mirrors mutations | Expose aggregate mutation selector from manager; migrate runtime |
voice_to_text | TransactionalDispatcher | None identified | Use as the minimal direct-binding reference implementation |
connection_flow | Custom main registry with derived effects | No Jotai duplication | Adopt shared dispatch/lease mechanics where compatible; document remaining deviation |
mcp_oauth | Custom main registry owning listeners, waiters, timers | No Jotai duplication | Separate pure transaction mechanics from resource registry; retain explicit resource policy |
user_input | Main registry plus renderer IPC projection | Legitimate cross-process projection | Keep boundary; audit naming and optionally replace atom backend only if it simplifies consumers |
Each keyed renderer machine exposes one domain hook:
interface AppRunView {
state: RunState;
projection: AppRunProjection;
send(event: AppRunInput): void;
}
function useAppRun(appId: number | null): AppRunView;
The projection is a reference-stable, pure function of the snapshot:
function projectAppRun(state: RunState): AppRunProjection;
It can expose convenient values such as isLoading, url, operationError,
and capabilities, but it does not store them elsewhere.
Non-React consumers and other machines receive a narrow facade:
interface ChatStreamStatusFacade {
getState(chatId: number): StreamState;
subscribe(chatId: number, listener: () => void): () => void;
}
The facade is injected at a composition root. A machine does not import another machine's manager, controller, provider, or atom.
Cross-key UI uses an aggregate manager snapshot only when per-key component subscriptions are impractical:
interface ImageGenerationProjectionSource {
getSnapshot(): readonly ImageGenerationJobView[];
subscribe(listener: () => void): () => void;
}
The manager owns reference stability and retention. React consumes it with
useSyncExternalStore; it is not copied into Jotai.
Events such as stream completion or app-run restart remain subscriptions, not state:
interface AppRunLifecycleEvent {
appId: number;
invocationRef: AppRunInvocationRef;
type: "restart-started" | "stopped";
}
Callbacks run after the committed snapshot is visible. Event APIs document whether they are lossless, replayable, or live-only.
Add small React helpers under src/state_machines/react.ts:
useMachineSelector(controller, selector, isEqual?);useKeyedMachineSelector(manager, key, selector, isEqual?);useProjectionSource(source).Requirements:
Prefer React's supported selector shim if already available transitively; otherwise keep the helper small and tested rather than implementing a broad state library.
Keep KeyedControllerHost, but standardize the common renderer manager
surface:
interface KeyedMachineManager<Key, State, Input> {
getSnapshot(key: Key): State;
subscribeKey(key: Key, listener: () => void): () => void;
send(key: Key, input: Input): void;
disposeKey(key: Key): void;
dispose(): void;
}
Do not force promise-returning dispatch, recovery indexes, or specialized registrations into this base interface. Those remain domain extensions.
Providers own managers and lifecycle only. They do not copy snapshots to atoms.
The standard provider shape is:
Domain hooks live beside the provider and return state/projection/actions.
Create typed adapters at the nearest common owner for:
app_run -> preview_iframe;chat_stream -> plan_handoff;-> screenshot;user_input -> chat_stream (preserve the existing facade direction).Record the final dependency graph in module headers and in a focused architecture test. It must remain acyclic.
Extend src/state_machines/boundaries.test.ts with enforceable rules:
state.ts and transition.ts cannot import @/atoms, Jotai, React, IPC, or
another machine;registerAtomWriter or projectToAtom outside the cross-process
allowlist fail the test.This is intentionally stricter for new code than for the initial migration. Temporary exceptions carry an owner and deletion PR.
app_runThis is the first and most important cleanup because it has the most confused
ownership and feeds preview_iframe.
Add app_run/projection.ts with a pure, cached projectAppRun selector.
Expose:
isLoading;startedAt;RunUrl | null;canStart, canRestart, canStop, and
canReload.Do not expose invocation identity to ordinary UI consumers unless required for diagnostics.
previewRuntimeAtomsDelete machine-owned storage:
previewRunStateByAppIdAtom;currentPreviewRunStateAtom;currentPreviewLoadingAtom;currentPreviewRunStartedAtAtom;appUrlByAppIdAtom;currentAppUrlAtom;dyad-app portion of previewErrorByAppIdAtom.Audit before deciding the fate of:
previewAppExitByAppIdAtom: if the UI needs the last output event timestamp
independently of current machine state, rename it to
lastPreviewExitEventByAppIdAtom and document it as diagnostics history;
otherwise derive exit information from RunState;previewReloadTokenByAppIdAtom: move iframe identity changes into
PreviewIframeState.iframeEpoch and delete the token;previewErrorByAppIdAtom: split independent iframe/client/sync diagnostics
into explicitly named keyed stores, then compose display priority in a pure
preview selector;applyUrl changes machine state; it must not separately write a URL atom.APP_EXIT must have one authoritative admission/commit path. Independent
diagnostic history, if retained, is written only after the machine admits
the correlated event.Migrate AppRunController to TransactionalDispatcher.
Preserve:
Delete:
processing flag;pendingEvents FIFO;AppRunManager.chat_streamDelete isStreamingByIdAtom after migrating all consumers.
Provide pure selectors:
selectIsStreamActive(state);selectCanSubmitImmediately(state);selectCanCancel(state);selectStreamError(state).React components that render one chat use a keyed machine selector. Tab-list rows subscribe per chat rather than reading a global map. If measurement shows that this creates unacceptable subscription overhead, add a manager-owned read-only active-chat index; do not restore a writable atom.
resyncChat receives a ChatStreamStatusFacade;plan_handoff receives the same facade and subscribes to the target chat;isStreamingByIdAtom directly.Retain, with documented ownership:
chatMessagesByIdAtom for optimistic/streaming renderer messages;streamingPreviewByChatIdAtom for high-frequency partial content;queuePausedByIdAtom only if pause is intentionally queue policy outside
the stream lifecycle;chatErrorByIdAtom only for errors not represented by StreamState.For each retained value, add a short ownership comment explaining why it is
not derivable from StreamState. Split mixed error state if necessary.
Migrate the controller transaction loop to TransactionalDispatcher, while
retaining the command scheduler that allows long-lived stream work without
blocking event admission.
Preserve:
streamFinished delivery;first_promptuseFirstPrompt() returning { state, projection, send/resume }.projectFirstPromptState to a pure cached selector without Jotai.home.tsx, TitleBar, SetupBanner, and
ProviderSettingsPage to the hook.firstPromptSagaProjectionWriteAtom,
firstPromptSagaAtom, its manual subscription, and disposal reset.image_generationSnapshotStore because it provides a real
retained cross-job read model.getProjection/subscribeProjection to
getJobsSnapshot/subscribeJobs for clarity._imageGenerationJobsAtom, imageGenerationJobsAtom,
setImageGenerationJobsProjectionAtom,
pendingImageGenerationsCountAtom, and
chatImageGenerationJobsAtom.dismissedImageGenerationJobIdsAtom as independent UI state.version_previewChatHeader from
isAnyCheckoutVersionInProgressAtom to the selector.activeCheckoutCounterAtom and
isAnyCheckoutVersionInProgressAtom.TransactionalDispatcher in a separate PR
with trace comparison and recovery tests.preview_iframepreviewRunStateByAppIdAtom.RUNTIME_RESTARTED with sufficient identity to reject duplicate or
stale notifications; do not deduplicate only by startedAt.PreviewIframeState.iframeEpoch the only iframe replacement/reload
identity.TransactionalDispatcher.plan_handoffwatch-stream-idle's Jotai subscription with the injected
ChatStreamStatusFacade.TransactionalDispatcher and timer leases.screenshotScreenshotRequestFacade with
requestCapture(appId, source).useCommitChanges, chat-stream command dependencies, and
other producers.pendingScreenshotAppIdsAtom and the provider consumer effect.ScreenshotManager: replacement,
queueing, or ignore-by-state must be a transition policy, not an incidental
Map overwrite.github_opsuseGithubOps and projectGithubOps as the reference
projection-free public API.TransactionalDispatcher.connection_flowTransactionalDispatcher for state commit, observers, and timer lease
cancellation if the commandless derived-effect model can be expressed
without changing public synchronous claim semantics.TimerLeaseScope.start/claimReturn results prevent direct dispatcher use,
extract a small dispatcher-backed core and document the facade boundary.mcp_oauthTransactionalDispatcher.user_inputThe main registry remains authoritative and the renderer remains a cross-process read model.
In the first cleanup pass:
In an optional later pass, compare a service-owned SnapshotStore plus domain
hooks against the current atoms. Migrate only if it reduces total adapters and
consumer complexity. Atom count alone is not sufficient justification.
Each PR must be independently reviewable and must delete the compatibility path it replaces. Do not land new hooks while leaving indefinite dual consumption.
This is the smallest proof that direct machine hooks can replace a global atom.
useFirstPrompt.ScreenshotRequestFacade.projectAppRun and direct hook consumers.preview_iframe.AppRunController to TransactionalDispatcher.isStreamingByIdAtom.Use separate commits, and split into multiple PRs if review size grows:
github_ops;preview_iframe;version_preview.Each migration requires before/after trace comparison and controller conformance.
Prefer separate PRs per registry:
connection_flow;mcp_oauth;user_input runtime mechanics only if the dispatcher fits its synchronous
registry contract.Resource ownership and cross-process protocols receive focused tests rather than a mechanical bulk conversion.
After all same-process writers are gone:
registerAtomWriter/projectToAtom code if only the
allowlisted cross-process projection no longer needs it;rules/state-machines.md, rules/jotai-state.md, and
docs/why-state-machines.md;Every domain continues to run:
Projection removal must not require transition changes unless the old projection exposed a missing domain fact. Such a change is isolated and reviewed as behavior, not folded into a mechanical consumer migration.
Every migrated controller runs runControllerConformanceSuite, covering:
For each deleted atom:
Do not mock Jotai to simulate machine lifecycle after the migration.
Required scenarios:
Use the renderer+IPC integration harness where possible. Use Playwright only for behavior requiring the real iframe, Electron output subscription, or browser interaction. Rebuild before E2E runs.
Projection removal must not replace one global atom rerender with broad provider rerenders.
Measure or assert:
Direct external-store subscriptions could rerender more components than fine-grained atoms.
Mitigation: selector-aware keyed hooks, scalar selectors, equality tests, and keeping high-frequency content outside lifecycle snapshots.
Deleting global maps can make “any entity active?” queries harder.
Mitigation: add manager-owned, read-only aggregate indexes only for actual consumers. Test reference stability and cleanup.
Replacing atom observation with direct events can change callback ordering.
Mitigation: specify post-commit delivery, carry invocation identity, and test re-entrant sends. Do not emit lifecycle events from command side effects when the transition itself is authoritative.
Splitting previewErrorByAppIdAtom may change precedence between app-run,
iframe, client, and sync errors.
Mitigation: inventory current precedence and encode it in one pure display selector with table-driven tests before storage changes.
Custom runtimes may contain undocumented scheduling behavior.
Mitigation: capture before/after traces, characterize scheduler concurrency, run conformance, and migrate one high-blast-radius controller per PR.
Several tests currently set lifecycle atoms directly.
Mitigation: introduce small test manager drivers and event fixtures. Tests should exercise the same authority boundary as production.
The cleanup is complete when:
app_run UI URL/loading/error state comes from its committed snapshot;isStreamingByIdAtom, the first-prompt projection atoms, image-generation
projection atoms, screenshot mailbox atom, and version checkout counter are
deleted;TransactionalDispatcher;rules/state-machines.md, rules/jotai-state.md, and
docs/why-state-machines.md describe the implemented architecture rather
than the transitional one.The number of pure transitions and domain states will not necessarily shrink; they encode real workflow complexity. The surrounding code should shrink materially:
The desired review experience is that a contributor can determine:
without searching for a second atom, counter, ref, or effect that must agree.