plans/codex-state-machines.md
Proposal only. This document records correctness lessons from the recent state-machine migration and recommends shared infrastructure that can make future machines robust by construction. It does not authorize implementation or require existing machines to migrate mechanically.
This review covered 30 state-machine implementation PRs merged between
2026-07-21 and 2026-07-23, primarily authored by keppo-bot, together with
the two wwwillchen planning PRs:
The migration's domain modeling is generally strong. Pure transitions, explicit commands, reference-stable snapshots, provider-owned managers, and structured ignored-event telemetry are all sound foundations.
The main correctness gap is one layer above the transition function. The shared kernel standardizes stores, hosts, React lifecycle, traces, and test utilities, but leaves these correctness-sensitive controller semantics to each domain:
Those mechanisms were deliberately excluded from the initial micro-kernel in #4014. The subsequent PR iterations provide enough evidence to revisit that boundary. The recommended direction is not a framework that owns domain policy. It is a small shared runtime that owns event transaction mechanics while leaving state shape, concurrency policy, and staleness policy domain-specific.
Most serious review findings were not missing transition cases. They came from orchestration surrounding otherwise reasonable transition tables.
| Failure class | Representative iteration |
|---|---|
| Re-entrant events reordered commands | #3969 review |
| An async command wedged a serial queue | #3968 review and fix |
| A synchronous runner throw left a machine permanently pending | #4029 review |
| A callback observed stale state | #4028 review |
| A local generation was mistaken for a globally unique identity | #4031 review |
| Late async setup escaped disposal | #4021 review |
| Terminal settlement depended on a fallible ancillary command | #4033 review |
| A wait state was entered without reinstalling its progress mechanism | #4058 review |
| Command data was ignored in favor of stale React closure state | #4059 review |
| Cross-machine queued work lacked durable ownership and settlement | #4047 review |
| Teardown order dropped the final projection update | #4045 review |
| UI treated dispatch as success and destroyed retryable input | #4061 review |
The present implementation also shows controller-semantic drift:
observeTransition have an event re-entrancy buffer.The state-machine rules have absorbed many of these lessons. The next step is to move the most universal rules into types, runtime mechanics, and reusable tests.
Add a small shared dispatcher that owns one event transaction:
Required guarantees:
The dispatcher must not choose domain concurrency. A domain should still inject its command scheduler and decide whether commands run serially, concurrently, or as independently tracked operations.
Replace the optional ignoredReason result shape with a discriminated union:
type TransitionResult<State, Command, Reason> =
| {
kind: "ignored";
state: State;
reason: Reason;
}
| {
kind: "applied";
state: State;
commands: readonly Command[];
};
Provide constructors with unambiguous semantics:
ignore(state, reason)change(nextState, commands?)stay(state, commands) for an applied command-only transitionThis makes it impossible to attach commands to an ignored event accidentally, and distinguishes deliberate command-only transitions from implicit no-ops.
Strengthen driveTransitionMatrix and exploreReachableStates so callers do
not need to reproduce the same validation loop.
Both helpers should assert:
exploreReachableStates should return the explored graph, including edges and
predecessors, rather than only a state array. This would make counterexamples
and coverage gaps much easier to diagnose.
Add an optional way for a machine to describe how each non-terminal state can make progress. For example:
{
state: "waitingSelectorReady",
progressBy: ["timer:settle", "external:selector-ready"],
}
The exploration tooling should reject reachable non-terminal cycles that have:
This targets machines that enter a valid state but lose the timer, subscription, callback, or acknowledgement needed to leave it. It would have caught the screenshot reload race from #4058.
Introduce a reusable TaskScope or ResourceScope for:
Suggested operations:
scope.replace(key, cleanup);
scope.remove(key);
scope.trackPromise(promise, lateCleanup);
scope.dispose();
Registering a cleanup after the scope has already been disposed must run that
cleanup immediately. dispose() must be idempotent.
Timer helpers should use the shared Clock. This scope should encapsulate the
pattern where disposal cleans up immediately and also cleans up external state
that appears after an awaited operation settles.
Prefer stable operation identities minted by IdSource over controller-local
numeric generations:
type OperationToken<Kind extends string> = {
kind: Kind;
id: string;
};
The complete token should cross every relevant IPC, queue, and persistence boundary. Entity identity should remain a separate explicit field rather than being inferred from the operation counter's scope.
Provide shared helpers for:
Create a shared primitive for workflows where one machine submits work to another and waits for acknowledgement:
created -> durably accepted -> executing -> acknowledged
\-> rejected or settled
The primitive should require:
An injected facade remains the composition boundary, but this primitive would make reload-safe acceptance and settlement part of the implementation rather than a convention.
Every controller runtime should pass the same adversarial suite:
Domain controller tests would remain responsible for domain behavior. The conformance suite would prove the shared execution and lifecycle contract.
replayTrace currently trusts a recorded ignored marker and skips the
transition. Replay should instead execute every event and verify:
Where deterministic replay matters, trace timestamps should use an injected clock. A replay mismatch should report the shortest divergent prefix.
The shared runtime should not:
The goal is to genericize linearization, lifecycle mechanics, correlation mechanics, and verification—not domain policy.
This phase should not change production scheduling semantics.
voice_to_text, image_generation, and screenshot.These machines are bounded enough to exercise synchronous emission, timers, cancellation, and late async completion without beginning with the most complex chat workflows.
The proposal is successful when: