Skip to content

fix(host): rescue the edit barrier when an applyEditSettled transition throws - #406

Merged
mtskf merged 12 commits into
mainfrom
fix/commit-transition-throw-strands-write-lock
Sep 9, 2026
Merged

fix(host): rescue the edit barrier when an applyEditSettled transition throws#406
mtskf merged 12 commits into
mainfrom
fix/commit-transition-throw-strands-write-lock

Conversation

@mtskf

@mtskf mtskf commented Sep 9, 2026

Copy link
Copy Markdown
Owner

Summary

An applyEditSettled transition that throws unwinds before the panel commits the state it would have returned, so the write lock stays HELD with no replacement settlement ever coming — the deferred side channels (context-handoff / codex-context-handoff / switch-to-text) sat in the barrier forever and their at-receipt guards never released. The step now rescues that case with a barrier drop, conditioned on the throwing event being the settlement itself.

Changes

  • host-session-step.ts: wrap deps.commitTransition(event) in a try/catch. On a throw, call settleEditBarrier(false) only when event.type === "applyEditSettled", then rethrow the transition error.
    • Conditional, because an unconditional rescue is a different bug: settle(false) calls dropAll() before consulting isLocked, so dropping on a non-settlement throw would destroy thunks that the still-pending real settlement would legitimately have drained. Only applyEditSettled ever releases the lock, so only it may rescue.
    • false, not true: the state was never committed, so the lock still reads held and settle(true) would take the barrier's WAIT arm and strand the thunks exactly as before. Only the failed verdict fires each onDrop, freeing the at-receipt guards so a retry is possible. The lock itself stays held — no step or queue policy can release state that was never committed.
    • The rescue does not step on the failure it recovers from: a throwing rescue settle goes to the (isolated) reporter and the transition error propagates as the triage payload, matching the existing effect-throw ordering. The reporter isolation is now a shared helper used by both paths.
  • host-session-core.ts: correct the dispatcher's failure-policy comment — a throwing applyEditSettled transition still leaves the lock held, but the side channels behind it are no longer stranded.
  • host-session-step.test.ts: 7 new tests pinning both directions.

Latent today: production's write validator is fail-closed (validate-for-write.ts turns parser throws into verdicts), so the only remaining throw sources are the reducer's own defensive exhaustive arms. No user-visible behaviour change, hence no CHANGELOG entry.

Related

Test Plan

  • pnpm compile — green (5 tsconfigs)
  • pnpm test:unit — 5373 passed / 279 files
  • pnpm build + pnpm package (vsix audit: 22 entries, no violations)
  • Biome clean on the changed files
  • Non-vacuity measured in both directions: against the unfixed source, the drop / verdict / rescue-settle-throw tests go red; relaxing the guard to an unconditional rescue turns the two non-settlement tests red.

mtskf added 12 commits September 9, 2026 16:24
…n throws

`commitTransition` runs outside the step's settle guard, which is the right
default: settling a step whose transition never happened would hand the
barrier a verdict for it. But an `applyEditSettled` transition unwinds before
the panel commits the state it would have returned, so the write lock
(`pendingApplyBaseVersion`) stays HELD with no replacement settlement ever
coming — the deferred side channels (context-handoff / codex-context-handoff /
switch-to-text) sat in the barrier forever and their at-receipt guards (the
Codex single-flight) never released.

Catch the transition throw and rescue with `settle(false)`, conditioned on the
throwing event being the settlement itself:

- Conditional, because an unconditional rescue is a different bug: `settle(false)`
  calls `dropAll()` before consulting `isLocked`, so dropping on a
  non-settlement throw would destroy thunks that the real settlement, still
  pending, would legitimately have drained. Only `applyEditSettled` ever
  releases the lock, so only it may rescue.
- `false`, not `true`, because the state was never committed: the lock still
  reads held and `settle(true)` would take the barrier's WAIT arm and strand
  the thunks exactly as before. Only the failed verdict fires each `onDrop`,
  which is what frees the guards so the user can retry. The lock itself stays
  held — no queue or step policy can release state that was never committed.
- The rescue does not step on the failure it recovers from: a throwing rescue
  settle is reported through the (isolated) reporter and the transition error
  propagates as the triage payload, matching the effect-throw ordering. The
  reporter isolation is now a shared helper used by both paths.

Latent today — production's write validator is fail-closed, so the only throw
sources are the reducer's own defensive exhaustive arms.

Tests pin both directions and were measured non-vacuous: three go red against
the unfixed source (drop, verdict, rescue-settle-throw), and relaxing the
condition to always-rescue turns the two non-settlement tests red.
The claim that applyEditSettled is "the only event that ever releases
the lock" was unqualified, but the core's disposed arm also clears
pendingApplyBaseVersion (on teardown). Scope the claim to the live
path and note why disposed needs no rescue: the barrier's own
isDisposed() check already drops deferred thunks regardless of
verdict there.
The catch block's bare event.type === "applyEditSettled" check gave no
compile-time guard: a new HostSessionEvent member that also releases
the write lock would silently skip the rescue and reintroduce the
exact stranding this module fixes.

Extract releasesWriteLockOnCommit(), an exhaustive switch with the
same never-assignment idiom as isEditApplied's outer switch, and use
it for the rescue condition. Not merged with isEditApplied's switch:
the two answer different questions, and isEditApplied's own default
message is pinned by an existing test.

Pin the runtime default arm with a test mirroring isEditApplied's
unknown-event test; the exhaustiveness itself is a tsc-time guard, the
same split isEditApplied's own JSDoc already documents.
"keeps the transition error when the rescue settle AND the reporter
throw" only asserted toThrow(transitionErr), which does not tell apart
"the rescue ran and both its own throws were isolated" from "the
rescue never ran at all" — both reach the caller with just
transitionErr.

Capture settleEditBarrier / onSettleError calls and assert
settleEditBarrier ran once with false and onSettleError ran once with
the settle error. Revert-check: temporarily deleting the whole
`if (releasesWriteLockOnCommit(event)) { ... }` rescue block turns
this test red (settles/reported stay empty) along with four sibling
tests, confirming the strengthened assertions actually detect the
rescue's absence.
"Drop them so the guards are freed and the user can retry" over-claimed
what settle(false) buys. Measured: a side channel that retries after
the rescue lands in editSettledBarrier.run(), finds the write lock
still held (the rescue never clears pendingApplyBaseVersion), and
re-defers behind it -- stranded again until dispose.

Reword to state the payoff is a one-shot guard/thunk release, and note
that releasing the lock itself is a separate follow-up.
The JSDoc named only the pre-existing drain-settle call (after
runEffects), but this PR added a second: the rescue settle in the
transition-throw catch block, which can mask a transition error the
same way the drain settle can mask an effect throw. Name both.
… a named helper

The catch arm held the rescue inline — three nesting levels and the full
rationale block — which buried the step's four-phase shape (transition →
verdict → effects → settle) behind it. The rescue now lives in a
rescueStrandedSideChannels closure next to reportSettleError, following the
precedent this same change set already uses for reportSettleError and for
hanging long why-blocks on a named helper's doc comment.

No rationale was dropped: the paragraphs moved verbatim into the helper's doc.
Two claims needed re-anchoring because the move invalidated their positional
wording — onSettleError's doc now names the helper instead of pointing at 'the
catch block below', and the settle-error note says 'the effect-throw path in
the step below' plus a parenthetical that the caller rethrows immediately,
which is what made the precedence self-evident while the code was inline.
The 3 comment sites explaining why a throw from the disposed transition
needs no rescue said the barrier's isDisposed() check 'already' drops the
deferred thunks, which reads as happening in this same step. It does not:
releasesWriteLockOnCommit(disposed) is false, so settleEditBarrier is never
called from this step at all.

The actual drop rides an in-flight apply's own LATER applyEditSettled step
(its dispatch fires post-dispose in every outcome arm, per
effect-executor.ts's runApplyEdit header) finding isDisposed() already true
(the panel sets its local disposed flag before dispatching the disposed
event, per quoll-editor-panel.ts's onDidDispose) and taking the barrier's
dropAll() branch there. If no apply was in flight, there was nothing
deferred to strand in the first place.

Also fixes an imprecise DRAIN/DROP conflation in the third site: the later
settlement DRAINs (runs) the thunks on the live path, but DROPs them via
isDisposed() if disposed won the race first — edit-settled-barrier.ts
treats these as distinct outcomes of settle().
releasesWriteLockOnCommit's disposed case answers false, but no test drove
a disposed event through step, so a mutation that moved disposed into the
same rescue arm as applyEditSettled passed the whole suite unnoticed.

Revert-check: reproducing that mutation (moving case "disposed" into the
applyEditSettled true-arm) turns this new test red (settles === [false]
instead of []) while every other test in the file stays green.
Chain a real editSettledBarrier through createHostSessionStep across
two steps: a throwing `disposed` transition (which needs no rescue of
its own), followed by the in-flight apply's own, independent
applyEditSettled settlement. The mid-state assertions confirm nothing
is dropped or run at the disposed step itself, and the follow-up step
confirms the drop rides that later settlement's isDisposed() check.

Neither existing test chains both steps through a real barrier:
edit-settled-barrier.test.ts pins the isDisposed-drop in isolation,
and the "does not attempt the rescue when a disposed transition
throws" test uses a stub settleEditBarrier with no follow-up step.
The comments describing why a throwing settlement rescue stays
conditioned on applyEditSettled characterized the barrier's later,
independent settlement as a DRAIN/DROP binary (drain on the live path,
drop if disposed won the race). edit-settled-barrier.ts's settle() has
four outcomes: DRAIN, DROP via a failed apply, DROP via isDisposed()
(the same `deps.isDisposed() || !applied` arm, two distinct causes),
and WAIT when a stash-drain re-acquires the lock. Reworded the module
header, the commitTransition dep doc, and rescueStrandedSideChannels's
doc to name all three arms and, where DROP is discussed in detail, its
two causes. releasesWriteLockOnCommit's doc makes no such enumeration
claim, so it is unchanged.
…nsition doc

The previous edit appended a sentence without re-wrapping, leaving a 90-column
line in a block whose other prose lines wrap around 78-82. Words, punctuation,
and line count are unchanged; only two line breaks moved.
@mtskf
mtskf merged commit 20502c8 into main Sep 9, 2026
2 checks passed
@mtskf
mtskf deleted the fix/commit-transition-throw-strands-write-lock branch September 9, 2026 08:19
mtskf added a commit that referenced this pull request Sep 10, 2026
…ransition (#409)

* fix(host): add the settlementTransitionFailed recovery arm

A throwing applyEditSettled transition unwinds before the panel commits the
state that releases the write lock, so the lock stayed held for the rest of the
panel's life and every later edit was stashed into a pendingEdit that could
never drain. Add the reducer arm that releases the lock, drops the stash, and
reposts the authoritative Document so the webview's single flight un-parks with
its replay buffer intact. The throw also abandoned the settlement's own
save-failure toast, so the arm always raises one outcome-blind internal-error
toast in its place, adding a dropped-edit clause only post-dispose, where the
replay buffer went with the iframe and the stash really was the only carrier.

The arm is a pure field reset by design: decideEdit and the outcome switch are
throw sources of the transition being recovered from, so the recovery must not
re-enter them. Wiring follows.

Also order the two withheld-ack effect lists so the user-visible
showResyncFailure precedes the logWarn the executor runs unguarded.

* fix(host): commit the write-lock recovery from the step's transition catch

PR #406's rescue released the deferred side channels' at-receipt guards but
never the write lock, so the payment was one-shot: a retried side channel
re-deferred behind a lock nothing would release. The catch now commits the
recovery transition first and drops the side channels second, so the drop is
durable and a retry runs. The recovery re-bases on the throwing settlement's
own observed version, so no second guarded version reader is introduced.

Also re-anchor the release-site claims in this module and broaden the
onSettleError channel, which now carries recovery failures too.

* fix(host): wire the panel's write-lock recovery

The panel hands the step its own state-committing lambda, so the recovery
releases the lock THROUGH the reducer and the panel never patches reducer state
itself. Pinned end to end against the real core + step + barrier + dispatcher
composition, including that the next edit is accepted and persisted.

Non-vacuity measured: stubbing commitWriteLockRecovery to return no effects
reddens the lock-release assertion.

* docs(host): re-anchor the write-lock release-site claim at every altitude

The recovery arm makes "applyEditSettled is the only live release site" false.
LEARNING 2026-09-09 recorded that this claim lives at several altitudes and that
reviews twice caught one copy left stale, so all of them are updated together
and the roster is pinned in the plan's inventory step.

Comment-only, plus the stale "STAYS locked" narration on the barrier-drop test,
which now says what the stub is isolating and points at the composition test
for the actual release.

* test(webview): name the same-epoch replay pin as the write-lock recovery's receiving side

The recovery's data-preservation claim rests on this test, but nothing in this
file said so — an unrelated webview-sync refactor could have deleted it and left
the claim silently unverified.

* fix(host): contain a throwing logWarn, and enforce three prose-only contracts

Review-cycle findings on the write-lock recovery, in four groups.

1. runEffects' `logWarn` was the one effect case with no `try` of its own, and
   several reducer arms put a triage log AHEAD of the effect that pays the
   incident — the drain `accept` arm returns `[...staleReBaseWarn, applyEdit]`.
   A throw there unwound the loop and abandoned the applyEdit, and since the
   committed state had already re-acquired the write lock, no settlement was
   ever dispatched: the same stranded lock this branch exists to repair, except
   the recovery hangs off the TRANSITION catch and never sees a runEffects
   throw. Guarded at that one seam rather than by reordering each effect list.

2. The recovery's loss clause was gated on `disposed` alone, so the alive
   withheld-ack branch dropped the stash and told the user nothing was lost.
   There is no ack Document in that branch, so the webview's retained replay
   buffer is never replayed; the recovery then clears the lock, the next
   lock-free resync bumps `externalEpoch`, and the buffer is dropped on
   `recordedEpoch > buf.epoch`. The gate is now "will any ack go out?".

3. Three contracts that the comments declared and nothing enforced:
   - `releasesWriteLockOnCommit` returns the SETTLEMENT rather than a boolean,
     so the version reaches the recovery through the same decision that
     selected it. A future member mis-placed in that arm was silently handed
     the caller's fabricated `null`; it is now TS2322.
   - `HostSessionInputEvent` excludes `settlementTransitionFailed` from both
     dispatch surfaces. A dispatched recovery would queue behind a sibling
     whose lock-held stash arm then loses its stash to it; injecting that
     dispatch used to type-check clean. Derived with `Exclude` so a new member
     is carried automatically. commitTransition / commitWriteLockRecovery keep
     the wide type — committing the recovery is the legitimate path.
   - `commitWriteLockRecovery` staying REQUIRED is pinned in
     types-equality.test.ts, the one test program `pnpm compile` checks.

4. Comment accuracy at every altitude where this branch left a claim behind
   (LEARNING 2026-09-09). The rescue's `settle(false)` rationale said `true`
   would take the barrier's WAIT arm; since the recovery releases the lock
   first, `true` would DRAIN and RUN the thunks — the choice is DROP-vs-RUN.
   Also: the ack-site census (two, now three), the withhold pair's scope and
   post-dispose route count (three, now four), the accepted residual's throw
   source, `refused`'s effect order, "unbounded" recursion, and the test
   comment that presented its own stub as the production mechanism.

Non-vacuity measured for every group. Reverting the logWarn guard reddens the
new containment test; restoring the old `lostStash` reddens the new loss claim;
gating the toast on `ackLabelObserved` reddens three core tests (it was green
before, because the ordering test read presence through `indexOf` and absorbed
absence as -1); gating the rescue on the recovery's success reddens exactly one
of 335 step tests. For the two type mechanisms the probes were run BEFORE the
narrowing as well as after: both were tsc-clean beforehand, which is the
measurement that the contracts were prose-only. Adding `?` plus a
`?? (() => [])` default to commitWriteLockRecovery leaves all 335 runtime tests
passing and reddens only the type pin.

* refactor(host): collapse the recovery arm's effect ternary and dedupe its test fixtures

The recovery arm spelled `toast` and `triage` out on both branches of its
return, so an ordering fix — the kind this PR made twice — could land on one
branch and miss the other. Only the ack half is conditional now, and the order
the surrounding comments justify lives in one list.

Also: two comment corrections (SettlementEvent's doc claimed a nullability that
belongs to the function's return type; a rationale restated 120 lines from the
doc that owns it is now a pointer, keeping the clause that is not duplicated),
three comment reflows left over 100 cols by earlier edits, and three local
fixtures in the new describe block. `messageOf` deliberately keeps its cast so a
missing effect still throws instead of reading as an empty string, which is what
would vacate the not.toContain pins.

* fix(host): contain every report that gates the lock-releasing dispatch

The per-effect `logWarn` guard reported its own failure through a bare
`console.error`, so a correlated console failure — VS Code patches the console
as one IPC family — still unwound `runEffects` from inside the guard meant to
contain it. On the drain `accept` arm that costs the WRITE and strands the write
lock for the panel's life.

Route every report made from a position where a throw must not unwind through
one owner, `reportContained`. It takes a thunk so argument evaluation is
contained too, and each site keeps its own console level and payload (the
per-site triage tokens are unchanged). Five sites: the `showError` and `logWarn`
per-effect guards, plus three that were never guarded and each needed only a
SINGLE console failure to strand the lock — `readVersionGuarded`'s and
`readCanWrite`'s catches (both evaluated while building the settlement event)
and the rejection arm's leading log, which sat outside that arm's own `try`.

Both guarded catches now carry `effect.message`, so triage keeps the identity of
the toast or log line that was lost.

Correct the `logWarn` header: the "ONLY effect with no try of its own" claim was
written in the present tense in the hunk that added the try, and the "fixes the
whole class at one seam" claim was wrong twice over — containment is best-effort,
and three effects still evaluate their injected builder outside every try
(`post` guards only `deps.send`). That gap is named where it stays findable.

* fix(host): hedge the recovery arm's loss claim, and pin the gate it rests on

The write-lock recovery arm is outcome-blind, so "A later unsaved edit was
dropped." was an over-claim on the alive path: with no version advance the epoch
does not move, the next same-epoch Document replays the retained buffer, and the
bytes land. Split the clause by branch — definite post-dispose, where the stash
really was the edit's only carrier, and hedged alive with a withheld ack, using
the same "may not have been saved" phrase RESYNC_FAILURE_MESSAGE uses so the two
toasts cannot disagree on certainty. The alive branch also names the remedy that
preserves the bytes (copy before reloading), which composes with the resync
toast's reload as an order rather than a conflict, and stands alone because that
toast is latched per panel. Each branch carries its own closing instruction
instead of sharing an appended one.

Pin the clause on the SHARED `ackLabelObserved` gate: keying it on the raw
`event.settledVersion` was undetectable by the whole session suite, because the
two directions already covered keep the disjunction intact. The new test drives
the state that separates them — alive, stash present, unobserved settled version,
label already raised by a lock-held resync — and asserts BOTH clauses absent,
since an absence check on the definite wording alone stays green against the
hedge.

Close the fail-OPEN derive: `HostSessionInputEvent`'s `Exclude` returns the
original union when the excluded literal does not match, so a one-character typo
re-admitted the forbidden dispatch with tsc silent, while its sibling `Extract`
is fail-CLOSED. One pin in the only type-checked test location observes the
exclusion expression itself.

Comment corrections, all measured against the code: the effect ORDER is defence
in depth, not a consequence of an unguarded `logWarn` (7 sites); the widened loss
gate is NOT the settlement arm's gate, and the divergence is now stated outright
with its reason; the four-step loss chain's third step is conditional; the alive
stash's survival is bounded by the ack; `isEditApplied`'s heading is no longer a
biconditional its own tail breaks; and a cross-reference drops a line number that
went stale inside the PR that added it.

* docs(host): reconcile two comments the hedged loss clause falsified

Self-review of the previous commit: `withholdAckEffects`' header quoted the
DEFINITE clause ("A later unsaved edit was dropped.") as what the recovery arm
says on this same withheld-ack condition. That path is now the HEDGED branch —
the definite wording belongs to post-dispose, where this pair is never built at
all. Name the hedge, and say why it reuses RESYNC_FAILURE_MESSAGE's phrasing.

The recovery arm's own note called both no-ack branches "losses" flatly, which is
what the hedge exists to qualify: they differ in certainty, and that difference is
exactly what the two wordings encode.

* refactor(host): unnest the loss clause and share the fixtures that pin it

The three-way loss clause was a nested ternary, which the house style forbids and
which the same file already solves one arm up with an if/else chain. Unnesting it
also puts each branch's commentary on its branch instead of in a header list the
reader has to re-index.

Tests: the two logWarn-containment cases carried a verbatim-duplicated 20-line
fixture, so they could drift apart; `expectNoLossClaim` replaces three copies of
the same not-dropped / not-hedge / Reopen triple, which makes this cycle's rule
structural rather than remembered — checking only "dropped" reads the hedge as a
pass, and a future no-loss site can no longer half-apply the check.

Comments: two in-PR second derivations became pointers to the note that owns each
argument. The standing "this does NOT close the class" guard stays; the history of
how the claim was wrong belongs in the PR description, not the source.

Non-vacuity re-measured after the refactor: reverting each fix still reddens the
tests that were extracted, so the shared fixtures detect the original defects.

* fix(host): route the remaining effect-executor reports through the contained owner

`reportContained` already existed for reports made from a position where a
throw must not unwind, but six such positions still reported through a bare
console, so one console fault could unwind `runEffects` from inside the guard
meant to contain it:

- `sendEditRejected`'s three recovery dispatches. A throw here skipped
  `editRejectedDeliveryFailed` entirely (measured `dispatched: []`), leaving the
  rejection `pending` with the webview's single flight parked and visible-edge
  resync suppressed. The host makes no further attempt of its own; recovery
  waits on an external `documentChanged` or a webview reload.
- `post`'s sync-throw catch, which `case "postRejectedDraft"` follows
  immediately with `sendEditRejected` — so an escape took the banner delivery,
  its recovery dispatch and the arm's `Cannot save:` toast with it.
- the reseed build-failure guard, which additionally had the bare log AHEAD of
  the toast: with a throwing console it produced no toast attempt at all
  (`toastAttempts: 0`), making silent the exact state its own comment says must
  not be. The signal now goes first, matching the three reducer-side sites.
- `reportResyncFailure`'s own fallback, the most correlated position of all
  since it runs only because `showError` just threw.

The delivery-refused site also moves its two injected seam reads inside the
thunk; argument evaluation happens before the call, so leaving them out
contained only half the site.

Each new wrap gets its own test: an unpinned guard is the one that regresses,
measured on the `showError` fallback, which was the only `reportContained` site
without a pin and survived reversion to a bare console with the file green. That
gap is closed here too.

Also makes this increment's own claims match what is measured:

- the exclusion type pin was fail-OPEN on its own literal — `Extract<T, U>`
  answers `never` both when the union excludes the member and when the literal
  matches nothing. It now asks two questions of ONE shared literal, so the pair
  is a biconditional rather than a convention about keeping two copies in sync.
- `reportContained`'s header conflated two different limits: the throw is fully
  contained, the report is not. Its list of calls that bypass the owner is now a
  rule rather than a roster.
- the withhold pair's ordering rationale no longer rests on a failing fallback
  console call, which the guard absorbs; it rests on the guard being removable.
- exports `RESYNC_FAILURE_MESSAGE` and pins the cross-module wording contract
  the recovery arm's hedge depends on. Rewording it previously left everything
  green.
- corrects the `false`-arm count in `isEditApplied`'s contract, the ALIVE
  replay claim that a withheld ack falsifies, the "NO LOSS" branch that is
  really "no stash loss", and a toast-ordering claim that rested on a
  notification stack order this repo cannot verify.

* docs(host): point each cross-reference at the claim it means

`reportContained`'s header carries two standing claims — the best-effort limit
and the effect-order rule — and two call sites pointed back at it by the same
marker, so one of them resolved to the wrong paragraph. Naming the order rule
gives each pointer a target it cannot miss, which is the failure mode that
replacing duplication with pointers introduces.

Also: a group comment counted three tests where there are four, and two headers
were left mid-sentence by earlier edits. Comment-only — no statement, assertion,
or test was added or removed.

Measured while here: the header's roster of console calls that deliberately do
NOT route through the helper matches a grep of every console call in the module,
so that completeness claim is now verified rather than asserted.

* fix(host): hedge the recovery loss clause for the alive withheld-ack case with no stash

An alive `settlementTransitionFailed` recovery whose ack is withheld and whose
`pendingEdit` is null fell into the no-loss branch, because `lostStash` requires
a stash. That state told the user to "Reopen the file" — which destroys the
webview buffer still holding the at-risk bytes. The recovery arm is
outcome-blind, so those in-flight bytes sit under the same conditional loss as a
stash: no ack means no replay, the next lock-free advance bumps the epoch, and
edit-sync drops the buffer.

Re-key the no-loss branch on `disposed || ackLabelObserved` so every alive
recovery with an unobserved label takes the copy-first hedge, and generalise the
hedge's wording so it no longer presupposes a stash that may not exist.

Also correct three claims this branch introduced earlier and one type-level
fail-open:

- the `case "logWarn"` roster claimed ONE remaining unguarded position class
  "and it is not a console call". `case "openExternal"` is a second one, and its
  delegate's open positions ARE console calls (bare `console.warn` twice, plus
  bare `console.error` and `deps.showError` outside its own `try`).
- the named ORDER RULE was stated as a universal that two reducer arms in this
  same repo contradict; restated in the count form the file already uses, with
  both exceptions named.
- the withhold pair's order was justified partly as a defence against the
  unguarded builder positions. No effect list reaching that helper evaluates a
  builder, so the order cannot cover them; only the guard-removal half stands.
- `RecoveryEventType = never` degenerated both event-union assertions to
  trivially true. One assertion asks the alias about itself, closing it.

Pinned in both directions: restoring the old condition reddens the new no-stash
hedge test, and inverting the new conjunct reddens all three no-loss sites plus
the post-dispose definite claim.

* docs(host): make the loss branch's state enumeration match its predicate

Branch 1's header claimed exactly two states reach it, but its first bullet was
keyed on `ackLabelObserved` alone — which is computed independently of
`state.disposed`, so a disposed arm with an ack and a stash satisfied the bullet
while landing in branch 2. The bullet's stated reason was also false post-dispose,
where there is no replay buffer left to replay. Qualifying it as the ALIVE case
makes the two bullets the branch predicate verbatim.

Also rephrases "asserts no stash", which read as the opposite of its intent.
Comment-only, zero line delta.

* docs(host): de-quantify two signal-first census claims, narrow a settle claim

- effect-executor.ts's ORDER RULE comment (and its second, duplicate
  reference to the same set later in the same file) asserted an exact count
  of reducer-side sites that put a user-visible signal ahead of a triage
  log. The count under-counted in both directions: a disposed signal-first
  arm was missing from it, and it omitted deliberate log-first exceptions.
  Replaced both with wording that names the closed set of sites citing the
  rule by name and explicitly disclaims closure in either direction, rather
  than asserting a count that goes stale on the next edit.

- Narrowed two "the panel's `step` settles unconditionally" claims (also in
  effect-executor.ts) to name the one case where that does not hold:
  `isEditApplied` itself throwing before `runEffects` runs
  (host-session-step.ts:390).

- test/extension/session/host-session-core.test.ts: replaced a comment
  claiming a specific wording assertion is uniquely the one that fails
  against the old condition with one describing which assertion actually
  distinguishes right from wrong, since `expect` throws at the first
  failure and the earlier framing no longer held once measured.

Comment-only; no behaviour, assertion, or type change.

* style(host): rewrap one comment line in the ORDER RULE block

Pure reflow after the census de-quantification left one line at 93 columns
while its neighbours sat at 65-85. No word, claim, or code changed.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant