plans/even-more-machines.md
Planning only. This document does not authorize implementation; each machine listed here gets its own design document (at the depth of version-preview-state-machine.md) before code. It is the third-generation survey and program plan, following:
rules/state-machines.md exists and is linked from
the rules index (AGENTS.md:38); src/state_machines/ matches its spec
(types with ignore reasons + TransitionObserver, snapshot_store,
keyed_host, react, testing); all four migrations landed with real
deletion-time disposal owners (apps.tsx:163, app-details.tsx:200,
ChatList.tsx:242). Its residuals are absorbed into Part 3 here.Method note: this plan was produced by a multi-agent survey (6 sweepers over disjoint slices, 4 ground-truth auditors, adversarial verification of every candidate, completeness critic). Every file:line below was verified against the current tree by an independent verifier agent; corrections from verification are incorporated. Two proposed candidates were killed in verification (ImportAppDialog saga, chat-mode latch) and are recorded in the rejected list.
Progress record (updated 2026-07-22). Phases 0, 1, 2, and 4 stage 1 are COMPLETE; Phase 3 is more than half landed; Phase 5 is the active frontier.
Evidence sections below are point-in-time records from the survey; where landed work changed the ground, the Phase plans section is authoritative. The detailed phase specs were produced the same way as the survey: five drafter agents grounded in the post-Phase-0 tree, each adversarially critiqued, with cross-phase conflicts adjudicated in the "Cross-phase handshakes" subsection.
chat_stream is the new drift. It landed in parallel with the kernel
and satisfies none of the conventions the other four machines now follow:
zero kernel imports, a module-global controller Map
(src/chat_stream/registry.ts:18) directly violating
rules/state-machines.md:30-32, no dispose(), reasonless local ignore(),
hand-rolled listener plumbing, no telemetry. Worse, two chat streams still
run outside the machine and write its projection by hand. Part 1 is
mostly a chat*stream convention-and-consolidation program. *(Phase 0
update: one external stream remains — plan implementation was folded;
useResolveMergeConflictsWithAI still streams outside the machine.)_userInputResolver.ts) the other two lack. The highest-value new machine
is the one that unifies them. (Phase 0 update: all three now ride the
shared resolver, and 8ad56501c added terminal settlement broadcasts; what
remains diseased is the classifier race, the multi-writer renderer atoms,
reload rehydration, and the duplicate questionnaire deadline — see
Phase 3.)Ordered so each step stands alone; all are behavior-preserving under the
existing src/chat_stream/__tests__/ and the streaming integration suites.
TransitionResult; tag every ignore() with a reason
("stale-stream-id", "already-streaming", …); thread an optional
TransitionObserver through createChatStreamController. Today the
controller conflates "ignored" with "command-only, same state"
(controller.ts:75 computes changed = result.state !== state), so a
submit-while-streaming is indistinguishable from a dropped event in any
future telemetry.dispose() (medium). Replace the hand-rolled listener
set (controller.ts:41-65) with SnapshotStore, extended with the
subscriber-count/unsubscribe hook the quiescence check needs. dispose()
must release the live transport: cancel ack timers, ipc.chatStream.release
for the active streamId (today only the terminal side effects do this,
commands.ts:199-207).runtimeDeps singleton (commands.ts:101-117) with a
ChatStreamManager (KeyedControllerHost<number, ChatStreamController>)
constructed at the app root, where useChatStreamRuntime already mounts —
controllers must outlive route components (background queue dispatch), so
the host lives at the root, not per page. Disposal decision (resolving a
real contradiction between audits): migrate to the host and keep the
quiescence self-GC. disposeKey(chatId) is called on chat deletion (next
to plan_handoff's at ChatList.tsx:241-242) and also clears the chat's
residue in queuedMessagesByIdAtom, queuePausedByIdAtom,
chatErrorByIdAtom, isStreamingByIdAtom — today that residue survives
deletion forever. Quiescence GC (terminal + settled + unobserved,
registry.ts:20-30) remains as an additional release path for
never-deleted idle chats, documented in the module header as the
concurrency model. Non-React callers (registerRendererIpcListeners.ts:8)
get the manager injected via a wiring handle.startImplementationStream
(src/plan_handoff/commands.ts) and useResolveMergeConflictsWithAI each
duplicate ~80 lines of chunk plumbing, write isStreamingByIdAtom
directly (breaking single-writer), and require queue-poked compensation.
Both are plain prompt submissions — folding them in is routing through the
machine's submit. This also closes a real race: the IPC stream client
keys entries by chatId only, so a machine stream and an external stream
colliding on one chat replaces the client entry and cross-attributes
terminal events (can deadlock the machine or freeze messages mid-stream).
Folding unlocks a cascade of deletions:
chatStreamCountByIdAtom — a hand-rolled generation counter with three
writers duplicating the machine's streamId; resync staleness guards
compare machine snapshots instead.useIntegrationContinuation's prevStreamingRef edge detector and
ChatPanel's store.sub edge detector — both replaced by a
stream-finished {chatId} signal emitted on the finalizing → idle /
→ errored transitions (observer- or atom-based).useStreamChat.cancelStream's non-machine bypass branch.chat_stream/registry; it submits through the
chatStream facade injected via PlanHandoffDeps
(src/plan_handoff/commands.ts:35, adapter at src/app/layout.tsx:35-46) —
the reference implementation of Part 3's composition rule.turnContexts
(per-stream targetAppId) moves into machine state; ack maps/timers scope
to the adapter instance with cleanup wired to dispose().streamId in chunk/end/error/start payloads so the stream client
can key entries by (chatId, streamId) and registered becomes
stale-checkable like every other event.useConnectionFlow still binds via raw useSyncExternalStore
(useConnectionFlow.ts:157) instead of useControllerSnapshot. Explicitly
optional in machine-followup; record the skip or do it in passing.Ranking is adjudicated, not raw verifier score: severity and incident-adjacency count for; a cheap non-machine fix that removes the headline bug counts against. Every candidate has a "quick fix first" line when one exists — those fixes ship as chores regardless of whether the machine is ever built.
| # | Candidate | Value | Effort | Confidence |
|---|---|---|---|---|
| 1 | Agent-paused user-input round-trip (consent + continuation + questionnaire) | High | large | 82–85/100 |
| 2 | Home first-prompt submission & resume saga | High | medium | 80 |
| 3 | Image generation job lifecycle | Medium-high | medium | 88 |
| 4 | GitHub repo operation lifecycle | High | large | 82 |
| 5 | MCP OAuth loopback flow | Medium | medium | 80 |
| 6 | Preview iframe identity + navigation (+ picker) | Medium-high | large | 82 |
| 7 | Neon linkage saga (after the lock fix) | Medium | medium | 84 |
| 8 | Screenshot capture pipeline | Medium | medium | 75 |
| 9 | FileEditor save/dirty; voice-to-text (carried over) | Medium | small | — |
| 10 | Chat tab session lifecycle | Medium-low | medium | 62 |
| — | Main-process stream engine | see Part 3 | large | 76 |
| — | Deferred: queue persistence, deep-link mailbox | see below | — | 75/78 |
Scope: src/ipc/utils/mcp_consent.ts,
src/pro/main/ipc/handlers/local_agent/tool_definitions.ts (agent-tool
consent), userInputResolver.ts/userInputResolvers.ts,
src/atoms/{chatAtoms,integrationAtoms,planAtoms}.ts (pending maps),
registerRendererIpcListeners.ts:135-151 (stream-end sweep),
useIntegrationContinue/Continuation.ts, QuestionnaireInput.tsx,
ChatInput.tsx consent banners, plan_handlers.ts/integration_handlers.ts.
Evidence (verified). Three instances of one cross-process topology:
mcp_consent.ts:17-26
(bare promise, no timeout, no abort signal) and
tool_definitions.ts:164-225 (a second, semantically divergent copy).
Meanwhile userInputResolver.ts is the already-clean generic factory
(timeout + abort + per-chat sweep) used by questionnaire and integration —
the consents are un-migrated laggards.pendingToolConsentsAtom, with main separately
resolving its maps at four other sites — cross-process bookkeeping synced
by hand.setTimeout that resets on chat switch while main's timer keeps counting
(QuestionnaireInput.tsx:90-105); plan_handlers.ts:199 silently discards
the resolver's matched/unmatched result while integration_handlers.ts:14-22
throws NotFound for the identical condition — the same flow disagrees with
itself on stale-request semantics.prevStreamingRef
(useIntegrationContinuation.ts:40-53) with a self-documented
write-before-IPC ordering hazard (useIntegrationContinue.ts:53-62).Verified reachable bugs:
waitForConsent has no
timeout/abort — the stream stays parked on an invisible consent with no
UI to resolve it (traced end-to-end; a later stream on the same chat does
not unblock it).mcp_consent.ts:171-184 settles the human waiter with
an injected fake decline) — including an explicit Decline.Sketch. One generic machine for the round-trip, keyed by requestId with a
per-chat index: prompted → awaiting-user (classifier-racing) → decided(human|classifier|timeout|abort) → resolved, main-authoritative with
a renderer projection replacing the five-writer atoms. Deadline lives in
exactly one place (main emits timed-out); decisions are correlated events,
so the classifier race is a pair of guarded transitions instead of a
Promise.race with comment-documented gaps; reload rehydration (a
get-pending contract on mount) is an explicit design requirement — an
in-memory renderer machine alone does not fix bug 1.
Staging. (a) DONE in Phase 0: both consent maps migrated onto the
shared src/ipc/utils/user_input_resolver.ts (5-min timeout + abort +
per-chat sweep; every wait now takes the stream's abort signal, which also
closes the before-quit waiter leak indirectly — bug 1 downgrades from
"parked forever" to "invisible banner, silent auto-decline after 5
minutes"); 8ad56501c additionally broadcasts a terminal settlement event on
every path. (b) build the machine; (c) port questionnaire and continuation
onto it, deleting prevStreamingRef (sequence after the chat_stream
folding: the port consumes Phase 1's submit-during-finalization enqueue
semantics rather than re-implementing stream-end detection). The full spec
is Phase 3.
Scope: src/pages/home.tsx, HomeChatInput.tsx,
pendingFirstPromptAtom + payload atoms, plus the three other surfaces that
fork on the flag (TitleBar.tsx:48-52, ProviderSettingsPage.tsx:265-268,
SetupBanner.tsx:41).
Evidence (verified). A literal 2-second setTimeout in the submit path
(home.tsx:312-314, with a test-mode special case); hasAttemptedAutoResumeRef
once-latch reset by a second effect watching the same atom (360-365); the
auto-resume effect fires on a 6-condition dependency conjunction then runs a
fire-and-forget IIFE (367-415); pendingFirstPromptAtom is a bare boolean
whose real payload (prompt, attachments, selected app, mode) is scattered
across four atoms and re-read at resume; no in-flight guard on submit. The
comments at home.tsx:308-311 and 388-400 document races already hit and
patched piecemeal — the same signature that preceded the first five machines.
Verified reachable bug: createApp succeeds, then the Neon template hook
or setAppTheme throws → error toast, prompt retained, but the app exists —
resubmit creates a second app. (Double-Enter double-create is reachable only
through the voice-toggle await gap, and the stuck-spinner needs a navigation
failure — both real but narrower.)
Sketch. Single-key machine: idle → validating → awaitingProviderSetup(armed payload) → creating → postCreate → dispatching → navigating, errors forking to failed(retainInput) vs
failedPartial(appCreated). PROVIDER_CONFIGURED is an event (from settings
page / deep link) instead of a dependency-edge effect; the armed payload is
state, not a boolean plus four atoms; the 2s sleep becomes an explicit
settle command with a test override. Cross-page + deep-link coordination puts
this closer to connection_flow complexity than a single-component machine.
Scope: useGenerateImage.ts, imageGenerationAtoms.ts,
ImageGenerationToast.tsx, plus main's image_generation_handlers.ts abort
coverage.
Evidence (verified). Module-level cancelledJobIds Set mirroring the
atom's status for mutation closures; a 2-minute setTimeout eviction whose
correctness depends on the abort error arriving in time; three independent
status writers with unconditional cancel mapping; the pending-count/toast
projection recomputed in three-and-a-half places while a derived
pendingImageGenerationsCountAtom already exists unused.
Verified reachable bug (deterministic, not a race): main's cancel aborts
only the initial fetch; the URL-download phase uses a separate controller and
the file save has no abort check — any cancel after the first fetch completes
yields success-after-cancel, which the renderer's Set check swallows: the
image is written to disk but media queries are never invalidated and the
result is silently orphaned. Cancel-after-success also flips a succeeded job
to cancelled.
Sketch. Keyed machine per jobId: pending → succeeded | failed | cancelling → cancelled; late SUCCEEDED-after-cancel must NOT be a plain
ignore — the cancelled state handles it (invalidate media queries or command
main to delete the file). Main's abort-coverage gap is in scope; fits
keyed_host + snapshot_store directly. Good early win: small surface,
clear bug, exercises the kernel on a genuinely new domain.
Scope: githubSyncAtoms.ts, GitHubConnector.tsx,
GithubBranchManager.tsx; main's github_handlers.ts stays (but see the
lock caveat).
Evidence (verified). GithubSyncState is 7 orthogonal patch-merged
fields — illegal combinations representable; recovery states derived by
parsing error-message substrings (includes("rebase-merge"),
"divergent branches"); a three-tier fallback guesses rebase-in-progress
(structured code → extra getGitState probe → message substring), duplicated
in both components; isSyncing written from 8+ sites including a nested
finally that clears it mid-composite; 8 independent in-progress booleans in
GithubBranchManager plus a duplicate conflict store; a generation ref +
prop-edge auto-sync effect.
Verified reachable bugs: stale-conflict UI (abort-and-switch clears the
merge but not the atom → "Resolve with AI" targets conflicts that no longer
exist, and would stream a prompt naming files with no conflict markers);
sticky syncSuccess banner persisting across history-invalidating
operations. (The mid-composite overlapping-push race was refuted — no await
in the window.)
Sketch. One machine keyed by appId: idle → pushing | rebasing | merging | switching-branch with conflicted(files) and rebase-paused first-class;
getGitState becomes a reconcile event; both components project one
snapshot. Caveat from verification: main's withLock(appId) covers only the
clean-workspace/auto-commit slices (github_handlers.ts:198, 967) — the
renderer machine carries more serialization burden than it may assume, or
main's lock coverage widens as part of the design. The
useResolveMergeConflictsWithAI ref-mirror is chat_stream folding scope
(Part 1), not this machine.
Scope: src/ipc/utils/mcp_oauth_flow.ts, entry via mcp_handlers.ts.
Evidence (verified). Port-keyed pendingFlows with manual supersede;
disposed closure boolean + map-identity liveness checks re-checked after
the async bind window; raced 500ms close fallback + 5-minute hand-managed
timeout. The densest hand-rolled orchestration left in main outside the
machines, and visibly the pattern connection_flow was built to replace.
Plausible traced bug (verifier-found): supersede deletes the old entry, awaits socket closes, then registers the new flow — a third Connect in that window is silently clobbered, yielding a misleading EADDRINUSE and an unsupersedable orphan listener until the 5-minute timeout.
Sketch. Per-port machine (multiple servers can share the default
callback port; per-serverId keying would break the supersede invariant):
idle → binding → awaitingCallback → exchanging → connected | failed | superseded | timedOut. The state-param CSRF check and
stale-callback-keeps-flow-alive rule become guarded transitions. Include
runOAuthFlow's provider.abort() coupling as a command — nudges effort to
the high end of medium.
Previously scoped out of app_run as "event-driven UI concerns with their own
working stale guards." That rationale is now falsified:
isComponentSelectorInitialized is set true once (PreviewIframe.tsx:876) and
never reset, and the remount key omits appId.
Quick fixes first (chores, regardless of the machine): key PreviewIframe
by ${selectedAppId}-${token} (PreviewPanel.tsx:229) — token collisions are
the common case since each app's counter starts at 0 and bumps per run, so
A→B switches routinely skip the remount, leaking picker state, enabled
buttons posting into a listener-less document, preserved-route restore, and
even app A's navigation history into app B.
Evidence (verified). prevAppUrlRef prop-edge detection as the sole
"app switched" signal; canGoBack/canGoForward written by four writers and
recomputed by an effect; currentIframeUrlRef written from 10 sites and read
inside a useMemo with pseudo-trigger deps; two parallel reload
mechanisms with different remount semantics (local reloadKey keys only
the <iframe>; the per-app token keys the whole component — bumped by
app_run and chat_stream as producers); five copy-pasted preservedUrls
mutation blocks; picker/selection state (isPicking, restore-handshake
boolean atom) leaking across iframe replacements — a reloadKey remount resets
neither flag, so the picker renders active over an iframe with no selector
(confirmed: requires a double-toggle to recover), and the
isRestoringQueuedSelectionAtom handshake leaks when the early-return guard
exits without clearing.
Sketch. Per-app machine owning {history, position, currentUrl, preservedUrl, iframeEpoch, selectorReady, picking, restoreQueued} with
IFRAME_REPLACED as a real event; canGoBack/Forward become selectors; the
two competing React keys collapse into one explicit epoch. The component is
2,196 lines with screenshot/visual-editing concerns interleaved — scope
discipline is the design doc's main job (screenshot pipeline stays out, see
candidate 8; picker/selection is in, as a sub-state or sibling sharing the
iframe-epoch events).
Chore first (closes the corruption bug): neon_handlers.ts has zero
withLock calls while github/app/git_branch handlers all serialize per app.
Verified trace: branch-switch parks at a network await; Disconnect (guarded
only by its own boolean) clears the project link and env; the switch resumes
on its stale row snapshot and re-injects DATABASE_URL for the disconnected
project — half-linked row with live credentials on disk. withLock(appId)
on the five Neon mutation handlers fixes this outright.
Machine (incremental on top): per-app unlinked → linking(create|connect) → linked(activeBranch) → switching-branch | unlinking, making the
compensation cascades (createProject's 4-level unwind, setActiveBranch's
manual revert) explicit states, collapsing the renderer's six booleans into a
projection. Build when Neon work is next scheduled; the lock ships now.
Chore first: pendingScreenshotAppIdAtom is a single global slot written
by commits and by stream finalization for possibly different apps
(background streams are explicitly supported); a commit's pending capture for
app A is silently clobbered by app B's stream. Making it per-app removes the
verified stale-thumbnail bug without a machine.
Machine (per-app, medium): idle → screenshotPending(source) → waitingForIframeReady → settleDelay → resolvingCommitHash → awaitingResponse(requestId) → saving, replacing two mirrored refs, two
hand-rolled requestId correlations, a 3s setTimeout, and five clear sites.
The prior survey deferred this with "at most a shared epoch helper" — that
helper never materialized; the kernel now is that helper. Coordinate scope
with candidate 6 (it consumes the same iframe-load/selector-init events).
Unchanged from the prior survey, evidence re-verified at current lines:
fileSaveQueue.ts fixed
the IPC-overlap half; the refs+mirrored-booleans dirty/saving tracking
remains. 4-state machine keyed appId:filePath with fileSaveQueue as the
executor.startAttemptRef generation counter,
isStartingRef, skipOnStopProcessingRef, stopReasonRef. Small kernel
machine with an epoch (app_run's runId pattern). Good next-small-PR
candidate.Dual hydration flag (ref + state), hydration inferred from a loading prop
edge, hydrate wholesale-replaces the four tab-tracking sets across an async
boundary (verified eviction: a chat opened before the initial chats query
resolves vanishes from the tab bar when hydration lands — real but
sub-second window in practice), 8+ writers to the persisted composite, and a
prevStreamingRef notification-dot detector that belongs to the chat_stream
stream-finished signal anyway. Do the hydrate-as-merge fix and the
signal migration; the full machine is opportunistic — exactly one async
boundary, all mutators synchronous, existing unit tests.
redo/appId/requestedChatMode on
queued items (schema lacks them; a queued Retry silently loses redo
semantics after restart — persist them zod-optional, or strip explicitly
and surface it). (Phase 0: shipped as persist, zod-optional —
src/ipc/types/queue.ts.)deep-link-received broadcasts in main.ts (connection*flow
has its own channel; no renderer consumer matches those types).
*(Phase 0: the vestigial broadcasts are deleted; the consume-once
dispatch remains deferred with the triggers restated in Phase 5's exit
criteria.)_Beyond the micro-kernel. The kernel itself is done and stays frozen; these are the conventions and facilities the next ten machines need that the first five got away without.
Motivated directly by the #4008 incident and by the main-engine audit: the
main-side stream lifecycle is eight module-level collections
(activeStreams, admissionPendingStreams, streamCompletions, two
block-count maps, two waiter maps, partialResponses) with four load-bearing
invariants that exist only as comments — including the admission
check-then-clear atomicity ("Do NOT introduce an await…",
chat_stream_handlers.ts:671-685) and the cancelTrackedStreams sole-sender
rule — maintained across four independent writer sites in three handler
files, with zero interleaving tests. The restore saga's barrier-leak hazard
("Leaking the block would permanently stall new streams… until the process
restarts", version_handlers.ts:1030-1032) rests on hand-placed try/finally.
Stage 1 (medium, high leverage): a shared pure protocol spec — typed
states/events for the main side of the stream lifecycle (tracked → admission-pending → admitted → streaming → unwinding → finalized) — plus an
interleaving co-simulation harness in src/state_machines/ that drives a
model of main against the renderer's real pure transition() through all
orderings (abort at every await point, barrier install/release at every
boundary) and asserts the renderer machine always reaches a terminal state
and never double-dispatches. This catches the #4008 class before review.
Checklist item: enumerate the compaction saga's (chats.pendingCompaction)
interleavings with admission/cancel while building the model — it was never
audited.
Stage 2 (large, optional, gated on stage 1 passing against current behavior): extract the admission/cancel/end-emission core into a real main-side machine following the connectionflow registry precedent, making the four invariants structurally unrepresentable. Note the prior survey _chose "main stays the engine" (more-state-machines.md:112-118) — stage 2 deliberately revisits that recorded decision, justified by the barrier machinery (#3596) that postdates the choice's rationale. Stream identity is part of the migration cost: main today keys by AbortController identity, not streamId — coordinate with Part 1 item 7.
process_manager.ts (run-state maps + processCounter generation guard) is
the same shape at lower severity: no incident, withLock holds. Deferred;
if the protocol-spec pattern proves out, a later follow-up unifies renderer
runId and main processId into one generation token over the run contract.
rules/state-machines.md is silent on how machines talk to each other, and the one existing edge (plan_handoff deep-importing chat_stream's registry) is about to be replicated by every candidate that must trigger chat streams (user-input round-trip, home saga). Rule to add: machines communicate only through facades injected via their Deps (or events); never import another machine's registry/controller module; the dependency graph is a DAG recorded in each machine's header. Part 1 item 5 is the reference migration.
Machines write Jotai atoms ad hoc today (chat_stream's commands write ~8
atoms inline; app_run pushes a run-state projection), and the rules doc says
nothing — which is how isStreamingByIdAtom grew multiple writers and
chatStreamCountByIdAtom grew three. Rule to add: one writer (the
controller/manager), atoms are read-only views, derived in one subscription
from the snapshot rather than per-command writes. Optional kernel helper
projectSnapshotToAtom(store, atom, selector) with a dev-mode single-writer
assertion. chat_stream's migration (Part 1) is the proof case.
The kernel defines TransitionObserver but the only inspection facility is
version_preview's bespoke ring buffer (debug.ts,
window.__dyadVersionPreviewLog). Promote it: generic
createTraceObserver(machineName) in the kernel — 100-entry ring buffer,
applied + ignored events with reasons, window.__dyadMachines index — wired
into all five machines (which also closes 1B's observer gap), with
captured-trace replay through transition() documented as a test technique.
Three candidates are persistence-shaped (tab session, queue persistence,
user-input rehydration), and the existing precedent is the 7-ref
useQueuePersistence. Decide once, before any of them: an explicit
hydrating state in machines that need it; persist-as-command with debounce
owned by the adapter; versioned zod schema for stored snapshots; teardown
flush semantics. Add to rules/state-machines.md.
connection_flow injects timers and mints flowIds as a one-off; chat_stream
mints streamIds; the new candidates need timers (loopback timeout, settle
delay, deadline) and IDs (requestId, jobId). Add minimal Clock and
IdSource interfaces to the kernel with fakes in testing.ts; extend the
rules doc's degrees-of-freedom section to renderer machines. Retrofit
optional; new timer-using machines must take them injected.
The kernel's no-domain-imports rule and the new composition rule (3.2) are
enforced nowhere. Cheapest form: a small vitest that reads
src/state_machines/*.ts and asserts imports are relative-or-react only,
plus one asserting no src/<machine>/ module imports another machine's
modules. An oxlint no-restricted-imports config if/when available.
src/state_machines/testing.ts has zero adopters — every machine kept
bespoke totality tests. Decide deliberately rather than drift: either migrate
the five machines' matrix tests to driveTransitionMatrix (mechanical, low
value) or drop the adoption expectation and keep the kit for new machines
only — and extend it with reachable-state exploration (current driver
requires hand-enumerated states) plus the 3.1 co-simulation harness either
way.
All seven DONE in Phase 0 (b44e54c3d + 1a7b9964a): Neon serializes via the
new shared src/ipc/utils/app_mutation_lock.ts; both token refreshes are
single-flight; PreviewIframe is keyed ${selectedAppId}-${token}; the
screenshot pending atom is per-app (pendingScreenshotAppIdsAtom); queued
items persist redo/appId/requestedChatMode (zod-optional,
src/ipc/types/queue.ts); the vestigial broadcasts are deleted from main.ts;
both consent maps ride the shared resolver. Two NEW chores from the Phase 4
compaction audit are OPEN: (8) thread abortController.signal into
performCompaction so Stop doesn't wait out a full summary generation;
(9) single-flight the pendingCompaction flag so two concurrent streams on
one chat can't both compact. Plus the chat-tab hydrate-as-merge chore from
Phase 5 item 7a. Original list, kept for the record:
refreshSupabaseToken /
refreshNeonToken are check-then-act across an await with refresh-token
rotation; two concurrent calls both POST the same refresh token and the
loser can invalidate the winner's rotated credentials (Neon's comment
admits bursts happen). Module-level in-flight-promise dedup.withLock(appId) on the five mutation handlers (candidate 7).${selectedAppId}-${token} (candidate 6).pendingScreenshotAppIdAtom per-app (candidate 8).redo/appId/
requestedChatMode (deferred queue-persistence candidate).deep-link-received broadcasts in
main.ts (deferred deep-link candidate; check the e2e fake-handler sends).userInputResolver (candidate 1 stage a —
also fixes before-quit never resolving MCP waiters).Adjudicated kills and survey rejections, beyond the standing list in more-state-machines.md (whose entries were re-checked and stand: terminal — zero commits touching terminal paths since the rejection; attachments; blueprint atoms — re-read, still a clean event-sourced reducer; Vercel):
code_explorer.ts, tsc scheduler) —
machine-shaped (generation counter, pending maps, crash-loop guard, idle
timer) but heavily commented, abstracted behind a scheduler, and covered by
five test files with no incident history. Revisit trigger: a host-lifecycle
bug or a second resident-process kind.Phases gate on dependencies, not the calendar; items within a phase are independent PRs unless a gate is stated. Overview (details in the per-phase sections below):
| Phase | Contents | Gates on | Status |
|---|---|---|---|
| 0 | Chores, doc rules, boundary test, resolver migration | — | DONE |
| 1 | chat_stream convention program (Part 1A remainder) | — | DONE |
| 2 | voice, image gen, home saga, MCP OAuth + trace/Clock facilities | — (independent of Phase 1) | DONE |
| 3 | user-input round-trip machine (src/user_input/) | gates met | items 3–5 left |
| 4 | stream protocol spec + co-simulation (stage 1) | stage 2 gates now met | stage 1 DONE |
| 5 | github_ops, preview iframe, screenshot + triggered tail | gates met | next |
Five items span phases; ownership is recorded here so no two phase PRs double-book or contradict each other:
version_preview debug.ts migration and
connection_flow wiring, which also closes 1B's plan_handoff/app_run
observer gap). Phase 1 PR 1 only threads observer support through
chat_stream; whichever of the two PRs lands second adds the one-line
createTraceObserver("chat_stream") construction. Neither blocks the
other.stream-finished signal — Phase 1 PR 4 creates it and migrates all
four edge detectors mechanically (ChatPanel, ChatTabs, TestsPanel,
useIntegrationContinuation). Phase 3 item 4 later deletes
useIntegrationContinuation wholesale. Phase 5 does not touch ChatTabs.Landed as 448e2a4dd, b44e54c3d, 1a7b9964a, 8ad56501c. Receipts:
src/ipc/utils/app_mutation_lock.ts primitive
that Phase 5 item 2 reuses for GitHub.src/state_machines/boundaries.test.ts enforces kernel purity and
machine-to-machine import isolation (3.7); exploreReachableStates landed
in the testing kit (3.8's extension — the kit-adoption decision is:
new machines use it; existing bespoke matrix tests stay).Landed as: PR 1 → #4019, PR 2 → #4021 + #4024, PR 3 → #4025, PR 4 → #4028, PR 5 → #4023. All exit criteria met; spec below kept as the record.
Bring chat_stream to full convention compliance, fold the last external
stream, and replace every ad-hoc "did a stream just finish?" detector with
one machine-emitted signal. Characterization net for every step:
src/chat_stream/__tests__/{transition,controller,queue_dispatch}.test.ts,
src/hooks/useStreamChat.test.tsx, and the integration suites
src/ipc/handlers/__tests__/{queued_message,pause_queue,streaming_renderer}.integration.test.tsx
plus chat_stream_message_projection.test.ts. A step that must change a
transition test's expectations is out of scope by definition.
Entry criteria: none beyond Phase 0 (landed).
Five PRs. Two of the survey's seven workstream items ride with their
neighbors rather than standing alone: kernel-type adoption and the
SnapshotStore/dispose rewrite are both consumer-invisible refactors of the
same files (one PR); the adapter's module-state cleanup restructures the
same commands.ts instance scoping the host migration creates (one PR).
The fold (PR 3), the signal cascade (PR 4), and the main-process contract
change (PR 5) stay isolated no matter what — they carry the documented
behavior deviations and the cross-process blast radius, and each must be
revertable on its own.
dispose() (medium). Consumer-invisible; zero
projection or UI changes.
transition() with the kernel's
TransitionResult<StreamState, StreamCommand, ChatStreamIgnoreReason>;
delete the local TransitionResult (src/chat_stream/state.ts:147-150)
and reasonless local ignore() (transition.ts:27-29). Canonical reason
set (exact mapping is the PR's job; the totality test asserts every
ignore carries one): "no-active-stream", "stale-stream-id",
"already-registered", "already-cancelling",
"chunk-while-streaming", "not-finalizing", "stream-active",
"too-late-to-cancel".changed = result.state !== state, controller.ts:75): it branches on
ignoredReason and reports through observeTransition. Thread
observer?: TransitionObserver<...> through
ChatStreamControllerOptions (controller.ts:27).useConnectionFlow binding item:
skip recorded, per machine-followup's explicit-optional status.SnapshotStore. Decision: extend SnapshotStore with a
read-only subscriberCount(): number accessor, NOT an onUnsubscribe
hook — the quiescence trigger point already lives in the controller's
own subscribe wrapper (controller.ts:48-53), so the kernel needs only
the read-side predicate and GC policy stays out of the kernel.ChatStreamController.dispose(): idempotent; settles the active
request's onSettled with { success: false } (no stranded callers);
runs a new ChatStreamCommands.releaseTransport({ chatId, streamId })
command wrapping today's cleanupStreamTransport (ack timer + chunk
map + preview clear + ipc.chatStream.release, commands.ts:199-207)
when non-terminal; then SnapshotStore.dispose(). It does NOT abort
main's stream: the production caller (PR 2's chat-deletion path) runs
after await ipc.chat.deleteChat, and main drains streams before the
mutation (mutateChatAfterDrainingStreams, chat_handlers.ts:157-159);
a stale release(key, streamId) is already a no-op (core.ts:591-596).
Post-dispose send() is a reasoned no-op observable via the observer.createRecordingCommandRunner (dispose emits releaseTransport exactly
once; post-dispose events ignored; onSettled fired).KeyedController shape (getSnapshot/subscribe/dispose).ChatStreamManager host, disposal ownership, instance-scoped
adapter state (large).
src/chat_stream/manager.ts wrapping
KeyedControllerHost<number, ChatStreamController>, replacing the
module-global Map (registry.ts:18) and the runtimeDeps singleton
(commands.ts:101-117; productionChatStreamCommands becomes a
createProductionChatStreamCommands(depsHandle) factory).renderer.tsx's App via useState (renderer.tsx:129), passed (a)
into registerRendererIpcListeners as a required option — replacing
the module import at registerRendererIpcListeners.ts:8, so
onChatStreamStart calls manager.notifyStreamRegistered(chatId) —
and (b) into a ChatStreamProvider wrapping RouterProvider
(renderer.tsx:181). useStreamChat, useChatStreamState, and the
layout facade (layout.tsx:35-46) read the manager from context;
useChatStreamRuntime becomes manager.registerRuntimeDeps(...).ensureController(...).send({type:"queue-poked"}) compensations at
:191/:213 swap mechanically to the context manager — the behavioral
fold stays PR 3), useStreamChat.ts:13, useChatStream.ts:7,
registerRendererIpcListeners.ts:8.manager.disposeChat(chatId): host.disposeKey + clears the chat's
residue in queuedMessagesByIdAtom, queuePausedByIdAtom,
chatErrorByIdAtom, isStreamingByIdAtom (today that residue survives
deletion forever); called in ChatList.handleDeleteChat next to
plan_handoff's (ChatList.tsx:241-242).onQuiescent → host.disposeKey, documented in manager.ts's
header as the release path for never-deleted idle chats (safe: terminal
controllers already released their IPC entry, so PR 1's dispose is a
no-op for them).turnContexts (commands.ts:161)
moves into machine state — startStream emits
stream-context { streamId, targetAppId } after async app-id
resolution (commands.ts:260-270); active states gain
targetAppId: number | null; end/error side-effect commands carry it;
stale-guarded with "stale-stream-id". The ack throttle maps/timers
(commands.ts:127-151) and latestChunkByChatId scope to the adapter
instance created per-manager by this PR's factory; cleanup wired
through PR 1's releaseTransport/dispose.src/chat_stream/registry.ts entirely.exploreReachableStates
(stream-context orderings: before/after registered, after terminal);
adapter test asserts two constructed adapters share no ack state;
full characterization net.grep -r "chat_stream/registry" src/ returns nothing;
controllers reachable only through a constructed owner; commands.ts
has zero module-level mutable bindings.useResolveMergeConflictsWithAI (medium). The last
external stream.
ipc.chat.createChat (:74), the selection writes (:92-93)
and navigation (:103-106); replace the ipc.chatStream.start block
(:110-216) with manager.ensure(newChatId).send({ type: "submit", request: { prompt, chatId: newChatId, appId, onSettled } }).idle, so submit takes idle→starting;
requestedChatMode stays undefined, reproducing the legacy wire
value (machine falls back to the cached chat, absent for a new chat →
undefined; commands.ts:272-291).isResolving decision: set true before createChat; cleared in
onSettled for all outcomes plus the createChat catch;
isResolvingRef stays as pre-createChat reentrancy guard only.
refreshApp() moves into onSettled.chatErrorByIdAtom (in-chat display) instead of the legacy
showError toast (:194); the machine's end path runs a superset of
legacy side effects (completion event, preview reload-token bump,
pending screenshot — commands.ts:454-472). Both intended.isStreamingByIdAtom/chatStreamCountByIdAtom/preview writes
(:96-100, :134-141, :172-176, :195-199, :220-225); both queue-poked
compensations; useStreamChat.cancelStream's non-machine bypass
branch (useStreamChat.ts:116-124); the machine's two defensive
projection guards — the runEndSideEffects merge-skip
(commands.ts:511-518) and the dispatchNextQueued isStreaming check
(commands.ts:602-609) — with their now-false "plan implementation /
merge-conflict" comments.grep -rn "ipc.chatStream.start" src/ matches only
src/chat_stream/commands.ts.stream-finished signal + generation-counter deletion cascade
(medium).
manager.subscribeStreamFinished(cb: (e: { chatId, streamId, outcome: "completed" | "cancelled" | "errored" }) => void), emitted from the
manager's production observer on finalizing → idle (outcome from
wasCancelled) and → errored. An atom would be a new
one-shot-as-state counter — exactly the smell being deleted
(Projections rule). Thin hook useStreamFinished(cb).store.sub scroll edge detector (ChatPanel.tsx:279-295), and the
third detector found during drafting: TestsPanel.tsx:565-573
(spec invalidation on stream end).streamGeneration(state)
selector (streamId when active, else lastStreamId) over
useChatStreamState. Documented deviation: the scroll fires at submit
instead of first chunk (double-RAF still waits for the placeholder
render).triggerResync's staleness guard (resyncChat.ts:101, 118-121) takes an
injected isStale: () => boolean; after PR 3 its only caller is the
machine's own onChunk (commands.ts:354), which closes over the
controller snapshot.chatStreamCountByIdAtom (chatAtoms.ts:83) and all
writers/readers (commands.ts:321-328, ChatPanel.tsx, resyncChat.ts).grep -rn "chatStreamCountByIdAtom\|prevStreamingRef" src/
returns nothing.ChatStreamParamsSchema
(src/ipc/types/chat.ts:137) gains streamId?: number;
chunk/end/error schemas (chat.ts:215/234/254) and the
chat:stream:start payload gain streamId?: number.createStreamClient (core.ts:430) drops chunk/end/error whose payload
streamId is present and ≠ the entry's — entries stay keyed by chatId,
so absent-streamId payloads route exactly as today. The registered
machine event gains optional streamId; a mismatched registration is
ignored with "stale-stream-id" (absent ⇒ current generation).Phase 1 exit criteria:
SnapshotStore with dispose(); registry.ts deleted;
controllers owned by ChatStreamManager in renderer.tsx and the
hybrid harness.manager.disposeChat and clears the four residue
atoms; quiescence GC documented in manager.ts.ipc.chatStream.start appears only in src/chat_stream/commands.ts;
cancel bypass and both defensive projection guards deleted.chatStreamCountByIdAtom and all prevStreamingRef detectors gone;
the four consumers ride stream-finished.Sequencing within Phase 1: PRs 1 → 2 → 3 → 4 are strictly sequential — PR 1's dispose feeds PR 2's host, PR 3 reads the manager from PR 2's context, and PR 4 needs PR 3's deletion of the counter's last external writer (PR 3's characterization test can be written any time earlier). PR 5 needs only PR 1 and proceeds in parallel from then on — its contract change is reviewed by whoever owns Phase 4's protocol doc. The phase's gate for downstream phases is PRs 3/4: Phase 3 consumes the submit-during-finalization enqueue semantics and the signal.
Risk profile (riskiest first): PR 2 is high-medium — it rewires the
composition root and IPC listener registration (startup-ordering exposure),
migrates six importers including the hybrid harness, and carries the
phase's only transition-graph change (the stream-context event); failures
are loud (nothing streams), not subtle. PR 4 is medium — the widest
subtle surface: the signal fires on finalizing → idle, strictly LATER
than the old projection-edge detectors (safer for continuation — messages
are synced — but a timing change on an agent-driving path), plus the
declared scroll-at-submit deviation. PR 5 is low-medium — mechanical
but wide (~12 send sites), and its one scary failure mode is silent: a
wrong echoed id or a schema slip makes the client drop legitimate events
(frozen stream, the #4008 symptom class); the cross-generation client
tests plus one manual smoke stream are the real net. PR 3 is
low-medium — low-traffic feature, pre-declared deviations; the sharp edge
is the guard-deletion cascade, sound only once the ipc.chatStream.start
grep is green (which its exit criterion enforces). PR 1 is low —
consumer-invisible, dispose() has no production caller until PR 2, and
the only hazard (notify-semantics drift in the SnapshotStore swap) is
exactly what the reference-stability tests pin. Cheap insurance: soak
after PR 2 before landing PR 3, and consider landing PR 5 early (it is
parallel-eligible after PR 1) so generation-tagged events are in
production before PRs 3/4 change stream behavior.
Landed as: item 1 → #4026, item 2 → #4029, 3a → #4030, 3b → #4032, item 4 → #4036, item 5 → #4040. Spec below kept as the record.
Four machines on the frozen kernel — image generation (§3), voice-to-text
(§9), home first-prompt saga (§2), MCP OAuth loopback (§5) — plus the two
facilities gated on a first consumer: the shared trace observer (3.4) and
Clock/IdSource (3.6). Nothing here waits on Phase 1: the home saga submits
through a facade over today's useStreamChat.streamMessage (exactly as
home.tsx:301-307 calls it now) and is insulated from Phase 1's internals by
the Composition rule.
Entry criteria: Phase 0 landed (verified). No Phase 1 gate.
src/state_machines/trace.ts.
createTraceObserver<S, E, C>(machine, key?, options?: { maxEntries?, describeState?, describeEvent?, describeCommand?, mute? })
returning a kernel TransitionObserver (types.ts:65-75); entries
{at, machine, key, from, event, to, commands, ignoredReason} in a
per-machine 100-entry ring; getTraceLog(machine?) exported for
main-process access; window.__dyadMachines (index + dump())
installed only when typeof window !== "undefined" so the same module
serves connection_flow's main-side registry (observer option at
registry.ts:71-76). mute keeps per-chunk ignores out of the buffer.window.__dyadVersionPreviewLog outright
(dev-only, no programmatic consumers; no deprecation alias).window, replay of a captured trace through version_preview's
transition() as the documented technique).window.__dyadMachines;
debug.ts gone.src/state_machines/clock.ts: Clock { now(); schedule(cb, ms); cancel(handle) }, IdSource { next(prefix) }, with
production systemClock/uuidIdSource and testing fakes
createFakeClock() (manual advance, pending-timer inspection) and
createSequentialIdSource(). Injection assignments: voice takes both;
image gen takes IdSource (jobId) + Clock (prune now()); home saga
takes Clock (settle delay); the MCP OAuth registry types its injected
timer/id options with these interfaces; retrofitting connection_flow
stays optional per 3.6.src/voice_to_text/{state,transition,controller,commands}.ts;
useVoiceToText.ts becomes a thin binding with an unchanged return shape
(HomeChatInput.tsx:64 and ChatInput.tsx:324 untouched).idle | acquiring{attempt} | recording{attempt} | stopping{attempt, reason: "user"|"duration"|"size"} | transcribing{attempt}. Events: TOGGLE, MEDIA_ACQUIRED{attempt}, MEDIA_DENIED{attempt,message}, SIZE_LIMIT_REACHED, DURATION_ELAPSED{attempt}, RECORDER_STOPPED{attempt,hasAudio}, TRANSCRIPTION_OK{attempt,text}, TRANSCRIPTION_FAILED{attempt,message}.
Commands: AcquireMedia, StartRecorder, StopRecorder{reason}, ReleaseMedia, ScheduleDurationLimit, CancelDurationLimit, Transcribe, DeliverTranscription{text}, NotifyError{message}.dispose() runs CancelDurationLimit, StopRecorder{reason:none},
ReleaseMedia through the adapter and discards in-flight
RECORDERSTOPPED/TRANSCRIPTION* results — matching today's
unmount-discards contract (useVoiceToText.ts:75-91), asserted by a
recording-runner dispose test. TOGGLE in acquiring →
ignore("start-in-flight"); MEDIA_ACQUIRED with a stale attempt is
an applied same-state transition emitting ReleaseMedia.startAttemptRef, isStartingRef,
skipOnStopProcessingRef, stopReasonRef, isMountedRef,
recordingTimerRef) outright; the four handle/buffer refs
(mediaRecorderRef, chunksRef, recordedBytesRef, streamRef)
move into the command adapter.driveTransitionMatrix totality (5 flat states — hand
enumeration is trivial and exact); fake-clock duration-limit;
stale-attempt release; useVoiceToText.test.ts kept as characterization.useRef calls; behavior preserved.AbortSignal.any([controller.signal, downloadTimeoutSignal]); (ii) controller.signal.aborted checks after
the download and inside withLock immediately before writeFile
(:210), throwing DyadErrorKind.UserCancelled; (iii) activeControllers
deletion moves to a finally — today only :89 and :217 delete it, so
every failure after the first fetch leaks the entry forever and a later
cancel of the dead job returns {cancelled:true}. Tests: handler vitest
covering abort-at-each-phase and the leak fix.
3b — renderer machine (medium).
src/image_generation/{state,transition,controller,commands}.ts +
ImageGenerationProvider at the layout root (jobs outlive dialogs; the
toast is global). Keyed KeyedControllerHost<string, …> by jobId via
injected IdSource (replacing caller-side crypto.randomUUID(),
ImageGeneratorDialog.tsx:99).
pending | cancelling | succeeded{result, lateAfterCancel?} | failed{message} | cancelled. Events: JOB_SUCCEEDED{result}, JOB_FAILED{message,kind}, CANCEL_REQUESTED, CANCEL_CONFIRMED{cancelled}
(submit is manager-level: creates the controller in pending with a
GenerateImage command). Commands: GenerateImage{params} (runner
converts resolve/reject into events per the expected-failures rule),
RequestCancel{jobId} (IPC result → CANCEL_CONFIRMED; a throw becomes
{cancelled:false} — cancel is best-effort by contract),
InvalidateMediaQueries.cancelling is an applied
transition to succeeded{lateAfterCancel:true} emitting
InvalidateMediaQueries, no success toast, and no delete-file command —
after 3a the only remaining window is IPC latency past the final
pre-write check, so a late success means the file is durably on disk;
deleting would add a second failure mode and an appear-then-vanish in
the library. Transition-cell calls: CANCEL_CONFIRMED in cancelling
is an applied same-state transition (bookkeeping; {cancelled:false}
means the job already settled — stay and await the settle event);
cancelling → cancelled happens solely on
JOB_FAILED{kind:UserCancelled}; the cancelled state keeps a
defensive ignore("already-terminal") for anything else.
CANCEL_REQUESTED in succeeded/failed →
ignore("already-terminal") (fixes the succeeded→cancelled flip,
useGenerateImage.ts:128-131).imageGenerationJobsAtom becomes a read-only view written
by one provider subscription; pendingImageGenerationsCountAtom /
chatImageGenerationJobsAtom stay derived;
dismissedImageGenerationJobIdsAtom remains view-local. All toast
orchestration moves into that single subscription (deleting the four
independent count computations — useGenerateImage.ts:51-53, :111-117,
:136-143; ImageGenerationToast.tsx:16-24).{start, cancel};
ImageGenerationProgressButton.tsx:12-13 and
ImageGenerationProgressDialog.tsx:228 are unchanged projection readers.cancelledJobIds + markCancelled (useGenerateImage.ts:26-34),
the useMutation plumbing and its three status writers,
useCancelImageGeneration's unconditional mapping (:123-152).clock.now(); disposes all on provider unmount.driveTransitionMatrix totality with the late-success cell
asserted applied-not-ignored; recording-runner cancel races; projection
single-writer test.succeeded{lateAfterCancel} with media invalidated — never a silent
orphan.src/mcp_oauth/{state,transition,registry}.ts following the
connection_flow registry precedent (explicitly constructed, injected
Clock/IdSource, commandless derived-effects documented as the deviation,
as connection_flow does). mcp_oauth_flow.ts shrinks to the HTTP-listener
auth() adapter; the entry point (mcp_handlers.ts:422) and the
{success, error} return contract are unchanged.idle | superseding{closing, next} | binding{flowId} | awaitingCallback{flowId} | exchanging{flowId} | connected | failed{message} | superseded | timedOut (terminals clear
the port entry after reporting). Events: CONNECT{flowId, expectedState, serverId}, SOCKETS_CLOSED, BINDS_SETTLED{boundHosts, anyInUse}, AUTHORIZED_SILENTLY, CALLBACK{state, code?, error?}, TIMEOUT{flowId}, EXCHANGE_OK, EXCHANGE_FAILED{message}. Derived
effects: RejectFlow, CloseSockets, Bind, StartTimeout/CancelTimeout, AbortProvider.CONNECT
while any non-terminal state holds the port transitions to
superseding{closing, next} synchronously — the entry is never absent
mid-supersede (today pendingFlows.delete at mcp_oauth_flow.ts:187
opens the window) — and a third CONNECT during superseding replaces
next, rejecting the queued flow explicitly.CALLBACK with
mismatched state in awaitingCallback → ignore("state-mismatch"),
flow stays alive; TIMEOUT for a non-current flowId →
ignore("stale-flow"); the HTTP handler consults a
claimCallback(port, state) accessor mirroring connection_flow's
ClaimReturnResult.provider.abort() coupling: registry.connect() takes an onAbort
callback per flow; AbortProvider fires it on every transition into
failed | superseded | timedOut, replacing the
listener.code.catch(() => provider.abort()) side-channel (:496). The
raced 500ms close fallback (:196-203) moves into the CloseSockets
runner, settling as SOCKETS_CLOSED{forced}.pendingFlows map (:51), PendingFlow, the disposed
closure boolean + map-identity liveness checks (:221-251, :367-399),
the hand-managed 5-minute timeout (:25, :401-408).exploreReachableStates totality (phase-projecting stateKey,
per-state event generator — the superseding×connect combinations are
exactly where the current code went wrong); a triple-Connect
regression; fake-clock timeout-vs-callback exclusivity; existing
mcp_oauth_flow.test.ts + integration suite pass unchanged.src/first_prompt/{state,transition,controller,commands}.ts +
FirstPromptProvider.
PROVIDER_CONFIGURED instead; the machine
emits NavigateHome itself.idle | checkingProviders{payload} | awaitingProviderSetup{payload} | creating{payload} | postCreate{payload, appId, chatId} | dispatching{appId, chatId} | navigating{appId, chatId} | failed{payload, message} | failedPartial{payload, appId, chatId, message}. The armed payload {prompt, attachments, selectedApp, chatMode} is captured into state — replacing the bare boolean
pendingFirstPromptAtom (chatAtoms.ts:70-71) plus the four scattered
payload atoms re-read at resume.SUBMIT{payload}, ARM_FOR_SETUP{payload}, DISARM, PROVIDERS_LOADED{anySetup}, PROVIDER_CONFIGURED, SETUP_DISMISSED, APP_CREATED{appId, chatId}, CHAT_CREATED{chatId}, CREATE_FAILED{message}, POST_CREATE_DONE, POST_CREATE_FAILED{message}, SETTLED, PREVIEW_DECISION{opened}, REFRESHED, RETRY, RESET. The
non-submit arming path is first-class: the setup pill
(home.tsx:473-476 → openAiSetupDialog :178-189) sends
ARM_FOR_SETUP{payload} (idle → awaitingProviderSetup without
creating) and the dialog's close handler (:191-199) maps to
SETUP_DISMISSED/DISARM — preserving the type-prompt → configure →
auto-resume flow the boolean encodes today.CreateApp, CreateChat, RunNeonTemplateHook, ApplyTheme, OpenPreviewIfSetupRequired, SubmitPrompt (facade over streamMessage,
adapter built at the root per the Composition rule, same shape as
layout.tsx:35-46), ScheduleSettle (Clock — the literal 2s at
home.tsx:312-314 becomes injected; test mode passes a zero-delay clock
instead of the isTestMode special case), RefreshQueries, NavigateHome, SelectChat, ShowSetupDialog, ClearEditingBuffer, ShowError.neonTemplateHook/setAppTheme failures land in failedPartial
carrying the created appId/chatId; RETRY resumes at
postCreate/dispatching with the existing app — never a second
createApp. Concurrency: single-flight; SUBMIT outside idle/failed* →
ignore("submission-in-flight") — closing the double-Enter window
through the voice-toggle await gap (HomeChatInput.tsx:123-125).firstPromptSagaAtom {phase, hasArmedPayload} written by the provider subscription; home.tsx's
spinner and all four flag surfaces read it. Editing atoms
(homeChatInputValue, attachments, homeSelectedApp) remain the input
buffer; the machine snapshots them at SUBMIT/ARM_FOR_SETUP and clears
via ClearEditingBuffer at the same commit point as today's :311
comment.send(SUBMIT)), the
openAiSetupDialog arm/disarm pair (:178-199), hasAttemptedAutoResumeRef
shouldOpenAiSetupDialogWhenProvidersLoad (:114-117,
:201-218), pendingFirstPromptAtom, home.tsx isLoading/loadingMode.exploreReachableStates totality (the generator provably
reaches failedPartial, which hand-enumerated matrices tend to omit);
recording-runner sequences for both §2 bug scenarios; home.test.tsx
migrated to the projection; fake-clock settle test.Phase 2 exit criteria:
window.__dyadMachines/getTraceLog; debug.ts deleted.succeeded{lateAfterCancel} + invalidation; cancelledJobIds
gone.useRef in the hook; API unchanged at both call sites.pendingFirstPromptAtom deleted; all four surfaces on the
projection/events; the 2s literal and both resume effects gone;
partial-failure resubmit reuses the created app.pendingFlows gone; triple-Connect regression green;
existing suites unchanged.Sequencing within Phase 2, smallest proof first: (1) trace observer — zero behavior change, instruments everything after it; (2) voice + Clock/IdSource — smallest machine, exercises both facilities; (3a) then (3b) image generation; (4) MCP OAuth; (5) home saga last — largest, spans pages, benefits from every facility proved earlier. Items 2–4 are mutually independent once (1) merges; (5) gates only on (1) and (2)'s Clock.
Landed as: items 1+2a → #4033, 2b → #4037. REMAINING: item 3 (questionnaire port), item 4 (continuation port — its Phase 1 gate is now open), item 5 (deletion + hardening sweep, strictly last).
One machine replaces the three hand-synced copies of the agent-paused
user-input round-trip (evidence in Part 2 §1; Phase 0 banked staging (a) —
see the annotated Staging paragraph there). What remains diseased: the
classifier Promise.race with an injected fake decline (mcp_consent.ts:170,
:182); eight ad-hoc writers to pendingToolConsentsAtom
(registerRendererIpcListeners.ts:75-146, ChatInput.tsx:227/233); no reload
rehydration anywhere; the questionnaire's duplicate renderer deadline
(QuestionnaireInput.tsx:90-105) and nested-setTimeout confirmation fade
(~:203); plan_handlers.ts:199 discarding the resolver's matched result while
integration_handlers.ts:14-22 throws NotFound for the same condition; and
the continuation's write-before-IPC hazard (useIntegrationContinue.ts:53-62).
Entry criteria:
stream-finished signal drives only button state here; it does NOT feed
the main registry (main uses its own stream-end knowledge — see item 4).
Gates item 4 only; items 1–3 do not need it.Design decisions (resolved):
followUpPrompt).
Three instances would triple the respond/get-pending contract surface,
and the per-chat sweep must clear a consent AND a questionnaire in the
same chat atomically — one keyed registry, one sweep. The divergent UI
surfaces (ChatInput banner, QuestionnaireInput panel, DyadAddIntegration
card) are projections filtered by kind; divergence lives in components,
never in transition().chatStream
facade are constructed at the composition root, mirroring
layout.tsx:35-46.TransitionResult commands executed by an injected runner.
Timers/ids via kernel Clock/IdSource."unknown-request", never auto-created.respond dispatches human-decided; if the transition is ignored
("already-settled"/"unknown-request") the handler throws
DyadErrorKind.NotFound. Renderers show confirmation UI only on a
successful respond; on NotFound they re-read the projection and toast
"request expired".chat-swept(chatId) — they fire on cancel/
abort/cleanup paths. stream-finished(chatId) is emitted from the
handler's natural-completion unwind only. Armed entries on chat-swept
settle as swept WITHOUT dispatch (a cancelled stream must not
auto-continue); armed entries advance only on stream-finished.Generic core (enumerated once). Per-request state:
awaiting(classifier: none | racing | review) → armed(followUpPrompt)
(follow-up kinds only) → due(followUpPrompt) (stream finished; dispatch
owed) | settled(outcome: human | classifier-approved | timed-out | swept | superseded | dispatched). Events: requested(descriptor),
human-decided(response), classifier-decided(approved, reason)
(classifier failure maps to approved:false — fail closed to review, as
today), timed-out(requestId), chat-swept(chatId),
stream-finished(chatId), follow-up-dispatched(requestId). Commands:
broadcast-requested, broadcast-classified, broadcast-settled(outcome)
(the every-settlement-path terminal event — rules/electron-ipc.md:93,
banked by 8ad56501c for consents; the machine generalizes it),
broadcast-follow-up-due(chatId, prompt), resolve-park(requestId, value),
persist-always(kind payload), schedule-deadline(requestId, ms),
cancel-deadline(requestId). Deadlines stay per-kind (5 min consents/
questionnaire, 30 min integration) with exactly one source: the registry
clock. Bug-4 window closed properly: armed → due on stream-finished
(broadcasting follow-up-due); the entry settles as dispatched only when
the renderer acknowledges via the follow-up-dispatched respond leg — and
getPending returns due entries, so a renderer that reloads mid-dispatch
re-dispatches idempotently on remount.
Bug → design element map (Part 2 §1): (1) reload-invisible consent →
getPending + projection hydration on mount; (2) decline dropped after
classifier-approve → single settlement transition: classifier-decided and
human-decided are correlated events, first applied wins, the loser is
ignore("already-settled") surfaced via NotFound + trace observer — the
fake-decline injection ceases to exist; (3) answers lost with positive
feedback → one deadline source, broadcast-settled(timed-out) removes the
UI the moment main times out, confirmation renders only on successful
respond; (4) continuation stalls after reload → armed/due are main-owned
states returned by getPending, arming happens inside the respond
transition atomically before the park resolves.
src/user_input/ machine core + IPC contract (large).
state.ts/transition.ts (pure, kind-discriminated descriptor union
for mcp-consent | agent-consent | questionnaire | integration),
commands.ts (types + main effects runner), registry.ts (explicitly
constructed; injected Clock/IdSource/broadcast/runner;
park(requestId) returns the promise the tool awaits;
request/respond/sweepChat/streamFinished/getPending/dispose API;
dispose() = abortAll for before-quit). Duplicate requested for a
live requestId supersedes (old park resolves null, outcome
superseded — matches user_input_resolver.ts:44-45).src/ipc/types/user_input.ts: respond
(user-input:respond, throws NotFound on ignored decide), getPending
(returns pending/armed/due descriptor snapshots incl. deadlineAt —
precedent: connection_flow's getStates); events requested,
classified, settled, followUpDue. Register in ipc_host; add
src/user_input/ to the boundaries inventory. No consumers wired yet.exploreReachableStates (all orderings
of human/classifier/timeout/sweep); registry.test.ts with fake
Clock/IdSource + recording runner (deadline single-source, cross-kind
chat sweep, park/resolve mapping, supersede, due-entry idempotence,
dispose).requireMcpToolConsent
(mcp_consent.ts:98-198) becomes stored-consent check +
registry.request(...); the classifier hook dispatches
classifier-decided — DELETE the Promise.race block (:135-197 collapses
to ~20 lines). requireAgentToolConsent (tool_definitions.ts:260-312)
likewise; DELETE both resolver instances (mcp_consent.ts:13-36,
tool_definitions.ts:164-196); rewire the five sweep sites to
registry.sweepChat. Legacy consent channels keep emitting (dual
emission) until item 5. Race test: settle classifier-decided(approved)
then invoke respond with Decline in the same tick — tool proceeds,
respond rejects NotFound, observer logs ignore("already-settled").
Standalone-revertable.
2b — renderer projection + rehydration (medium): one subscription in
src/user_input/projection.ts (mounted from registerRendererIpcListeners)
is the SINGLE writer of a new userInputRequestsAtom; on mount it calls
getPending (merge: events during hydration win by requestId, per the
Persistence rule). pendingToolConsentsAtom becomes a derived read-only
view during migration; ChatInput's optimistic remove/rollback
(ChatInput.tsx:227/233) moves into the adapter as a
respondingRequestIds overlay so single-writer holds. DELETE the five
consent listeners (registerRendererIpcListeners.ts:73-140) plus the
consent leg of the stream-end sweep (:143-146). Repoint
useNotificationHandler's consent legs (agent.onConsentRequest :340,
mcp.onConsentRequest :354, mcp.onConsentClassified :369) at the new
requested/classified events — OS notifications must keep firing.
Rehydration test: unmount/remount the harness renderer mid-consent;
getPending resurfaces the banner and clicking still resolves the park
(extend src/ipc/handlers/tests/local_agent_consent.integration.test.tsx).questionnaire); plan_handlers.ts:196-201's
respondToQuestionnaire is DELETED in favor of unified respond (NotFound
semantics — the bug-3 fix). DELETE the renderer 5-min timer
(QuestionnaireInput.tsx:90-105), the nested fade timeouts (questionnaireSubmittedChatIdsAtom (planAtoms.ts:37): the confirmation
renders from a short-lived settled entry the projection adapter retains
(settledAt + one adapter-owned timer + CSS transition).
pendingQuestionnaireAtom becomes a derived view; DELETE the
plan:questionnaire listener leg in usePlanEvents.ts:74-82 and the
sweep leg (registerRendererIpcListeners.ts:147-152). Repoint
useNotificationHandler's questionnaire leg (settled(timed-out) broadcast clears the panel;
a subsequent submit gets NotFound and shows no confirmation.followUpPrompt; respond transitions awaiting → armed
atomically before the park resolves; the registry's own
stream-finished (main-side, natural completion only — see the feeding
decision) moves armed → due and broadcasts follow-up-due; the
renderer adapter submits through the injected chatStream facade and
acknowledges via follow-up-dispatched. DELETE
useIntegrationContinuation.ts entirely and its mount site,
pendingContinuationProviderAtom, the integration sweep leg
(registerRendererIpcListeners.ts:153-158); pendingIntegrationAtom
becomes a derived view — which requires deleting useIntegrationEvents.ts's
atom write (:33-37) and re-homing its showUserInputNotification call
(:39) onto the new requested event; useIntegrationContinue shrinks to
a respond call + projection reads. Tests: respond → renderer remount →
getPending returns the armed/due entry; stream end → follow-up
submitted exactly once through a fake facade; reload between Continue
and stream end no longer loses the message (bug 4).registry.dispose() into before-quit next to the
stream abort (chat_stream_handlers.ts:554); wire the trace observer.
Exit greps cover both the old atoms AND the old channel names.Phase 3 exit criteria:
src/user_input/; the resolver
instances and legacy consent paths deleted.Promise.race/fake-decline gone; the race is a transition-matrix
test.getPending rehydrates after reload (integration-tested);
OS notifications still fire (useNotificationHandler on new events).src/user_input/; verification block
green plus npm test -- src/user_input/ and the consent integration
suite.Sequencing within Phase 3: item 1 gates everything. 2a → 2b → 3 (item 3 rebases on 2b's projection plumbing); item 4 additionally waits on Phase 1 PR 3 and can land any time after that gate opens; item 5 strictly last. Items 2–4 may interleave with Phase 4 work.
Landed as: PR A → #4027 (driver), PR B → #4031 (protocol + model + suite +
tripwire + compaction scenarios). Stage 2 (item 7) remains unscheduled —
but note both of its entry gates are NOW SATISFIED (cosim suite green;
streamId echo landed as #4023), so opening it is a decision to make, not a
dependency to wait on. Still open from item 6's audit: the two compaction
chores (thread the abort signal into performCompaction; single-flight
the pendingCompaction flag).
Build the cross-process stream protocol as a checked artifact instead of
comments: a pure shared protocol module, a pure model of main's stream
lifecycle grounded line-by-line in chat_stream_handlers.ts, and an
interleaving co-simulation harness that drives that model against the
renderer's real transition() — reproducing the #4008 deadlock class
before review instead of after release. Stage 1 requires zero production
behavior changes (the one allowed edit is type-only, item 1); stage 2 is
specified only as a gated follow-on (item 7). The co-simulation harness is
a pre-authorized named facility under the Risks section's kernel-freeze
guardrail.
Entry criteria: none. Stage 1 does not depend on Phase 1 — the driver
detects ignores structurally (same reference + zero commands) and consumes
the machine's local TransitionResult; Phase 1's kernel-types migration
changes neither the state/event alphabet nor the harness API (only the
suite's ignore assertions may tighten once reasons exist).
src/chat_stream/protocol.ts — shared protocol module (small).
@/ipc/types (type-only imports keep it pure), the FIFO delivery
assumption (Electron delivers webContents.send in order — an
explicit protocol assumption the co-sim encodes as single-queue
delivery), and the invariants I1-I4 as named doc-commented constants.cancelTrackedStreams is the SOLE wasCancelled:true sender
(chat_stream_handlers.ts:370, grep-pinned); the handler emits at most
one non-cancelled end, and MAY emit error+end together on the
apply-error path (:2174-2190); the handler's !aborted guard at :2122
is not re-checked before its end emission, so a cancel landing during
the post-:2122 awaits legally yields cancel's early terminals PLUS a
late handler end; the outer catch emits error even when aborted
(:2202-2208). Late/duplicate handler terminals after cancel are LEGAL
protocol behavior — the renderer machine is explicitly built to
tolerate them (finalizing/errored ignore stream-ended,
transition.ts:311/:337).src/chat_stream/, NOT the
kernel — the kernel-purity test forbids kernel files importing
@/ipc/types (boundaries.test.ts:84-87), while main handlers importing
src/chat_stream/protocol.ts is legal (handlers aren't a policed
machine dir; precedent connection_flow_handlers.ts:11-16).satisfies ChatResponseEnd pattern already exists at
:371/:2190/:2196).src/chat_stream/main_model.ts — the pure main-side model (medium).
tracked (completion
registered :587, controller + admission marker :601-602, before the
first await at :616) → admission-pending (the while-loop :632) ↔
waiting-chat-barrier (:633-644) ↔ waiting-app-barrier (:657-669,
re-loops) → admitted (barrier-check + marker-clear + start-emit is
ONE atomic model action — :685-692 has no intervening await) →
streaming (each await an interleaving point; abort observed at
:537/:2102/:2122 — plus the post-:2122 awaits as explicit points, per
item 1's contract) → unwinding-completed | unwinding-errored | unwinding-aborted → finalized (finally :2211-2236: untrack,
chat:stream:end iff not aborted :2223-2225,
resolve-completion-before-untrack :2231-2236).tracked, though validation
failure (:592-597) leaves a completions-only entry — with the two
call-site asymmetries noted (cancelTrackedStreams includes
completions-only chats :329-338; cancelActiveStreamsForApp excludes
them :409-415).request-received, barrier-installed{scope} /
barrier-released{scope}, cancel-chat (compound, modeling
cancelTrackedStreams :325-382: abort every tracked controller of the
chat INCLUDING admission-pending ones, emit end{wasCancelled}+stream:end
immediately :366-373, then block on completions), cancel-app
(chat-granularity, modeling :409-415 + :346-353: select the app's
chats having ≥1 non-admission-pending controller, then abort ALL
controllers of the selected chats — pending included; chats whose
controllers are all admission-pending, and completions-only chats, are
skipped), llm-settled{completed|errored|aborted}, handler-unwound,
quit (:554-566).wasCancelled:true is emitted only by the cancel action, and
finalized never emits chat:stream:end for an aborted stream —
duplicate non-cancelled handler terminals stay legal per item 1.
I3 (early-notify safety): after cancel's early terminals, no
stream reaches admitted for a barred scope until release. I4
(barrier hygiene, stated as its checkable consequence): given all
injected barriers released, at quiescence both block-count maps are
empty and no stream is parked in waiting-* (exercises the
waiter-wakeup logic :219-234/:253-258 and the re-loop); bracket
placement in callers is out of model scope — covered by
version_handlers' try/finally and item 5's tripwire.exploreReachableStates, one
scenario per invariant, quit-at-every-state smoke.src/state_machines/cosim.ts — generic interleaving driver (medium).
./types, passing the kernel boundary check. API:
runCosim({ participants, channels, scenario, assertions, maxSchedules }) — participants are structural
(state, event) => { state, commands } (compatible with kernel and
local result shapes; ignores detected structurally); channels are FIFO
queues; the scheduler enumerates interleavings by DFS over enabled
actions (advance a participant, deliver a channel head, inject a
scenario action) with a seen-set on (state keys, channel contents), a
maxSchedules bound, and shortest-failing-schedule reporting (the
failing trace as an ordered action list a reviewer replays by hand).src/chat_stream/__tests__/cosim.chat_stream.test.ts.
transition() with the adapter's
event mapping replicated exactly: chat:stream:start → registered;
chunk → chunk-received{streamId}; end → stream-ended{streamId};
error → stream-errored{streamId}; finalize-complete{ok} scheduled
by the driver as a separate step after run-end-side-effects (its
delivery position is itself an interleaving point — submits during
finalizing must enqueue). Renderer commands feed back into the
model: start-stream → request-received, request-abort →
cancel-chat, dispatch-next-queued → counted, enqueue-message →
scenario queue.isStreamActive is false; (b) at most one
dispatch-next-queued per generation (the finalizing→idle
single-dispatch guarantee); (c) the FIRST terminal carrying the
current generation must advance the machine; subsequent
same-generation terminals must be ignored with no state change (the
legal-duplicates contract from item 1, as a positive assertion);
(d) model invariants I1-I4 hold.cancelling{registered:false} → model cancel path emits
end{wasCancelled} with chat:stream:start never sent → renderer must
finalize (transition.ts:235-259). Paired with a harness self-test: a
deliberately mutated transition that waits for registered before
finalizing (the pre-3ac500962 bug) must be reported as a deadlock with
a minimal trace — proving the harness detects the incident class.src/chat_stream/__tests__/cosim.*.test.ts, so the plan's standard
verification command and default CI already run it.src/ipc/handlers/__tests__/chat_stream_protocol_drift.test.ts,
AST-based source assertions (boundaries.test.ts already parses
production sources with the TS compiler API), pinning: (a) no await
between the app-barrier check and admissionPendingStreams.delete
(:657-686); (b) wasCancelled: true appears in exactly one production
emission site (:370); (c) the finally-block chat:stream:end is
guarded by !abortController.signal.aborted (:2223-2225). Each
failure message names main_model.ts and the cosim suite as the things
to update in the same PR. A banner comment on the four grounded
handler regions points at the model.streaming — no new model states. Two scenarios encoded, two
findings recorded as NEW chores (not stage-1 code changes):
performCompaction receives no abort signal
(compaction_handler.ts:189-210, loop :220-223), so an aborted stream
keeps summarizing and broadcasts completion after the renderer was
told the stream ended. Model as legal (the handler's completion
resolves only after unwind, so restore/delete stay safe); record the
UX gap (Stop latency = full summary generation) as a chore: thread
abortController.signal into performCompaction.pendingCompaction=true (local_agent_handler.ts:574) before either
clears it, inserting two summaries. Encoded as a model scenario
documenting current behavior; chore candidate:
clear-flag-before-summarize or single-flight per chat.Phase 4 exit criteria:
Sequencing within Phase 4: item 1 first (everything imports it); items 2 and 3 in parallel; item 4 gates on both and is the bulk; items 5 and 6 branch off item 4's skeleton. Item 7 is a recorded gate, not work. Nothing here blocks or is blocked by Phases 1–3.
Both entry criteria are now met (#4025 folded the merge-conflict stream; #4029/#4026 landed Clock/IdSource and the trace observer). Every item below remains to do.
The two large renderer machines (github_ops; preview iframe
identity/navigation/picker), the screenshot machine sharing the iframe's
postMessage adapter, an honestly re-scoped Neon item, and the
trigger-gated tail. Phase 0 already banked the biggest §6/§8 symptoms:
PreviewIframe is keyed ${selectedAppId}-${key} (PreviewPanel.tsx:230), so
the cross-app leaks (picker state, history, preserved-route bleeding into
app B) are FIXED — only same-app reloadKey leaks remain;
pendingScreenshotAppIdsAtom is per-app; and the five Neon mutation
handlers are whole-op serialized via createAppMutationLock
(neon_handlers.ts:51-63), demoting the Neon machine to UI consolidation.
These specs are written against that reality.
Entry criteria:
chatStream facade; today useResolveMergeConflictsWithAI
still streams directly).createTraceObserver (wired into every machine built here).SerializedIpcError has no code
field (core.ts:167-172), serializeIpcError drops it (:214-233), and
deserializeIpcError discards name for any error with a valid
DyadErrorKind (:242-253), so GitStateError.code
(git_utils.ts:2339-2350) never crosses the boundary and the err?.code
checks (GitHubConnector.tsx:253-256, GithubBranchManager.tsx:205)
always see undefined — which is why the probe and substring fallbacks
always run.code?: string to SerializedIpcError, copy it in serialize, restore
both name and code in deserialize (every domain gains coded errors
for free). Extend GIT_ERROR_CODES (git_utils.ts:2353-2357) with
MERGE_CONFLICT, NON_FAST_FORWARD, DIVERGENT_BRANCHES,
UNCOMMITTED_CHANGES; main parses git output once (the substring
checks at github_handlers.ts:864-875 and git_branch_handlers.ts:218-230
move into git_utils as coded throws).error.code === "REBASE_IN_PROGRESS"
across a real round-trip.handlePushToGithub runs setRemote+pull+push
unlocked (:807-907) and git_branch_handlers locks the clean-check but
runs gitCheckout/gitMerge outside it (:208/:211-215, :309/:312-316).
Justification: version_handlers, chat_handlers, proposal_handlers, and
app_handlers already take withLock(appId) for their git writes —
main's lock is the only place UI ops, stream auto-commits, and restore
barriers all meet; a renderer machine can never exclude the non-UI
writers.createAppMutationLock (Neon precedent, neon_handlers.ts:51-63):
push, rebase, rebaseAbort/Continue, disconnect, createRepo,
connectExistingRepo, mergeAbort, pull, fetch,
create/delete/switch/rename/mergeBranch. withLock is
non-reentrant (lock_utils.ts:29-48): every inner withLock(appId)
reachable from a wrapped handler is removed in the same PR — all four:
github_handlers.ts:198, github_handlers.ts:967-969,
git_branch_handlers.ts:208, :309 — and prepareLocalBranch (called
from the now-wrapped createRepo/connectExistingRepo) drops its
internal lock. Reads (getGitState :992-1004, getConflicts, list*)
stay unlocked so reconcile probes never queue behind a long push.
Cost accepted: a network push holds the app's lock for its duration —
per-app only, the tradeoff Neon shipped.withLock(appId).src/github_ops/{state,transition,controller,commands}.ts +
useGithubOps, keyed by appId on a provider-owned host.
idle | running(op, next?) | conflicted(files, origin) | rebase-paused | switch-blocked(target, blockingOp, hasConflicts),
with a banner context field ({kind, code?, message} | null)
replacing syncSuccess/syncError/rebaseStatusMessage. op enumerates:
push(normal|force|lease) | pull | fetch | rebase | rebase-continue | rebase-abort | merge-abort | merge(branch) | switch(branch) | create-branch(name, from, thenSwitch) | delete-branch | rename-branch | disconnect | connect-repo(create|existing, thenAutoPush).OP_REQUESTED(op), OP_SUCCEEDED(op), OP_FAILED(op, {code?, kind, message}), CONFLICTS(files), GIT_STATE(mergeInProgress, rebaseInProgress) (the reconcile event — getGitState results become
data, not control flow), ABORT_AND_SWITCH_CONFIRMED,
BLOCKED_DISMISSED, RESOLVE_WITH_AI_STARTED, BANNER_DISMISSED,
RECONCILE_REQUESTED (mount/app-focus; answered by probe-git-state - probe-conflicts). Commands: run-op, probe-git-state,
probe-conflicts, invalidate-branches, refresh-app, notify,
start-conflict-resolution(files) via the injected chatStream facade.running(rebase, next: push) on OP_SUCCEEDED enters running(push), deleting the
mid-composite isSyncing clear (handleSyncToGithub's finally at
GitHubConnector.tsx:294-296, reached mid-composite via the call at
:365 inside handleRebaseAndSync :359-400); (b) OP_FAILED dispatches on
code only (item 1); (c) OP_REQUESTED while running →
ignore("op-in-flight"), surfaced as disabled buttons from the
projection — next is reserved for transition-driven composites,
never user-enqueued; (d) auto-sync-after-link is the thenAutoPush
flag on connect-repo — deleting the lastAutoSyncedAppIdRef
edge-detector (:117, :403-434) and the triggerAutoSync prop pair;
(e) both components become projections of one snapshot; (f) branch
inventory stays a TanStack query invalidated by invalidate-branches
— the machine owns operation lifecycle, not data fetching; (g) the
UnconnectedGitHubConnector device-flow + repo-setup form stays out
(device flow is connection_flow's; the form is dialog-local state).conflicted → running(switch) in one snapshot (no stale
conflict store for Resolve-with-AI to read); banner clears on every
history-invalidating OP_REQUESTED.switch-blocked).disposeKey at BOTH app-deletion sites (apps.tsx:162-167
and app-details.tsx:200-202).exploreReachableStates; recording
runner; projection stability; e2e suites as characterization.grep -n 'includes("rebase\|divergent' in src/components
returns nothing; both components contain zero operation-lifecycle
useState.history,
position, currentUrl, preservedUrl, iframeEpoch,
selectorReady, picking, restoreQueued. It EXCLUDES: the
screenshot pipeline (item 5), visual-editing toolbar + AST state,
annotator mode, cloud-sandbox banner, device-mode UI, console/network
forwarding — all stay component-side.reloadKey remount STILL leaks — handleReload (:1455-1489) bumps
reloadKey keying only the inner <iframe> (:2112), resetting neither
isPicking (:287) nor isComponentSelectorInitialized (:881, no
reset), so a same-app reload shows an active picker over a
selector-less document (double-toggle to recover); the
isRestoringQueuedSelectionAtom handshake still leaks (effect
:720-746 early-returns at :722 without clearing); canGoBack/Forward:
recompute effect :1279-1280 plus writers :1290-1291, :1369-1370,
:1421-1422, :1527-1528; currentIframeUrlRef ~10 writers read inside
the iframeSrc memo with pseudo-trigger deps (:286, :1582-1599); five
copy-pasted preservedUrls blocks; prevAppUrlRef (:1284-1295)
survives as a within-mount edge detector.iframeEpoch replaces reloadKey;
<iframe key={epoch} src={selectSrc(snapshot)}>; the PreviewPanel
${appId}-${token} key stays as component identity, and token bumps
(producers: app_run/commands.ts:75,180,183; chat_stream/commands.ts:466)
surface as IFRAME_REPLACED{reason:"external"} on mount;
(b) IFRAME_REPLACED{reason:"external"} truncates history to
[currentUrl] — recorded decision preserving today's semantics
(history does NOT survive token remounts; only the current route is
restored, as preservedUrls does now); (c) RELOAD_REQUESTED bumps the
epoch and hard-resets selectorReady + picking — reload
deliberately drops picking, matching remount semantics and killing the
double-toggle bug; (d) canGoBack/Forward become selectors;
(e) restoreQueued is machine state: dispatchable only when
selectorReady, preserved across IFRAME_REPLACED, cleared only by
SELECTION_RESTORED — the leak becomes unrepresentable;
(f) preservedUrl is just currentUrl retained by the surviving
controller — DELETE previewCurrentUrlAtom and all five mutation
blocks, with the machine absorbing the per-app entry cleanup that
clearPreviewRuntimeForApp does today (previewRuntimeAtoms.ts:367);
(g) APP_URL_CHANGED (hook subscription) replaces prevAppUrlRef.APP_URL_CHANGED(url), NAVIGATE(path), NAVIGATED_IN_APP(kind, url), GO_BACK, GO_FORWARD, RELOAD_REQUESTED, IFRAME_REPLACED(reason), IFRAME_LOADED, SELECTOR_READY, PICKER_TOGGLED, SELECTION_RESTORE_QUEUED, SELECTION_RESTORED. Commands:
post-to-iframe(msg), clear-preview-error. The window message
listener becomes a thin adapter in the hook routing postMessage types
to this machine and item 5's — no machine-to-machine import.disposeKey at both app-deletion sites (apps.tsx:162-167,
app-details.tsx:200-202).reloadKey, prevAppUrlRef, currentIframeUrlRef, the
iframeSrc memo, canGoBack/Forward state+effect, preservedUrls
plumbing, isRestoringQueuedSelectionAtom.grep -n reloadKey returns nothing.idle → pending(source) → waitingSelectorReady → settling → resolvingCommit → awaitingResponse(requestId) → saving → idle; events CAPTURE_REQUESTED(source), SELECTOR_READY, SETTLE_ELAPSED (injected Clock), COMMIT_RESOLVED(hash), RESPONSE(requestId, ok, dataUrl?) (stale → ignore("stale-request")),
APP_HIDDEN (keeps pending, preserving today's resume-on-return),
SAVED. Commands: resolve-commit-hash, post-capture-request,
save-screenshot + invalidations, check-existing-screenshots.pendingScreenshotAppIdsAtom
stays as the producer-facing request INBOX — chat_stream/commands.ts:467
and useCommitChanges keep writing it (no machine imports another's
controller); the screenshot hook adapter subscribes, dispatches
CAPTURE_REQUESTED, and clears the consumed entry. The atom's role
changes from tracked state to consumed mailbox, documented in the
machine header.disposeKey at both app-deletion sites.captureTimeoutRef, the
SCREENSHOT_CAPTURE_DELAY_MS timeout plumbing.unlinked → linking(create|connect) → linked(activeBranch) → switching-branch | unlinking; events
LINK/SWITCH/UNLINK REQUESTED/SUCCEEDED/FAILED + RECONCILE(appRow);
commands are the existing contract calls + invalidation. Build
trigger: the next scheduled Neon feature PR, or the first NeonConnector
state bug.hydrateChatTabSessionAtom wholesale-replaces the tab sets
(chatAtoms.ts:211-213), evicting chats opened before the initial query
resolves; merge instead. (The notification-dot detector migration is
Phase 1 PR 4's — handshake 2.) The full §10 machine waits for its
trigger: first tab-session hydration bug after the merge fix, or when
a persistence-convention reference implementation is wanted.appId:filePath
(clean → dirty → saving → saving-dirty); events CONTENT_LOADED,
EDITED, SAVE_REQUESTED(blur|button), SAVE_SUCCEEDED(warning?),
SAVE_FAILED; enqueueFileSave (fileSaveQueue.ts:7-27) stays the
executor as a command runner; replaces the six refs + two mirrored
useStates (FileEditor.tsx:143-154) and the re-dirty check (:291-296).
Trigger: next dirty/save bug, or the first second consumer of
fileSaveQueue.Phase 5 exit criteria:
code and restores name; git handlers throw
coded errors; zero error-substring parsing in the two components.Sequencing within Phase 5: items 1 and 2 are independent, parallel, and both gate item 3 (the machine dispatches on error codes and assumes whole-op main-side exclusion). The GitHub track (1→2→3) goes first among the big machines: its precursors are main-side and ready, it deletes the most duplicated recovery logic, and it closes the remaining verified §4 bugs — the iframe machine's urgency dropped after Phase 0 killed the cross-app leaks. Items 4 and 5 share one design doc and one postMessage adapter (build 4, then 5 against 4's adapter); they can start in parallel with the GitHub track once Phase 2's Clock/IdSource exists. Items 6 and 7 are trigger-gated and never block phase exit.
Per phase, narrowest first, then the standard checks:
npm test -- src/chat_stream/ src/state_machines/
npm test -- src/plan_handoff/ src/app_run/ src/connection_flow/ src/version_preview/
npm test -- src/ipc/handlers/__tests__/queued_message.integration.test.tsx \
src/ipc/handlers/__tests__/pause_queue.integration.test.tsx
npm run fmt && npm run lint && npm run ts
npm test
Machine PRs additionally follow rules/state-machines.md's test requirements (totality, reference stability, fake runners, constructed-owner isolation); Phase 4 adds the co-simulation suite as a required gate for any change to chat_stream transitions or main's stream handlers.
disposeKey on chat deletion (plus
documented quiescence GC), observer wired, zero external streams writing
its projection, zero cross-machine module imports.stream-finished signal exists and prevStreamingRef /
chatStreamCountByIdAtom / the ChatPanel store.sub edge detector are gone.