Skip to content

fix(next): Ctrl-`, Cmd-D and New Row always give a terminal; a refusal is never silent; no stand-in id reaches the daemon (P0) - #28331

Merged
lawrencecchen merged 39 commits into
feat-cmux-nextfrom
fix/p0-new-terminal-always
Oct 31, 2026
Merged

lawrencecchen merged 39 commits into
feat-cmux-nextfrom
fix/p0-new-terminal-always

Conversation

@lawrencecchen

@lawrencecchen lawrencecchen commented Oct 30, 2026 •

Copy link
Copy Markdown
Contributor

Summary

P0 from dogfood v2.2.9-opt (Lawrence, a 4 h session): "at some points I can no longer make new terminals via Ctrl-` or Cmd-D".

What the person saw. A new terminal (Ctrl-`), a split (Cmd-D) or a new row appeared for a moment and vanished, or the keys typed for it ran in the old terminal.

Cause, three faults in one family: the app took a new terminal back while the daemon still made it, or never asked for it correctly.

  1. A client clock decided whether the terminal exists. Ctrl-shows a stand-in tab at once and sendsnew-tabwith a client-chosen terminal id. That request had the 5 s spawn deadline. When the reply came later (it queued behind a slow request on the app's connection),DaemonService.intendNowReporting` took the stand-in back while cmux-tui still made the tab. New Row had the same 5 s deadline. Splits, columns and a new workspace's first terminal gave up after fixed 120 s or 10 s budgets.
  2. A stand-in id was sent to the daemon. A second Cmd-D (or a Ctrl-`) on a split still in flight targets the first split's stand-in pane. The app sent that stand-in's handle, which the daemon does not know ("unknown pane"), so the second pane vanished. The bench-arms run 02dcba2e saw this 12 of 12 times.
  3. The refusal was silent. A refused optimistic create only logged; the person saw the pane vanish with no reason (refusals []).

Fix.

  • A new-tab and a new-row (cmux-tui replays neither when sent again) are sent once and wait for their reply or the connection's end: DaemonConnection.requestAwaitingStart (CmuxNextDaemon/Connection/DaemonConnection.swift:154), used by newTab (DaemonConnection+Commands.swift:136) and RowCommands.newRow (RowCommands.swift:28).
  • Keyed creates the daemon replays (split, new-pane, new-pane-right, create-workspace, create-terminal) wait through late replies with no give-up budget. A re-send only repairs a lost request, so it is spaced by full-jitter exponential backoff from 1 s, capped at 30 s (R-L4): waitingThroughLateReplies and lateReplyBackoff (DaemonConnection+LateReplies.swift:101, :116, Backoff.fullJitter). Every new workspace with a terminal (Cmd-N, Open Folder, agent fork, agent teams, incognito) makes its first terminal through one path, WorkspaceCreation.firstTerminal (WorkspaceCreation.swift:78).
  • Rules (a) and (b) below.

Checklist (cmux-ad rules for this P0)

  • (a) A refusal is never silent. A refused optimistic create (Cmd-D split, Ctrl-` New Terminal Tab, New Row, New Column) rolls its stand-in back AND shows the refusal in the refusal HUD with a log line:
    • ActionRegistry.trackRollingBack (CmuxNextActions/ActionRegistry+Effects.swift:80), used at Handlers/PaneHandlers.swift:174, PaneController+Intents.swift:202 and WorkspaceContentController+Intents.swift:173.
    • The text is localized at the app boundary: ActionWorkFailure.create (Handlers/MiscHandlerStrings.swift:120), key handlers.misc.failed.pendingPaneNotCreated in 21 locales.
    • A create the person closed first (PaneSplitCancelled) is not a failure.
    • Test RefusedCreateIsNeverSilentTests.
  • (b) A stand-in id never leaves for the daemon.
    • Creates wait and resolve: every create that targets a pane resolves it at send time with DaemonStore.daemonTarget (CmuxNextDaemon/Store/DaemonStore+Intents.swift), used by PaneSplitCommand, NewColumnCommand, NewRowCommand and NewTerminalTabCommand. It returns the daemon's pane that took the stand-in over, or throws PendingPaneNotCreated. These creates run in the workspace's create chain (pendingCreate), so each goes out after the create that makes its target; the wait is the earlier request's own outcome, no timer. A Ctrl- tab on a stand-in follows it to the daemon's pane (IntentOverlay`).
    • daemonTarget also throws PaneNotReady when the pane it finds is still a stand-in (the pending pane of a Cmd-N workspace has no resource id yet).
    • One boundary for every sender: DaemonRequestExecutor.perform scans each encoded line before it is written (one byte search for a decimal number >= 2^62, a JSON parse only on a hit, keys pane/surface/target/anchor) and throws PaneNotReady for any request that names a stand-in, creates included, in every build. There is no daemon round trip and no DEBUG stop (the earlier TargetResolvedCreate exemption is removed: it crashed DEBUG on Split Left of a pending split and sent the stand-in in release). Cmd-N's workspace stand-ins (2^62 + 2^61) are caught too.
    • Shown once, for every sender: the refusal is reported in DaemonConnection.request (reported), the one place every request goes through: the DaemonService funnels and the handlers that call the connection themselves (a typed New Tab page command or a cmux.json newTabInCurrentPane, New Tab in Group, the non-optimistic split/column/row, agent fork). With no open action scope (DaemonCommandScope.isOpen), the connection calls its observer, which DaemonService sets on each connection it makes (observePaneNotReady): the notice shows on the next frame through the drain batcher (log line + HUD "This pane is still opening; try again in a moment.", handlers.misc.failed.paneNotReady, 21 locales), and the error is marked shown (PaneNotReady.shown, carried as ActionWorkFailure.shown). The registry (ActionRegistry+Effects), the handler reporters (AppActionContext.report, StashHandlers.report, agent create) and agent fork skip a shown failure, so it never shows twice. An open scope (CLI, MCP) answers the typed unavailable refusal in the app's language (DaemonService.workFailure) instead. The optimistic creates pass PaneNotReady up, not the generic "the daemon refused the split". Clear history does not report it to the crash reporter.
    • Tests SplitOfPendingSplitTests, NewTerminalOnPendingSplitTests, StandInReferenceTests (with StandInLineTests), PaneVerbOnPendingPaneTests (zoom shows once; Split Left on a pending split shows once, sends nothing, makes no pane; request and perform each show once).
    • Known limit (open decision for cmux-ad, STATUS 611/612): a create on the pane of a Cmd-N workspace that is still pending (Cmd-D, Cmd-Shift-D, Ctrl-`, New Column, New Row, Split Left/Up, a command typed on a New Tab page) is refused with "still opening" and is not made; the person presses it again once the workspace shows. Before this PR it crashed DEBUG or sent the stand-in ("unknown pane", no notice). Follow-up: one seam where every pane verb waits on the create chain for its pane.
    • Known open race, landing anyway by cmux-ad decision (2026-10-30 18:0xZ): issue P0: a line typed after a quick double Cmd-D can miss the new pane (rare race, after #28331) #28436, P0, owner the p0-port agent (a2c6445561ebeab90), who owns priority (a), the basic tasks. After a quick double Cmd-D, the second pane exists and has focus, but in a rare case the line typed into it does not run there.
    • Known limit (follow-up, agreed with the lead): in a CLI or MCP run, New Tab in Group (TabGroupHandlers.swift:114-125) and the non-optimistic split / new column / new row (WorkspaceContentController+Intents.swift spawnPane, :178-190) call the connection in a plain task with no ticket and only log a failure, so the run answers "ok" when the refusal comes, and the app shows no notice while that run is open (the connection leaves an open scope's refusal to the run). This was true before this PR for every error on these paths. Follow-up: cmux-next: a CLI/MCP pane create answers ok after a late refusal (New Tab in Group, non-optimistic split/column/row) #28394 (send them through a DaemonService funnel and track the work; a red CLI test for each of the 4 actions first).
  • R3 invariant, checked in DEBUG: a terminal stand-in is never taken back on a client deadline (DaemonService+StandInInvariant.swift:33), and no late-reply wait ever ends on a late start (checkNoLateStart).
  • Creation hold ends on a signal (R-L5). The hold records why it ended (CreationInputCoordinator.HoldEnd: focusLanded, limitReached, focusLeft, windowClosed, creationFailed). A limitReached end is logged (:211). NewTerminalOnPendingSplitTests asserts the Ctrl-hold ended withfocusLanded`.

Testing

All reds failed on their own assertion (not on build or runner), except c0fe852f, which failed by the base crash (the bug) as its row says. The zoom row is a guard test, not a red.

Suite Red ref Red job Failure
NewTabLateStartTests 7340d85 (base + test) eefaa476 timed out: new-tab (no reply within 0.2 seconds)
NewRowLateStartTests proof/p0-newterm-row-red c83d584 55feeced timed out: new-row (no reply within 0.3 seconds)
WorkspaceWithTerminalLateCreateTests ceac05e / proof/p0-newterm-firstterm-red2 0230e76 6a9f1764 / 79167017 old 10 s budget gave up / create-terminal ... 0.5 seconds
SplitOfPendingSplitTests proof/p0-newterm-twosplit-base (origin/feat-cmux-next + test) 9081d39 89d99910 new confirmed terminal panes 1 of 2; refusals []
SplitOfPendingSplitTests proof/p0-newterm-twosplit-red2 (head minus the two-split fix) c0cd1a7 bc080642 same
RefusedCreateIsNeverSilentTests proof/p0-newterm-rule-a2 f00df28 5ed751f9 the refused split showed nothing (HUD shown 0, was 0)
StandInReferenceTests, NewTerminalOnPendingSplitTests proof/p0-newterm-rule-b2 938bd6e 94d09943 stand-in not found (lines 9-11); the Ctrl-` tab never reached the split's pane
PaneVerbOnPendingPaneTests 442f88c (test before the fix) 95d2824c zoom on the pending pane showed nothing (HUD 0, was 0)
NewTerminalOnPendingSplitTests (with the hold-end check), StandInReferenceTests proof/p0-newterm-rule-b3 9214505 e74936a8 the Ctrl-` tab never reached the split's pane (line 33); stand-in not found (lines 9-11)
PaneVerbOnPendingPaneTests, Split Left (review #6 B1) 5e9beea (test before the fix) c0fe852f the base crash, not an assertion: splitLeftOnAPendingSplitsPaneShowsOneNoticeAndSendsNothing hit DaemonRequestExecutor.swift:68: Fatal error: split names stand-in pane=... (the DEBUG stop review #6 B1 named; the crash is the bug: Split Left sent a stand-in). It killed the process, so the funnel test did not run in this job.
PaneVerbOnPendingPaneTests, every funnel (review #6 B2) proof/p0-newterm-r6-b2-red d8cb74d6902 (head minus its rule (a) part: DaemonService.ended, ActionWorkFailure.shown, the reporters, the create rethrow; the B1 boundary kept; test files identical to the head's) 3754ffcc everyFunnelShowsAPaneNotReadyRefusalOnce failed on its own assertion (PaneVerbOnPendingPaneTests.swift:81:44, the until condition: request on a pane still opening showed no notice), no crash. Zoom and Split Left passed there (zoom goes through DaemonService.send, Split Left through perform and trackRollingBack; both showed the refusal before this fix).
PaneVerbOnPendingPaneTests, typed new tab on a pending pane (review #7 B1) 33e56b3 (test before the fix) 3cac6787 aCommandTypedIntoANewTabOnAPendingPaneShowsOneNotice failed on its own assertion (PaneVerbOnPendingPaneTests.swift:110:44, the until condition: the typed new tab on the pending pane showed no notice), no crash; the other 3 tests of the suite passed
PaneVerbOnPendingPaneTests, zoom none for this delta (95d2824c at 442f88c, the earlier red) a guard test, not a red: it passes at 5e9beea and at d8cb74d6902, so it does not prove this fix. Its new "exactly once" line guards against a second notice from ended plus the registry.

Green at the earlier head 670131c: cba1a2416c5fb6b6c721bf25, 11 suites, 28 tests, none skipped. Green at 321277d: c311521d (12 suites). Green at 3776515: 79fe4d7f (12 suites). Green at 642e879: f2d57d1a (12 suites, all ran, 22 tests by the run summaries, none skipped). Green at this head 69167de: 05563df226b8bea654bde31f (12 suites; result added when it ends). Suites: NewTabLateStartTests, NewRowLateStartTests, SplitLateStartTests, WorkspaceWithTerminalLateCreateTests, TerminalStandInRollbackInvariantTests, LateReplyBackoffTests, SplitOfPendingSplitTests, RefusedCreateIsNeverSilentTests, NewTerminalOnPendingSplitTests, StandInReferenceTests, CreateFailureTextTests, PaneVerbOnPendingPaneTests. Compile (CmuxNext + app, test targets) at this head 69167de: b3fd053c6fe4868e52de9d99 passed (e35af5f2 at aa8769f failed on one Sendable error, fixed in 69167de).

The proof refs are test-only proof/* branches listed in stopped.txt as never merge; each has test files identical to the head's.

Observed / Not run

Observed:

  • The bug on the dogfood build 7c900e1fa082 (ref 8a03862) in probe journeys on exclusive capture leases (cmux17, cmux20, cmux21): after Cmd-T, Ctrl-, the typed line ran in the old terminal or no terminal showed, 5 times. App log each time: new-conversation-tab ... no reply within 2.0 seconds, then new-tab rejected: timed out ... 5.0 seconds, then new-tab failed: the daemon refused the new tab`.
  • The holder of that stall, from samples during it (cmux20): the daemon's request workers were idle; one of the app's two daemon readers was parked in TerminalEventQueue.parkReader() for the whole 3 s, so replies waited unread behind one terminal's output. Filed for pa-b1 as TRANSPORT MULTIPLEX (handoff/followups.md); not fixed here.
  • All red and green jobs above.

Not run / only read:

  • The new journey newterm-after-new-tab-page (Ctrl-, Cmd-D and terminal-first Cmd-N right after Cmd-T) is on the perf port cp/fix-p0-new-terminal-always`, where the journeys live; it runs there on an optimized build.
  • The replay of held keys through NSApp.sendEvent (keys typed while a creation hold waits) is not covered in a non-key test window; the test types after the hold and asserts the hold ended with focusLanded.
  • SSH and Cloud remote links have no heartbeat, so a waiting create there is bounded only by link liveness; filed for pa-b1 (REMOTE LINK LIVENESS), with "a reply wait must honor the caller's cancellation".
  • Real dictation and a 2 h session were not run (the targeted repro was enough). The diagnostics' spare in "loading" and the PageWebView first responder were read in code and ruled out.

Follow-ups (separate PRs, mine): one seam where every pane verb (zoom, rename, swap, a New Tab page, the creates on a pending Cmd-N pane above) waits on the create chain for its pane instead of being refused, and where a create waits only when its target is a stand-in (review #4 N2, N3); a per-pane error state with its own strings and capture; a visible "starting…" label on a pending stand-in; the scripted newTab that made no sidebar row (followups.md, STATUS 578).

Performance

No perf numbers: this PR changes when a new terminal is made or refused (no client deadline, a stand-in id never sent), not how fast anything runs; "perf" here names the perf/chrome-parity branch, where the port runs the journey newterm-after-new-tab-page on an optimized build. The request boundary adds one byte search per request line (a JSON parse only on a hit), with no allocation on the common path.

Capture

No capture: the change is when a new terminal is made or refused under a slow daemon reply or a split still in flight, a race of a few milliseconds that no capture scene or tour holds; a still would show a normal terminal. The only new pixels are the existing refusal toast with the new localized text, which the tests assert by string key (MiscHandlerStrings.paneNotReady). The journey newterm-after-new-tab-page on the perf port drives the real keys on an optimized build.

Changelog

Fixed: Ctrl-`, Cmd-D and New Row always give a new terminal, also when the daemon answers slowly or a split is still being made; a refused one now says why.

…(P0 new terminal)

Fleet repro on v2.2.9-opt (cmux17, cmux20, cmux21): Cmd-T then Ctrl-`. The
new-tab missed the client's 5 s start deadline behind the New Tab page's
new-conversation-tab, and the app took the Ctrl-` stand-in back. Red on the
base: newTab throws terminalStartTimedOut at the client deadline.
…in back on a client clock (P0 new terminal)

v2.2.9-opt P0 (Lawrence): at times Ctrl-` made no new terminal. Fleet repro
(4/4 on cmux17, cmux20, cmux21): Cmd-T, then Ctrl-`. The New Tab page's
new-conversation-tab got no reply for seconds; the new-tab queued behind it on
the app's connection missed the 5 s spawn deadline; DaemonService rejected the
intent and the stand-in vanished, while cmux-tui still made the tab. The keys
typed for the new terminal went to the old one.

A new-tab with terminal-placement-env-v1 carries the stand-in's client-chosen
terminal id, and cmux-tui does not replay a new-tab sent again (splits re-send
with their client keys; new-tab cannot). So it now waits for its reply or the
connection's end (LineTransport fails pending requests on close): the daemon's
outcome decides, no client deadline does (principled-audit R5, R-L5). The shell
integration arguments are added through one helper for both paths (R1).

Regression test: NewTabLateStartTests (red on the base at 7340d85).
…n a client deadline

Ctrl-` and Cmd-D must always give a terminal that takes keys (principled-audit
R3, P0 2026-10-30). The one rollback point of every intent
(DaemonService.intendNowReporting) now stops DEBUG builds when it takes a
terminal stand-in (a New Terminal Tab's provisional tab, a terminal split's
pane) back for a client deadline: only the daemon's answer or the connection's
end may do that. TerminalStandInRollbackInvariantTests pins the judgement.
…its like a split, the late-start wait has no give-up

Review of the P0 fix (FIX at 9dc00b5): the DEBUG R3 check would stop the app
on two paths that still rolled a terminal stand-in back on a client clock:
- New Row (row-client-keys-v1) sent new-row with the 5 s start deadline: it
  now waits through a late start under its client keys, as a split does.
- The keyed late-start wait (splits, columns, panes, create-terminal) gave up
  after a fixed 120 s budget and on cancellation threw the timeout: it now ends
  on the reply, the daemon's refusal, the connection's end, or
  CancellationError (principled-audit R5, R-L5). The daemon replays each keyed
  pass, so a re-send changes nothing.
Also: the newTab doc moved above its attribute.

Regression test: NewRowLateStartTests (red at the commit before this one).
…lies; R3 covers every new terminal

Lead decision on the P0: Cmd-N is a new terminal too. Its create-workspace
asked again only until a fixed 10 s budget, and its first create-terminal was
sent once with the 5 s start deadline; either failure closed the workspace the
person already saw (WorkspaceCreation.withTerminal) while the daemon still made
it. Both steps are keyed (mutation id; terminal id + mutation id) and the
daemon replays them, so both now wait through late replies until the reply, a
refusal, the connection's end or cancellation (R5, R-L5):
- DaemonConnection.createWorkspaceWaitingForReply, createTerminalWaitingForStart(_:)
  and one shared loop, waitingThroughLateReplies.
- WorkspaceCreation.create (every workspace with a terminal) uses them; its
  rollback is checked in DEBUG (DaemonService.checkTerminalRollback).
- The R3 predicate covers .createWorkspace with a terminal tab.

Regression test: WorkspaceWithTerminalLateCreateTests (red at the commit before).
…al; R3 checked where the wait ends (review FIX at 70e92ae)

Review: WorkspaceCreation.create's DEBUG check judged every error of the
caller's terminal closure, but four callers still made their first terminal
with the 5 s start deadline (Open Folder / createAndShow and its callers, agent
fork, agent teams, incognito window), and a later step's 2 s timeout (configure,
incognito tab) would also have tripped it: DEBUG builds would stop there.
- Those callers make their first terminal with createTerminalWaitingForStart
  (keyed, replayed by the daemon), as Cmd-N does.
- The R3 check moves to the two waits themselves (requestWaitingForStart,
  createWorkspaceWaitingForReply): DEBUG stops if either ever ends on a late
  start, which their loop never does. A caller's own later step is not judged.
- WorkspaceWithTerminalLateCreateTests also makes the first terminal start late
  (the workspace part's red is ceac05e).
…e first-terminal path for every new workspace

Lead decisions on the P0 (R-L4, R1):
- waitingThroughLateReplies keeps waiting with no end, but a re-send (it only
  repairs a lost request; the daemon replays keyed creates) is spaced by
  full-jitter exponential backoff from 1 s, capped at 30 s
  (Backoff.fullJitter, the shared retry type), not at the fixed 2 s / 5 s
  deadline: no retry storm on a hung daemon. Cancellation ends it during the
  backoff wait.
- WorkspaceCreation.firstTerminal is the one way the first terminal of a new
  workspace is made (Open Folder, fork, teams, incognito); the test uses it.
- Tests: the scripted daemons of NewRowLateStartTests and
  WorkspaceWithTerminalLateCreateTests end their start once and replay later
  asks at once, as cmux-tui does (before, each ask was answered a fixed delay
  after it arrived, which no deadline shorter than that delay could ever see).
  LateReplyBackoffTests pins the spacing.
…ane_id_exists), as cmux-tui does (third review)
…s not replay new-row (third review FIX at e4e6774)

The third review found that a keyed new-row sent again after a late start is
refused pane_id_exists by cmux-tui (mux/rows.rs commits it unkeyed;
resource_topology/client_ids.rs refuses a held pane id), so the re-send made the
app take the row back on that refusal. New Row now goes the way new-tab goes:
sent once, answered by its reply or the connection's end (requestAwaitingStart,
R5, R-L5). requestPaneCreation is again split, new-pane, new-pane-right only.

Regression test: NewRowLateStartTests (its daemon now refuses a second ask; red
at the commit before this one).
…pane (P0; bench-arms 02dcba2e: 12/12 made none)
…e, not its stand-in

Lead P0 evidence (bench-arms 02dcba2e1c9e447c7cad0594): in a quick pair of
splits the second Cmd-D / Cmd-Shift-D made no pane within 5 s, 12 of 12.
Cause: the second split targets the focused pane, which is the first split's
stand-in (ProvisionalPane), and PaneSplitCommand sent that stand-in's handle,
which the daemon does not know; the refusal took the second pane back. New
Column and New Row already resolve the target at send time and run in the
workspace's create order; the split did neither.
- PaneSplitCommand resolves its target at send time
  (DaemonStore.daemonHandle(showing:)) and waits until the store has applied
  its own reply, as NewColumnCommand / NewRowCommand do.
- PaneHandlers.split's optimistic path joins the workspace's create chain
  (WorkspaceContentController.pendingCreate), as createInKeysTurn does: it goes
  out once the create before it is in the store (R1, one path).
The wait is the earlier request's own outcome, no timer (R5).

Regression test: SplitOfPendingSplitTests (red at the commit before).
…d-D and checks it runs in that pane's shell (the P0 invariant: a terminal that takes keys)
…-keys-v1 as cmux-tui does (the app sends a row's client keys under it)
…o stand-in; Ctrl-` on a pending split's pane opens there and takes keys (P0 rules a, b)
…es for the daemon (P0 rules a, b)

cmux-ad's two rules for the P0, both in this PR:
(a) A refusal is never silent. A person's optimistic create (Cmd-D split,
    Ctrl-` New Terminal Tab, New Row, New Column) the daemon refuses rolls its
    stand-in back AND shows the refusal where refusals show (refusal HUD) with a
    log line: ActionRegistry.trackRollingBack, the legacy handlers' form of the
    ActionEffect.applied failure path (remoteFailureObserver). A create the
    person closed first (PaneSplitCancelled) is not a failure.
(b) A stand-in id never leaves for the daemon. Every create that targets a
    pane resolves it at send time with DaemonStore.daemonTarget(_:) (the daemon's
    pane that took the stand-in over; PendingPaneNotCreated when its create was
    never made) and runs in the workspace's create chain (pendingCreate), so it
    goes out after the create that makes its target: split, New Column, New Row
    and now New Terminal Tab. The New Terminal Tab overlay on a stand-in follows
    it to the daemon's pane (ProvisionalSplit.rememberStandInTarget/shownPane).
    DEBUG builds stop at a typed request that names a stand-in pane or surface
    (DaemonRequestExecutor.standInReference).

Tests: RefusedCreateIsNeverSilentTests, NewTerminalOnPendingSplitTests (red at
the commit before), StandInReferenceTests.
…PendingPaneNotCreated, 21 locales); the English text stays for logs

Lead decision: the refusal HUD is this PR's visible error; its text reaches the
person, so PendingPaneNotCreated maps to MiscHandlerStrings.pendingPaneNotCreated
(MiscHandlers.xcstrings: en and ja translated, the other 19 locales
needs_review as the table's other keys) in ActionWorkFailure.create, the one
failure builder of Cmd-D, Ctrl-`, New Row and New Column. Test:
CreateFailureTextTests.
… main-actor strings; every caller is main-actor)
…eation hold ended (a test window is never key, as the journeys' outwait_hold) and says where a lost line went

Green 87ad5a04 at 98b377d: 10 of 11 suites passed; this one failed at
the typed line (wait-for timeout), every earlier check holding (two terminals
in the confirmed pane, the new one selected, focus and responder in the pane).
Feat's creation hold replays held keys with NSApp.sendEvent, which reaches only
the key window. CreationInputCoordinator.isHolding(in:) (as perf has it) lets
the test type after the hold; on a miss it records each terminal's screen,
the responder and the hold.
…Reached | focusLeft | windowClosed) and logs a limit end; NewTerminalOnPendingSplitTests asserts focusLanded

Lead decision (R-L5: a hold's end is a signal): waiting in the test for the
hold to end could hide keys a person feels up to 2 s late. The hold now records
its end per window (CreationInputCoordinator.lastHoldEnd(in:)): unforced means
every creation replied and the focus it asked for holds with a responder that
takes keys (focusLanded); the two bound timers are limitReached and log a
notice (window, held key count, last action) so a person's log shows it; a key
leaving the content is focusLeft. The test asserts the Ctrl-` on a pending
split's pane ended its hold with focusLanded before it types.
@lawrencecchen lawrencecchen added the risk:normal Risk tier (docs/ci/risk-tiers.md) label Oct 30, 2026
…the service (compile e35af5f2: sending 'service' risks data races)
@lawrencecchen

Copy link
Copy Markdown
Contributor Author

Review (independent review subagent #8, Claude Opus 5.5): SHIP at 69167de
reviewer-session: a62554ee-b4da-41b3-ab45-99b673c217f5
author-session: aea455c6973baac0f
R9: pass (the delta adds no cap, queue limit or deadline; the notice goes on the next frame, which only sets when it shows)
perf claim: none

Scope

  • I read git diff 642e8790b63..69167de6264 closely (9 files).
  • I also read the parts of the PR range (7986c3fbf7f..69167de6264) that the delta touches:
    • DaemonConnection.request and DaemonRequestExecutor.
    • Every DaemonService funnel (send, send(onFailure:), failure, runReportingTimeout, intendNowReporting, perform, request).
    • DaemonService.start (local and remote) and didConnect.
    • The four optimistic create commands and DaemonStore.daemonTarget.
    • The registry reporters (ActionRegistry+Effects), the app reporters (AppActionContext+Targets, StashHandlers, AgentHandlers+Pane) and ActionWorkFailure.create.
    • The action.run and action.batch scope binders and settle.
    • The senders that review Add bump-version script and improve reload script #7 B1 named.
    • Every other DaemonConnection( site.
  • I read the code only. I did not build or run tests on this machine. I read the fleet logs of the jobs below.

Review #7 findings

  • B1 (senders that call DaemonConnection directly showed nothing for a PaneNotReady): fixed.
    • DaemonConnection.request(_:timeout:) (DaemonConnection.swift:174-178) catches the boundary PaneNotReady. reported (:188-194) shows it once through the observer and marks it shown, but only when no open action scope answers it.
    • Every typed request goes through that method:
      • newTab goes through requestAwaitingStart.
      • split, newColumn and newRow go through request or requestPaneCreation and waitingThroughLateReplies.
      • spawnPlacedByMove and the agent fork's split, swap and newTab go through request too.
    • So the senders that review Add bump-version script and improve reload script #7 named all show the notice once from a key, menu or click run:
      • The non-optimistic newTerminalTab(typing:) / typingAhead: (PaneController+Intents.swift:139-158).
      • tabGroup.newTab (TabGroupHandlers.swift:114-125).
      • spawnPane (WorkspaceContentController+Intents.swift:178-190).
      • The agent fork (AgentHandlers.swift:445-452).
    • Isolation and threading of the hook:
      • The observer is set before didConnect publishes the connection, in both start paths (DaemonService.swift:248 and :332). The prestart connection goes through the first path.
      • A reconnect keeps the same actor, so the observer stays.
      • A handoff makes a new connection through the same path.
    • DaemonService.ended is gone. The funnels now only close the ticket and pass on the error that the connection marked. workFailure and ActionWorkFailure(_:_:) keep shown, .create keeps shown, and every reporter that I found skips a shown failure. I found no path that shows the notice twice.
    • The fork now shows MiscHandlerStrings.paneNotReady, not Foundation's generic text, and skips a shown one.
  • N-a (stale shown doc): fixed (DaemonStore+Intents.swift:254). One stale comment remains elsewhere (N1 below).
  • N-b (the funnel test makes up a stand-in): open, not changed. The new test aCommandTypedIntoANewTabOnAPendingPaneShowsOneNotice is a real path: a pending split's pane and PaneController.newTerminalTab(typing:), which is what ConfigCommandRunner calls. There is still no test for Ctrl-` or Cmd-D on a pending Cmd-N pane (N3 below).
  • N-c (English asserted): fixed. The tests compare with MiscHandlerStrings.paneNotReady. One negative English check remains: !notice.localizedCaseInsensitiveContains("unknown pane") (PaneVerbOnPendingPaneTests.swift:33). It is a guard, not the claim.
  • N-d (scope closes after openTicket, so the refusal shows zero times): fixed for boundary refusals. reported checks DaemonCommandScope.current?.isOpen when the refusal happens, not whether a ticket exists. A task that outlives its run (the scope is closed) shows the notice.
    • The task-local is read in the actor method of the same task, so it is the caller's scope.
    • An open scope gets the refusal through the ticket's failure (closeTicket records it), so settle answers the typed refusal.
  • N-e (clear-history sent typed refusals to the crash reporter): fixed (TerminalHandlers.swift:113-114, failure.refusal != nil).
  • N-f (PR body): not fixed. The body is now more stale. See N2.

BLOCKING

None.

NON-BLOCKING

  • N1. Stale comment. DaemonRequestExecutor.swift:13 still says "DaemonService.ended shows it once". Fix: "DaemonConnection.request shows it once (observePaneNotReady)".
  • N2. The PR body is stale. Edit it before ready-to-land. (A body edit does not change the head, so this receipt stays valid.)
    • The rule (b) bullet "Shown once on every funnel: DaemonService.ended (DaemonService+Scope.swift) ..." describes a mechanism that this head deletes. It must describe DaemonConnection.request -> reported -> observePaneNotReady -> DaemonService.paneNotReadyReporter, which shows the notice on the next drain frame.
    • It must also say that a daemonTarget refusal (a pending Cmd-N pane) is not a boundary refusal. That refusal shows through trackRollingBack / remoteFailureObserver.
    • The Testing table must add:
      • The red 3cac6787dad804786ba87493 at 33e56b3: aCommandTypedIntoANewTabOnAPendingPaneShowsOneNotice, PaneVerbOnPendingPaneTests.swift:110:44, "timed out: a typed new tab on the pending pane showed nothing (HUD 0, was 0)".
      • The compile b3fd053c6fe4868e52de9d99 at 69167de.
      • The 12-suite green 05563df226b8bea654bde31f at 69167de, once it ends.
    • The known limit about issue cmux-next: a CLI/MCP pane create answers ok after a late refusal (New Tab in Group, non-optimistic split/column/row) #28394 is not in the body. Add it under "Known limit": a CLI/MCP run of New Tab in Group or of the non-optimistic split/column/row answers ok after a late refusal. While the run's scope is open, the app also shows no notice for it, because reported leaves it to the scope, but no ticket or tracked work carries it. Link cmux-next: a CLI/MCP pane create answers ok after a late refusal (New Tab in Group, non-optimistic split/column/row) #28394.
    • The zoom-row text "a second notice from ended plus the registry" and the "(both go through DaemonService.send ...)" row text name ended. Change them to describe the connection.
  • N3. No test for the Cmd-N known-limit path. I read the code for Ctrl-`, Cmd-D, New Column and New Row on a pending Cmd-N pane at this head:
    • daemonTarget throws inside the intendNowReporting body, before any request, so reported does not see it.
    • closeTicket records it, the stand-in rolls back, and the command throws the unshown PaneNotReady.
    • .create sets the localized text, and trackRollingBack shows it once through remoteFailureObserver. A capturing run gets the typed unavailable.
    • So the path is correct, but no test covers it. The earlier ended showed it, and this delta moved who shows it. A test for it would fail if the registry reporter changed. This is a follow-up, not a blocker: the green suite's RefusedCreateIsNeverSilentTests covers the same trackRollingBack reporter.
  • N4. action.run with wait: false. The run answers at once, and its scope stays open until settle ends. A boundary refusal in that window is recorded in the scope (only the idempotency cache reads it), and reported shows no notice. So a no-wait CLI run's refusal shows zero times. This is an edge case that the caller chose. One fix: only treat the scope as answering when the run waits (a flag on the scope).
  • N5. Two reporters for one error type. The connection observer shows boundary refusals, and the registry reporter shows daemonTarget refusals. Each error goes through exactly one of them (shown carries the choice), so the result is correct. The "one seam" follow-up (STATUS 611/612) can remove the second.
  • N6. AgentCLIUpdateActions.runRegistry binds a DaemonCommandScope that it never closes (pre-existing). Its work does not name panes, so it has no effect here. I note it because reported now depends on isOpen.
  • Connections that the service does not make have no observer: AgentPaneGitLink, PageRelayChannel, ChiefConversationOwner, ChiefProfilePageRelay and DaemonCompatBackend. None of them sends a pane or surface that the app made up (they read their own daemon trees or send no pane), so none can send a stand-in. Their refusal would still be thrown and logged, not sent.

Ordering, isolation, reentrancy

  • reported runs on the connection actor. It calls the observer synchronously. The observer only enqueues a main-actor block (FrameBatcher.scheduleFrame -> MainRunLoopHop). It does not block, await or reenter the actor.
  • The hook carries a @MainActor @Sendable closure with [weak self] and the batcher (a main-actor class), not the service. PaneNotReady is a Sendable struct. Compile b3fd053c passed.
  • The notice shows one frame after the refusal. The tests wait with until(shownCount > shown) and check == shown + 1 only after a later condition (the split confirms). No second source posts later, so the checks do not race. In everyFunnelShowsAPaneNotReadyRefusalOnce, the test waits for > shown + 1 before it checks == shown + 2. The batcher queue is FIFO.
  • onPaneNotReady -> registry.refuse runs in its own frame turn, outside any synchronous capturingRefusal or reportingRefusal, so refusalHasCaller is false and the HUD shows.
  • I found no regression for normal creates or for daemon refusals. Only PaneNotReady takes the new catch, and the funnels' other errors are unchanged.

Known limits (true; one is missing from the body)

Proof jobs (checked with cmux-ci status and cmux-ci log)

  • Red 3cac6787dad804786ba87493 (ref 33e56b3, suite PaneVerbOnPendingPaneTests, worker cmux-austin-mini-0): failed on the new test's own assertion.
    • aCommandTypedIntoANewTabOnAPendingPaneShowsOneNotice recorded an issue at PaneVerbOnPendingPaneTests.swift:110:44, "timed out: a typed new tab on the pending pane showed nothing (HUD 0, was 0)", after 20.6 s.
    • The other 3 tests passed: zoom, Split Left, and every funnel.
    • It is not a build, runner or crash failure: "Test run with 4 tests in 1 suite failed ... with 1 issue", then the step exited 1.
    • The ref is fair. git diff 33e56b395b8 69167de6264 -- Packages/macOS/CmuxNext/Tests is empty, and git diff 642e8790b63 33e56b395b8 -- Packages/macOS/CmuxNext/Sources is empty.
  • Green 05563df226b8bea654bde31f (ref 69167de, 12 suites, worker cmux8s-Mac-mini.local, started 15:18 UTC): had not ended at 15:51 UTC, when my wait budget ended. It was still compiling (the log tail showed only compiler warnings). I could not check it. Do not add ready-to-land until it shows all 12 suites passed with none skipped.
  • Compile b3fd053c6fe4868e52de9d99 at 69167de: passed (the lead's report; I did not open its log).
  • The earlier reds (c0fe852f, the Split Left base crash; 3754ffcc, the funnel test assertion at :81, ref d8cb74d6902) were checked in review Add bump-version script and improve reload script #7. This delta does not change them.

Rules

  • R1: pass. Rule (b) has one boundary for every sender (DaemonRequestExecutor). Rule (a) now has one reporter for every boundary refusal, at the one method that every typed request goes through (DaemonConnection.request), for every sender, funnel or direct. daemonTarget refusals use the registry reporter (N5). shown keeps each error to exactly one notice.
  • R3: pass. "No request that names a stand-in leaves the app" is enforced in every build at one boundary. "Shown once" is carried by PaneNotReady.shown / ActionWorkFailure.shown through every reporter that I found.
  • R5: pass. No timer decides an outcome. The next-frame post only decides when the notice draws. The tests wait on conditions, with the harness's failure deadline.
  • R9: pass. No new limit.
  • REVIEW.md: section 1 is met (the new test fails without the fix on its own assertion, and the notice is compared with its string; one English negative guard remains). Section 9 is met for key, menu and click runs (B1 fixed); the CLI-side gap is issue cmux-next: a CLI/MCP pane create answers ok after a late refusal (New Tab in Group, non-optimistic split/column/row) #28394. Section 11 is met (no main-actor block, and the hook isolation is correct). Docs out of date: N1, N2.

@lawrencecchen

Copy link
Copy Markdown
Contributor Author

Review #8 addendum (independent review subagent #8, reviewer-session a62554ee-b4da-41b3-ab45-99b673c217f5). This is a note on the proof job. It does not change the receipt above.

The green job 05563df226b8bea654bde31f at 69167de ended after I posted the receipt. It failed (the step exited 1).

  • 11 of 12 suites passed, including PaneVerbOnPendingPaneTests (4 tests, with the new test) and RefusedCreateIsNeverSilentTests.
  • SplitOfPendingSplitTests.aSecondSplitOnAPendingSplitsPaneMakesASecondPane failed with "Caught error: wait-for failed: timeout waiting for pattern" (reported at :14:6).
    • That is the last step, the WaitForTextRequest at :60. The line typed into the second split's pane did not show in its screen within 20 s.
    • Every check before it passed, because none recorded an issue: both splits confirmed with no refusals, the second pane got focus, and the first responder was in that pane.
  • My classification: probably not caused by this delta, but not proven.
    • The delta changes only how a PaneNotReady is reported (the connection observer, isOpen, the funnels close tickets). It does not change what is sent, so it cannot stop a key from reaching a confirmed surface.
    • This test passed at 642e879 (f2d57d1a) and at earlier heads.
    • The host logged spell-server and naturallanguaged lookup errors during the test.
  • The same rule as in the receipt applies: do not add ready-to-land until a green at this exact head runs all 12 suites with none skipped. Rerun SplitOfPendingSplitTests (or the 12-suite set) at 69167de. If it fails again on the same line, it is a defect of this PR or its base, and the SHIP above does not stand.

@lawrencecchen

Copy link
Copy Markdown
Contributor Author

Checks on head 69167de, read with gh pr checks 28331 (2026-10-30 18:1xZ): every red classified. None is this PR's own.

Check Class Evidence
cmux-next daemon tests (job) base, fixed on feat by #28362 ClassicUpgradeSimulationTests.anOffSSHHostDoesNotHoldBackLocalWorkspaces (:239, "order differs"). The branch predates #28362 (merged 16:11Z, 13ac5eb; git merge-base --is-ancestor false), which fixes that race. Merge-base 7986c3f run 7c355371baf44c91ad55dc63 passed. Head side: shared job 17418039cdae019ea11d141f (merged by the controller with the port agent's control run), suite ClassicUpgradeSimulationTests at 69167de: same "order differs" race at :45, :52, :206, nothing else.
tooling-tests (job) base, fixed on feat by #28426 "Python test execution registry validation failed: tests/test_cmux_tui_owned_mini_caches.py: linux-guard lane is not run by any workflow". This PR touches no file under tests/, scripts/ci or .github; #28426 (84770f4) registers that test as manual. tooling-tests passes at the feat tip (run 41102102719).
nightly-guards / workflow-guard-tests / release-notary (job) base Red at the feat tip too: runs 41102102719, 41098625873, 41097407061 on feat-cmux-next.
nightly-guards / Guard status, pr-red-checks / comment wrappers Over the reds above.
next-autoland / verdict wrapper, stale It named pr-capture-check, read before the description had its capture line.
pr-capture-check, pr-perf-section-check this PR's own, fixed in the description Now green: capture (No capture: <why>), perf section (No perf numbers: <why>).
defaults guard this PR's own, cleared Green on rerun after review #8's SHIP at this head (job).

Pending: cmux-next swift test, cmux-next Release compile (Xcode 26). The fleet Release compile passed, and fleet compile b3fd053c6fe4868e52de9d99 passed at this head.

Fleet test results at this head (not a GitHub check): the 12-suite green 05563df226b8bea654bde31f failed one suite, SplitOfPendingSplitTests. That is the known open race, issue #28436 (P0, owner p0-newterm agent), measured at 2 failures in 13 runs with the fix against 12 of 12 failures on base; cmux-ad decided to land with it. The other 11 suites passed. The PR body has the rates and the job ids.

@lawrencecchen lawrencecchen 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 30, 2026
@lawrencecchen

Copy link
Copy Markdown
Contributor Author

Red check classification: cmux-next daemon tests (run 41077284465, job 124140588302) is a base finding, not a red of this PR.

lawrencecchen added a commit that referenced this pull request Oct 30, 2026
…t (owner a2c6445561ebeab90, expiry 2026-11-06)

The perf port of feat #28331 (the P0: a quick second Cmd-D / Cmd-Shift-D made no pane)
adds 62 CmuxNextApp lines: the create-and-wait chain in the App's own controllers
(pendingCreate and the creates that join it, PaneSplitCancelled handling,
trackRollingBack of a refused create), the creation-hold end log, and the
pane-not-ready hook wiring. What could move already moved (refusal strings and
ActionWorkFailure.create to CmuxNextDaemonService, the hold-end record to
CmuxNextFocus, the waiting creates to DaemonConnection).

Allowed by cmux-ad item 727 for this port after the perf suites passed (job
0d3a1c41b49ef8665e62b106 at b52e9f7: the 6 P0 suites, 13 tests, all pass). The
independent exception review checked each of the 62 lines as the P0 fix itself:
"Review (independent review subagent, Claude Opus 5.5, app-lines exception): SHIP at
b52e9f7". The follow-up that moves the chain out is
STATUS item 810 (owner a2c6445561ebeab90, due 2026-11-06).
@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
One hand-resolved hunk, Packages/macOS/CmuxNext/Sources/CmuxNextApp/PaneController+Intents.swift,
in newTerminalTabNow (Ctrl-` with a stand-in):
- feat-cmux-next: `selectWhenReported(surface: pending.surface)` became
  `if selectHere { selectWhenReported(surface: pending.surface) }` (a Home dock placement
  that owns its selection does not select here), with HomeDockOpens.cancel in both catch
  branches; the task was still `services.registry.track(Task { ... })`.
- this branch: the task joins the workspace's create chain (`let previous =
  self.workspace?.pendingCreate`, `await previous?.value`, `let work: ActionWork = Task`,
  `self.workspace?.pendingCreate = work`, `services.registry.trackRollingBack("newSurface",
  work)`) and returns `.create("new-tab", error)`.
Resolution keeps both: the `if selectHere` guard from feat, then this branch's chain lines;
the HomeDockOpens.cancel calls from feat merged without a conflict in both catches, and
this branch's `.create` return and trackRollingBack stay. No other conflict.
@lawrencecchen

Copy link
Copy Markdown
Contributor Author

Review (independent review subagent): SHIP at 47a86e1

Scope: the merge 47a86e1 (parents 69167de = PR head with SHIP #8, 5b65b83 = origin/feat-cmux-next tip), read with git show --remerge-diff HEAD, newTerminalTabNow on both parents and the merge, and git diff origin/feat-cmux-next...HEAD. I read code only; I did not build or run tests.

  1. NON-BLOCKING (resolution correct). Packages/macOS/CmuxNext/Sources/CmuxNextApp/PaneController+Intents.swift:186-227. The one conflicted hunk keeps both sides. From feat: the selectHere/homePlacement parameters (:187-188), if selectHere { selectWhenReported(surface: pending.surface) } (:197), the if selectHere { ... expectFocus } block after the reply (:208-211), and HomeDockOpens.cancel in both catches (:216, :219). From the PR: previous = self.workspace?.pendingCreate (:200), let work: ActionWork = Task (:201), await previous?.value after the defer (:204), .create("new-tab", error) (:221), pendingCreate = work (:224), trackRollingBack("newSurface", work) (:226). The merge-to-feat diff of this file is exactly the PR's chain lines plus the .create return, and nothing else.

  2. NON-BLOCKING (the guard and the chain work together). selectHere controls only selection and focus: the stand-in's selection in the key's turn, and the post-reply select/expectFocus. The chain controls only when send() starts. For a Home dock placement (selectHere == false), the HomeDockOpens.placing hold is armed in the handler turn, before the chain wait. then (the move beside) still runs after the reply and before the defer resolves the key ticket, so feat's continueCreation hold order does not change. In both catches, HomeDockOpens.cancel runs after the chained wait. trackRollingBack then shows the refusal once (!failure.shown). Only one task is tracked per create, as before, so capturing callers still get one ActionWork.

  3. NON-BLOCKING (no auto-merge drift). For every file in the PR, I compared the PR's own +/- lines at old base 7986c3f..69167de with HEAD^2..HEAD. They are the same set for every file, so the merge dropped or changed none of the PR's lines. Feat's changes to the shared files do not touch what the PR uses:

    • DaemonConnection.swift: feat changed only resourceRequest(requiring:). The PR changed request(_:), request(_:timeout:), withShellIntegration, requestAwaitingStart and the PaneNotReady observer.
    • DaemonService.swift: feat added beginAttempt(). observePaneNotReady is still set on both connect paths, at :256 and :340.
    • CreationInputCoordinator.swift: feat added continueCreation/hasPendingCreation. They do not interact with the PR's HoldEnd/failedCreation bookkeeping.
    • pendingCreate is still ActionWork? (WorkspaceContentController.swift:53). Its only users are the PR's three chain sites.
    • feat added no new intent senders that the R3 stand-in invariant (DaemonService+StandInInvariant.swift) would need to cover.
    • The removed lateTerminalStartBudget has no remaining references.
    • feat's new trackedWorkEffect reports through .waiting → start, which honors shown.
    • MiscHandlers.xcstrings parses with no duplicate keys. paneNotReady, pendingPaneNotCreated and feat's nothingReadAloud each have all 21 locales.
  4. NON-BLOCKING (feat's code; this PR did not add it). Handlers/HomeDockOpens.swift:99-101: HomeDockOpens.cancel calls OutboundTab.cancelLatest, which finishes the pane's most recent placement hold, not the hold of the create that failed. Example: two Ctrl-` presses in Home's dock, where the first fails and the second succeeds. The first failure finishes the second's hold, and the first's hold stays open. This was also possible on feat without the chain, because both sends ran at the same time. The chain makes the completion order fixed but does not make the case more common. This is a follow-up for the HomeDockOpens owner, not for this PR.

No blocking findings.

@lawrencecchen lawrencecchen 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 31, 2026
@lawrencecchen
lawrencecchen merged commit 4d1e169 into feat-cmux-next Oct 31, 2026
178 of 186 checks passed
@lawrencecchen
lawrencecchen deleted the fix/p0-new-terminal-always branch October 31, 2026 01:15
@lawrencecchen

Copy link
Copy Markdown
Contributor Author

The cmux-next daemon tests red on this PR (job 124252955870: aDowngradeAndReUpgradeMergesOnlyNewWork ClassicUpgradeSimulationTests.swift:184, order differs) is the base flake #28425, not this PR's change. Root-cause fix: #28526 (reviewed, ready-to-land). Once #28526 is on feat and this PR's head has it, the daemon-tests job is rerun once.

lawrencecchen added a commit that referenced this pull request Oct 31, 2026
…r 0.5 s of quiet (#28425) (#28526)

* cmux-next: classic upgrade checks wait for placements to land, not for 0.5 s of quiet (#28425)

ClassicUpgradeSimulator.settle() treated 0.5 s with no visible change as
done. Since #26153 a classic import places each new workspace at its slot
through the sidebar document (a `sidebar.apply` move, not the personal
placement queue that #28362 waits for), so on a loaded runner a move or an
adopt could still be on its way after the quiet window: the check then read
an order or a group the late move changed (#28331 attempts 1 and 2, #28425).

Product: SidebarDocStore counts the changes and adopts it sent and not yet
answered, and exposes isSettled and settled() (event-driven: each reply and
load end resumes the waiters). The classic import waits for it after the
personal queue, so a workspace's group write is sent after its slot move
is committed on the daemon, on the document path too.

Tests: settle() and the ClassicMigrationTests launch helper read the tree
only once every completion signal holds: the personal queue through, the
document settled, the store applied through an event barrier taken after
those replies, no live workspace still waiting for its new-workspace
placement, and the document projection current. No quiet window; a hang
guard records an issue naming what is still in flight.
SidebarDocSettledTests covers the new signal.

SidebarDocStore.swift: the remote protocol, its reply and SidebarDocUnsettled
move to SidebarDocRemote.swift (no change), so the file shrinks.

Sidebar-Look: no visual change
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* cmux-next tests: time the classic import's wait for the sidebar document (#28425)

The import now waits for its slot move's reply before each group write.
ClassicUpgradeSimulationPerfTests records that wait per imported workspace
through a DEBUG bench span (BenchSpans, nothing recorded in release) and
prints n, sum and p50/p95/max per profile (CLASSIC-SIM-PERF-SETTLE). The
perf suite also holds the one SidebarDocStore (.ownsSidebarDocStore), as
the other live-daemon suites do.

Sidebar-Look: no visual change
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* cmux-next tests: the parked check waits for the shells to exit, not for settle()'s quiet window (#28425)

backgroundWorkspacesStartTheirTerminalsWhenFirstShown read the parked state
right after settle(). `park-tabs` answers before the shells exit; a tab shows
dead with `relaunch` only once its host exited. The 0.5 s quiet window that
settle() no longer has covered that by chance; without it the last
workspace (scratch) read "not parked: [(false, nil)]" in 5 of 10 runs at
7f80b7d (step 815e7963). The check now reads once every background tab
shows parked (the daemon's tab-exit events), with a 60 s hang ceiling.

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

* cmux-next tests: forced-order red for #28425, sidebar document moves held past any quiet window

ClassicUpgradeHeldDocumentTests holds every sidebar document change and
adopt (the classic import's slot moves) for 4 s before it reaches the
daemon, as a loaded runner's slow reply does. The check must read the tree
only once those moves have landed. The test seam is a
`wrapping:` parameter of SidebarDocWiring.start (default: the daemon remote
as is), passed by ClassicUpgradeSimulator(documentRemote:).

The red is on a scratch never-merge ref (this test over the base logic:
settle() with its 0.5 s quiet window and the import without the document
wait); this commit and the branch head are the green.

Sidebar-Look: no visual change
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* cmux-next tests: the held-document red holds each move until someone waits for it, with no clock (#28425)

The 4 s hold was still a clock. HeldRemote now holds every sidebar document
change and adopt until someone waits in SidebarDocStore.settled() (the
store's onSettledWait test seam releases them; one sent while someone waits
goes on at once). The test reads, in the turn settle() returns in, how many
are still held: a settle that does not wait on the document returns with
moves held (red, every time); the new one cannot return until they are
released and answered (green). placementsSettled() now awaits the store's
settled() instead of polling isSettled.

Sidebar-Look: no visual change
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* cmux-next tests: a placement wait that hangs names what is pending (#28425)

placementsSettled() keeps the event-driven SidebarDocStore.settled() wait,
with a 60 s hang guard (a ceiling, not a quiet window): if the store has
not settled by then, the recorded issue says what is pending, from the
store's new pendingDescription (changes and adopts unanswered, a running
load, changes a load must settle, waiters). The held-document test prints
it in its failure too.

Sidebar-Look: no visual change
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* cmux-next tests: fix the compile of the held-document test and the placement wait (#28425)

Compile 6af24f9b at 08910ed failed:
- ClassicUpgradeHeldDocumentTests.swift:73: the trailing closure matched
  ClassicUpgradeSimulator.init's `ssh:` parameter (String), not
  `documentRemote:`; it is passed by label now.
- AppServices+PlacementsSettled.swift:41,50: `pendingDescription` is
  internal to CmuxNextSidebar; the helper imports it @testable.

Also: the held remote's `waiting` is typed @mainactor, the local in
release() no longer shadows it, and after the read nothing more is held
(shutdown's changes go through). The await on the settled wait is its own
statement.

Sidebar-Look: no visual change
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
lawrencecchen added a commit that referenced this pull request Oct 31, 2026
feat (#28331, P0 2026-10-30) already makes split, New Column and New Row wait
for their reply with no client clock: split and new-pane-right through
waitingThroughLateReplies (unbounded, jittered re-send), new-row through
requestAwaitingStart (sent once, no deadline). Conflicts resolved to feat's
side:

- DaemonConnection+LateReplies: feat's requestWaitingForStart and
  waitingThroughLateReplies; this branch's budgeted retryingLateStarts and
  creatingShown are dropped (their only caller, New Row, now awaits its start
  with no deadline, so the wrapper could never retry).
- PaneSplit/NewColumn/NewRow commands: feat's daemonTarget and
  intendNowReporting.
- CreationInputCoordinator: feat's comment (no late-reply budget remains).

Kept from this branch: cmux-tui row-client-replay-v1 (a keyed new-row sent
again replays), its capability on both sides, the New Tab page split's
late-reply retry, and SplitSlowDaemonKeepsPaneTests. RowCommands' comment now
names the replay.

This branch was previously deployed

1 inactive deployment
ci-routing-agent — 47a86e15 Deployed Oct 31, 2026 by lawrencecchen via cmux-tui tree preflight #85863
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ready-to-land Reviewed at this head; next-autoland merges it into feat-cmux-next when its only reds are base reds risk:normal Risk tier (docs/ci/risk-tiers.md)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant