Skip to content

fix(quit): Cmd-Q with local terminals asks how to quit (Keep Sessions default, End Sessions Keep Layout, End Everything); Don't ask again writes app.quitBehavior (QUIT P0) - #29984

Merged
lawrencecchen merged 7 commits into
feat-cmux-nextfrom
fix/p0-quit-reopen
Nov 1, 2026

Conversation

@lawrencecchen

@lawrencecchen lawrencecchen commented Nov 1, 2026 •

Copy link
Copy Markdown
Contributor

What

QUIT P0 (Lawrence 2026-11-01 01:05Z: "after quitting and reopening, i lose all the workspaces and terminals ... cmd q needs to prompt for what type of quit"). A person's Cmd-Q (menu, Dock) with local terminals now asks how to quit, in one standard cmux dialog (CmuxDialogSpec, key hints):

  • Keep Sessions Running (default, Return): terminals and agents keep running in their daemons and come back at the next launch.
  • End Sessions, Keep Layout: ends the local terminals, keeps the tabs (they restart in place).
  • End Everything: ends them and deletes the local workspaces.
  • Cancel (Escape).
  • Don't ask again writes the choice to app.quitBehavior (Settings > General > When Quitting), which then applies with no question. A remembered End Everything deletes the local workspaces at every later Cmd-Q with no question: this is the same as setting "End Everything" in Settings today, and the choice is shown on the destructive (red, no key) button; Settings > When Quitting takes it back.

The four buttons do not fit on one line of the 380 pt dialog card (review finding), so CmuxDialogView now stacks a button row that does not fit: each button the card's width, the default (Keep) on top, as NSAlert does. Dialogs whose buttons fit are unchanged.

Scripts (cmux app quit, the socket), SIGTERM/SIGINT/SIGHUP and power off never ask. With no local terminal there is nothing to choose: it quits with Keep. The unsaved-changes question stays the quit's only question when it shows (#17501). End Sessions and End Everything are destructive buttons with no key.

History (why this reverses a decision)

Takes back aba72a3 (cx-7vu, 2026-10-11, from Lawrence's 2026-10-07 "no confirm dialogs"): it made QuitPolicy.decide quit with Keep and no question unless incognito programs ran, so app.quitBehavior: "ask" (the default) never asked. Lawrence's newest words (2026-11-01 01:05Z) ask for the prompt; the newest words win. That commit is older than 7 days, so no take-back hold applies.

Cause (STATUS 2718)

  • The missing ask: proven from code above, the same on feat and on the dogfood line.
  • The ended daemon on Lawrence's Mac: the version handoff at the reopen (a new dogfood build over an older daemon). The journey quit-reopen-handoff reproduces that handoff on the fleet (log line daemon <pid> runs build b3b8b44...; handing off to be22bd3b...), and there every workspace, terminal, the holder and the scrollback came back. The holder-mode startup pass that can end a shell is fixed separately (fix/p0-quit-reopen-holder, risk:high).

Tests

  • QuitChoiceTests (CmuxNextAppTests): red commit 8316ec3, fix 6bfec69. Because the feat base does not compile (NativePopupHost, fix(next): menus opened inside a native popup keep the popup open (bookmark bubble's Folder picker) #28704; fix base red: Takes back #28704: NSMenu tracking goes through one seam adapter (compile + assume_isolated/observer rows) #29856), the proof ran on scratch refs = the base red: Takes back #28704: NSMenu tracking goes through one seam adapter (compile + assume_isolated/observer rows) #29856 head 87682a6 plus these commits (never merged):

    • red: 8e4b6a0 (test only) — fleet step 1930abc2b7aacbe63c95d704 on aws-m4pro-6: cmdQWithLocalTerminalsAsksWithKeepAsTheDefault() recorded an issue at QuitChoiceTests.swift:15 ("quit with no question"); the 5 guard tests pass on the base too.
    • green: 808b04d — fleet step 6b5e5badc14beec232d50cdf on aws-m4pro-1: Test run with 6 tests in 1 suite passed.
    • compile: cmux-ci compile-check --app --package CmuxNext --recipe-version 2 of 808b04d: b67588d83ea2b504d8f83090 passed (CmuxNext, app, cmux-tui; the log compiles QuitPolicy.swift and QuitChoiceTests.swift).
  • Journeys (kit cp/journey-quit-reopen-restore@7d7593f260e, independent review SHIP; a REAL Cmd-Q by CuaDriver to the key window, whole-host lease) on the dogfood build 9276e456: quit-reopen-restore RED 3/3 (cmux22, cmux23) and quit-reopen-handoff RED 2/2 (cmux19), each ONLY at the dialog check quit_asked (log quit origin=interactive behavior=ask then quit choice=keep); every restore check passed (same daemon or a logged handoff, holder kept, workspaces, terminals, focus and scrollback 1..400 back). Green rounds on this fix's build (job b18f6c6e6283198241db0eb2) follow.

  • CmuxDialogButtonFitTests (CmuxNextDesignTests): red 325b2a3, fix 1ac555c; on the scratch ref: red 4297aca step 2df0dc59f812dd14e73a0a32 (aws-m4pro-1): FAILED at CmuxDialogButtonFitTests.swift:25/28 (the base squeezes the buttons to 0 and 99 pt and the default is not on top); green 3eac1f0 step a87cf09a4e9d66c2ea92b510 (aws-m4pro-1): CmuxDialogButtonFitTests 2/2 and QuitChoiceTests 6/6 passed. Compile of 3eac1f0: 9b7f249e686afab9d998cf93 passed.

  • Scripts that assumed Cmd-Q never asks now answer the dialog's Keep: scripts/fleet-quit-persistence.sh (check quit-asks), scripts/cmux-next/dialogs-undo-live.py (quit-asks), scripts/cmux-next/quit-launch-bound.py (interactive and wedged modes). plans/cmux-next/quit-persistence.md records the change.

  • Journey rounds on feat DEV builds (kit a1f077f line, whole-host, REAL Cmd-Q): base 66cbc3a8 (feat 8922fc2 + base red: Takes back #28704: NSMenu tracking goes through one seam adapter (compile + assume_isolated/observer rows) #29856): no dialog (red). Fix 8faff1dd (this PR on that base): the dialog shows with Keep default and is answered in 5/5 rounds (cmux21); every restore check passes in 5/5 (same daemon pid, holder, 4 workspaces, 5 terminals, focus on the split, scrollback 1..400).

  • Known open fault, NOT this PR's, tracked separately (lead 03:09Z): in 4 of those 5 rounds the first line typed into the restored, focused terminal right after the reopen was lost (keys handled by the window's responder, nothing reached the PTY). The base build shows it too (1 of 3), the dogfood line does not (0 of 5). Its own red-first fix PR follows; the journey keeps the step (not weakened).

Risk

risk:normal by the classifier (Swift UI logic, copy, tests; no new delete: End Everything's delete is existing code). Treated with high-tier evidence anyway, because a destructive quit choice is now one click from Cmd-Q: an independent review receipt at the exact head and a green compile. Ships behind the existing app.quitBehavior setting ("keep" restores the old no-question quit).

Localization

One key changed: quit.button.quitEverything ("Quit Everything") is now quit.button.endKeepLayout ("End Sessions, Keep Layout"), with the translations of the existing Settings choice settings.choice.quitEndKeepLayout (all 20 locales; the 8 required ones marked translated). scripts/localization_catalog.py check: no new parity error.

Changelog

  • Changed: Cmd-Q with running terminals asks whether to keep them running, end them and keep the layout, or end everything; "Don't ask again" remembers the choice in Settings.

Observed / Not run

  • Observed: the green QuitChoiceTests step above; the compile above; the prepush gate (no new violations); journey rounds on the dogfood build (red at the dialog check; every restore check passed).
  • Not run yet: the journey on this fix's build, the speed numbers. Capture: follows with the green journey round on the fix build (the journey's check frames show the dialog).

… (default), End Sessions Keep Layout, End Everything, Cancel; Don't ask again writes app.quitBehavior (QUIT P0)

Lawrence 2026-11-01: "cmd q needs to prompt for what type of quit". Takes back
aba72a3 (cx-7vu, 2026-10-11, Lawrence 2026-10-07 "no confirm dialogs"),
which made QuitPolicy.decide quit with Keep and no question unless incognito
programs ran, so app.quitBehavior "ask" (the default) never asked.
@github-actions github-actions Bot added the risk:normal Risk tier (docs/ci/risk-tiers.md) label Nov 1, 2026
@github-actions

github-actions Bot commented Nov 1, 2026 •

Copy link
Copy Markdown
Contributor

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

Review b3c6d56: 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=pr29984 -f ref=fix/p0-quit-reopen -f platform=macos -f backend_mode=local

red: cmux-next checks (god files, concurrency, crash safety, l10n); no finished base run on 654a733 to compare; do not merge until green; see lane-rules

Default changes

This pull request changes 1 default at b3c6d56f9f23 (what everyone gets without configuring anything):

  • tunable-source.CmuxNextDaemonService/QuitRules.swift:decide: "5e3de5985f428d625d27e9aa70e663e4b20332e0c93b671442e50ad78751db17" -> "a5f06c96703de34d7abdc18cb89ac1bf164eac38c4eb8e065ff5028f35f17aba"

Signed off on this commit by review (claude-review-subagent-29984).

Merge receipt: merged as 88dcb8942b63 with these defaults changed, signed off by review (claude-review-subagent-29984).

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

Shipped in:

@lawrencecchen

Copy link
Copy Markdown
Contributor Author

Review (claude-review-subagent-29984): FIX at 6bfec69
reviewer-session: 4fff48d4-ee31-4df5-a26d-adfde447c311
author-session: a8c03bd88d9f5386c
R9: pass
perf claim: none

Scope: git diff 8922fc217e2..6bfec69a871 (2 commits: red 8316ec3, fix 6bfec69). I read the code only. I did not build or run tests. I checked the fleet evidence against the code.

Blocking

B1. The four buttons do not fit in the dialog's fixed 356 pt button row

QuitAlertContent.main now gives [.keep, .endKeepLayout, .endEverything, .cancel]. CmuxDialogView.build() puts every button in one horizontal NSStackView with a required row.widthAnchor == contentWidth. contentWidth is CmuxDialogMetrics.width - 2*padding = 380 - 24 = 356 pt at userScale 1. Also, CmuxDialogView.widthAnchor == 380 is required. Each CmuxDialogButtonView has an intrinsic width of title + 2*space4 (16 pt), at least 72 pt. The title is drawn in body 12/13 pt, and in medium weight for the default button. The key hint glyph is added after the title.

Approximate widths at 12 pt:

  • "Keep Sessions Running ↩": about 160 pt
  • "End Sessions, Keep Layout": about 160 pt
  • "End Everything": about 95 pt
  • "Cancel ⎋": 72 pt

With 3 x 4 pt spacing, the total is about 500 pt in a 356 pt row. The old 3-button row was about 340 pt and only just fit. German ("Sitzungen beenden, Layout behalten", "Alles beenden", ...) is wider still. Nothing in CmuxDialogView wraps, stacks vertically or detaches buttons. Thus the required widths conflict. AppKit breaks a constraint at runtime: buttons get clipped, or they overflow the 380 pt glass card. The row draws the primary button last (rightmost), so Keep Sessions Running, the default, is the button most likely to be pushed off the card. The fix commit's QUIT P0 dialog is the one surface this PR adds. The PR has no capture of it ("a capture follows"), and the unit tests check only content.buttons ids, not layout.

Fix options, choose one, then attach a real capture (cmux-ci capture <sha> --state ...) in en and de:

  • Shorter labels that fit, for example "Keep Running ↩", "End Sessions", "End All", "Cancel ⎋". Measure them.
  • Let CmuxDialogView lay out a row that does not fit as a vertical stack (the macOS alert behavior for 3+ long buttons). This is a design-system change, so tell the owner.
  • Make the session choice a CmuxDialogField (a popup or radio) with Quit ↩ / Cancel ⎋ buttons.

B2. The repo's own quit acceptance scripts still assert "Cmd-Q never asks" (cx-7vu), and this PR does not update them

debug.quit {open:true} is QuitOrigin.interactive, so with local terminals it now shows the dialog:

  • scripts/fleet-quit-persistence.sh step 5 (the Q7 per-release acceptance test): check quit-no-prompt fails. Nothing presses the dialog, so check app-quits-on-keep (wait_exit 15 s) also fails. Every check after it that depends on that quit then runs against the wrong state. The header comment "Cmd-Q (keep, no question: cx-7vu)" is now wrong.
  • scripts/cmux-next/dialogs-undo-live.py (the "quit" leg): check("quit-no-prompt", not asked) fails, and the app does not exit, so quit-app-exits also fails.
  • scripts/cmux-next/quit-launch-bound.py --quit interactive,wedged (the default modes): with --workspaces 8 terminals, nothing answers the dialog. The quit waits max(quit_bound*4, 60) s, then it is force-stopped and recorded as over its bound.

These are not CI jobs, but they are the checks that prove this area. Update them in this PR to expect the question and press keep (as restore-journey.py, journeys/driver.py and quit-leaves-no-helpers already do). Optionally, also add a leg that presses end-keep-layout and end-everything through the dialog. Also update plans/cmux-next/quit-persistence.md, which still describes the no-question quit.

Checked and correct

  • Policy (QuitPolicy.decide):
    • With ask and terminals > 0 and not alreadyAsked, it asks with offersSessionChoice = true and default keep.
    • A remembered keep / endKeepLayout / endEverything quits with no question.
    • With no local terminal it quits with keep.
    • Incognito-only (or a remembered choice plus incognito programs) gives the Quit/Cancel confirmation. There, Quit gives prompt.defaultChoice (remembered ?? keep), and there is no suppression box, so nothing is remembered.
    • .scripted uses the remembered choice or keep. .explicit runs as asked. .signal and .powerOff give keep with no question.
    • alreadyAsked (the unsaved question) still suppresses every later question. The coordinator also skips the facts read then.
  • Answers (QuitAlert.answered): keep → .keep, end-keep-layout → .endKeepLayout, end-everything → .endEverything, cancel/unknown → cancel. quit → defaultChoice. remember is read from the check box only when showsSuppression. complete(..., remember:) → QuitSteps.run(remember: QuitPolicy.remembered(choice)) → settings.setSetting(app.quitBehavior, by: .user). "Don't ask again" + Cancel writes nothing.
  • Keys: Keep is .default (Return), Cancel is .cancel (Escape), and both end choices are .destructive with no key, so no key presses them. A second Cmd-Q (requestQuit and shouldTerminate while isQuitting) calls answerDefault() → content.buttons.first = keep. SIGTERM while asking → answerKeepingSessions() (keep, no remember). An update relaunch records .explicit(.keep), so it never asks.
  • press() aliases: "end-keep-layout" and "end-everything" are now real ids. The old "confirm-quit-everything" and "quit-everything" map to end-keep-layout, the same choice they gave before. "end" reports whether the end choices show. "quit" → "keep" when there is no quit button. Scripts that press end-everything (picker-e2e, render-scenes, new-workspace-real-typing) or keep / the prompt default (driver.py, restore-journey, quit-hidden-chromium-live, quit-leaves-no-helpers) still resolve. DebugQuit fixture_quit ids are unchanged. No other Swift code refers to the removed confirmQuitEverything or quitEverything.
  • The scripted quit action (AppActions.bindApp, registry.isCapturingRefusal) stays .scripted and never asks. quitKeepSessions / quitEndSessions / quitEndEverything stay .explicit.
  • Dialog: still one CmuxDialogSpec with identifier cmux.dialog.quit and standard key hints. No NSAlert. No new blocking main-thread call.
  • Localization: Quit.xcstrings parses. quit.button.quitEverything is removed, and nothing in code uses it now. quit.button.endKeepLayout has 21 locales, and en, de, fr, ar, es, zh-Hant, zh-Hans, ko and ja are all translated. The values are the same as settings.choice.quitEndKeepLayout in every locale. The Swift defaultValue matches en.
  • Risk: no new delete line (End Everything's delete is existing code), so risk:normal is consistent with docs/ci/risk-tiers.md. Takes back aba72a3 (2026-10-11), which is older than 7 days, so no hold applies.
  • Fleet evidence agrees with the code:
    • Scratch 808b04d has the same Quit sources and test as the PR head (empty diff).
    • Red step 1930abc2b7aacbe63c95d704 at 8e4b6a0 has the same test file as 8316ec3. Only cmdQWithLocalTerminalsAsksWithKeepAsTheDefault failed (QuitChoiceTests.swift:15:25), and the other 5 tests passed on the base.
    • Green step 6b5e5badc14beec232d50cdf: 6 tests in 1 suite passed.
    • Compile b67588d83ea2b504d8f83090: CmuxNext, app and cmux-tui all ok at 808b04d.

Non-blocking

  • N1. Test coverage is policy-only. eachButtonAnswersItsChoice tests QuitPolicy.remembered (the setting written), not the button → choice mapping in QuitAlert.answered / press(), which is what this PR changed. A test that drives QuitAlert with a CmuxDialogCenter (press end-keep-layout with remember on → .quit(.endKeepLayout, remember: true); press end-everything; second Cmd-Q → keep) would guard the real dialog path. Rename or extend that test. Terminals + incognito (the main dialog with the incognito line) and remembered + incognito (the Quit/Cancel confirmation) are also untested.
  • N2. Product risk to note in the PR: End Everything (delete every local workspace) is now one click from Cmd-Q. With "Don't ask again" checked, one misclick makes every later Cmd-Q delete all local workspaces silently. Before this PR, the dialog did not offer End Everything ("End Everything ... is the File menu's"). If B1 is fixed by a redesign, consider keeping End Everything out of the remembered choices, or say in the PR why it stays.
  • N3. The QuitAlert doc comment line "again" is a check box that writes app.quitBehavior. The dialog blocks the window QuitCoordinator picks, or" is longer than the file's other comment lines. This is cosmetic.

…stack inside it, the default on top (QUIT P0 review, red)
…n top, each the card's width (the quit choice's four buttons); the quit scripts answer the quit dialog's Keep (QUIT P0 review)

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

6 issues found across 12 files

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="plans/cmux-next/quit-persistence.md">

<violation number="1" location="plans/cmux-next/quit-persistence.md:21">
P2: This update lists "End Everything" as a direct dialog choice with no confirmation step. §4.1 and decision D1 in this same file put it behind a confirmation. Either state the confirmation here or correct the design section to match the shipped behavior.</violation>

<violation number="2" location="plans/cmux-next/quit-persistence.md:21">
P2: This update says Cmd-Q uses one cmux dialog, but §1.1 still describes a system `NSAlert` followed by a second alert. Update §1.1 to match the current flow so the plan does not document contradictory behavior.</violation>
</file>

<file name="Packages/macOS/CmuxNext/Tests/CmuxNextDesignTests/CmuxDialogButtonFitTests.swift">

<violation number="1" location="Packages/macOS/CmuxNext/Tests/CmuxNextDesignTests/CmuxDialogButtonFitTests.swift:19">
P2: This test lays out the dialog without a superview, so `view.bounds` probably stays zero and the card-containment assertions cannot pass. Attach the view to a sized host and pin it before calling `layoutSubtreeIfNeeded()`, as CmuxDialogLightContrastTests does.</violation>
</file>

<file name="scripts/cmux-next/quit-launch-bound.py">

<violation number="1" location="scripts/cmux-next/quit-launch-bound.py:368">
P2: The probe loop does not handle the app exiting without a prompt. `self.call` goes through `bench.app_client`, which retries for 30 s and then raises `SystemExit`, and that is not caught by `except (OSError, ValueError)`. The run then aborts with a misleading 'control socket did not answer' error instead of recording the quit. Open one client before the loop and close it in `finally`, so a closed connection raises a catchable `OSError`.</violation>
</file>

<file name="Packages/macOS/CmuxNext/Sources/CmuxNextDesign/Dialog/CmuxDialogView.swift">

<violation number="1" location="Packages/macOS/CmuxNext/Sources/CmuxNextDesign/Dialog/CmuxDialogView.swift:234">
P2: The horizontal row includes a leading spacer, so it needs `buttons.count` spacing gaps, not `buttons.count - 1`; near the cutoff, buttons that do not fit stay on one line and may be compressed or overflow. Include the spacer's gap in the fit calculation.</violation>
</file>

<file name="scripts/cmux-next/dialogs-undo-live.py">

<violation number="1" location="scripts/cmux-next/dialogs-undo-live.py:206">
P3: The module docstring still says this case has “no dialog” and “quits at once,” but this test now waits for and accepts the Keep Sessions prompt. Update the quit description so the script’s help text matches its behavior.</violation>
</file>

Reply with feedback, questions, or to request a fix.

View guided diff | Turn on auto-fix | Re-trigger cubic

Update 2026-11-01 (QUIT P0, #29984): from 2026-10-11 (cx-7vu, aba72a3fbae, Lawrence 2026-10-07 "no confirm dialogs")
until #29984, an interactive quit asked nothing and kept the sessions. Lawrence 2026-11-01 asked for the prompt again
("cmd q needs to prompt for what type of quit"): with local terminals and `app.quitBehavior` "ask", Cmd-Q asks Keep
Sessions Running (default) / End Sessions, Keep Layout / End Everything / Cancel in one cmux dialog (the four buttons

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: This update lists "End Everything" as a direct dialog choice with no confirmation step. §4.1 and decision D1 in this same file put it behind a confirmation. Either state the confirmation here or correct the design section to match the shipped behavior.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At plans/cmux-next/quit-persistence.md, line 21:

<comment>This update lists "End Everything" as a direct dialog choice with no confirmation step. §4.1 and decision D1 in this same file put it behind a confirmation. Either state the confirmation here or correct the design section to match the shipped behavior.</comment>

<file context>
@@ -15,6 +15,12 @@ the "quit = plain NSAlert" decision; the default choice is Keep sessions running
+Update 2026-11-01 (QUIT P0, #29984): from 2026-10-11 (cx-7vu, aba72a3fbae, Lawrence 2026-10-07 "no confirm dialogs")
+until #29984, an interactive quit asked nothing and kept the sessions. Lawrence 2026-11-01 asked for the prompt again
+("cmd q needs to prompt for what type of quit"): with local terminals and `app.quitBehavior` "ask", Cmd-Q asks Keep
+Sessions Running (default) / End Sessions, Keep Layout / End Everything / Cancel in one cmux dialog (the four buttons
+stack when they do not fit on one line); "Don't ask again" writes the choice. Scripts, signals and power off never ask.
+
</file context>
Suggested change
Sessions Running (default) / End Sessions, Keep Layout / End Everything / Cancel in one cmux dialog (the four buttons
Sessions Running (default) / End Sessions, Keep Layout / End Everything (after a confirmation) / Cancel in one cmux dialog (the four buttons


@Test func buttonsThatDoNotFitStackInsideTheCardWithTheDefaultOnTop() {
let view = CmuxDialogView(spec: Self.quitSpec)
view.layoutSubtreeIfNeeded()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: This test lays out the dialog without a superview, so view.bounds probably stays zero and the card-containment assertions cannot pass. Attach the view to a sized host and pin it before calling layoutSubtreeIfNeeded(), as CmuxDialogLightContrastTests does.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At Packages/macOS/CmuxNext/Tests/CmuxNextDesignTests/CmuxDialogButtonFitTests.swift, line 19:

<comment>This test lays out the dialog without a superview, so `view.bounds` probably stays zero and the card-containment assertions cannot pass. Attach the view to a sized host and pin it before calling `layoutSubtreeIfNeeded()`, as CmuxDialogLightContrastTests does.</comment>

<file context>
@@ -0,0 +1,44 @@
+
+    @Test func buttonsThatDoNotFitStackInsideTheCardWithTheDefaultOnTop() {
+        let view = CmuxDialogView(spec: Self.quitSpec)
+        view.layoutSubtreeIfNeeded()
+        let card = view.bounds
+        #expect(card.width > 0 && card.height > 0)
</file context>

ask_end = time.time() + 10
while time.time() < ask_end:
try:
asking = self.call("debug.quit", {}).get("result") or {}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: The probe loop does not handle the app exiting without a prompt. self.call goes through bench.app_client, which retries for 30 s and then raises SystemExit, and that is not caught by except (OSError, ValueError). The run then aborts with a misleading 'control socket did not answer' error instead of recording the quit. Open one client before the loop and close it in finally, so a closed connection raises a catchable OSError.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At scripts/cmux-next/quit-launch-bound.py, line 368:

<comment>The probe loop does not handle the app exiting without a prompt. `self.call` goes through `bench.app_client`, which retries for 30 s and then raises `SystemExit`, and that is not caught by `except (OSError, ValueError)`. The run then aborts with a misleading 'control socket did not answer' error instead of recording the quit. Open one client before the loop and close it in `finally`, so a closed connection raises a catchable `OSError`.</comment>

<file context>
@@ -361,6 +361,17 @@ def quit(self, mode, pages, out, label):
+            ask_end = time.time() + 10
+            while time.time() < ask_end:
+                try:
+                    asking = self.call("debug.quit", {}).get("result") or {}
+                except (OSError, ValueError):
+                    break  # the app is gone or going: nothing asks
</file context>

row.translatesAutoresizingMaskIntoConstraints = false
row.spacing = Metrics.space2
let needed = buttons.reduce(0) { $0 + $1.intrinsicContentSize.width }
+ row.spacing * CGFloat(max(buttons.count - 1, 0))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: The horizontal row includes a leading spacer, so it needs buttons.count spacing gaps, not buttons.count - 1; near the cutoff, buttons that do not fit stay on one line and may be compressed or overflow. Include the spacer's gap in the fit calculation.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At Packages/macOS/CmuxNext/Sources/CmuxNextDesign/Dialog/CmuxDialogView.swift, line 234:

<comment>The horizontal row includes a leading spacer, so it needs `buttons.count` spacing gaps, not `buttons.count - 1`; near the cutoff, buttons that do not fit stay on one line and may be compressed or overflow. Include the spacer's gap in the fit calculation.</comment>

<file context>
@@ -228,6 +223,31 @@ public final class CmuxDialogView: NSView {
+        row.translatesAutoresizingMaskIntoConstraints = false
+        row.spacing = Metrics.space2
+        let needed = buttons.reduce(0) { $0 + $1.intrinsicContentSize.width }
+            + row.spacing * CGFloat(max(buttons.count - 1, 0))
+        guard needed > width else {
+            row.orientation = .horizontal
</file context>
Suggested change
+ row.spacing * CGFloat(max(buttons.count - 1, 0))
+ + row.spacing * CGFloat(buttons.count)

Update 2026-11-01 (QUIT P0, #29984): from 2026-10-11 (cx-7vu, aba72a3fbae, Lawrence 2026-10-07 "no confirm dialogs")
until #29984, an interactive quit asked nothing and kept the sessions. Lawrence 2026-11-01 asked for the prompt again
("cmd q needs to prompt for what type of quit"): with local terminals and `app.quitBehavior` "ask", Cmd-Q asks Keep
Sessions Running (default) / End Sessions, Keep Layout / End Everything / Cancel in one cmux dialog (the four buttons

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: This update says Cmd-Q uses one cmux dialog, but §1.1 still describes a system NSAlert followed by a second alert. Update §1.1 to match the current flow so the plan does not document contradictory behavior.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At plans/cmux-next/quit-persistence.md, line 21:

<comment>This update says Cmd-Q uses one cmux dialog, but §1.1 still describes a system `NSAlert` followed by a second alert. Update §1.1 to match the current flow so the plan does not document contradictory behavior.</comment>

<file context>
@@ -15,6 +15,12 @@ the "quit = plain NSAlert" decision; the default choice is Keep sessions running
+Update 2026-11-01 (QUIT P0, #29984): from 2026-10-11 (cx-7vu, aba72a3fbae, Lawrence 2026-10-07 "no confirm dialogs")
+until #29984, an interactive quit asked nothing and kept the sessions. Lawrence 2026-11-01 asked for the prompt again
+("cmd q needs to prompt for what type of quit"): with local terminals and `app.quitBehavior` "ask", Cmd-Q asks Keep
+Sessions Running (default) / End Sessions, Keep Layout / End Everything / Cancel in one cmux dialog (the four buttons
+stack when they do not fit on one line); "Don't ask again" writes the choice. Scripts, signals and power off never ask.
+
</file context>

check("quit-setup-program", loop_pid, loop_pid)
reply = rpc("debug.quit", {"open": True})
print("quit open:", json.dumps(reply)[:200])
# QUIT P0: with a local terminal, Cmd-Q asks how to quit; its default (Keep Sessions) keeps the program running.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: The module docstring still says this case has “no dialog” and “quits at once,” but this test now waits for and accepts the Keep Sessions prompt. Update the quit description so the script’s help text matches its behavior.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At scripts/cmux-next/dialogs-undo-live.py, line 206:

<comment>The module docstring still says this case has “no dialog” and “quits at once,” but this test now waits for and accepts the Keep Sessions prompt. Update the quit description so the script’s help text matches its behavior.</comment>

<file context>
@@ -203,13 +203,19 @@ def alive(pid):
         check("quit-setup-program", loop_pid, loop_pid)
     reply = rpc("debug.quit", {"open": True})
     print("quit open:", json.dumps(reply)[:200])
+    # QUIT P0: with a local terminal, Cmd-Q asks how to quit; its default (Keep Sessions) keeps the program running.
     asked = False
     deadline = time.time() + 20
</file context>

lawrencecchen added a commit that referenced this pull request Nov 1, 2026
…pp quits

P0 journey quit-leaves-no-helpers (perf v2.1, job 2769005f506cef2d9a476d02,
cmux24): 5 s after a normal quit the app's acpmux daemon and the cmux-tui
daemon's cmux-browser-host still ran. Port of cp/lag-quit-helpers
(267b25d) and the stop rule of cp/redesign-quit-helpers to feat, written
for feat's acpmux: perf's daemon_lifecycle.rs and cmux-owner-liveness crate
do not exist here; feat's own stop is daemon/unused_exit.rs.

Root cause. The app starts acpmux detached (setsid, adopted by launchd) so
durable agents outlive a quit, and nothing ties it to the app's exit: on a
home of its own it stops only after 60 min unused, on the shared ~/.acpmux
never. The browser host waits its 5 min idle delay after the app's provider
left.

Fix, the way a Chromium child ends when the browser process goes:
- The launcher names the app (ACPMUX_OWNER_APP_PID). acpmux follows it with
  one thread blocked in the kernel (kqueue EVFILT_PROC NOTE_EXIT on macOS, a
  pidfd on Linux; daemon/owner_exit.rs, no polling).
- After the app exited, unused_exit waits OWNER_EXIT_GRACE (2 s) with no
  client instead of its grace, on any home, and stops only when Hub::in_use
  finds nothing: a running agent or agent host, a turn, a queued prompt, a
  pending permission, a background task, a peer link keep it, and it asks
  again every 60 s. So Keep Sessions Running (#29984) keeps working agents.
  While the app runs nothing changes; a daemon with no owner named (the
  CLI's, a service unit's, a Cloud machine's) is unchanged. Agents do not
  inherit the variable.
- cmux-browser-host: the provider leaving (an edge: connected, then not) with
  no session and no agent connection exits the host at once; a session keeps
  it, with the whole delay after it. The daemon starts it again on the next
  agent connect or provider request.

The app does nothing new at quit and nothing on its main thread: the helpers
see its exit.

Tests: acpmux tests/daemon_lifecycle.rs quit_helpers_* (real binary, fake
owner app), unused_exit quit_helpers_* (paused clock), owner_exit unit tests;
cmux-browser-host idle_exit quit_helpers_*; AcpmuxOwnerAppPidTests.
@lawrencecchen

Copy link
Copy Markdown
Contributor Author

Review (claude-review-subagent-29984): FIX at 1ac555c
reviewer-session: 3e56ae40-152a-43ee-b328-46e2cd92625f
author-session: a8c03bd88d9f5386c
R9: pass
perf claim: none

Scope: the full PR over merge base 8922fc2 (4 commits). This round I looked closely at 325b2a3 (red) and 1ac555c (fix). I read the code only. I did not build or run tests.

Blocking

B1. Two more quit scripts are wrong after this change (same class as the previous B2; I missed the first one in round 1)

  1. scripts/cmux-next/quit-hidden-chromium-live.py:1115 now presses End Sessions, Keep Layout instead of Keep.
    • It picks next(b for b in buttons if "keep" in str(b)). debug.quit reports sheet.buttons in drawn order: ["end-keep-layout", "end-everything", "cancel", "keep"] (QuitAlert.spec: the other choices, then Cancel, then the primary).
    • On the base, the only id that contained "keep" was keep. Now end-keep-layout matches first.
    • The setup always has terminals, so the dialog always shows. The run that is meant to reproduce Leo's keep quit thus silently runs an End quit: it ends the sessions and takes the End deadline path.
    • Fix: choice = "keep" if "keep" in buttons else (buttons[0] if buttons else None), or press (sheet.get("prompt") or {}).get("default").
  2. scripts/fleet-quit-persistence.sh step 9, "second-quit-keeps", is now racy and usually fails.
    • It sends debug.quit {"open":true} twice back to back. The first opens an interactive quit, which awaits QuitFactsReader.read (up to 1 s) before it shows the dialog.
    • The second arrives while isQuitting is true but sheet is still nil. requestQuit and shouldTerminate then call sheet?.answerDefault(), which does nothing, and the dialog shows later with nobody to answer it.
    • Then wait_exit 15 fails, and the next launch (open -n) starts a second instance next to the stuck one. The comment "the first already keeps" is also no longer true.
    • Fix: wait for "asking": true, then send the second open (this tests what the PR claims: a second Cmd-Q while asking presses Keep). Or press keep as in step 5.

Checked and correct (this round)

  • Previous B1 (button fit) is fixed in the design system, not by a special case.
    • CmuxDialogView.buttonRow adds the buttons' intrinsic widths plus space2 spacing. Rows that fit stay as before (spacer + buttons, right-aligned).
    • A row that does not fit becomes a vertical NSStackView. Each button is contentWidth wide, in reversed spec order, so the default (spec last) is on top, as NSAlert does it.
    • The widths are valid at build time: CmuxDialogButtonView.init ends with refresh(), which sets attributedTitle with the key hint.
    • Initial focus still goes to the default button (buttonViews.first { $0.role == .default }). Key handling, the key hints and the tab chain are unchanged.
    • Other dialogs change only when their row was already too wide (then clipped), so this is only a fix for them.
  • CmuxDialogButtonFitTests is behavioral. It lays out a real CmuxDialogView and checks each button frame against the card, its intrinsic width and which button is on top.
    • Red step 2df0dc59f812dd14e73a0a32 (4297aca, the same test file as 325b2a3) shows the bug on the base: one button 0 pt wide (needs 169.5), one 99 pt (needs 101.5), and end-keep-layout on top. "Fits stays on one line" passed on the base, as it should.
    • Green step a87cf09a4e9d66c2ea92b510: CmuxDialogButtonFitTests 2/2 and QuitChoiceTests 6/6 passed.
    • Compile 9b7f249e686afab9d998cf93 is done at 3eac1f0.
    • The PR's 12 files are the same in 3eac1f0 and in this head (empty diff).
  • Previous B2:
    • dialogs-undo-live.py: the quit leg now presses keep and checks quit-asks.
    • fleet-quit-persistence.sh step 5: presses keep and checks quit-asks.
    • quit-launch-bound.py: presses keep in the interactive and wedged modes.
    • quit-persistence.md records the change.
  • Round-1 items unchanged at this head: the policy, the answer mapping, "Don't ask again" → app.quitBehavior, scripted, signal and power-off quits never ask, the unsaved question stays the only one, the second Cmd-Q while asking presses Keep, the catalog (21 locales, the 9 required ones translated, no key removed that code still uses), and risk:normal (no new delete). The PR body now explains the remembered End Everything.

Non-blocking

  • N1. A fast double Cmd-Q in the product. This is the same race as B1.2: a second Cmd-Q during the facts read (up to 1 s, before the dialog shows) is dropped, and the dialog then waits. The result is safe (Keep is the default), but "Cmd-Q Cmd-Q quits" fails for a fast double press. A principled fix: QuitCoordinator records a pending default answer when an interactive repeat arrives while deciding, and ask presses the default at once when it is set. Follow-up is fine.
  • N2. quit-launch-bound.py new ask loop.
    • except (OSError, ValueError): break # the app is gone does not catch the app-gone case. self.call → bench.app_client retries for 30 s and then raises SystemExit, which ends the whole run.
    • This happens only when an interactive quit exits without asking (no local terminal), which the script's setup does not normally produce.
    • Check launch.process.poll() or bench.alive(pid) before each poll instead.
  • N3. Tab order in the stacked layout. It follows spec order, so it runs bottom to top (End Sessions, Keep Layout → End Everything → Cancel → Keep), the reverse of the visual order. Consider ordering focusables top to bottom when stacked.
  • N4. Round-1 N1 still applies. eachButtonAnswersItsChoice tests QuitPolicy.remembered, not the QuitAlert button → choice path.

… the second-quit check waits for the dialog; stacked dialog buttons tab top to bottom (review FIX on #29984)
lawrencecchen added a commit that referenced this pull request Nov 1, 2026
… the second-quit check waits for the dialog; stacked dialog buttons tab top to bottom (review FIX on #29984)

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

1 existing issue remains and 2 new issues found across 5 files (changes from recent commits).

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="scripts/fleet-quit-persistence.sh">

<violation number="1" location="scripts/fleet-quit-persistence.sh:219">
P3: The second-Cmd-Q variant never checks that the quit dialog appeared. If the 15-second poll times out, the script still sends the second quit and only `second-quit-keeps` reports a failure. Track an `asked` flag like step 5 and add a `check second-quit-dialog-shown` so a missing prompt is reported as the cause.</violation>
</file>

<file name="scripts/cmux-next/quit-hidden-chromium-live.py">

<violation number="1" location="scripts/cmux-next/quit-hidden-chromium-live.py:1116">
P3: The default id is taken from `prompt.default` without checking it against the dialog's buttons. In the incognito-only sheet, `prompt.default` can name a button the dialog does not show (for example "keep" with buttons [quit, cancel]), so the press is a no-op and the run waits until SIGKILL. Choose the first candidate that is present in `buttons`, falling back to buttons[0].</violation>
</file>

Requires human review: Auto-approval blocked because this review re-detected 1 unresolved issue already reported by Cubic.

View guided diff | Turn on auto-fix | Re-trigger cubic

# A second Cmd-Q while the quit dialog shows answers its default (Keep). It is
# sent once the dialog shows: before that the quit is still reading its facts.
rpc_ok debug.quit '{"open":true}' >/dev/null
for i in $(seq 1 15); do

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: The second-Cmd-Q variant never checks that the quit dialog appeared. If the 15-second poll times out, the script still sends the second quit and only second-quit-keeps reports a failure. Track an asked flag like step 5 and add a check second-quit-dialog-shown so a missing prompt is reported as the cause.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At scripts/fleet-quit-persistence.sh, line 219:

<comment>The second-Cmd-Q variant never checks that the quit dialog appeared. If the 15-second poll times out, the script still sends the second quit and only `second-quit-keeps` reports a failure. Track an `asked` flag like step 5 and add a `check second-quit-dialog-shown` so a missing prompt is reported as the cause.</comment>

<file context>
@@ -213,8 +213,14 @@ rpc_ok action.run '{"id":"selectWorkspaceByNumber","args":{"index":1}}' >/dev/nu
+# A second Cmd-Q while the quit dialog shows answers its default (Keep). It is
+# sent once the dialog shows: before that the quit is still reading its facts.
 rpc_ok debug.quit '{"open":true}' >/dev/null
+for i in $(seq 1 15); do
+  rpc debug.quit '{}' 2 2>/dev/null | grep -q '"asking": *true' && break
+  alive "$APP_PID" || break
</file context>

buttons = [b.get("id") for b in sheet.get("buttons") or []]
choice = next((b for b in buttons if "keep" in str(b)), buttons[0] if buttons else None)
# The default (Keep Sessions Running, id "keep"), never another id that merely contains "keep".
choice = (sheet.get("prompt") or {}).get("default") or ("keep" if "keep" in buttons else (buttons[0] if buttons else None))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: The default id is taken from prompt.default without checking it against the dialog's buttons. In the incognito-only sheet, prompt.default can name a button the dialog does not show (for example "keep" with buttons [quit, cancel]), so the press is a no-op and the run waits until SIGKILL. Choose the first candidate that is present in buttons, falling back to buttons[0].

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At scripts/cmux-next/quit-hidden-chromium-live.py, line 1116:

<comment>The default id is taken from `prompt.default` without checking it against the dialog's buttons. In the incognito-only sheet, `prompt.default` can name a button the dialog does not show (for example "keep" with buttons [quit, cancel]), so the press is a no-op and the run waits until SIGKILL. Choose the first candidate that is present in `buttons`, falling back to buttons[0].</comment>

<file context>
@@ -1112,7 +1112,8 @@ def one_run(index):
         buttons = [b.get("id") for b in sheet.get("buttons") or []]
-        choice = next((b for b in buttons if "keep" in str(b)), buttons[0] if buttons else None)
+        # The default (Keep Sessions Running, id "keep"), never another id that merely contains "keep".
+        choice = (sheet.get("prompt") or {}).get("default") or ("keep" if "keep" in buttons else (buttons[0] if buttons else None))
         run.note(f"quit sheet asked ({buttons}); pressing {choice}")
         rpc("debug.quit", {"press": choice}, timeout=5)
</file context>
Suggested change
choice = (sheet.get("prompt") or {}).get("default") or ("keep" if "keep" in buttons else (buttons[0] if buttons else None))
choice = next((c for c in [(sheet.get("prompt") or {}).get("default"), "keep"] if c in buttons), buttons[0] if buttons else None)

@blacksmith-sh

blacksmith-sh Bot commented Nov 1, 2026 •

Copy link
Copy Markdown

Found 9 test failures on Blacksmith runners:

Failures

Test View Logs
/tmp/tmp6pm4mh35/summary-fail.json (live):/summary-fail.json (live): View Logs
/tmp/tmpx37uyvo5/summary-missing.json (live):/summary-missing.json (live): View Logs
[new_workspaces] debug.new_workspace timed 0 of 50 new workspace rows/
[new_workspaces] debug.new_workspace timed 0 of 50 new workspace rows
View Logs
[new_workspaces] the window draws no frames/[new_workspaces] the window draws no frames View Logs
[tabs] debug.tab_switch timed 0 of 40 switches/
[tabs] debug.tab_switch timed 0 of 40 switches
View Logs
[tabs] the window draws no frames/[tabs] the window draws no frames View Logs
convention violations found (ERROR lines above)./
convention violations found (ERROR lines above).
View Logs
test_every_part_of_bun_run_check_runs_in_the_warn_branch (main.WebviewsWarnSplitCov
ersCheck.test_every_part_of_bun_run_check_runs_in_the_warn_branch)/
test_every_part_of_bun_run_check_runs_in_the_warn_branch (main.WebviewsWarnSplitCov
ersCheck.test_every_part_of_bun_run_check_runs_in_the_warn_branch)
View Logs
test_no_runner_picker_returns_a_hosted_mac (main.NoHostedMacInNightlies.test_no_run
ner_picker_returns_a_hosted_mac)/
test_no_runner_picker_returns_a_hosted_mac (main.NoHostedMacInNightlies.test_no_run
ner_picker_returns_a_hosted_mac)
View Logs

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

@lawrencecchen

Copy link
Copy Markdown
Contributor Author

Review (claude-review-subagent-29984): SHIP at 132fa6e
reviewer-session: 4b074c19-374a-4716-975b-6887ee261604
author-session: a8c03bd88d9f5386c
R9: pass
perf claim: none

Scope: the full PR over merge base 8922fc2 (5 commits). This round I looked closely at 132fa6e. I read the code only. I did not build or run tests.

Blocking

None.

Round-2 findings: all fixed

  • Script pressed the wrong button. quit-hidden-chromium-live.py now presses prompt.default, else "keep" exactly, else the first button. With the script's setup (terminals, app.quitBehavior "ask"), prompt.default is keep. A substring match can no longer select end-keep-layout.
  • Racy second-quit check. fleet-quit-persistence.sh step 9 now waits (up to 15 s) for "asking": true before it sends the second debug.quit {"open":true}. Then requestQuit sees isQuitting with the sheet present and calls answerDefault(), which presses keep. The checks second-quit-keeps and second-quit-daemon-alive now test what the PR claims.
  • N2 (quit-launch-bound.py). It catches SystemExit from bench.app_client and stops polling. The app-gone case no longer ends the whole run.
  • N3 (tab order). focusables is reversed when the button row is vertical, so Tab goes top to bottom (keep, cancel, end-everything, end-keep-layout). One-line rows are unchanged. The key handling, the default and cancel buttons, and the initial focus (the default button) are unchanged. tabWalksTheStackedButtonsTopToBottom reads the real focusables.
  • Double Cmd-Q race in the product. The author records it as a follow-up. The behavior is safe (it fails to Keep), so it does not block.

Evidence (verified)

  • Proof ref. a9e83b6 has the same content as this head for all 12 PR files (empty diff).
  • Compile. 6de1645ea95a14715e63064d is done at a9e83b6.
  • Tests. Step 8f430248a2788061953067b4 on aws-m4pro-9 at a9e83b6: CmuxDialogButtonFitTests 3/3 passed and QuitChoiceTests 6/6 passed. The step exited 0.
  • Earlier red steps. 1930abc2b7aacbe63c95d704 (the quit question) and 2df0dc59f812dd14e73a0a32 (button fit) failed on the base as described in round 2.

Carried forward from rounds 1-2 (unchanged)

  • Quit decision.
    • The quit asks only for a person's quit with local terminals and app.quitBehavior "ask". Keep is the default (Return) and Cancel is Escape.
    • Both end choices are destructive and have no key.
    • "Don't ask again" writes app.quitBehavior with the chosen value.
    • A remembered choice and the incognito-only question work as before.
    • Scripted, signal and power-off quits never ask. The unsaved-changes question stays the only question.
    • A second Cmd-Q while the dialog asks presses Keep.
  • Old button ids. confirm-quit-everything and quit-everything still work.
  • Catalog. quit.button.endKeepLayout has 21 locales, and the 9 required ones are translated. The code no longer uses the removed key.
  • Risk. risk:normal: there is no new delete line.

Non-blocking

  • N1. Tab-order test has no red run. tabWalksTheStackedButtonsTopToBottom has no red step of its own. By reading the code, it fails on 1ac555c, where the order was the spec order. It came from a non-blocking finding, so this does not block.
  • N2. Script default is not always a button id. In quit-hidden-chromium-live.py, prompt.default is a QuitSessionsChoice, not a button id. On the incognito-only Quit/Cancel dialog it would press keep, and QuitAlert.press maps quit to keep but not keep to quit. This script's setup never shows that dialog. Pressing buttons[-1] (the primary, drawn last) would be more general.
  • N3. Dialog path still untested. The QuitAlert button-to-choice path (answered / press) still has no unit test (round-1 N1).

…or and the End Everything guard, b52ff17): the session choice moves into QuitRules.decide; a remembered End Everything still asks with its own dialog
lawrencecchen added a commit that referenced this pull request Nov 1, 2026
…pp quits (#30090)

* test(quit): red: the app's idle acpmux and its browser host outlive the app's quit

P0 journey quit-leaves-no-helpers (perf v2.1, job 2769005f506cef2d9a476d02,
cmux24): 5 s after a normal quit the app's acpmux daemon and the cmux-tui
daemon's cmux-browser-host still ran. Port of cp/lag-quit-helpers
3ce76b2 to feat, where the acpmux half is new (perf's
daemon_lifecycle.rs does not exist here).

On feat the app starts its acpmux detached (AcpmuxDaemonLauncher, setsid)
and nothing ties it to the app's exit: a daemon on a home of its own stops
only after 60 min unused (daemon/unused_exit.rs), the shared ~/.acpmux never.
The feat linger probe (bench-startup.py watch_linger) could not see this: it
lists only /Contents/MacOS/ processes, and acpmux runs from
Contents/Resources/bin.

- acpmux tests/daemon_lifecycle.rs
  owner_exit_a_daemon_stops_soon_after_its_app_is_gone_and_its_last_client_left:
  the real binary with ACPMUX_OWNER_APP_PID = a fake app (a sleep child)
  still runs 15 s after the app is gone and its last client left.
  owner_exit_a_running_turn_keeps_the_daemon_after_its_app_is_gone: a guard
  (Keep Sessions Running, #29984): a turn in progress keeps it.
- cmux-browser-host idle_exit
  a_host_whose_app_provider_left_with_nothing_else_exits_at_once: the host
  waits its whole 5 min delay after the app's provider left. provider_left
  is a stub here (the old behavior) so the test compiles.

* test(quit): one name prefix (quit_helpers_) for the quit helper tests, so one fleet filter runs them all

* test(quit): red: the app's acpmux spawn environment names no owner app

The daemon can follow its app only if the app names itself: on feat
AcpmuxDaemonLauncher.spawnEnvironment sets no ACPMUX_OWNER_APP_PID (git grep:
no hit in Packages/). AcpmuxOwnerAppPidTests: the spawn environment names this
app's pid over an inherited value, signed spawn too.

* fix(quit): the app's idle acpmux and its browser host stop when the app quits

P0 journey quit-leaves-no-helpers (perf v2.1, job 2769005f506cef2d9a476d02,
cmux24): 5 s after a normal quit the app's acpmux daemon and the cmux-tui
daemon's cmux-browser-host still ran. Port of cp/lag-quit-helpers
(267b25d) and the stop rule of cp/redesign-quit-helpers to feat, written
for feat's acpmux: perf's daemon_lifecycle.rs and cmux-owner-liveness crate
do not exist here; feat's own stop is daemon/unused_exit.rs.

Root cause. The app starts acpmux detached (setsid, adopted by launchd) so
durable agents outlive a quit, and nothing ties it to the app's exit: on a
home of its own it stops only after 60 min unused, on the shared ~/.acpmux
never. The browser host waits its 5 min idle delay after the app's provider
left.

Fix, the way a Chromium child ends when the browser process goes:
- The launcher names the app (ACPMUX_OWNER_APP_PID). acpmux follows it with
  one thread blocked in the kernel (kqueue EVFILT_PROC NOTE_EXIT on macOS, a
  pidfd on Linux; daemon/owner_exit.rs, no polling).
- After the app exited, unused_exit waits OWNER_EXIT_GRACE (2 s) with no
  client instead of its grace, on any home, and stops only when Hub::in_use
  finds nothing: a running agent or agent host, a turn, a queued prompt, a
  pending permission, a background task, a peer link keep it, and it asks
  again every 60 s. So Keep Sessions Running (#29984) keeps working agents.
  While the app runs nothing changes; a daemon with no owner named (the
  CLI's, a service unit's, a Cloud machine's) is unchanged. Agents do not
  inherit the variable.
- cmux-browser-host: the provider leaving (an edge: connected, then not) with
  no session and no agent connection exits the host at once; a session keeps
  it, with the whole delay after it. The daemon starts it again on the next
  agent connect or provider request.

The app does nothing new at quit and nothing on its main thread: the helpers
see its exit.

Tests: acpmux tests/daemon_lifecycle.rs quit_helpers_* (real binary, fake
owner app), unused_exit quit_helpers_* (paused clock), owner_exit unit tests;
cmux-browser-host idle_exit quit_helpers_*; AcpmuxOwnerAppPidTests.

* fix(quit): review: no daemon a command starts follows the app; kqueue waits without re-adding; docs

Review of 6418b9e (SHIP, findings 1, 3, 4, 5):
- spawn_detached (CLI auto-start) and chat_fork's harness drop
  ACPMUX_OWNER_APP_PID: only the app's launcher names an owner, so a daemon
  a descendant starts never follows the app that started an older one.
- owner_exit (macOS): after EINTR the kevent call passes no change, not the
  same change with flags 0.
- unused_exit docs: ACPMUX_UNUSED_EXIT_SECS=0 means no grace; the owner rule
  can still stop the daemon.
- owner_exit unit tests take the quit_helpers_ prefix, so the fleet filter
  runs them.
…sion question (Ask is not a remembered choice; QUIT P0), the remembered-choice cases unchanged
@lawrencecchen

Copy link
Copy Markdown
Contributor Author

Review (claude-review-subagent-29984): SHIP at b3c6d56
reviewer-session: c90dc234-054d-4f00-badc-b3d1925e344e
author-session: a8c03bd88d9f5386c
R9: pass
perf claim: none

Scope: the full PR over the new merge base 654a733. This round I looked closely at merge cbde57d (origin/feat-cmux-next, which brought b52ff17: the QuitRules refactor and the remembered End Everything guard) and at b3c6d56. I read the code only. I did not build or run tests.

Blocking

None.

The merge kept feat's End Everything guard

  • QuitRules.decide (CmuxNextDaemonService). Over the merge base, the only change is the last branch.
    • feat's if remembered == .endEverything { guard !alreadyAsked else { return .quit(.endKeepLayout) }; return .ask(... confirmsEndEverything: true) } is unchanged, and it still runs before the session-choice rule.
    • unattended() (scripted End Everything → End Sessions, Keep Layout) is unchanged.
    • .explicit, .signal and .powerOff are unchanged.
  • The new last branch is offersSessionChoice = remembered == nil && facts.terminals > 0. This is the same rule that I reviewed in rounds 1-3, now in its new home. QuitPolicy.decide only maps the setting and delegates to it. The App-side QuitFacts.swift and QuitPrompt.swift stay deleted, as feat deleted them.
  • QuitAlertContent.main. feat's confirmsEndEverything branch is unchanged: title quit.endEverything.title, buttons [.keep, .cancel, .endEverything], no suppression box, so Return keeps and End Everything has no key. It still comes before the session-choice and incognito branches.
  • QuitAlert.press.
    • "end-everything" now always goes to center.press. Both the session choice and feat's confirmation have that button, so feat's "presses the new button" still holds.
    • Before, on a dialog with only the old Quit Everything button, "end-everything" finished as End Everything with no button. That path is gone, because no dialog lacks the button now.
    • "end" reports either end button.
    • Scripts that press end-everything (picker-e2e, render-scenes, new-workspace-real-typing) get the same result as before.
  • Round-1 N2 is now closed. If a person checks "Don't ask again" and clicks End Everything in the session choice, the PR writes end-everything. feat's guard then asks with its own dialog on every later Cmd-Q, and that dialog has no suppression box. So a remembered End Everything can no longer delete the workspaces silently.

The test change is honest

  • QuitEndEverythingGuardTests.theOtherRememberedChoicesStillQuitAtOnce removes one line: decide(.interactive, behavior: .ask, facts: 490 terminals) == .quit(.keep). That line encoded the cx-7vu no-prompt rule that this PR takes back on purpose, and .ask is not a remembered choice.
  • The new assertion expects .ask with offersSessionChoice, default .keep and !confirmsEndEverything. That is the PR's specified behavior, and it also proves that Ask does not enter the guard's path.
  • The four remembered-choice lines (endKeepLayout, keep, and End Everything under powerOff and signal) are unchanged. The other four guard tests are unchanged.
  • QuitChoiceTests drops its remembered End Everything case, because feat's aRememberedEndEverythingAsksOnCmdQ owns it. The test was not weakened: that rule now asks, and the guard test checks it.
  • No other test or source calls decide with the old expectation. I searched Tests and Sources.

Catalog, scripts and evidence

  • Catalog. Quit.xcstrings parses, and every table: "Quit" key in QuitStrings.swift is in it.
    • quit.button.endKeepLayout, quit.button.endEverything, quit.endEverything.title and quit.endEverything.line each have 21 locales, and none of the 9 required locales is missing.
    • The removed quit.button.quitEverything key and the confirmQuitEverything case are not used anywhere.
  • quit-hidden-chromium-live.py. It now presses "keep" when it shows, else buttons[-1] (the primary, drawn last). This also fixes my round-3 N2: on an incognito-only dialog it presses Quit, not a missing keep.
  • fleet-quit-persistence.sh. Steps 5 and 9 are as reviewed in round 3.
  • CmuxDialogView and its tests. They are unchanged since round 3.
  • Fleet runs at the head itself:
    • Compile e05b92c706e4697b3e5435ac is done at b3c6d56. CmuxNext, app and cmux-tui are all ok.
    • Step d60059e07efb3773fabfd62c at b3c6d56 exited 0: CmuxDialogButtonFitTests 3/3, QuitChoiceTests 6/6 and QuitEndEverythingGuardTests 5/5.
    • The earlier red steps (1930abc2b7aacbe63c95d704 and 2df0dc59f812dd14e73a0a32) still prove that the tests fail without the fix.
  • Risk. No new delete line, so risk:normal stays correct. The merge brings b52ff17 from feat, and this PR does not take it back.

Non-blocking (carried forward)

  • Fast double Cmd-Q. A second Cmd-Q during the facts read (before the dialog shows) is dropped. The author records this as a follow-up. It is safe because Keep is the default.
  • Untested button path. The QuitAlert.answered / press path from button to choice still has no unit test. eachButtonAnswersItsChoice tests QuitPolicy.remembered.

@lawrencecchen

Copy link
Copy Markdown
Contributor Author

Check classification at b3c6d56 (author):

  • cmux-next checks (god files, concurrency, crash safety, l10n) (job 124750105060): BASE. Its red sub-checks are the namespace lint on Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneHandoffPrompt.swift:11, the webviews warn-split test (check:session-info-names) and "Checks job structure"; none of those files is in this diff.
  • nightly-guards / workflow-guard-tests / release-notary (job 124750104889): BASE. tests/test_nightly_step_coverage.py fails on nightly.yml job pick-nightly-sign-runner; this PR changes no workflow.
  • nightly-guards / Guard status: wrapper of the line above. pr-red-checks / comment: cancelled.
  • defaults guard (job 124750065263): it ran at 04:29Z, before the receipt. The one default it lists is the hash of CmuxNextDaemonService/QuitRules.swift:decide, the rule this PR changes on purpose (Ask with local terminals asks how to quit). Receipt: fix(quit): Cmd-Q with local terminals asks how to quit (Keep Sessions default, End Sessions Keep Layout, End Everything); Don't ask again writes app.quitBehavior (QUIT P0) #29984 (comment) (SHIP at this head). Rerun requested.
  • Fleet at this head: compile e05b92c706e4697b3e5435ac passed; suites d60059e07efb3773fabfd62c: CmuxDialogButtonFitTests 3/3, QuitChoiceTests 6/6, QuitEndEverythingGuardTests 5/5.

@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 Nov 1, 2026
lawrencecchen added a commit that referenced this pull request Nov 1, 2026
… the second-quit check waits for the dialog; stacked dialog buttons tab top to bottom (review FIX on #29984)

(cherry picked from commit 132fa6e)
@lawrencecchen
lawrencecchen merged commit 88dcb89 into feat-cmux-next Nov 1, 2026
181 of 189 checks passed
@lawrencecchen
lawrencecchen deleted the fix/p0-quit-reopen branch November 1, 2026 05:25
lawrencecchen added a commit that referenced this pull request Nov 1, 2026
…y workspace (port of feat b52ff17 to v2.2.12-opt)

On 2026-10-31 18:46 a Cmd-Q typed mid-sentence, with app.quitBehavior
end-everything set, quit with no dialog and closed all 900 local
workspaces. With the quit question of #29984 a person can store End
Everything from the dialog, so this line needs the guard too (review FIX on
#30182).

- An interactive quit with End Everything remembered asks "Quit and close
  every workspace?" (Return keeps sessions running, Escape cancels, End
  Everything has no key). After the unsaved-changes question it ends the
  sessions but keeps the workspaces.
- A scripted quit with no flag maps End Everything to End Sessions, Keep
  Layout. --end-everything and the File menu item run as before.
- The files are feat's at 88dcb89 (b52ff17 + #29984): QuitPolicy,
  QuitAlert, QuitAlertContent, QuitStrings, Quit.xcstrings, QuitChoice,
  readiness-soak.py, update-e2e.py, QuitChoiceTests, QuitEndEverythingGuardTests.
- Port difference: feat's CmuxNextDaemonService/QuitRules.swift holds six
  types; here they are one file per type (QuitProgram, QuitAgentFacts,
  QuitFacts, QuitPrompt, QuitDecision, QuitRules), byte-identical code, so
  no new multi-type file.

(cherry picked from commit b52ff17, files split)

This branch was previously deployed

1 inactive deployment
ci-routing-agent — b3c6d56f Deployed Nov 1, 2026 by lawrencecchen via cmux-tui tree preflight #93639
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