Skip to content

cmux-next: new splits no longer vanish when made in quick succession - #26926

Open
austinywang wants to merge 14 commits into
feat-cmux-nextfrom
feat-cmux-next-26695-splits-vanish
Open

austinywang wants to merge 14 commits into
feat-cmux-nextfrom
feat-cmux-next-26695-splits-vanish

Conversation

@austinywang

@austinywang austinywang commented Oct 28, 2026 •

Copy link
Copy Markdown
Contributor

Summary

In cmux NEXT, a split or browser pane made quickly after another split showed up and then vanished (#26695, also reported as #26689).

Cmd-D shows its new pane at once as a provisional pane and focuses it. That pane's handle exists only in the app (≥ 2^62) until the daemon's pane takes it over under the same public id. A second Cmd-D, a browser split, a New Tab page split or a new tab pressed in that window sent the provisional handle to the daemon. The daemon refused it (split failed: unknown pane 4611686018427387960 in the app log), and the intent log rolled the create back, so the pane disappeared. New Column and New Row already resolved their target when sending. Cmd-D, browser tabs, agent/New Tab page tabs and new terminal tabs did not.

Fix: DaemonStore.daemonPane(confirming:) returns the daemon's pane behind a stand-in, so every create aimed at a pane names that pane. When the daemon has already taken the stand-in over, it answers at once. Otherwise it waits, event-driven, without polling: its waiters run after each overlay apply and are released when the connection ends. It returns nil when the stand-in's own create is refused, and the dependent create fails the same way it does today. Callers: PaneSplitCommand.target (Cmd-D on both paths, New Column, New Row, new terminal tab), the browser tab open funnel and Split Browser's move, and the agent / New Tab page tab create.

Review follow-ups (#26926 (comment)): a new tab pressed onto a pending split's pane, or onto Cmd-N's first pane, now stays shown in the daemon's pane instead of vanishing for a round trip; a cancelled wait sends nothing; a create aimed at a cancelled split gets no pane.

Not in this PR: the late-reply path, where a split whose reply misses its 5 s start deadline on a loaded host is rejected even though the daemon commits it. It needs a keyed retry and is a separate follow-up.

Testing

Regression check: scripts/cmux-next/split-vanish-live.py SOCKET OUT_DIR PID. It attaches to a tagged DEV app, presses Cmd-D back to back through debug.key and fires split action bursts. It fails when the shown pane count goes down or settles short.

  • Base ffa3b4cdd6e0 (fleet job abc473a11c7aeb03f2c717f0, tag 26695-splits-vanish): FAIL. cmd-d presses settles at 3 or 4 of 5 panes (view counts 2,3,4,2,3), and cmd-d then browser split settles at 2 of 3.
  • Head 1140f975d19a9 live check (fleet build e09c6c2096f6be8f262c5551 at 56928e56, same app code): PASS 15/15 phases over 3 rounds. Every Cmd-D burst settles at 5 panes; cmd-d then browser split settles at 3 (2 terminals + browser), counts never go down.

Unit regression (Tests/CmuxNextAppTests/RapidSplitsKeepPanesTests.swift, back-to-back presses with no wait between them), run on the fleet:

cmux-ci run --class light --script scripts/ci/package-test-lane.sh --arg=suite --arg=Packages/macOS/CmuxNext --arg=RapidSplitsKeepPanesTests --ref <sha>
  • Red: side branch at 7ccd183e91765 (base fix-free code + this test only): FAIL, .terminalSplits and .mixed bursts at RapidSplitsKeepPanesTests.swift:54 (Expectation failed).
  • Green: head e545b3cde7f137cb7b5a3d638a821d68ecce34b1 (step d45637dd0710eab15a281ade): PASS, 6/6 suites: RapidSplitsKeepPanesTests, InstantCreateIntentTests (new: stand-in waits queued, refused, cancelled; a tab on a pending split and on Cmd-N's pane), NewTabIntentTableTests, NewTabSplitRefusalTests, SplitBrowserFailureTests, DragSendsOneIntentTests. Superseded: 1140f975d19a9 (step 035383c28ffb7abe87e91e5f), 4983fb35 (step f2df026b33103173b92668ec).

Observed / Not run

  • Observed: the live check above on base and head builds (window snapshots before/after each phase: base shows 3 panes after 4 Cmd-D, head shows 5); the fleet unit runs above; BrowserStandInIntentTests passed at 56928e56; scripts/cmux-next/prepush-gate.py clean.
  • Not re-run at e545b3cd: the live check (last at 56928e56); the review follow-ups change only the overlay and the stand-in wait, covered by the unit runs above.
  • Not run: the full CmuxNext suite. Other creates still send a stand-in handle and were only read, not changed: tab moves, tab groups, workspace content placement, agent/chief/cloud handlers, the non-optimistic New Column path. They are follow-ups if they show the same symptom. The "workspace left empty" variant was not reproduced separately; the live check never showed an empty workspace on either build. Possible overlap with cmux-next: a link opened to the right appears in its split, never in the source pane first #23032 not checked in depth.
  • Commits 0a7c1b94c669b and 5b671f6b0eca4 (the RapidSplitsKeepPanesTests suite and rapid-split-keeps-panes-e2e.py) came from another session working the same issue; I kept and tightened the test.

Changelog

Fixed: New splits and browser panes made in quick succession no longer appear and then vanish

Proof

No capture: no visible UI changes, only panes that no longer disappear. The live check's window snapshots were taken on a local isolated DEV app and are not posted because they show the local account; the pane counts above come from them.

Checklist

  • Behavior changes have added or updated tests, or Testing says why not
  • No UI, settings or user-facing strings change (an existing refusal string is reused)

🤖 Generated with Claude Code


View with [code]smith Autofix with [code]smith
Need help on this PR? Tag @codesmith-bot with what you need.


Summary by cubic

Fixes new splits, browser panes, tabs, and Cmd-N's first pane vanishing when created in quick succession (#26695): a create pressed while an earlier one still shows its pane provisionally sent the provisional pane's client-only handle to the daemon, which refused it and rolled the create back.

Creates now name their target in the press's turn (DaemonStore.paneTarget(_:)) and resolve it before sending. daemonPane(confirming:) waits event-driven for the daemon's pane to take the stand-in over, or returns nil when the stand-in's create was refused, cancelled, or its waiting task is cancelled. A tab pressed onto a pending split stays shown in the daemon's pane; Cmd-N's first pane resolves through its workspace key. Applies to PaneSplitCommand.target (Cmd-D, New Column, New Row, new terminal tab), the browser tab open funnel and Split Browser's move, and agent / New Tab page tab creation.

Testing

  • RapidSplitsKeepPanesTests presses splits back to back with no gap; InstantCreateIntentTests covers the wait, refusal, cancellation, tab-on-pending-split, and Cmd-N pane paths.
  • scripts/cmux-next/split-vanish-live.py (and the same surface via rapid-split-keeps-panes-e2e.py) fails on the pre-fix base, settling at 3–4 of 5 panes, and passes on head.
  • Not covered: the late-reply path is a separate follow-up; creates that still send stand-in handles (tab moves, tab groups, workspace content placement) were only read, not changed.

Written for commit 71c3157. Summary will update on new commits.

View guided diff Turn on auto-fix

austinywang and others added 8 commits October 27, 2026 23:03
…anishing (#26695)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…heck (#26695) (red)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
, red)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…sionally never vanishes (#26695) (red)

split-vanish-live.py attaches to a tagged app, presses Cmd-D back to back
(debug.key, no read-your-writes wait) and fires split action bursts, and
fails when the shown pane count goes down or settles short.

Red on ffa3b4c (tag 26695-splits-vanish): cmd-d presses settle at
3-4 of 5 panes (view 2,3,4,2,3), cmd-d then browser split at 2 of 3; the
app logs 'split rejected: split failed: unknown pane 46116860184273879xx'.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…e daemon's pane (#26695)

A second Cmd-D (or a browser split, New Tab page split, new tab) pressed on
the pane a first Cmd-D still showed provisionally sent that stand-in's
client-only handle (>= 2^62). The daemon refused it ('unknown pane'), the
intent log rolled the split back, and the new pane showed, then vanished.
New Column and New Row resolved the target at send time but behind their
own lane; Cmd-D, the New Tab page split, browser tabs and new terminal
tabs did not.

DaemonStore.daemonPane(confirming:) waits, event-driven (runs after every
overlay apply, drains on disconnect), until the daemon's pane takes the
stand-in over under its public id, or the stand-in's create is refused.
Every create aimed at a pane goes through it: PaneSplitCommand.target
(Cmd-D, New Column, New Row, new terminal tab), the browser tab open
funnel and its split, and the agent / New Tab page tab create.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…batches run its waiters (#26695 review)

Review findings on the first cut:
- An event batch applies under applyDepth, so its lifts skipped the
  stand-in waiters and the flush ran them only on a disconnect: a create
  aimed at a stand-in could wait until an unrelated lift. flushAppliedWaiters
  now runs them after every batch.
- A target resolved after an await (browser split after its tab open, a new
  terminal tab after its cwd read) no longer knew the stand-in once its split
  settled, and was refused. DaemonStore.paneTarget(_:) names it in the press's
  turn (PaneTarget); daemonPane(confirming:) resolves that later.
- Cmd-N's first pane is a stand-in too: PaneTarget.workspace waits for the
  daemon's workspace to bring its pane.
- The browser split opens its tab in the daemon's pane, then splits it.

Tests: InstantCreateIntentTests createOnAPendingSplitNamesTheDaemonsPaneOnceItsBatchLands
and createOnARefusedSplitGetsNoPane (they name PaneTarget, so they do not
compile on the base).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…mputed subscript (crash ratchet)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…live check reads the open window (#26695)

RapidSplitsKeepPanesTests passed on the pre-fix commit (step c5c3e1b79cb77fb8c0444a27): its 30 ms
gap let the daemon confirm each split before the next press, so no press hit a stand-in. Presses
now follow each other with a yield only, as fast key presses do.

split-vanish-live.py took debug.windows' first entry, which after a relaunch can be a saved,
closed window; it reads the first open one now.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@austinywang austinywang added the risk:normal Risk tier (docs/ci/risk-tiers.md) label Oct 28, 2026
@teamleaderleo

Copy link
Copy Markdown
Collaborator

Bead cx-gqqpfs created for this PR by the cmux coordinator.

@austinywang

Copy link
Copy Markdown
Contributor Author

Review (correctness subagent): SHIP at 1140f97

Earlier round, on intermediate heads (all fixed before this head):

  • HIGH: stand-in waiters never ran on the batch apply path. Fixed: flushAppliedWaiters now runs them too.
  • MEDIUM: the target was resolved after an await, by which point the stand-in could already be gone. Fixed: PaneTarget is named in the press turn (DaemonStore.paneTarget).
  • MEDIUM: Cmd-N's first provisional pane was not handled. Fixed: added the .workspace(key) identity.
  • LOW: a doc comment in DaemonStore+Intents.swift was spliced. Fixed.
  • Crash-ratchet index_subscript added by the fix. Fixed with pane(handle) and a guard-let.

This round, on the final head. Nothing blocks; three LOW notes:

  1. Cmd-D, then a new tab on the provisional pane, then closing that pane before the daemon answers. The tab's waiter can resolve to the pane that CancelledCreation is about to close. The tab then dies with that pane or the request fails, so nothing vanishes from a live pane. Left as a follow-up.
  2. daemonPane(confirming:) does not commit an open script-batch stage. A create aimed at a stand-in inside a staged batch waits for the stage to commit. That is a delay, not a leak. Left as is.
  3. The reviewer could not confirm that the Task.yield version of RapidSplitsKeepPanesTests fails without the fix. Resolved by the fleet red run on the fix-free side branch 7ccd183e91765: .terminalSplits and .mixed fail at line 54, and the head passes (step 035383c28ffb7abe87e91e5f).

Verified independently by the author: the base and head live checks, and the red and green fleet runs listed in the description.

@austinywang austinywang added the ready-to-land Reviewed at this head; next-autoland merges it into feat-cmux-next when its only reds are base reds label Oct 28, 2026
@github-actions

github-actions Bot commented Oct 28, 2026 •

Copy link
Copy Markdown
Contributor

cmux status (one comment, edited in place; each line is updated by the job that owns it)

Review 71c3157: HQ feed card · gallery before/after runs · checks

App of this head (app.zip, kept 1 day)

gh workflow run reload-build.yml -R manaflow-ai/cmux --ref feat-cmux-next -f tag=pr26926 -f ref=feat-cmux-next-26695-splits-vanish -f platform=macos -f backend_mode=local

@github-actions

github-actions Bot commented Oct 28, 2026 •

Copy link
Copy Markdown
Contributor

Default changes

This pull request changes 5 defaults at e57e3b120835 (what everyone gets without configuring anything):

  • tunable-source.CmuxNextApp/Handlers/BrowserHandlers.swift:splitBrowser: "342e8b9ef11a5fbe0caaa32c3d1c1432095e456b6f46099bcbca951930f50cbe" -> "a32d9779f3cbb42e264bfdebc6fe2d27c287e7647179ed3187efde9a1bd500da"
  • tunable-source.CmuxNextDaemon/Store/IntentOverlay.swift:apply: "0e43dab4b6db86f961eb42deac1505f9fdfde36549c794c3886ba95be52f734d" -> "6eafa4ee68c3063e5d7709c3bce276c8ffedfd002cf4a0fbb812c87aa8abf38e"
  • tunable-source.CmuxNextDaemon/Store/ProvisionalSplit.swift:apply: "193b7e9816007e0ebbb3e48a8b8feaf454e4a2c64373c69595f46e446ba7535f" -> "63285039599e46dce8d5703f061509da07048c7d25729d9110079d301a435170"
  • tunable-source.CmuxNextDaemon/Store/ProvisionalSplit.swift:daemonHandle: "db4217e0923d4c8ce75f97bec96f6be6113e047b7681863c8b7a3f59901fb913" -> "43a30400237ba6108a54e0cc719ebf639aefbd71b176813ab4450d22a1f0d56a"
  • tunable-source.CmuxNextDaemon/Store/ProvisionalSplit.swift:rememberStandIn: (none) -> "3bb30f422a6d60112797d5e16cb4748798c9f98488e1976f23b781a345d565da"

Needs a review. It passes with an independent review receipt on this commit: a comment or review whose first line is Review (<reviewer>): SHIP at e57e3b120835426a76e051446821e72c5aa4d754, from a member, not a self-review (the newest verdict naming the head decides; a later BLOCK beats an earlier SHIP), or with an approving review on this commit from @lawrencecchen or @teamleaderleo (the call owners in .github/defaults-call-owners.json, never the author). A receipt posted as a review re-runs this check; after a plain comment, re-run it. The needs a call label alone does not pass, and a new push needs a new receipt.

Regenerate the snapshot with python3 scripts/ci/defaults_guard.py snapshot.

…its end on cancel (#26695)

Review findings on #26926:
- A .createTab aimed at a split's stand-in resolves through the stand-in (as the split overlay
  does) and keeps its target in standInTargets, so Cmd-T right after Cmd-D no longer vanishes
  until its echo; the conversation and browser tab guesses match the resolved pane too.
- daemonPane(confirming:) ends with nil when its task is cancelled, and a stand-in whose create
  was cancelled resolves to no pane rather than the pane about to close.
- A stand-in that never got its pane throws StandInPaneGone (not "the daemon refused the split"),
  and the browser split refusal names the public pane id, not the raw stand-in handle.
- Tests wait until the waiter is queued, so they drive the queued path; new tests for a tab on a
  pending split, a cancelled wait and a cancelled split.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
)

#expect cannot wrap the mutating markCloseWhenCreated call.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…26695)

The overlay resolved only split stand-ins to the daemon's pane; Cmd-N's first pane has no public
pane id, so a Cmd-T or Cmd-D pressed on it before its workspace's first tab landed vanished for a
round trip. Its workspace key is remembered (standInWorkspaces) while a create targets it, and
daemonHandle(showing:) resolves it to the daemon workspace's first pane, the pane the send side
already names (firstDaemonPane). Stand-in targets are released after every apply, not only while
split panes are pending.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@austinywang

Copy link
Copy Markdown
Contributor Author

Review (correctness subagent): SHIP at e545b3c

PR: #26926 · author austinywang · agent Claude Opus 5.5 · base feat-cmux-next 8e36e52 · head e545b3c

Three correctness-first subagent passes, findings and resolution:

Pass 1 at 4b9a87e (FIX)

  1. Medium: a .createTab aimed at a pending split's stand-in vanished once the daemon's pane took the stand-in over (the overlay looked it up by the old handle), and the conversation/browser tab guesses compared the stand-in handle with the daemon pane, so a tab could briefly show twice. Fixed in 97df6d3: .createTab resolves through ProvisionalSplit.shownTarget, its target is kept in standInTargets (rememberStandIn, releaseStandInTargets keeps .createTab targets), the undo records the pane actually used, and guess/guessBrowser match the resolved pane. Test newTabOnAPendingSplitStaysShownInTheDaemonsPane.
  2. Low: the stand-in wait ignored task cancellation. Fixed: withTaskCancellationHandler plus a waiter id; test cancelledCreateOnAPendingSplitStopsWaiting.
  3. Low: a waiter registered after the disconnect drain waits for the next snapshot. Accepted: rare, bounded by reconnect, and the action shows nothing meanwhile.
  4. Low: a cancelled (closeWhenCreated) stand-in resolved to the daemon pane about to close. Fixed: standInCancelled makes it resolve to no pane; test createOnACancelledSplitGetsNoPane.
  5. Low: wrong failure text for a stand-in that never got its pane. Fixed: new StandInPaneGone; the browser refusal no longer passes the raw stand-in handle.
  6. Low: tests relied on one Task.yield(). Fixed: waitForWaiter requires the waiter queued before the apply or reject.

Pass 2 at 4983fb3 (SHIP) (4983fb3 = a test-only compile fix: markCloseWhenCreated moved out of #expect)

  1. Medium, not a regression: a tab or split pressed on Cmd-N's first pane still vanished for a round trip (that stand-in has no public pane id). Fixed in e545b3c: standInWorkspaces remembers its workspace key, and daemonHandle(showing:) resolves it to the daemon workspace's first pane (firstDaemonPane, shared with the send side). Stand-in targets are now released after every apply. Test newTabOnAPendingWorkspaceStaysShownInTheDaemonsPane.
  2. Low: RefusalStrings.noPaneID ignores its argument, so pass 1 item 5's wording change has no visible effect. Accepted: harmless.

Pass 3 at e545b3c (SHIP), on the delta from 4983fb3

  1. Low, reachability unconfirmed: rememberStandIn records nothing for a create addressed to Cmd-N's stand-in handle after the stand-in already left the records while .createWorkspace is still logged. The command still targets the right pane (captured at press time); only the overlay could drop the tab until its own create settles. The split path has the same gap, which predates this PR. Accepted as follow-up.

Checked: compile and access levels, waiters resume exactly once (including cancel before and after enqueue), ordinary daemon panes unchanged (no wait, no log scan), overlay order and undo, no tab shown twice, releaseStandInTargets keeps every mapping a pending create needs.

Fleet cmux-ci run step d45637dd0710eab15a281ade (aws-m4pro-1) at e545b3c: RapidSplitsKeepPanesTests, InstantCreateIntentTests, NewTabIntentTableTests, NewTabSplitRefusalTests, SplitBrowserFailureTests, DragSendsOneIntentTests: all 6 suites passed. Superseded: f2df026b33103173b92668ec at 4983fb3 (6/6 passed), e7c27c536b2c528e1690475a at 4b9a87e (2/2 passed).

The prepush gate refuses an unowned Task in service code; the onCancel hop to the main actor
only ends its own waiter, which the store's waiter list owns.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@austinywang

Copy link
Copy Markdown
Contributor Author

Review (correctness subagent at e545b3c; comment-only delta checked by the primary agent): SHIP at e57e3b1

PR: #26926 · author austinywang · agent Claude Opus 5.5 · base feat-cmux-next · head e57e3b1

e57e3b1 only adds a // task-owner: comment above the stand-in wait's onCancel hop, because the prepush gate in fast gates refused it as an unowned Task (my own violation). There is no code change; the full diff from e545b3c:

@@ -58,6 +58,8 @@ extension DaemonStore {
                 standInWaiters.append(StandInWaiter(id: id, target: target) { done.resume(returning: $0) })
             }
         } onCancel: {
+            // task-owner: a one-shot hop to the main actor (onCancel runs on any thread) that ends
+            // this waiter; the store's waiter list owns it, and a waiter already run is a no-op.
             Task { @MainActor in self.dropStandInWaiter(id) }
         }
     }

The findings, their resolution and the subagent SHIP for the code are in #26926 (comment). The fleet step d45637dd0710eab15a281ade (6/6 suites passed) ran at e545b3c, which has the same code apart from this comment.

# Conflicts:
#	Packages/macOS/CmuxNext/Sources/CmuxNextDaemonService/NewColumnCommand.swift
@austinywang

Copy link
Copy Markdown
Contributor Author

Sweep: blocker was a merge conflict with feat-cmux-next (1332 behind). Merged origin/feat-cmux-next at f8bd6ac (new head 71c3157). Resolved Packages/macOS/CmuxNext/Sources/CmuxNextDaemonService/NewColumnCommand.swift: base renamed the call to newColumn(beside:left:width:options:) for New Column Left; kept that plus this PR's async stand-in target (try await target()). This head needs CI and a review receipt before relabel.

@lawrencecchen lawrencecchen removed the ready-to-land Reviewed at this head; next-autoland merges it into feat-cmux-next when its only reds are base reds label Oct 31, 2026
@lawrencecchen

Copy link
Copy Markdown
Contributor

Removed ready-to-land: this PR conflicts with feat-cmux-next (DIRTY), so no merge can take it. Merge feat-cmux-next into the branch, resolve, and get a new receipt at the new head; then add the label again.

@teamleaderleo

Copy link
Copy Markdown
Collaborator

For awareness: this PR conflicts with feat-cmux-next, so it can't land until it's rebased or base is merged in. Its current review verdict stays as is; a hand-resolved merge gets a fresh review at the new head.

This branch was previously deployed

1 inactive deployment
ci-routing-agent — 71c31574 Deployed Oct 30, 2026 by austinywang via cmux-tui tree preflight #85548
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

risk:normal Risk tier (docs/ci/risk-tiers.md)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants