Repository navigation
fix(workspace): seed pending conditions when a window adopts, restores or wakes a session - #1083
Conversation
…s or wakes a session Fixes #895. A permission or question pending when a window closed disappeared from the adopting window. `useWorkspaceAdoption` seeded adopted runtimes from `emptyRuntime()`, whose `conditions` is null, and providers publish conditions only when they CHANGE — the OpenCode Terminal package and claude-code-headless both deduplicate — so nothing ever re-sent them. Dispatch lost ACTION/QUESTION and orchestration summaries stopped naming the blocker, while the raw TUI still showed the prompt. Main already cached the snapshot (`lastConditionsSnapshot`) and no renderer path read it. It now rides `SessionBackendSnapshot`, which is already the level-triggered fact a late renderer seeds from and which adoption already fetches — one round trip, so readiness and conditions cannot be seeded from two different instants. The issue named adoption and recovery; there are THREE seeding sites with the same hole, and all three are fixed: - adoption (`useWorkspaceAdoption`), - cold restore (`rehydrate`, where `base` is `emptyRuntime()`), - waking a parked session (`session.ts`, where the spread predates the recovery). Two rules the tests pin: - A snapshot the LIVE channel has already written wins on `ts`. This is not hypothetical on the adoption path: session routing moves to this window before the offer arrives, and `onSessionConditions` creates a runtime for a session it has never seen, so a prompt dismissed while main's cached snapshot is in flight is already in the map. Seeding over it would put the dismissed prompt back in the surface the user acts on. - The seed goes through the same projection the live channel uses, so the composer picker cannot disagree with the conditions beside it. That is why `applyConditionSnapshot` moved out of `useIpcSubscriptions` into `session-runtime/conditions.ts`: there are two producers now, and a second copy of the projection is how one of them would go stale. Tests, all failing first, one per path plus the main-side source: `useWorkspaceAdoption` (seed, in-flight clear, picker projection), `rehydrate` (seed, in-flight clear), `sessionRecovery` (wake), and `sessionManager.wake` — the last because every renderer test seeds a STUBBED snapshot, so without it a production snapshot that omits the field leaves all of them green. Mutation-tested, 9 mutations, no survivors: each of the three seeding sites reverted, ignoring what the live channel wrote, a tie going the wrong way, dropping the held snapshot, inverting the comparison, skipping the picker projection, and main omitting the field. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…d of ordering a second producer Review findings on #1083. The first attempt was built on the wrong substrate, and this replaces it rather than patching it. It carried the cached snapshot on `SessionBackendSnapshot` — an INVOKE reply — and ordered it against live events by comparing `ts`. The review showed that ordering cannot be made right: - `ts` is `Date.now()` at 1 ms resolution, and the two snapshots being compared are two entries of ONE monotone sequence in main, so an equal `ts` means "we cannot tell", not "the same facts". OpenCode calls `emitConditionsSnapshot` from five sites with no dedupe latch, so a permission set and answered inside one millisecond produces exactly that tie — and the tie rule put the ANSWERED PROMPT BACK on screen, where pressing "1" writes a raw keystroke into a live agent's composer. - The seed also diverged from the live fold on unread, attention and the feed-debug log, so an adopted blocked agent never showed NEW after its prompt was answered. - And it was a second producer of `runtime.conditions`/`picker`, which is the shape the projection was extracted to prevent. Main already had the answer, and `session:resync-routing` has been using it: re-emit the cached snapshot on the ORDINARY `session:conditions` channel. There is then nothing to order — main updates its cache before forwarding, so the cache is never older than what the renderer has folded, and the re-emit is just the next event in the same stream. The renderer's one handler applies the projection, the unread mark and the debug log exactly as it does for a live change. `session:reseed-conditions` takes the session ids and checks ownership per session. The renderer asks for it once its runtimes exist — after adoption seeds, after rehydrate publishes, after a wake settles — because every one of those paths rebuilds from `emptyRuntime()` and would otherwise overwrite what it just received. It is a hint, not a step: a preload without it still restores the workspace. Also from the review: - FINDING 2 (high): the adoption seed clobbered an EXIT observed while `getBackendSnapshot` was in flight, repainting a dead agent as `started` with an enabled composer. Both sibling seed sites already guarded this; adoption did not, and the first attempt had hardened only `conditions`. - FINDING 5: the live `session:conditions` handler — the projection's only call site — had NO coverage, so dropping the picker projection there left the suite green. It has a test now, and so does the fold itself, including that the picker kind comes from provider policy. Mutation-tested, 9 mutations, no survivors: the live channel dropping the projection, a hardcoded picker kind, adoption never asking, adoption asking BEFORE it seeds, the exit guard dropped, main skipping the ownership check, and main sending an empty snapshot when it has none. 169 files / 1220 tests pass. `npx tsc -b` clean on Node 24. Fixes #895 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
This review changed the design rather than the code. Finding 6's parenthetical — "main already has a seeding path for this cache… it reuses the one fold and needs no ordering key, because the re-emit is just another event in the same ordered stream" — is the whole answer, and I had walked past The substrate was wrong (findings 1, 3, 4, 6, 7)I was carrying the snapshot on an invoke reply and then hand-ordering it against live events. That cannot be made right:
That removes, rather than fixes:
The renderer asks after its runtimes exist — adoption after its seed, rehydrate after it publishes, a wake after it settles — because all three rebuild from 2. The adoption seed clobbered an observed exit (HIGH) — valid, fixedRight, and the sharp part is that I had established the live channel writes during the fetch and then hardened exactly one field. Both sibling sites already had 5. The live channel had zero coverage (MEDIUM) — valid, fixedThe most useful finding for the long run: the module was justified by "two producers must project identically", and the original producer's call site was untested, so dropping the projection there passed. There are tests now for the fold itself — including that the picker kind comes from Mutation resultsYour count was right and mine was wrong: I had reported "no survivors" for a battery of five mutations I chose, which is a statement about my test list, not about the code. Nine now, chosen from your findings:
No survivors. Also confirmedThank you for verifying the six rationale claims rather than taking them — 169 files / 1220 tests pass across unit, renderer and system. |
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ened Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Fixes #895.
The bug
A permission or question pending when a window closes disappears from the adopting window.
useWorkspaceAdoptionseeds adopted runtimes fromemptyRuntime()—conditions: null— and providers publish conditions only when they change (the OpenCode Terminal package and claude-code-headless both deduplicate), so nothing ever re-sends them. Dispatch loses ACTION/QUESTION and orchestration summaries stop naming the blocker; the raw TUI still shows the prompt.Main already cached the snapshot in
lastConditionsSnapshot. No renderer path read it.The fix
conditionsridesSessionBackendSnapshot— already the level-triggered backend fact a late renderer seeds from, and already fetched during adoption. One round trip, so readiness and conditions cannot come from two different instants.The issue named adoption and recovery; there are three seeding sites with the same hole, and all three are fixed:
useWorkspaceAdoptionemptyRuntime()rehydratebaseisemptyRuntime()on a cold restoresession.tswakeTwo rules the tests pin
A snapshot the live channel already wrote wins on
ts. Not hypothetical on the adoption path: session routing moves to this window before the offer arrives, andonSessionConditionscreates a runtime for a session it has never seen (prev[sessionId] ?? emptyRuntime()). A prompt dismissed while main's cached snapshot is in flight is therefore already in the map, and seeding over it would put the dismissed prompt back — in the surface the user acts on. Ties go to what is already there.The seed uses the same projection the live channel uses.
conditionsis not the only thing that projection owns — the composer picker is derived from the same snapshot. That is whyapplyConditionSnapshotmoved out ofuseIpcSubscriptionsintosession-runtime/conditions.ts: there are two producers now, and a second copy is how one of them goes stale.Tests — all fail first
One per path, plus the main-side source:
useWorkspaceAdoption.renderer.test.tsx— seed, in-flight clear, picker projectionrehydrate.renderer.test.ts— seed, in-flight clearsessionRecovery.renderer.test.tsx— wakesessionManager.wake.test.ts— the important one: every renderer test seeds a stubbed snapshot, so if the production snapshot omitted the field they would all still pass.Mutation results
conditionsfrom the snapshotNo survivors.
npx tsc -bclean on Node 24.--project unit --project renderer src/renderer/src/workspace/ src/renderer/src/session-runtime/ src/main/sessionManager.wake.test.ts: 1144 passed.🤖 Generated with Claude Code